How do I work with Thread Local Storage in Rust?

Walkthrough

Thread Local Storage (TLS) allows each thread to have its own independent copy of a variable. Unlike regular static variables which are shared across all threads, thread-local variables have a separate instance per thread. This is useful for maintaining per-thread state without synchronization overhead.

Key concepts:

  • thread_local! — Macro to declare thread-local variables
  • LocalKey — Type returned by thread_local!, provides access methods
  • with() — Access the thread-local value through a closure
  • Per-thread state — Each thread has its own copy
  • No synchronization needed — No locks required for thread-local data

When to use Thread Local Storage:

  • Per-thread buffers or caches
  • Thread-specific context or state
  • Random number generators per thread
  • Avoiding synchronization overhead
  • Tracking thread-local metrics

When NOT to use Thread Local Storage:

  • Data that needs to be shared between threads
  • Simple global configuration
  • When you need the value outside closures

Code Examples

Basic Thread Local Variable

use std::thread;
 
thread_local!(static COUNTER: i32 = 0);
 
fn main() {
    COUNTER.with(|c| {
        println!("Main thread counter: {}", c);
    });
    
    let handle = thread::spawn(|| {
        COUNTER.with(|c| {
            println!("Spawned thread counter: {}", c);
        });
    });
    
    handle.join().unwrap();
}

Mutable Thread Local Variable

use std::thread;
use std::cell::RefCell;
 
thread_local!(static COUNTER: RefCell<i32> = RefCell::new(0));
 
fn increment() {
    COUNTER.with(|c| {
        *c.borrow_mut() += 1;
    });
}
 
fn get_counter() -> i32 {
    COUNTER.with(|c| *c.borrow())
}
 
fn main() {
    increment();
    increment();
    println!("Main: {}", get_counter());
    
    let handle = thread::spawn(|| {
        increment();
        increment();
        increment();
        println!("Thread: {}", get_counter());
    });
    
    handle.join().unwrap();
    println!("Main again: {}", get_counter());
}

Thread-Local Buffer

use std::thread;
use std::cell::RefCell;
 
thread_local!(static BUFFER: RefCell<Vec<u8>> = RefCell::new(Vec::new()));
 
fn process_data(data: &[u8]) -> Vec<u8> {
    BUFFER.with(|buf| {
        let mut buffer = buf.borrow_mut();
        buffer.clear();
        buffer.extend_from_slice(data);
        buffer.push(b'!');
        buffer.clone()
    })
}
 
fn main() {
    let result1 = process_data(b"hello");
    println!("Result 1: {:?}", String::from_utf8_lossy(&result1));
    
    let handle = thread::spawn(|| {
        let result = process_data(b"world");
        println!("Thread result: {:?}", String::from_utf8_lossy(&result));
    });
    
    handle.join().unwrap();
}

Thread-Local Random Generator

use std::thread;
use std::cell::RefCell;
 
// Note: In practice, use rand::thread_rng() which is already thread-local
 
struct SimpleRng {
    state: u64,
}
 
impl SimpleRng {
    fn new(seed: u64) -> Self {
        Self { state: seed }
    }
    
    fn next(&mut self) -> u64 {
        // Simple LCG
        self.state = self.state.wrapping_mul(1103515245).wrapping_add(12345);
        self.state
    }
}
 
thread_local!(static RNG: RefCell<SimpleRng> = RefCell::new(SimpleRng::new(1)));
 
fn random_u64() -> u64 {
    RNG.with(|rng| rng.borrow_mut().next())
}
 
fn main() {
    println!("Main: {}", random_u64());
    println!("Main: {}", random_u64());
    
    let handle = thread::spawn(|| {
        // Different seed in new thread
        RNG.with(|rng| *rng.borrow_mut() = SimpleRng::new(2));
        println!("Thread: {}", random_u64());
        println!("Thread: {}", random_u64());
    });
    
    handle.join().unwrap();
    println!("Main again: {}", random_u64());
}

Thread-Local Context

use std::thread;
use std::cell::RefCell;
 
#[derive(Debug, Clone)]
struct RequestContext {
    request_id: String,
    user_id: Option<String>,
}
 
thread_local!(static CONTEXT: RefCell<RequestContext> = RefCell::new(RequestContext {
    request_id: String::new(),
    user_id: None,
}));
 
fn set_context(request_id: &str, user_id: Option<&str>) {
    CONTEXT.with(|ctx| {
        let mut ctx = ctx.borrow_mut();
        ctx.request_id = request_id.to_string();
        ctx.user_id = user_id.map(|s| s.to_string());
    });
}
 
fn log_context(message: &str) {
    CONTEXT.with(|ctx| {
        let ctx = ctx.borrow();
        println!("[{}] {} (user: {:?})", ctx.request_id, message, ctx.user_id);
    });
}
 
fn main() {
    set_context("req-001", Some("alice"));
    log_context("Processing request");
    
    let handle = thread::spawn(|| {
        set_context("req-002", Some("bob"));
        log_context("Processing in thread");
    });
    
    handle.join().unwrap();
    log_context("Back in main");
}

Thread-Local Metrics

use std::thread;
use std::cell::RefCell;
use std::collections::HashMap;
 
thread_local!(static METRICS: RefCell<HashMap<String, u64>> = RefCell::new(HashMap::new()));
 
fn increment_metric(name: &str) {
    METRICS.with(|m| {
        *m.borrow_mut().entry(name.to_string()).or_insert(0) += 1;
    });
}
 
fn get_metrics() -> HashMap<String, u64> {
    METRICS.with(|m| m.borrow().clone())
}
 
fn process_items(items: &[&str]) {
    for item in items {
        increment_metric(&format!("processed_{}", item));
        increment_metric("total");
    }
}
 
fn main() {
    process_items(&["a", "b", "a", "c"]);
    println!("Main metrics: {:?}", get_metrics());
    
    let handle = thread::spawn(|| {
        process_items(&["x", "y", "x", "x", "z"]);
        println!("Thread metrics: {:?}", get_metrics());
    });
    
    handle.join().unwrap();
    println!("Main still: {:?}", get_metrics());
}

Thread-Local with Complex Initialization

use std::thread;
use std::cell::RefCell;
 
struct Database {
    connection_string: String,
    connected: bool,
}
 
impl Database {
    fn new(conn_str: &str) -> Self {
        println!("Creating database connection for thread");
        Self {
            connection_string: conn_str.to_string(),
            connected: true,
        }
    }
}
 
thread_local!(static DB: RefCell<Option<Database>> = RefCell::new(None));
 
fn get_db() -> &'static RefCell<Option<Database>> {
    // Lazy initialization pattern
    DB.with(|db| {
        if db.borrow().is_none() {
            *db.borrow_mut() = Some(Database::new("localhost:5432"));
        }
        db
    });
    // This is a bit of a hack - in practice use OnceLock
    DB.with(|db| db)
}
 
fn query(sql: &str) {
    DB.with(|db| {
        if let Some(db) = db.borrow().as_ref() {
            println!("Query on {}: {}", db.connection_string, sql);
        }
    });
}
 
fn main() {
    query("SELECT 1");
    
    let handle = thread::spawn(|| {
        query("SELECT 2");
    });
    
    handle.join().unwrap();
}

Thread-Local Logger

use std::thread;
use std::cell::RefCell;
use std::io::Write;
 
thread_local!(static LOGGER: RefCell<Vec<String>> = RefCell::new(Vec::new()));
 
fn log(message: &str) {
    LOGGER.with(|logs| {
        logs.borrow_mut().push(format!("[{}] {}", 
            std::thread::current().name().unwrap_or("unknown"),
            message
        ));
    });
}
 
fn get_logs() -> Vec<String> {
    LOGGER.with(|logs| logs.borrow().clone())
}
 
fn main() {
    log("Starting");
    
    let handle = thread::Builder::new()
        .name("worker".to_string())
       spawn(move || {
            log("Working");
            log("Done");
        });
    
    handle.unwrap().join().unwrap();
    
    log("Finishing");
    
    println!("Main logs: {:?}", get_logs());
}

Thread-Local for Recursion Tracking

use std::thread;
use std::cell::RefCell;
 
thread_local!(static DEPTH: RefCell<usize> = RefCell::new(0));
 
fn recursive_function(n: usize) -> usize {
    DEPTH.with(|d| *d.borrow_mut() += 1);
    
    let current_depth = DEPTH.with(|d| *d.borrow());
    println!("Depth: {}, computing for n={}", current_depth, n);
    
    let result = if n <= 1 {
        1
    } else {
        n * recursive_function(n - 1)
    };
    
    DEPTH.with(|d| *d.borrow_mut() -= 1);
    result
}
 
fn main() {
    println!("Result: {}", recursive_function(5));
}

Thread-Local with Cell for Simple Values

use std::thread;
use std::cell::Cell;
 
thread_local!(static STATE: Cell<i32> = Cell::new(0));
 
fn get_state() -> i32 {
    STATE.with(|s| s.get())
}
 
fn set_state(value: i32) {
    STATE.with(|s| s.set(value));
}
 
fn main() {
    set_state(42);
    println!("Main: {}", get_state());
    
    let handle = thread::spawn(|| {
        set_state(100);
        println!("Thread: {}", get_state());
    });
    
    handle.join().unwrap();
    println!("Main again: {}", get_state());
}

Combining Thread-Local with Arc

use std::thread;
use std::sync::Arc;
use std::cell::RefCell;
 
struct SharedConfig {
    name: String,
}
 
thread_local!(static CACHE: RefCell<Option<Arc<SharedConfig>>> = RefCell::new(None));
 
fn get_cached_config() -> Arc<SharedConfig> {
    let config = Arc::new(SharedConfig {
        name: String::from("shared-config"),
    });
    
    CACHE.with(|cache| {
        if let Some(cached) = cache.borrow().as_ref() {
            Arc::clone(cached)
        } else {
            cache.borrow_mut() = Some(Arc::clone(&config));
            config
        }
    })
}
 
fn main() {
    let config = get_cached_config();
    println!("Config: {}", config.name);
}

Thread-Local for JSON Parsing

use std::thread;
use std::cell::RefCell;
 
// Reusable buffer for string parsing
thread_local!(static STRING_BUFFER: RefCell<String> = RefCell::new(String::new()));
 
fn process_string(input: &str) -> String {
    STRING_BUFFER.with(|buf| {
        let mut buffer = buf.borrow_mut();
        buffer.clear();
        buffer.push_str(input);
        buffer.push_str("_processed");
        buffer.clone()
    })
}
 
fn main() {
    println!("{}", process_string("hello"));
    println!("{}", process_string("world"));
}

Thread-Local Initialization with const fn

use std::thread;
use std::cell::Cell;
 
const INIT_VALUE: i32 = 100;
 
thread_local!(static VALUE: Cell<i32> = Cell::new(INIT_VALUE));
 
fn main() {
    VALUE.with(|v| println!("Initial: {}", v.get()));
    
    VALUE.with(|v| v.set(200));
    VALUE.with(|v| println!("After set: {}", v.get()));
}

LocalKey Methods

use std::thread;
use std::cell::RefCell;
 
thread_local!(static DATA: RefCell<Vec<i32>> = RefCell::new(Vec::new()));
 
fn main() {
    // with() - access through closure
    DATA.with(|d| {
        d.borrow_mut().push(1);
        d.borrow_mut().push(2);
        println!("Inside with: {:?}", d.borrow());
    });
    
    // try_with() - fallible version (can fail during thread destruction)
    match DATA.try_with(|d| d.borrow().len()) {
        Ok(len) => println!("Length: {}", len),
        Err(_) => println!("Thread is being destroyed"),
    }
}

Thread-Local for Memoization

use std::thread;
use std::cell::RefCell;
use std::collections::HashMap;
 
thread_local!(static CACHE: RefCell<HashMap<u64, u64>> = RefCell::new(HashMap::new()));
 
fn expensive_computation(n: u64) -> u64 {
    CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        if let Some(&result) = cache.get(&n) {
            return result;
        }
        
        // Simulate expensive computation
        let result = n * n + n + 1;
        cache.insert(n, result);
        result
    })
}
 
fn main() {
    println!("Compute 5: {}", expensive_computation(5));
    println!("Compute 5 again: {}", expensive_computation(5)); // Cached
    println!("Compute 10: {}", expensive_computation(10));
}

Summary

Thread Local Macro:

// With immutable value
thread_local!(static NAME: Type = value);
 
// With mutable value (RefCell)
thread_local!(static NAME: RefCell<Type> = RefCell::new(value));
 
// With simple mutable value (Cell)
thread_local!(static NAME: Cell<Type> = Cell::new(value));

LocalKey Methods:

Method Description
with(f) Access value through closure
try_with(f) Like with, but returns Result

When to Use RefCell vs Cell:

Type Use Case
Cell<T> Simple Copy types, replacement
RefCell<T> Complex types, need references

Common Patterns:

Pattern Description
Per-thread buffer Avoid allocation in hot paths
Per-thread RNG Thread-safe random numbers
Per-thread context Request tracking
Per-thread metrics Thread-local counters
Memoization cache Per-thread result caching
Recursion tracking Track call depth

Key Points:

  • Each thread has its own copy of thread-local variable
  • No synchronization overhead for thread-local access
  • Use RefCell for mutable data, Cell for simple values
  • Access through with() closure
  • Automatically cleaned up when thread exits
  • try_with() handles thread destruction edge case
  • Great for avoiding allocation in hot paths
  • Not shared between threads (by design)
  • Combine with Arc for thread-safe shared references