so before we start this post we just wanna clarify that this post is intended as lighthearted. it's not "async fn are bad and you should feel bad" but "here's our experience trying to use async fn and why we don't enjoy it at all".
there is, ofc, the obvious one: lack of async traits. this isn't that big of an issue in the real world tho, but it does lack the ergonomics you'd usually want. ah well. it's fine tho.
but honestly? from all the ways rust does safety... we feel like async fn lacks a lot of them. with threads, there's Send and Sync, and altho those are purely about soundness, their effects actually extend far beyond pure soundness in practice. (besides, unsafe code can in fact willingly ignore Send and Sync.) remember "fearless concurrency"? yeah we know it's outdated and nobody talks about it anymore, but well, Send and Sync were the main driving factors of that phrase. it means we can just Know nothing particularly bad is happening with our structs because different execution contexts aren't gonna be interacting with the same thing. but async fn, despite being equivalent to threads in many ways (and fairly different in many other ways), doesn't have an equivalent to these. and we think this is the biggest difference and the main thing we get caught up on when using async fn. (we feel like we can also point to UnwindSafe/RefUnwindSafe for Send/Sync-adjacent stuff here, but we feel like those are the wrong abstraction for async fn.)
aside from that... we actually don't think there's anything else we can particularly complain about with async fn. just the one minor ergonomic kludge (which everyone already knows about) and the one major (to us, we mean we haven't seen anyone else complain about it so it must be just us) ergonomic kludge.
FYI, async fn actually does interact with Send and Sync, and it makes sure you uphold all the same thread-safety guarantees when using async fn as in all other Rust code. Its interaction with Send and Sync is just famously implicit: you can’t see it it the type signature, but the returned future type actually does have some Send and Sync implementations that depend on which kind of data you hold over .await points in the function body.
The fact that Send and Sync are tracked properly then means that you can only skip the requirement of using thread-safe approaches to shared ownership or shared mutability, if you aren’t spawning your async fns to be scheduled between different thread in a multi-threaded runtime. E.g. compare the typical tokio::spawnwith a Send bound on the F: Future type, with the API around tokio::task::LocalSet[1], and also similarly the entry-point Runtime::block_on, where the futures involved do not need to satisfy a Send bound.
Yeah, but see, those thread-local asyncs don't provide the training wheels that keep you from observing broken logical invariants across yield points. Instead you have to rely solely on your reading comprehension and ability to spot that you haven't fully completed a transaction by the time you awaited on something.
We guess the fact they're not the default (you have to go out of your way to not require Send/Sync) helps here, tho we still worry about it.
Arguably, they do provide the same level of explicitness with regards to holding up invariants / synchronization, and the like. This is because Rust always prohibits shared mutability by default; concurrent accesses tend to be super safe even within the same thread.
What does not needing to meet a Send bound actually give you? In terms of standard-library primitives, well… you can
use Rc instead of Arc, the two have the same API, so not really a difference, just different performance
use RefCell instead of RwLock (or Mutex); the two have almost the same API. Slightly different method names, sure… and instead of locking, it will panick; but if you thing about it: in a single-threaded environment, any locking is always automatically a deadlock; so the panicking is something like a “perfect deadlock detection system”, nothing more
use Cell instead of Atomic… types. Yeah, okay, Cell is a bit more flexible than the atomics that std offers (though it’s comparable to AtomicCell of crossbeam). Other than that, they’re, again… essentially the same kind of API. Sure, atomics are more complex, you’ll need to worry about orderings and the like, of course Cell doesn’t have API related to that because it doesn’t need to
as far as I can tell, non-thread-safe Rust doesn’t allow you to do anything dangerous, even with thread-lookalikes such as async fns. The single-threaded alternatives are just slightly more performant than their thread-safe counterparts, and they can prevent deadlocks.
So what you're wanting for is "Send between tasks" and "Sync between tasks". That in synchronous code you can do an operation on Rc<State> and nobody else can observe it (because it is !Sync) in a transitive state. An asynchronous code, however, when you yield some other task starts up and can have access to that same Rc<State> and observe your transitive state(s).
What I think you want to express is some way to say that the futures given to task::spawn_local are as you pass them[1] not able to reach any shared-mutable objects. But you want to be able to use such types internally to the future.
Since std doesn't have any task-spawning interface, this still has potential to be provided. I think you'd need unsafe auto trait TaskSend {} for "has no (exposed) shared mutability" (std actually has this as Freeze) and unsafe auto trait TaskSync {} for "cannot reach (exposed) shared mutability."
This is legitimately something that I could see being used... though because I don't see any way that this impacts soundness, it would likely end up being more like UnwindSafe where it's barely even useful as a lint due to many types not providing it when they should.
If you or anyone were to bring a prototype to wg-async with a demonstration of bugs that it can catch, I'm sure they'd consider it. In fact, there's a current initiative to examine classical async bugs in the wider cross-language ecosystem and how async Rust does or doesn't address them, so now might be the best time to bring it up (with worked examples showing how it can prevent bugs).
it can be used to impact soundness. this "Send between tasks"/"Sync between tasks" property would propagate to the task itself, yeah? which would be useful if you wanna have things you can borrow in the task but not across yield points. (we ran into something like that when making async_serde, and altho we've given up on async_serde for now, the parts we did do, we used a closure to enforce the borrowing only within sync context, so that it couldn't leak back out and cause an UAF.)
and unlike catch_panic, async fn are far more used.
I'm not sure if I understand the OP correctly, but what I really find troublesome is that when I use async fn, the question whether my returned Future is Send depends on the function's body:
pub mod m {
async fn nop() {
}
pub async fn foo() {
// We later add some more code to this function without changing its signature:
//struct NotSend(std::marker::PhantomData<*const ()>);
//let _not_send = NotSend(std::marker::PhantomData);
nop().await;
}
}
pub fn run() -> impl std::future::Future<Output = ()> + Send {
m::foo()
}
Changing only the body of foo (and not its signature) will cause the run function to fail to compile. I think rustdoc doesn't include the information whether an async function returns a Sendable future, right?
I think making an async fn removing the Send property is a breaking change, semver-wise, right? But this break can happen unnoticed (you will only notice if there is some code which uses the async fn in a way that expects the Future to be Send, like the run function).
So maybe it would be a good practice to include tests for all async fn's which ought to return a Future that is Send? Or is there some sort of annotation to demand that an async fn produces a Sendable future?