346: Runtime Context
Tutorial
The Problem
Distributed systems attach request IDs, trace spans, and user session tokens to every operation without threading those values through every function call. Thread-local storage solves this: each thread has its own copy of the context variable, invisible to other threads. This enables "ambient context" — logging, metrics, and tracing libraries can read the current request ID without the application code explicitly passing it. Java's ThreadLocal, Python's contextvars.ContextVar, and Go's context.Context (passed explicitly) all address the same problem. Rust uses thread_local! for synchronous code and task-local variables (via tokio::task_local!) for async code.
🎯 Learning Outcomes
thread_local! and RefCell<Option<T>>.with() closure APIwith_context as a scoped setter that restores the previous valueCode Example
#![allow(clippy::all)]
//! # Runtime Context
//! Task-local storage and context propagation in async code.
use std::cell::RefCell;
use std::thread_local;
thread_local! {
static CONTEXT: RefCell<Option<String>> = RefCell::new(None);
}
pub fn set_context(ctx: String) {
CONTEXT.with(|c| *c.borrow_mut() = Some(ctx));
}
pub fn get_context() -> Option<String> {
CONTEXT.with(|c| c.borrow().clone())
}
pub fn clear_context() {
CONTEXT.with(|c| *c.borrow_mut() = None);
}
pub fn with_context<R>(ctx: String, f: impl FnOnce() -> R) -> R {
let old = get_context();
set_context(ctx);
let result = f();
match old {
Some(c) => set_context(c),
None => clear_context(),
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_propagation() {
set_context("test".into());
assert_eq!(get_context(), Some("test".into()));
clear_context();
assert_eq!(get_context(), None);
}
#[test]
fn with_context_restores() {
set_context("outer".into());
let inner = with_context("inner".into(), || get_context());
assert_eq!(inner, Some("inner".into()));
assert_eq!(get_context(), Some("outer".into()));
}
}Key Differences
| Aspect | Rust thread_local! | OCaml Domain.DLS |
|---|---|---|
| Scope | Per OS thread | Per domain (OCaml 5) |
| Async task scope | Requires tokio::task_local! | Requires effect-based propagation |
| Initialization | Lazy, per thread | Via new_key factory |
| Interior mutability | RefCell needed | Implicit (mutable by default) |
| Cross-thread visibility | None (by design) | None (by design) |
OCaml Approach
OCaml 5 uses Domain.DLS (Domain-Local Storage) for the equivalent:
let key = Domain.DLS.new_key (fun () -> None)
let set_context ctx =
Domain.DLS.set key (Some ctx)
let get_context () =
Domain.DLS.get key
let with_context ctx f =
let old = get_context () in
set_context (Some ctx);
let result = f () in
Domain.DLS.set key old;
result
In OCaml 4, plain ref values suffice since threads share a GIL — each "thread-local" is just a per-thread mutable reference without synchronization concerns. Effect-based frameworks (OCaml 5) can propagate context automatically across continuations.
Full Source
#![allow(clippy::all)]
//! # Runtime Context
//! Task-local storage and context propagation in async code.
use std::cell::RefCell;
use std::thread_local;
thread_local! {
static CONTEXT: RefCell<Option<String>> = RefCell::new(None);
}
pub fn set_context(ctx: String) {
CONTEXT.with(|c| *c.borrow_mut() = Some(ctx));
}
pub fn get_context() -> Option<String> {
CONTEXT.with(|c| c.borrow().clone())
}
pub fn clear_context() {
CONTEXT.with(|c| *c.borrow_mut() = None);
}
pub fn with_context<R>(ctx: String, f: impl FnOnce() -> R) -> R {
let old = get_context();
set_context(ctx);
let result = f();
match old {
Some(c) => set_context(c),
None => clear_context(),
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_propagation() {
set_context("test".into());
assert_eq!(get_context(), Some("test".into()));
clear_context();
assert_eq!(get_context(), None);
}
#[test]
fn with_context_restores() {
set_context("outer".into());
let inner = with_context("inner".into(), || get_context());
assert_eq!(inner, Some("inner".into()));
assert_eq!(get_context(), Some("outer".into()));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn context_propagation() {
set_context("test".into());
assert_eq!(get_context(), Some("test".into()));
clear_context();
assert_eq!(get_context(), None);
}
#[test]
fn with_context_restores() {
set_context("outer".into());
let inner = with_context("inner".into(), || get_context());
assert_eq!(inner, Some("inner".into()));
assert_eq!(get_context(), Some("outer".into()));
}
}
Deep Comparison
OCaml vs Rust: Runtime Context
Overview
See the example.rs and example.ml files for detailed implementations.
Key Differences
| Aspect | OCaml | Rust |
|---|---|---|
| Type system | Hindley-Milner | Ownership + traits |
| Memory | GC | Zero-cost abstractions |
| Mutability | Explicit ref | mut keyword |
| Error handling | Option/Result | Result<T, E> |
See README.md for detailed comparison.
Exercises
log(msg: &str) that prepends the current context (request ID) to every message; demonstrate nested contexts with different IDs.with_context block; observe that the spawned thread does NOT inherit the context; manually copy it by capturing the value before spawning.tokio::task_local!, implement the same with_context pattern for async tasks; verify that concurrent tasks each see their own context independently.