459: Thread-Local Storage
Tutorial Video
Text description (accessibility)
This video demonstrates the "459: Thread-Local Storage" functional Rust example. Difficulty level: Fundamental. Key concepts covered: Functional Programming. Some per-request or per-thread state shouldn't be shared: random number generator seeds, per-thread error codes, per-thread profiling counters, locale settings. Key difference from OCaml: 1. **Ergonomics**: Rust's `thread_local!` is a language
Tutorial
The Problem
Some per-request or per-thread state shouldn't be shared: random number generator seeds, per-thread error codes, per-thread profiling counters, locale settings. Global shared state requires synchronization; passing context through every function is verbose. Thread-local storage (TLS) provides a third option: each thread has its own independent copy of a variable, accessible without synchronization. Accessing TLS is as fast as a local variable with OS thread support, and Rust's thread_local! makes it safe and ergonomic.
TLS appears in Rust's panic handling (PANIC_COUNT), allocator state, async executor task-local values, and per-thread performance counters.
🎯 Learning Outcomes
thread_local! creates per-thread variable storageThreadLocalKey::with(|val| ...) provides safe access to TLS valuesCell<T> (for Copy types) and RefCell<T> (for complex types) work in TLS!Send)Code Example
thread_local! {
static COUNTER: Cell<usize> = Cell::new(0);
}
COUNTER.with(|c| {
c.set(c.get() + 1);
});Key Differences
thread_local! is a language-level macro with clear semantics; OCaml 5.x's Domain.DLS requires explicit key creation and lookup.Cell<T> or RefCell<T> for TLS to provide interior mutability; OCaml uses ref values which are always mutable.Domain.DLS values live for the domain's duration.with callback prevents TLS references from escaping the thread; OCaml has no such enforcement.OCaml Approach
OCaml 4.x uses let state = ref initial_value per thread — module-level references are per-thread since each thread has its own OCaml runtime state in Thread contexts (this is subtler in OCaml 4.x). OCaml 5.x provides Domain.DLS.get/set (domain-local storage) as the explicit per-domain storage mechanism, analogous to thread-local storage for domains.
Full Source
#![allow(clippy::all)]
//! # Thread-Local Storage — Per-Thread State
//!
//! Each thread gets its own copy of thread-local data.
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::thread;
// Thread-local counter
thread_local! {
static COUNTER: Cell<usize> = const { Cell::new(0) };
}
// Thread-local string buffer
thread_local! {
static BUFFER: RefCell<String> = RefCell::new(String::new());
}
/// Increment the thread-local counter
pub fn increment_counter() -> usize {
COUNTER.with(|c| {
let val = c.get() + 1;
c.set(val);
val
})
}
/// Get the thread-local counter value
pub fn get_counter() -> usize {
COUNTER.with(|c| c.get())
}
/// Append to the thread-local buffer
pub fn append_buffer(s: &str) {
BUFFER.with(|b| {
b.borrow_mut().push_str(s);
});
}
/// Get the thread-local buffer contents
pub fn get_buffer() -> String {
BUFFER.with(|b| b.borrow().clone())
}
/// Clear the thread-local buffer
pub fn clear_buffer() {
BUFFER.with(|b| {
b.borrow_mut().clear();
});
}
/// Demonstrate thread-local isolation
pub fn thread_local_isolation(num_threads: usize, increments: usize) -> Vec<usize> {
let results = Arc::new(std::sync::Mutex::new(Vec::new()));
let handles: Vec<_> = (0..num_threads)
.map(|_| {
let results = Arc::clone(&results);
thread::spawn(move || {
// Each thread starts with counter = 0
for _ in 0..increments {
increment_counter();
}
let final_count = get_counter();
results.lock().unwrap().push(final_count);
})
})
.collect();
for h in handles {
h.join().unwrap();
}
Arc::try_unwrap(results).unwrap().into_inner().unwrap()
}
/// Thread-local random number generator pattern
pub mod rng {
use std::cell::Cell;
thread_local! {
static SEED: Cell<u64> = Cell::new(12345);
}
pub fn next_u64() -> u64 {
SEED.with(|s| {
// Simple LCG
let x = s.get().wrapping_mul(6364136223846793005).wrapping_add(1);
s.set(x);
x
})
}
pub fn seed(value: u64) {
SEED.with(|s| s.set(value));
}
}
/// Thread-local allocation tracking
pub mod alloc_tracking {
use std::cell::Cell;
thread_local! {
static ALLOCATIONS: Cell<usize> = const { Cell::new(0) };
static BYTES: Cell<usize> = const { Cell::new(0) };
}
pub fn record_allocation(bytes: usize) {
ALLOCATIONS.with(|a| a.set(a.get() + 1));
BYTES.with(|b| b.set(b.get() + bytes));
}
pub fn get_stats() -> (usize, usize) {
(ALLOCATIONS.with(|a| a.get()), BYTES.with(|b| b.get()))
}
pub fn reset() {
ALLOCATIONS.with(|a| a.set(0));
BYTES.with(|b| b.set(0));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_counter_basic() {
// Reset for this test
COUNTER.with(|c| c.set(0));
assert_eq!(get_counter(), 0);
increment_counter();
assert_eq!(get_counter(), 1);
increment_counter();
assert_eq!(get_counter(), 2);
}
#[test]
fn test_buffer() {
clear_buffer();
append_buffer("hello");
append_buffer(" world");
assert_eq!(get_buffer(), "hello world");
clear_buffer();
assert_eq!(get_buffer(), "");
}
#[test]
fn test_thread_isolation() {
let results = thread_local_isolation(4, 100);
// Each thread should have counted to 100 independently
for r in results {
assert_eq!(r, 100);
}
}
#[test]
fn test_rng() {
rng::seed(42);
let a = rng::next_u64();
let b = rng::next_u64();
assert_ne!(a, b);
// Reseeding gives same sequence
rng::seed(42);
assert_eq!(rng::next_u64(), a);
}
#[test]
fn test_alloc_tracking() {
alloc_tracking::reset();
alloc_tracking::record_allocation(100);
alloc_tracking::record_allocation(200);
let (count, bytes) = alloc_tracking::get_stats();
assert_eq!(count, 2);
assert_eq!(bytes, 300);
alloc_tracking::reset();
let (count, bytes) = alloc_tracking::get_stats();
assert_eq!(count, 0);
assert_eq!(bytes, 0);
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_counter_basic() {
// Reset for this test
COUNTER.with(|c| c.set(0));
assert_eq!(get_counter(), 0);
increment_counter();
assert_eq!(get_counter(), 1);
increment_counter();
assert_eq!(get_counter(), 2);
}
#[test]
fn test_buffer() {
clear_buffer();
append_buffer("hello");
append_buffer(" world");
assert_eq!(get_buffer(), "hello world");
clear_buffer();
assert_eq!(get_buffer(), "");
}
#[test]
fn test_thread_isolation() {
let results = thread_local_isolation(4, 100);
// Each thread should have counted to 100 independently
for r in results {
assert_eq!(r, 100);
}
}
#[test]
fn test_rng() {
rng::seed(42);
let a = rng::next_u64();
let b = rng::next_u64();
assert_ne!(a, b);
// Reseeding gives same sequence
rng::seed(42);
assert_eq!(rng::next_u64(), a);
}
#[test]
fn test_alloc_tracking() {
alloc_tracking::reset();
alloc_tracking::record_allocation(100);
alloc_tracking::record_allocation(200);
let (count, bytes) = alloc_tracking::get_stats();
assert_eq!(count, 2);
assert_eq!(bytes, 300);
alloc_tracking::reset();
let (count, bytes) = alloc_tracking::get_stats();
assert_eq!(count, 0);
assert_eq!(bytes, 0);
}
}
Deep Comparison
Thread-Local Storage
OCaml
(* No built-in TLS; use Domain.DLS *)
let key = Domain.DLS.new_key (fun () -> ref 0)
let () = Domain.DLS.get key := 42
Rust
thread_local! {
static COUNTER: Cell<usize> = Cell::new(0);
}
COUNTER.with(|c| {
c.set(c.get() + 1);
});
Key Differences
| Feature | OCaml | Rust |
|---|---|---|
| Syntax | Domain.DLS | thread_local! macro |
| Type | 'a key | Static with Cell/RefCell |
| Cleanup | On domain exit | On thread exit |
Exercises
thread_local! { static RNG: RefCell<XorShift> = ... } where XorShift is a simple random number generator seeded with the thread ID. Verify that different threads produce different random sequences.thread_local! { static ALLOC_COUNT: Cell<usize> = const { Cell::new(0) } } to count allocations per thread. Wrap allocation-using code and verify counts are independent per thread.thread_local! { static REQUEST_ID: Cell<u64> = const { Cell::new(0) } }. Write with_request(id, || ...) that sets the ID for the duration of the closure, then restores it — enabling implicit context propagation through function calls.