chinedufn / swift-bridge

swift-bridge facilitates Rust and Swift interop.
https://chinedufn.github.io/swift-bridge
Apache License 2.0
805 stars 58 forks source link

Give users more control over the async runtime #58

Open chinedufn opened 2 years ago

chinedufn commented 2 years ago

Right now we lazily create an async runtime whenever you first call an async Rust function from Swift.

https://github.com/chinedufn/swift-bridge/blob/72c2fa5f2214e0845d9b3fa29b56ffbf94a97c68/src/async_support.rs#L7-L16

Users currently have no control over the async runtime. We chose this approach as a quick way to quickly get async functions working, but now that we have a few users it's time to revisit it.

This issue was inspired by a comment https://github.com/chinedufn/swift-bridge/issues/50#issuecomment-1104625311 showing an error due to our current async implementation expecting futures to be Send.


This issue is dedicated to thinking through how much control we want to give users over the async runtime.

Do we want users to be able to supply their own async runtime?

#[swift_bridge(async_runtime = MyImplementationOfALazyRuntime)]
mod ffi {
    extern "Rust" {
        async fn foo();
    }
}

Do we want to just provide one multi threaded and one single threaded async runtime and give users the ability to choose which one to use? For example:

mod ffi {
    extern "Rust" {
        #[swift_bridge(async_runtime = "single")]
        async fn foo();
    }
}

Or some other approach?

This needs some thinking. We should have a good sense of what people need to be able to do.. and then use that to inform our design.

I generally lean towards giving users as much flexibility as possible.. which has me leaning towards first exploring approaches where users can supply their own async runtimes.. but yeah let's investigate all of this a bit more.

chinedufn commented 2 years ago

Marinated on this a bit.

I'm liking the idea of the user bringing their own runtime (by adding an annotation at either the module, extern or individual function level as illustrated in this issues body).

Since the vast majority of users won't need to customize their own runtime, we can consider providing rt-multi and rt-single feature flags.

rt-multi would lazily initialize a multi threaded async runtime (basically what we already do today). rt-single would do the same thing but with a single threaded runtime.

// Assuming the `rt-single` feature flag was enabled.
#[swift_bridge(async_runtime = swift_bridge::async::RuntimeSingleThreaded)]
mod ffi {
    extern "Rust" {
        async fn foo();
    }
}

These are just some ideas.. I generally dislike the idea of including too many batteries here. It might make sense to not provide any default runtimes and just push everything into user-land.

And then just have our guides / docs show how to initialize your own runtime easily.

struct MyRuntime;
impl swift_bridge::async::AsyncRuntime for MyRuntime {
    fn spawn(&self, task: swift_bridge::async::AsyncFnToSpawn) {
        // ...
    }
}

This all needs more design thinking.. Just jotting down thoughts as they come to me.


An open question is how libraries should handle async runtimes.

Say you have a library my-api-library that exposes async functions to Swift.

Then my-binary crate depends on my-api-library.

How does my-api-library spawn async tasks? Needing every library to bundle its own async runtime would be wasteful.

Perhaps the library would use a "runtime" that just buffered tasks, and then the runtime in the final binary would be responsible for running those tasks. Something along the lines of:

// Trait for defining an async runtime
use swift_bridge::async::AsyncRuntime;

pub struct LibraryRuntime {
    buffer: Vec<AsyncFnToSpawn>,
    real_runtime: Option<Box<dyn AsyncRuntime>>
}

impl LibraryRuntime {
    fn set_runtime (&self, runtime: Box<dyn AsyncRuntime>) {
        for task in self.buffer {
            runtime.spawn(task);
        }
        self.buffer.resize(0);
        self.real_runtime = Some(runtime);
    }
}

impl AsyncRuntime for MyRuntime {
    fn spawn(&self, task: swift_bridge::async::AsyncFnToSpawn) {
        if let Some(runtime) = &self.real_runtime {
            runtime.spawn(task);
        } else {
            // Would need interior mutability...
            self.buffer.push(task);
        }
    }
}

And then the final executable would use my_api_library::LibraryRuntimeInstance and call set_runtime on it.. Where LibraryRuntimeInstance is just a lazily initialized LibraryRuntime.

And my_api_library would have its bridge look like:

#[swift_bridge(async_runtime = LibraryRuntimeInstance)]
mod ffi {
    extern "Rust" {
        async fn foo();
    }
}

With this sort of setup.. swift-bridge could provide the "LibraryRuntime" (with a better name...) that libraries would depend on.

I've papered over a lot of details (i.e. a library A that depends on a library B. Library A would need to provide a runtime to library B.. but that should be fine.. it would just pass down its own LibraryRuntime into library B's LibraryRuntime.) .. but in general this could work.

Would just need to feel it out.

chinedufn commented 1 year ago

Related #119