Skip to content

Instantly share code, notes, and snippets.

@JarredAllen
Last active April 22, 2024 18:36
Show Gist options
  • Save JarredAllen/6cd2fd5faead573d1120a96135ed3346 to your computer and use it in GitHub Desktop.
Save JarredAllen/6cd2fd5faead573d1120a96135ed3346 to your computer and use it in GitHub Desktop.
Which red is your function?

Which "Red" is your function?

Forenote

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.

Why do functions have colors?

To make a point about async Javascript, Nystrom made a hypothetical language which has the following rules:

  1. Every function has a color, either red or blue.
  2. The way you call a function depends on its color.
  3. You can only call a red function from within another red function.
  4. Red functions are more painful to call.
  5. 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:

  1. 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.
  2. There is a green equivalent for every primitive red function in the standard library.
  3. There is a green function that wraps any red function and calls it.
  4. 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.

What's this about multiple reds?

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:

Function coloring

Some functions in this library have the color "crimson", with the following properties:

1. As far as the compiler is concerned, crimson functions are indistinguishable from red functions.

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.

2. This library has a green function, block_on_crimson, which can call any crimson function.

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.

3. Red functions remember if they're called from a crimson function or from crimson.

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:

4. Crimson functions may not be called from red functions, nor from block_on

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.

But what's this metaphor really about?

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).

Where do we go from here?

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.

@JarredAllen
Copy link
Author

By the way, I think there's been some sort of editing mishap in this part:

A red function called in such a way acts identically to ly slow down your program and in the wa crimson function, including calling other red and crimson functions as though it were crimson.

Good catch, I've fixed the mistake.

@NicolasVidal
Copy link

Thank you for this article. This reminds me of the Burn (https://burn.dev/) crate which tries to be an abstraction over deep learning backends, and the solution was to pass the Backend as a generic type everywhere. Am I getting this analogy right ?

@Ciel-MC
Copy link

Ciel-MC commented Feb 15, 2024

How nice of them, I can call these crimson functions easily. I wonder they included [...]

Is it supposed to say "I wonder why they included [...]"?

@teor2345
Copy link

Even better, tokio comes with a few pink functions, which you can't call from a crimson function, or they will panic.

I still remember finding an escape hatch, which is tokio::spawn_blocking(|| std::thread::spawn(|| tokio::Runtime::block_on(fut))). This is extremely difficult to call and handle panics correctly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment