3 ms·
> edit: I'm curious, what's the ergonomics like for this? Is it just "oh, your code won't compile if you don't add this magic incantation to say it has `Send`?"
by duped 3y ago
> edit: I'm curious, what's the ergonomics like for this? Is it just "oh, your code won't compile if you don't add this magic incantation to say it has `Send`?" Or is it "your code will fail in intermittent, hard-to-debug ways as state gets mangled during work-steals if you don't do this right"?
You write some code that looks like this.
struct Server {
// ...
}
impl Server {
async fn serve(&self) {
loop {
let server = self.clone();
let message = read_message().await;
// Each message handler is a new task, seems reasonable?
spawn(async move {
let result = server.handler(message).await;
write_response(result);
})
}
}
async fn handler(&self, arg:Message) -> Result<U, Error> {
let this = do_this();
let that = do_that(arg).await;
do_the_rest(this, that);
}
}
And everything works fine.
Then one day you change the implementation of `do_this` such that the type of `this` is no longer `Send`. You will get a nasty compile error that spawn(...) doesn't work because the type created by the anonymous scope in `async move { }` is not Send. The reason is not necessarily obvious (and the error message is unhelpful). If `this` is not `Send` then you can't hold it across the `.await` of `do_that(arg).await`, because each .await represents a point in execution where the future may yield and be scheduled onto another thread by the executor.
If you can make the type Send then everything is fine. If you can't (which is entirely possible!) then you need to change the scheduling of the future to `spawn_local` (or whatever your async executor calls it). This may require adding a bunch of boilerplate to even call `spawn_local` in the first place.
This is the issue with Send. It's not just adding type annotations. There are subtle ways it infects your code that may cause it to break later in non-obvious ways, because if a type implements Send is not always obvious.
- diath 3y ago> And everything works fine. Until the handler needs to access a resource that's shared by tasks.
- cmrdporcupine 3y agoOk, but it's not Send that's doing it, and causing you problems. It's using async. You were always sending crap across concurrent execution contexts. But the language syntactically kinda-hid it from you and you got away with not worrying about it. And now it bit you in the ass. In general, this is the problem with implicit/masqueraded behaviour. Why back in the day we moaned about REST vs RPC (stop trying to hide that there's a network there!). Or why it's a bad idea to have languages where "+" is a string append operator. Etc. In systems design: surprises suck. Tokio is sending your stuff around, passing it around. So of course it has to be Send. Yeah, you can avoid that by doing thread per-core and just never having things send. But I swear, it's going to bite you in the ass in the end anyways. It's your control flow that's confusing (because it's hidden), not the Send. In the end, C++ or etc would just let you hang yourself here, and you'd be puzzling over a segfault or deadlock later, at 3am. (There are some things that piss me off tho. AtomicPtr<T> being Send+Sync while *T/*const T is not makes no sense. Both are equally unsafe, and there's no difference in how they behave across thread boundaries, really.)
- duped 3y agoThe problem that I'm trying to illustrate is Send + Async which always comes up in this context, not just Send. > You were always sending crap across concurrent execution contexts. But the language syntactically kinda-hid it from you and you got away with not worrying about it. And now it bit you in the ass. I disagree. Here's a more realistic example async fn func (resource: Resource) { let a = foo(resource).await; let b = bar(resource).await; baz(a, b).await } Assume that this isn't "crap" and the only correct way to implement this code. There are two fundamentally distinct async operations over the same resource and you can't call them in parallel. Now say you have an async callstack that looks like this let task = async move { outer(inner(func(resource).await).await).await; }; spawn(task); That call to func() could be buried deep in the callstack instead of inner or outer or anywhere else, and very small changes to the implementation of func can cause it to create a compiler error a mile away from where the problem actually is. I don't feel like this is inherent complexity and it has nothing to do with tokio, it has a bunch of related problems with the limitations of Rust, from the stripped-down generator design to lack of specialization for traits. imo these limitations make async code quite fragile to write in practice, and it's kind of frustrating to be gaslit repeatedly with "no it's actually good that your code is hard to write."