When your resource cannot move: !Send in an async Rust server
Table of Contents
Most async Rust is written on the assumption that your state can move. You wrap
it in an Arc, hand clones to every request handler, and the runtime schedules
them wherever it likes.
Then you meet a type that says no.
The constraint #
I maintain a service that renders 3D product previews. It uses three-d with a headless OpenGL context, and that context comes with three rules:
- It is neither
SendnorSync, so it cannot cross a thread boundary or be shared between threads. - It panics if you create it on any thread other than the main one.
- It panics if you create a second one.
Rule 1 is common enough. Rules 2 and 3 together are what make it interesting: there is exactly one of these objects, it lives at a fixed address in your process, and the async runtime is not allowed anywhere near it.
This is not a Rust problem. It is a graphics problem that Rust is simply honest about. The same constraint exists in C++; you just find out at runtime.
The wrong instinct #
The reflex is to reach for spawn_blocking, or a LocalSet, or to wrap the
thing in a Mutex and hope.
None of those help. spawn_blocking runs your closure on a blocking thread,
not the main thread. A LocalSet keeps futures on one thread, but that thread
is whichever one you built it on, and main has usually already been given away
to the runtime by then. And a Mutex solves sharing, which was never the
problem — the problem is location.
The shape that works #
Invert the usual arrangement. Instead of running the server on the main thread and spawning workers, run the server as a spawned task and keep the main thread for the thing that cannot move:
pub async fn run() {
let (request_tx, request_rx) = mpsc::channel::<(Request, ResultChannel)>(10);
// The HTTP server is what gets spawned.
tokio::spawn(async move {
serve(config.port, request_tx).await;
});
// The renderer is created on the main thread and never leaves it.
let renderer = Renderer::new(config.models_base_url.clone());
dispatch(renderer, request_rx).await;
}
dispatch is an ordinary loop that owns the renderer for the life of the
process:
pub async fn dispatch(renderer: Renderer, mut request_rx: Receiver<(Request, ResultChannel)>) {
loop {
let (request, response_tx) = request_rx.recv().await.unwrap();
let pixels = renderer.render(request).await;
let _ = response_tx.send(pixels);
}
}
The piece that makes this pleasant to use is the type of the channel item:
type ResultChannel = oneshot::Sender<Result<DynamicImage>>;
mpsc::channel::<(Request, ResultChannel)>(10)
Every request carries its own return envelope. A handler sends the request plus
one half of a oneshot, then awaits the other half. From inside the handler it
reads like a normal await on a function call — the channel hop is an
implementation detail, not something every caller has to think about.
The part that is not about threads #
There is a second limit hiding behind the first. One context means one render at
a time, so requests must not pile up faster than they drain. The mpsc channel
has a bound, but a full channel only makes senders wait — it does not stop you
accepting a thousand connections that each buffer a multi-megabyte texture
upload while they wait their turn.
So admission happens at the front door, before anything large is allocated:
let semaphore = Arc::new(Semaphore::new(1));
Worth being precise about why. The semaphore is not protecting the renderer — the channel already guarantees serial access, because there is one consumer. The semaphore is protecting memory. Without it the service does not race; it runs out of RAM, which is a considerably more annoying way to fail.
What this costs #
The control flow is genuinely harder to follow than a normal handler. A new reader sees an HTTP handler that sends into a channel, a loop somewhere else that receives from it, and a reply arriving through a second channel they have not met yet. Nothing about that is obvious, and it is the first question anybody asks.
The honest answer is that the structure is not a design preference. It is the shadow of a constraint the library imposes, and code shaped by an external constraint always reads as if someone was being clever for no reason. The fix is a comment at the top of the module saying which constraint, so the next person knows there was a reason before they try to simplify it.
Which is the general lesson, really: when something cannot move, stop trying to move it, and move everything else instead.