346: Runtime Context
Difficulty: 3 Level: Advanced The runtime is the engine behind async Rust โ a `Handle` lets you interact with it from outside async code.The Problem This Solves
When you write `#[tokio::main]`, tokio creates a runtime, starts a thread pool, and runs your async code inside it. But sometimes you need to interact with the runtime from synchronous code โ spawn a task from a callback, block on a future from a library that doesn't expose async, or shut down the runtime cleanly after all work is done. The `Handle` pattern solves this: a `Handle` is a cheaply cloneable reference to a running runtime. You can spawn tasks on it, block on futures from sync code (`Handle::block_on`), or pass it to other threads. This is what `tokio::runtime::Handle::current()` does โ it returns a handle to the runtime that's currently driving the current async context. This example builds a minimal runtime (a worker thread + a channel for tasks) to illustrate the concept without requiring tokio.The Intuition
Think of the runtime as a web server's event loop (like Node.js's libuv). A `Handle` is like an `EventEmitter` reference you can pass around โ it lets external code post work to the loop. In Python: `loop = asyncio.get_event_loop()` gives you a handle to submit coroutines from sync code with `loop.run_until_complete(coro)`. In tokio: `tokio::runtime::Handle::current().spawn(async { ... })` submits a task from sync code to the running async runtime.How It Works in Rust
struct Handle {
sender: mpsc::SyncSender<Box<dyn FnOnce() + Send>>,
}
impl Handle {
// Submit sync work to the runtime thread
fn spawn_sync(&self, f: impl FnOnce() + Send + 'static) {
let _ = self.sender.send(Box::new(f));
}
// Block the calling thread until the runtime thread completes the work
fn block_on_simple<T: Send + 'static>(&self, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
self.spawn_sync(move || { let _ = tx.send(f()); });
rx.recv().unwrap() // wait for result
}
}
// Graceful shutdown: signal the worker to stop after current task
fn shutdown(mut self) {
*self.shutdown.lock().unwrap() = true;
self.handle.spawn_sync(|| {}); // wake the worker thread
if let Some(w) = self.worker.take() { w.join().unwrap(); }
}
The runtime worker thread loops on `rx.recv()`, executing tasks as they arrive. Dropping the sender (`drop(tx)`) causes `recv()` to return `Err`, cleanly ending the loop. The shutdown flag ensures the worker stops after the current task.
What This Unlocks
- FFI callbacks into async โ pass a `Handle` to a C callback; the callback uses it to submit work to the async runtime.
- Sync โ async bridge โ call async functions from synchronous library code using `Handle::block_on`.
- Multi-runtime setups โ isolate different subsystems (HTTP, background jobs) on separate runtimes, each accessible via their own handle.
Key Differences
| Concept | OCaml | Rust |
|---|---|---|
| Runtime | Lwt scheduler (implicit global) | Explicit runtime (`tokio::Runtime`) |
| Handle | N/A โ Lwt is global | `tokio::runtime::Handle` (clonable reference) |
| Submit from sync | `Lwt_main.run` (blocks) | `handle.spawn(...)` or `handle.block_on(...)` |
| Worker thread model | Single-threaded event loop | Multi-threaded work-stealing (tokio default) |
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::future::Future;
use std::task::{Context, Poll};
// Minimal runtime handle concept
struct Handle {
sender: mpsc::SyncSender<Box<dyn FnOnce() + Send>>,
}
impl Handle {
// Spawn a synchronous task on the runtime
fn spawn_sync(&self, f: impl FnOnce() + Send + 'static) {
let _ = self.sender.send(Box::new(f));
}
// Bridge: run an async-style function from sync context
fn block_on_simple<T: Send + 'static>(&self, f: impl FnOnce() -> T + Send + 'static) -> T {
let (tx, rx) = mpsc::channel();
self.spawn_sync(move || {
let _ = tx.send(f());
});
rx.recv().unwrap()
}
}
struct Runtime {
worker: Option<thread::JoinHandle<()>>,
handle: Handle,
shutdown: Arc<Mutex<bool>>,
}
impl Runtime {
fn new() -> Self {
let (tx, rx) = mpsc::sync_channel::<Box<dyn FnOnce() + Send>>(100);
let shutdown = Arc::new(Mutex::new(false));
let sd = Arc::clone(&shutdown);
let worker = thread::spawn(move || {
while let Ok(task) = rx.recv() {
task();
if *sd.lock().unwrap() { break; }
}
});
Self {
worker: Some(worker),
handle: Handle { sender: tx },
shutdown,
}
}
fn handle(&self) -> &Handle { &self.handle }
fn shutdown(mut self) {
*self.shutdown.lock().unwrap() = true;
self.handle.spawn_sync(|| {}); // wake worker
if let Some(w) = self.worker.take() { w.join().unwrap(); }
}
}
fn main() {
let rt = Runtime::new();
let handle = rt.handle();
// Spawn tasks from sync context
handle.spawn_sync(|| println!("Task 1 on runtime"));
handle.spawn_sync(|| println!("Task 2 on runtime"));
// Block on result
let result = handle.block_on_simple(|| {
thread::sleep(std::time::Duration::from_millis(5));
42
});
println!("Got: {result}");
rt.shutdown();
println!("Runtime shut down");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn block_on_returns_value() {
let rt = Runtime::new();
let r = rt.handle().block_on_simple(|| 99);
assert_eq!(r, 99);
rt.shutdown();
}
#[test]
fn spawn_executes_task() {
let rt = Runtime::new();
let (tx, rx) = mpsc::channel();
rt.handle().spawn_sync(move || { tx.send(1).unwrap(); });
assert_eq!(rx.recv_timeout(std::time::Duration::from_millis(100)).unwrap(), 1);
rt.shutdown();
}
}
(* OCaml: thread-local context via Domain-local storage *)
let current_context : string option ref = ref None
let with_context name f =
let prev = !current_context in
current_context := Some name;
let result = f () in
current_context := prev;
result
let get_context () =
match !current_context with
| Some name -> name
| None -> "root"
let () =
Printf.printf "Context: %s\n" (get_context ());
with_context "worker-1" (fun () ->
Printf.printf "Inside: %s\n" (get_context ());
with_context "nested" (fun () ->
Printf.printf "Nested: %s\n" (get_context ())
);
Printf.printf "Back to: %s\n" (get_context ())
);
Printf.printf "Back to: %s\n" (get_context ())