Before getting started: no, there is no such thing as an async exception in Rust. I'll explain what I mean shortly. Notice the comma in the title :).

GHC Haskell supports a feature called asynchronous (or async) exceptions. Normal, synchronous exceptions are generated by the currently running code from doing something like trying to read a file that doesn't exist. Asynchronous exceptions are generated from a different thread of execution, either another Haskell green thread, or the runtime system itself.

Perhaps the best example of using async exception is the timeout function. This function will take a certain number of microseconds and an action to run. If the action completes in that time, all is well. If the action doesn't complete in that time, then the thread running that action receives an async exception.

Rust does not have exceptions at all, much less async exceptions. (Yes, panics behave fairly similarly to synchronous exceptions, but we'll ignore those in this context. They aren't relevant.) Rust also doesn't have a green thread-based runtime like Haskell does. There's basically no direct way to compare this async exception concept from Haskell into Rust.

Or, at least, there wasn't. With Tokio, async/.await, executor, tasks, and futures, the story is quite different. A Haskell green thread looks quite a bit like a Rust task. Suddenly there's a timeout function in Tokio. This post is going to compare the Haskell async exception mechanism to whatever powers Tokio's timeout. It's going to look at various trade-offs of the two different approaches. And I'll end with my own personal analysis.

Async exceptions in Haskell

The GHC Haskell runtime provides a green thread system. This means that there is a scheduler which assigns different green threads to actual OS threads to run on. These threads continue operating until they hit yield points. A common example of a yield point would be socket I/O. Take the pseudocode below:

socket <- openConnection address
send socket "Hello world!" -- yields
msg <- recv socket -- yields
putStrLn ("Received message: " ++ show msg)

Each time we perform what looks like blocking I/O in Haskell, in reality we are:

However, yield points happen far more often than just async I/O. Every time we perform any allocation, GHC automatically inserts a yield point. Since Haskell (unfortunately) tends to do a lot of heap allocation, this means that our code is implicitly littered with huge numbers of yield points. So much so, that we can essentially assume that at any point in our execution, we may hit a yield point.

And this brings us to async exceptions. Each green thread has its own queue of incoming async exceptions. And at each yield point, the runtime system will check if there are exceptions waiting on that queue. If so, it will pop one off the queue and throw it in the current green thread, where it can either be caught or, ultimately, take down the entire thread.

My best practice advice is to never recover from an async exception. Instead, you should only ever clean up your resources when an async exception occurs. In other words, if you ever catch an async exception, you may do some cleanup, but then you must immediately rethrow the exception.

Since an async exception can occur anywhere, we have to be highly paranoid when writing resource-safe code in Haskell. For example, consider this pseudocode:

h <- openFile fp WriteMode
setPerms 0o600 h `onException` closeFile h
useFile h `finally` closeFile h

In a world without async exceptions, this is exception safe. We first open the file. If opening throws an exception, then the openFile call itself is responsible for releasing any resources it acquires. Next, if setPerms throws an exception, our onException call ensures that closeFile will close the file handle. And finally, when we call useFile, we use finally to ensure that closeFile will be called regardless of an async exception occurring.

However, in a world with async exceptions, lots more can go wrong:

Instead, in Haskell, we have to mask async exceptions, which temporarily stops them from being delivered. The code above could be written as:

mask $ \restore -> do
    h <- openFile fp WriteMode
    setPerms 0o600 h `onException` closeFile h
    restore (useFile h) `finally` closeFile h

However, dealing with masking states is really complicated in general. So instead, we like to use helper functions like bracket:

bracket (openFile fp WriteMode) closeFile $ \h -> do
    setPerms 0o600 h
    useFile h

There are many more details around implementation and usage of async exceptions in Haskell, but this is sufficient for our comparison for now.

Canceled futures in Rust

The Future trait in Rust defines an abstraction for anything that can be awaited on. The core function is poll, which works something like this:

The Waker can then interact with the executor to make sure that the task which is awaiting gets woken up when the Future is ready.

In a simple async application in Rust, you'll have a task that waits on one Future at a time. For example, in pseudocode again:

async {
    let socket = open_connection(&address);
    socket::send("Hello world!").await;
    let msg = socket::recv().await;
    println!("Received message: {}", msg);
}

Each of those awaits is a yield point. The executor can allow another task to run, and will wake up the current task when the I/O is complete. This is very similar to the Haskell example I gave above.

However, unlike Haskell:

If there are no async exceptions, how exactly does a timeout work in Rust? Well, instead of a task waiting for a single Future to complete, it waits for one of two Futures to complete. You can check out the code yourself, but the basic idea is:

Personally, I think this is a pretty elegant solution to the problem. Like the Haskell solution, it means that the action can only be stopped at a yield point. However, unlike the Haskell solution, yield points will be far less common in a Rust program, since we don't have the implicit sprinkling of yields caused by allocation.

But now, let's talk about resource management. I made it clear that properly handling resources in the presence of async exceptions in Haskell is tricky. Not so in Rust! The standard way to handle resources is with RAII: you define a data type and stick a Drop on it. And in the world of cancellable Futures, this all works perfectly:

The example below is more verbose than the Haskell equivalent above, but that's because we're defining a synthetic Resource struct. In real life code, such structs would likely already exist.

NOTE: You'll need at least Rust 1.39 to run the code below, and add a dependency on Tokio with a line like: tokio = { version = "0.2", features = ["macros", "time"] }.

use tokio::time::{delay_for, timeout};
use std::time::Duration;

struct Resource;

impl Resource {
    fn new() -> Self {
        println!("acquire");
        Resource
    }
}

impl Drop for Resource {
    fn drop(&mut self) {
        println!("release");
    }
}

async fn worker() {
    let _resource = Resource::new();
    for i in 1..=10 {
        delay_for(Duration::from_millis(100)).await;
        println!("i == {}", i);
    }
}

#[tokio::main]
async fn main() {
    println!("Round 1");
    let res = timeout(Duration::from_millis(2000), worker()).await;
    println!("{:?}", res);

    println!("\n\nRound 2");
    let res = timeout(Duration::from_millis(1000), worker()).await;
    println!("{:?}", res);

    println!("\n\nRound 3");
    let res = timeout(Duration::from_millis(500), worker()).await;
    println!("{:?}", res);
}

My analysis

The big point in Haskell's favor in all of this is its ability to preempt inside of computations. Whereas Rust's model lets you preempt most I/O actions, there won't be many yield points in other code. This can lead to lots of accidental blocking. There has been some discussion recently about possible mitigations of this issue at the executor level.

Haskell's advantage here is diminished by the fact that, if you have code that does not allocate any memory, you don't get any yield points. However, in practice, this almost never happens. This did affect some of my coworkers recently, so it's not unheard of. But it's relatively rare, and you can insert yield points back into an optimized application with -fno-omit-yields. You can argue that the fact that this sometimes fails spectacularly is even worse.

I like the fact that, in Rust, you know exactly where your program may simply stop executing. Every time you see a .await, you know "well, it's entirely possible that the executor will just drop me before I come back." And the fact that ownership, RAII, and dropping solves resource management exactly the same in async and synchronous Rust code is beautiful.

Haskell pays a lot for the ability to kill threads with async exceptions. Every bit of code that manages resources needs to pay a cost in cognitive overhead. In practice, this truly does lead to a large number of bugs. Figuring out how and when to mask exceptions, and whether to have interruptible or uninterruptible masking (something I didn't really discuss), is another major curve ball. I think proper API design can mitigate a lot of the pain here. But the base library does not contain such API design, and bad practices abound.

And finally, a question: how important is cancellable tasks/killable threads in practice? Being able to time things out is certainly powerful, in some cases. Racing two actions to see which one completes first? Less valuable in my opinion. I certainly teach it when I give Haskell training, but there were usually more elegant ways to solve the same problem.

Since I'm stuck with async exceptions, I'll use timeout and race in Haskell, because using them isn't the dangerous part, it's having them in the first place. Were I to design a runtime system for Haskell from the ground up, I'm not sure I'd introduce the concept. It certainly solves some really tricky problems, like interrupting long-running pure code. But I'm not convinced the feature really pulls its weight.

On the other hand, in Rust, the feature is essentially free. The Future trait was designed to solve a bunch of general problems, and then at the library level it's possible to introduce a solution to cancel tasks. Pretty nifty.

And finally, where these two languages are the same. They both elegantly and easily solve async I/O problems in general. You get to write blocking-style code without the blocking. And both of them have pretty complicated details under the surface (Haskell: masking, Rust: the poll method) which we can usually, and fortunately, ignore and leave to others to mess around with.

Further reading

Feel free to check out our Haskell and Rust homepages for lots more content. If you're interested in learning all about exception handling in Haskell, check out our safe exception handling tutorial. And if you want to learn about async/await in Rust, I'd recommend lessons 8 and 9 of the Rust Crash Course.

Set up an engineering consultation

Subscribe to our blog via email
Email subscriptions come from our Atom feed and are handled by Blogtrottr. You will only receive notifications of blog posts, and can unsubscribe any time.

Do you like this blog post and need help with Next Generation Software Engineering, Platform Engineering or Blockchain & Smart Contracts? Contact us.