Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: Any reason to demand Sync for Rust async host function? #10262

Closed
xdlin opened this issue Feb 20, 2025 · 7 comments
Closed

Question: Any reason to demand Sync for Rust async host function? #10262

xdlin opened this issue Feb 20, 2025 · 7 comments

Comments

@xdlin
Copy link

xdlin commented Feb 20, 2025

Hi, I got a trouble when calling another async function within WIT async host function because my function depends on a task_local storage which is not Sync, and I found that wit-bindgen generate host function with following signature:

  pub trait Host: Send + ::core::marker::Send {
      type Data;
      fn host_func(
          accessor: &mut wasmtime::component::Accessor<Self::Data>,
          id: wasmtime::component::__internal::String,
      ) -> impl ::core::future::Future<
          Output = wasmtime::Result<
              wasmtime::component::__internal::String,
          >,
      > + Send + Sync + ::core::marker::Send
      where
          Self: Sized;

Is is possible to loose this restriction?

@pchickey
Copy link
Contributor

Thanks, this is a great question that I've been wrestling with as well so hopefully this explanation helps you and others who run into this.

The quick answer is: this restriction is not always ideal but its essentially required for Wasmtime to interoperate with the Rust async ecosystem, and lifting it would create considerable difficulty throughout the Wasmtime ecosystem. Its an unfortunate problem I've run into a bunch, especially as I'm currently working on no_std based single-threaded embeddings for wasmtime, but I don't have a satisfactory way to resolve it. If you are using Wasmtime in a single-threaded context, we currently don't have a better answer than to lie to the type system and write unsafe impl Send for MyCtxType {} unsafe impl Sync for MyCtxType {} for the types you impl Host on, in order to work around this.

Here's lots more details, possibly more details than you need:

Wasmtime's async support exists so that Wasmtime can be embedded in async Rust applications. While. not all async Rust applications use an executor that requires Futures which are Send, in practice, many of our production users are using the Tokio ecosystem and have a hard requirement on Tokio's multi-threaded scheduling in order to maximize the capacity of their systems.

Wherever possible, we have kept Wasmtime's interfaces agnostic on requiring Send types. For example, the Store<T> type is careful to not put any Send constraints on T where possible. This lets Rust's type checker determine whether your Store is Send based on whether your T is Send, which is the way things Should Work.

However, if you use Store::limiter_async you end up with a Send constraint on the ResourceLimiterAsync impl - like the Host trait you showed, its an #[async_trait] that sprinkles Send on all of the Futures returned by those methods, which will end up implying Send on everything they capture, which will include Self. This all comes down to what ResourceLimiterAsync is designed to do: it exists to provide a programming hook so that a Wasmtime Store's desire for more memory will await on resources, effectively to give the async runtime the ability to pause a store's execution and resume it, possibly on another thread, when resources are available. Due to the Rust type system we basically had to pick whether to put a Send on ResourceLimiterAsync in order for it to work on multi-threaded Tokio (henceforth I'll refer to this as just Tokio), or else if we left Send off it wouldn't work on Tokio.

Now, you might point out that, like we sometimes see in the various Rust async ecosystems, there could be a limiter_async_local variant of that method which doesn't have the Send constraint so that users could pick whether they are Send or not. This might be possible, but each case we do this for would increase the complexity of Wasmtime's implementation, and there end up being many such cases all throughout Wasmtime - the ResourceLimiterAsync is just one example. You might also notice things like, hey, over in the plain old synchronous Store::limiter there's no Send requirements on the ResourceLimiter itself, but there are Send requirements on the impl FnMut(&mut T) -> &mut dyn ResourceLimiter accessor function - if we were to drop that Send constraint there, we would break Rust's ability to make any <T: Send> Store also be Send, so it would require yet more gymnastics, perhaps culminating in breaking it into distinct Store and StoreLocal that differ only in Send constraints. In my opinion, that would be a total mess - it would be much harder for users to understand Wasmtime, and much harder for maintainers to maintain it.

So, now that I've laid out how Send is infectious in not just the async Wasmtime apis but also in other places, we can generalize that to the futures returned by those Host methods - thats just table stakes for running on an async runtime. When it comes to the constraint put on the type T that impls Host itself (rather than the Futures that methods on T return), that comes down to the the ResourceTable abstraction: any value that the resource table is given ownership of (via push or the push_child variant) must be Send. ResourceTable is a heterogeneous collection, and in order for ResourceTable to be Send, all of the values it owns are Box<dyn Any + Send>. In practice many of the methods in various Host traits are implemented in such a way that the Future captures an item owned by the ResourceTable.

I want to conclude that I sympathize with this Send infection making Wasmtime difficult to program with in situations where you cannot, or don't want to, impl Send on the structures interacting with Wasmtime. I'm working directly in those situations, and I don't like that I end up sprinkling /* SAFETY: this is only executed in a single threaded environment */ unsafe impl Send all over my codebase for the T in Store<T> or for anything I put in the ResourceTable. All I can do is apologize that, when I encountered this problem, I went back and worked through the way that dropping that constraint would require all of Wasmtime to contort to permit it, and decided that it simply wasn't worth it.

At the end of the day, the best answer I have is, this isnt the sort of property that Rust's type system lets us make parametric or truly "zero cost" to the programmer, without costs to the wasmtime project that we as maintainers couldn't stomach. So we had to pick one side and live with the fallout. We picked interoperability with Tokio because some important production uses (including me, years ago) demanded it, and that means other users (including me, now) have to unfortunately live with it.

@xdlin
Copy link
Author

xdlin commented Feb 20, 2025

Hi @pchickey thanks a lot for your explanation in great details, now I totally understand it, that's the trade-off we have to pay to keep a maintable system, which is fair enough.

Then I'll try to find some workaround in my own code to make type checker happy

@pchickey
Copy link
Contributor

I'll close this but if you or anyone has further questions feel free to continue discussion here.

@PureWhiteWu
Copy link
Contributor

Hi @pchickey thanks very much for your detailed explanation! I know why Send is required, because tokio multi thread runtime requires Send, but why is Sync also required in the return future of host_func? This will cause things like task_local unable to compile.

@pchickey
Copy link
Contributor

Sync ends up being a constraint for essentially all of the same reasons that Send does, but I'm not sure that I understand the question. What is task_local and how are you using it that is unable to compile?

@PureWhiteWu
Copy link
Contributor

PureWhiteWu commented Feb 21, 2025

For multi-thread runtimes, only Send is required for Future types, so I wonder why is Sync required here for the returned Future of host_func.

As far as I know, the Future will not be accessed by multi threads at the same time, so it doesn't need Sync here. What Futures need is only Send, because it may be send across threads, but will only be executed(polled) on one thread at a moment, since the Future::poll func requires &mut self.

@PureWhiteWu
Copy link
Contributor

And for task_local, this is a mechanism provided by tokio to bind some context to an asynchronous task, similar to synchronous thread_local. Here's the documentation: https://docs.rs/tokio/latest/tokio/macro.task_local.html.

task_local's usage is like thread_local, which needs a RefCell to make it interior mutable. Common examples are like this:

task_local! {
    static CTX: RefCell<Context>;
}

CTX.scope(RefCell::new(Context::new()), async { inner future here }).await;

Because there's a RefCell here, which is not Sync, which will cause the entire Future !Sync.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants