Tree-Structured Concurrency II: Replacing Background Tasks With Actors
— 2025-07-02
- structured concurrency
- the background task problem
- the actor pattern
- how are actors different from globals?
- conclusion
- appendix a: actor pattern template
Structured concurrency
(Tree-)Structured Concurrency is neat because it greatly simplifies concurrent programs. It greatly reduces, if not outright eliminates the possibility of logical races due to concurrency issues. And conceptually it’s not that hard either, as we can encode structured concurrency with just two rules:
- Every child computation (except the root) must have exactly one logical parent at any given time during execution.
- Every parent computation may have multiple child computations executing concurrently at any given moment.
Every child must always have exactly one parent. But every parent may have multiple children. These two rules allow us to infer a third rule that is at the core of the system:
- Child computations will never outlive their parents.
If we apply these rules, we’ll notice that the call-graph of a program will naturally form a tree. This is nothing special, since non-concurrent programs already work this way. If that’s not intuitive: realize that for example flame graphs and flame charts are nothing more than a visualization of tree-shaped data.
The background task problem
One of the most common questions people ask when learning about structured concurrency is:
“How do I run work after returning from a function?”
This is a fair question! A common example is when implementing an HTTP server: you
may want to log metrics on every request, but you don’t want to delay sending
the response until logging has completed. At which point people often reach for
unstructured concurrency in the form of background tasks, often using some
form of task::spawn
:
/// Some HTTP handler
async fn handler(req: Request, _context: ()) -> Result<Response> {
// Get the body from a request
let body = req.body().try_to_string()?;
// Create a detached background task that
// does some work in the background
task::spawn(async {
background_work(body).await.unwrap();
});
// Construct a response and return
// it from the handler
Ok(Response::Ok())
}
In this example, task::spawn
creates a background task which is not owned by
any parent. This violates the second rule of structured concurrency: every
computation should always have one logical parent. We can see this being a
problem if background_work
were to complete unsuccessfully. Right now if it
fails we have no way to propagate the error or retry the operation, so all we
can realistically do is let it crash.
In this example we also have no way of cancelling the work done in the task since it’s not rooted in any logical parent. But despite those issues, use case itself is also a very reasonable one: we shouldn’t have to wait for background work to finish before sending off a response. So: what do we do?
The actor pattern
What we want is for work to happen in the background, while simultaneously also being rooted in some parent computation. I believe the right way to solve this is by using actors: types that we can send data and will schedule work for us.
In frameworks like actix and choo this is done using messages that are dispatched using channels or event emitters. But languages like Swift have actor support built directly into the language, and rely on methods instead of messages.
But for what we’re trying to do here we don’t really need any fancy libraries or frameworks: we can easily build our own actors using structs, impl blocks, and channels. Here is a basic template you can use to implement your own actors with. Without comments this comes out to maybe 15 lines total:
// Using the `async-channel` and
// `futures-concurrency` libraries
use async_channel::{self as channel, Sender, Receiver};
use futures_concurrency::prelude::*;
/// The type of data we will send to our actor.
/// This is just a simple alias to our HTTP
/// framework's `Body` type.
type Message = Body;
/// Our custom actor type
struct Actor(Receiver<Message>);
/// A handle to the actor which can be
/// used to schedule messages with.
type Handle = Sender<Message>;
impl Actor {
/// Create a new `Actor` instance
fn new(capacity: usize) -> (Self, Handle) {
let (sender, receiver) = channel::bounded(capacity);
(Self(receiver), sender)
}
/// Start listening for incoming messages
/// and handle them concurrently
async fn run(&mut self) -> Result<()> {
// Using `ConcurrentStream` from `futures-concurrency`
// to take work from the channel and execute it
// concurrently. To bound the amount of concurrency,
// you can use the provided `.limit` combinator.
self.0.co().try_for_each(|msg| {
// Work is scheduled here
background_work(body).await?;
Ok(())
}).await?;
Ok(())
}
}
Now to use this we need to create an instance, run it concurrently with our HTTP server, and make sure request handlers in the server have access to it. I’m most familiar with the Tide HTTP framework so I’ll be using that here, but your favorite framework probably allows you to do something similar. Here is the way to tie this all together:
use futures_concurrency::prelude::*;
#[async_main]
async fn main() -> server::Result<()> {
// Create an instance of our actor
let (actor, handle) = Actor::new();
// Create an instance of our server
// and pass it the actor handle
let mut app = Server::with_context(handle);
app.at("/submit").post(handler);
// Start both the actor and the server
// and execute them concurrently
let a = app.listen("127.0.0.1:8080");
let b = actor.run();
(a, b).try_join().await?;
Ok(())
}
And now that our server has a handle, we can change our request handler function to schedule work on the actor rather than in a dangling task:
async fn handler(req: Request, handle: Handle) -> Result<Response> {
let body = req.body().try_to_string()?;
handle.send(body).await?; // ← Send work to the actor
Ok(Response::Ok())
}
While this still relies on performing an async operation that may potentially fail, this is still meaningfully different. Rather than waiting for the work to have been completed, all we’re now waiting for is for the work to have been enqueued. And unless something is going horribly wrong in the system, that should happen virtually instantly. And if for some reason it doesn’t: that’s why we always add timeouts to our computations right? …right?
How are actors different from globals?
This section was added on 2025-06-02, after publishing the post.
I’ve had a number of people ask what the difference is between the actor pattern shown in this post, and the global task pool that manages the spawned tasks. After all: if you take the program as the root, both variants end up looking tree-shaped.
The difference between the two is that under structured concurrency we’re not treating the program as the root, but the main function as the root. The actor pattern in this post turns an unreachable, global task pool into a reachable task pool with a locality that can be managed however we like. Sy Brand put this particularly well; paraphrasing ever so slightly:
[…] that "oh, it's structured as long as you look at it this way" smells like "well my program doesn't leak memory since it will all be reclaimed when it terminates".
The point of structured concurrency is that everything is always managed and reachable by having all computation existing in the same tree. This makes it possible to always handle errors, restart instances, and shut things down gracefully.
Conclusion
In this post we’ve answered one of the most common questions people have when first encountering structured concurrency: “How do I schedule background work without relying on dangling tasks?”
The simplest answer to this question are actors: types that provide some (shareable) handle that can be used to receive work on. We’ve shown how to define an actor in about 15 lines of code, how to integrate it with an HTTP server, and finally using it to replace an existing background task.
I think of structured concurrency the way I think about memory safety: the more we make it the default, the fewer concurrency issues people will have overall. And that seems important because concurrent, asynchronous, and parallel computing tends to some of the hardest code to reason about and debug.
Appendix A: Actor pattern template
use async_channel::{self as channel, Sender, Receiver};
use futures_concurrency::prelude::*;
type Message = ();
type Handle = Sender<Message>;
struct Actor(Receiver<Message>);
impl Actor {
fn new(capacity: usize) -> (Self, Handle) {
let (sender, receiver) = channel::bounded(capacity);
(Self(receiver), sender)
}
async fn run(&mut self) -> Result<()> {
self.0.co().try_for_each(|msg| {
todo!("schedule work here")
}).await
}
}