This post relies on the "function coloring" as introduced by Bob Nystrom in What Color is Your Function?, and extended by Without Boats in Let futures be futures. I'll open with a quick summary, but I recommend you read them both because they're good posts. I mostly complain about a minor pain point I experience with async Rust, with a few thoughts towards a solution at the end.
To make a point about async Javascript, Nystrom made a hypothetical language which has the following rules:
- Every function has a color, either red or blue.
- The way you call a function depends on its color.
- You can only call a red function from within another red function.
- Red functions are more painful to call.
- Some core library functions are red.
This was a metaphor for the rules around async functions, with async functions being red and non-async functions being blue. However, as detailed in Boats's post, Rust doesn't quite have the same pain, because there's a way you can call a red function from a blue function. Boats extends this hypothetical by adding a new option for coloring your functions, green, with the following properties:
- Green functions can be called like blue functions. The only way to tell the difference between a green or blue function is in documentation written by the author, if they bothered to write any.
- There is a green equivalent for every primitive red function in the standard library.
- There is a green function that wraps any red function and calls it.
- Calling a green function from inside a red function is very bad.
In this metaphor, green functions are blocking functions, and specifically there exists the green function block_on
which takes a red function as its argument and returns its result.
Now, you're using this hypothetical 3-color language perfectly well. Everything seems normal. But then, you pull in a new third-party library, and it has this note in its documentation:
Some functions in this library have the color "crimson", with the following properties:
You call them in exactly the same way, and get the result back in the same way. Whether a function is crimson or red is indicated in the documentation if the developer for that function put it there.
Inside a crimson function, you can do all the other things that a red function can do, including calling blue and red functions. You also may not call any green functions, or else something very bad will happen.
This support even extends to higher-order functions. A red function that calls other red functions can be given a crimson function instead, and the function works the same as before (though it also becomes crimson).
Okay, this is a little anoying that you have to keep track of red and crimson functions, and I hope all my dependencies that made crimson functions wrote that they're crimson in their documentation.
How nice of them, I can call these crimson functions easily. I wonder they included that when I can just use the standard library's block_on
function that calls any red function, though? I guess I'll have to keep reading to find out.
A red function called in such a way acts identically as a crimson function, including calling other red and crimson functions as though it were crimson.
They haven't said any way crimson functions are different from red functions? Oh wait, there's one last difference they documented:
If you attempt to call a crimson function from a red function (other than ones that remember they're called as a crimson function), the program will crash and immediately exit. You also may not use the
block_on
adapter from the standard library that normally calls red functions, or else you'll get the same crash.
Ah, now it makes sense why they provided block_on_crimson
. And now, if you want to use this crate, you have to go through every place where you call a red function through block_on
and change it to use block_on_crimson
. And I sure hope you didn't use the green equivalents to any higher-order red functions, as this library may not have any equivalents.
In case you can't tell, this metaphor is about tokio
(and maybe other async runtimes? I don't really have much experience using them, so idk how much this applies). With how tokio
is implemented, a lot of its very helpful functions can only be called when running its runtime, or else they panic. And this works great most of the time, as I can just use tokio
's green function to call my crimson functions. However, on the rare occasion when I can't use tokio's runtime, I'm now suddenly blocked off from all of those goodies.
And no offense intended to the tokio
developers, it's a great crate! The developers are doing the best job they can with the Rust language as it is, but the fact that futures cannot encode what runtime support they need (if any), means they're stuck with this problem. And if anyone else makes a competing async runtime, they could end up in the same situation with another color (idk if any of the others have the same thing, or if it's just tokio).
I'm not sure what options are available for fixing this nuisance, if any. If I could make backwards-incompatible changes to the Rust language, I would change the Future
trait to look like this:
/// A future that runs in a given context.
pub trait Future<Context> {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
And then, I'd introduce a bunch of traits to express things that a runtime can support (these might be in core
or std
, if thought sufficiently important, or they might be in third-party libraries), like:
/// A context that support futures waking.
pub trait WakeContext {
/// Wake the future that was given this context.
fn wake(&self);
}
/// A context that can spawn new tasks.
pub trait SpawnContext {
/// The handle to a task returned by the `spawn` function.
///
/// There probably should be some trait bound on this so we can `.join()` the handle.
type JoinHandle<Output>;
/// Spawn the given task to run independently, and return the given handle.
fn spawn<Fut: Future<Self, Output=Output>, Output>(&self, task: Fut) -> JoinHandle<Output>;
}
/// A context that can wake a task when a file descriptor polls as awake (only on Unix).
pub trait FileDescriptorPollContext: WakeContext {
/// When any events in `events` are polled to happen, write to `revents` and wake the associated task.
fn wake_when_poll_result(&self, fd: BorrowedFd, events: PollFlags, revents: &mut PollFlags);
}
/// A context that can wake after a duration.
pub trait TimedContext: WakeContext {
/// Wake after `duration` elapses.
fn wake_when_poll_result(&self, duration: Duration);
}
// and so on
Now, when I want to write a future, I can specify what it needs:
impl<Context> Future<Context> for MyFuture
where Context: .. // Whatever requirements go here
{
type Output = ..;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
..
}
}
You can even write futures that don't have any bounds on the runtime, if they're simple enough:
/// A future that never returns `Ready`, works in any context.
pub struct Pending<T>;
impl<T, Context> Future<Context> for Pending<T> {
type Output = T;
fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output> {
Poll::Pending
}
}
Third party libraries which make async runtimes can indicate which traits their context supports. The tokio
developers could even make a specific struct for their context, and futures that only work that specific context and nothing else.
And then, when you write an async fn
or an async { .. }
block, the compiler, as part of its desugaring, sees the trait bounds on all the futures you call, and infers appropriate trait bounds for your future (or returns a compile error if they're not compatible, e.g. because one requires an exact context struct while another requires a trait that this context doesn't implement).
This doesn't fix the "shade of red problem", since futures still have the same requirements, but now it's at least documented in the method signature of all your futures, and the compiler will alert you immediately if you try to do anything illegal, instead of generating runtime errors when you run the code. And if you have many layers deep of futures calling poll
on each other before you get to an async fn
, then you'll have a lot of changes if you change those bounds (hopefully IDE tooling can help with that). But, on the net, I think solving this problem would have been worth the downsides to this interface.
Of course, I can't make backwards-incompatible changes. I'm not in charge, and even if I were, the benefits to making this change would be way short of the drawbacks. Someone smarter than me might be able to do something hacky to make it go over an edition boundary in a backwards-compatible way, but almost certainly not without forcing major version bumps on every async crate in Rust, and probably also with very clunky interop between the old and new edition until everyone switches over, which I again don't think would be worth it.
So, having said all this, I'm not sure what there is to do about this nuisance of async Rust, but I figured I'd write it down, see if anyone else has the same problems as me, or if anyone facing this problem is smart enough to make a backwards-compatible solution.
Saving the first comment for any updates I want to add later.