Overview
Thread-Local Storage (TLS) provides each thread in a program with its own isolated copy of specific variables. When a thread accesses a thread-local variable, it retrieves or modifies only its own copy, preventing data races and eliminating the need for synchronization mechanisms like locks or mutexes for those specific variables.
The concept emerged from the need to maintain per-thread state in concurrent programs without the overhead and complexity of explicit synchronization. Operating systems implement TLS at a low level, typically using special registers or dedicated memory regions indexed by thread identifiers. High-level languages expose this functionality through various APIs, allowing developers to declare variables that exist independently for each thread.
Thread-local storage solves specific problems in concurrent programming:
State Isolation: Each thread maintains its own version of a variable, preventing accidental sharing and data corruption. When Thread A modifies a thread-local variable, Thread B's copy remains unchanged.
Context Propagation: Framework code can store request-specific data (user sessions, transaction IDs, logging contexts) in thread-local storage, making this data accessible throughout the call stack without passing it as parameters.
Performance Optimization: Thread-local caches and buffers eliminate contention that would occur if threads shared these resources, improving throughput in multi-threaded applications.
# Basic thread-local storage demonstration
Thread.current[:request_id] = "req-123"
def process_data
# Access thread-local data without passing parameters
puts "Processing in request: #{Thread.current[:request_id]}"
end
process_data # => "Processing in request: req-123"
The implementation varies across programming environments. Some languages provide dedicated thread-local variable declarations, while others offer APIs for storing and retrieving thread-scoped data. Ruby uses the Thread class with hash-like access for thread-local storage, along with fiber-local variables for more granular control in fiber-based concurrency.
Key Principles
Thread-local storage operates on the principle of thread-specific memory allocation. When a program declares a variable as thread-local, the runtime system allocates separate memory locations for that variable in each thread. The system maintains a mapping between thread identifiers and memory locations, ensuring each thread accesses only its own copy.
Isolation Boundaries: Thread-local variables exist within the scope of a single thread's execution. When a thread terminates, its thread-local storage becomes eligible for garbage collection (in managed languages) or must be explicitly freed (in manual memory management systems). This lifecycle ties thread-local data directly to thread lifetime.
Access Patterns: Accessing thread-local storage involves a lookup operation using the current thread's identifier. Modern implementations optimize this with techniques like:
- Special processor registers dedicated to thread-local storage pointers
- Inline caching of frequently accessed thread-local variables
- Compile-time resolution of thread-local addresses when possible
Memory Layout: Thread-local storage typically resides in one of three locations:
-
Static TLS: Allocated at program load time, with fixed offsets for each thread-local variable. Fast access but limited to variables known at compile time.
-
Dynamic TLS: Allocated at runtime when threads create thread-local variables. Slower access but supports runtime-determined thread-local storage.
-
Thread Control Block: A per-thread data structure maintained by the runtime that holds references to thread-local storage segments.
Initialization Semantics: Thread-local variables follow specific initialization rules. In most implementations, when a new thread starts, its thread-local variables receive either:
- Default values (zero for primitives, null for references)
- Values from an initialization function called once per thread
- Copies of values from a parent thread (in some fork-based concurrency models)
# Thread-local initialization in Ruby
Thread.current[:counter] ||= 0
5.times do
Thread.current[:counter] += 1
end
puts Thread.current[:counter] # => 5
# New thread starts with nil
Thread.new do
puts Thread.current[:counter].inspect # => nil
end.join
Visibility Constraints: Thread-local storage creates hard isolation boundaries. A variable stored in one thread's local storage cannot be accessed by another thread through the same variable name. This prevents common concurrency bugs but requires explicit mechanisms for inter-thread communication when needed.
Fiber Interactions: In languages supporting lightweight concurrency primitives like fibers or green threads, thread-local storage behavior requires careful specification. Some systems provide fiber-local storage as a separate mechanism, while others make thread-local storage accessible across all fibers within a thread.
# Ruby distinguishes thread-local and fiber-local storage
Thread.current[:shared] = "thread-level"
Thread.current.thread_variable_set(:truly_local, "thread-only")
Fiber.new do
puts Thread.current[:shared] # => "thread-level" (accessible in fiber)
puts Thread.current.thread_variable_get(:truly_local) # => "thread-only"
Thread.current[:shared] = "modified"
end.resume
puts Thread.current[:shared] # => "modified" (change visible across fibers)
Ruby Implementation
Ruby provides multiple mechanisms for thread-local storage, each with different scoping and inheritance characteristics. The primary interface uses Thread.current with hash-like access, while thread variables offer stricter isolation.
Thread.current Hash Access: The most common approach treats Thread.current as a hash, storing key-value pairs specific to the current thread.
# Storing thread-local data
Thread.current[:user_id] = 42
Thread.current[:session] = { token: "abc123" }
# Retrieving thread-local data
user = Thread.current[:user_id]
session = Thread.current[:session]
# Multiple threads maintain separate copies
threads = 5.times.map do |i|
Thread.new do
Thread.current[:thread_num] = i
sleep 0.01
puts "Thread #{Thread.current[:thread_num]}"
end
end
threads.each(&:join)
# Output shows each thread maintains its own value
Thread Variables: Ruby 2.0 introduced thread variables via thread_variable_set and thread_variable_get, providing true thread-local storage that fibers cannot access or modify.
Thread.current.thread_variable_set(:count, 0)
# Thread variables don't leak into fibers
Fiber.new do
Thread.current.thread_variable_set(:count, 10)
puts Thread.current.thread_variable_get(:count) # => 10
end.resume
puts Thread.current.thread_variable_get(:count) # => 0 (unchanged)
# Regular thread-local storage (hash access) shares with fibers
Thread.current[:shared_count] = 0
Fiber.new do
Thread.current[:shared_count] = 10
end.resume
puts Thread.current[:shared_count] # => 10 (modified by fiber)
RequestStore Pattern: The RequestStore gem provides a standard pattern for request-scoped storage in web applications, handling thread pool reuse correctly.
require 'request_store'
# Safe for thread pool environments
RequestStore.store[:current_user] = User.find(params[:id])
# Automatically cleans up between requests
# Handles Rack middleware integration
def current_user
RequestStore.store[:current_user]
end
Thread-Local Singleton Pattern: Creating singleton instances per thread requires careful initialization.
class ThreadLocalCache
def self.instance
Thread.current[:cache_instance] ||= new
end
def initialize
@data = {}
end
def get(key)
@data[key]
end
def set(key, value)
@data[key] = value
end
end
# Each thread gets its own cache instance
thread1 = Thread.new do
cache = ThreadLocalCache.instance
cache.set("key", "value1")
puts cache.get("key") # => "value1"
end
thread2 = Thread.new do
cache = ThreadLocalCache.instance
puts cache.get("key").inspect # => nil (different instance)
cache.set("key", "value2")
end
thread1.join
thread2.join
Thread-Local Database Connections: Managing per-thread database connections prevents connection sharing issues.
class ConnectionPool
def self.connection
Thread.current[:db_connection] ||= establish_connection
end
def self.establish_connection
# Create new connection for this thread
Database.connect(
host: 'localhost',
pool: false # Disable connection pooling
)
end
def self.close_connection
conn = Thread.current[:db_connection]
conn&.close
Thread.current[:db_connection] = nil
end
end
# Usage in threaded environment
threads = 10.times.map do
Thread.new do
conn = ConnectionPool.connection
result = conn.execute("SELECT * FROM users")
# Each thread uses its own connection
ensure
ConnectionPool.close_connection
end
end
threads.each(&:join)
ActiveSupport::CurrentAttributes: Rails 5.2+ provides a framework for thread-local attributes with automatic cleanup.
class Current < ActiveSupport::CurrentAttributes
attribute :user, :request_id, :user_agent
def user=(user)
super
Time.zone = user.timezone
end
end
# Set thread-local attributes
Current.user = User.find(session[:user_id])
Current.request_id = request.uuid
# Access anywhere in the call stack
def process_order(order)
order.processed_by = Current.user
logger.info("Processing order", request_id: Current.request_id)
end
# Automatically resets between requests in Rails
Practical Examples
Web Request Context: Web frameworks commonly use thread-local storage to maintain request-scoped data accessible throughout the request lifecycle.
class RequestContext
def self.set(key, value)
Thread.current[:request_context] ||= {}
Thread.current[:request_context][key] = value
end
def self.get(key)
Thread.current[:request_context]&.fetch(key, nil)
end
def self.clear
Thread.current[:request_context] = {}
end
end
# Middleware sets context
class ContextMiddleware
def call(env)
RequestContext.clear
RequestContext.set(:request_id, SecureRandom.uuid)
RequestContext.set(:start_time, Time.now)
status, headers, body = @app.call(env)
duration = Time.now - RequestContext.get(:start_time)
logger.info(
"Request completed",
request_id: RequestContext.get(:request_id),
duration: duration
)
[status, headers, body]
ensure
RequestContext.clear
end
end
# Controllers access context without passing parameters
class OrdersController
def create
order = Order.create(order_params)
order.request_id = RequestContext.get(:request_id)
order.save
end
end
Thread-Local Logger Context: Adding request-specific information to all log messages without modifying every logging call.
class ThreadLocalLogger
def self.with_context(context = {})
previous = Thread.current[:log_context] || {}
Thread.current[:log_context] = previous.merge(context)
yield
ensure
Thread.current[:log_context] = previous
end
def self.log(level, message)
context = Thread.current[:log_context] || {}
full_message = context.merge(message: message)
logger.send(level, full_message.to_json)
end
end
# Usage
ThreadLocalLogger.with_context(user_id: current_user.id, request_id: request.uuid) do
ThreadLocalLogger.log(:info, "Processing payment")
process_payment
ThreadLocalLogger.log(:info, "Payment completed")
end
# All logs include user_id and request_id automatically
Per-Thread Performance Metrics: Collecting metrics without synchronization overhead.
class ThreadMetrics
def self.increment(metric_name, value = 1)
metrics = Thread.current[:metrics] ||= Hash.new(0)
metrics[metric_name] += value
end
def self.timing(metric_name)
start = Time.now
yield
ensure
duration = Time.now - start
increment("#{metric_name}_time", duration)
increment("#{metric_name}_count")
end
def self.report
Thread.current[:metrics] || {}
end
def self.reset
Thread.current[:metrics] = Hash.new(0)
end
end
# Usage in worker threads
worker = Thread.new do
ThreadMetrics.reset
100.times do
ThreadMetrics.timing(:process_item) do
process_item
end
end
metrics = ThreadMetrics.report
puts "Processed #{metrics[:process_item_count]} items"
puts "Average time: #{metrics[:process_item_time] / metrics[:process_item_count]}"
end
worker.join
Transaction Context Management: Maintaining database transaction state per thread in a connection pool environment.
class TransactionManager
def self.begin_transaction
raise "Transaction already active" if active?
Thread.current[:transaction] = {
connection: ConnectionPool.acquire,
savepoints: [],
started_at: Time.now
}
connection.begin_transaction
end
def self.commit
raise "No active transaction" unless active?
transaction = Thread.current[:transaction]
transaction[:connection].commit
ConnectionPool.release(transaction[:connection])
Thread.current[:transaction] = nil
end
def self.rollback
return unless active?
transaction = Thread.current[:transaction]
transaction[:connection].rollback
ConnectionPool.release(transaction[:connection])
Thread.current[:transaction] = nil
end
def self.active?
Thread.current[:transaction].present?
end
def self.connection
transaction = Thread.current[:transaction]
raise "No active transaction" unless transaction
transaction[:connection]
end
def self.with_transaction
begin_transaction
yield
commit
rescue StandardError => e
rollback
raise
end
end
# Usage
TransactionManager.with_transaction do
User.create(name: "Alice")
Order.create(user_id: user.id)
# Both operations use the same connection from thread-local storage
end
Design Considerations
When to Use Thread-Local Storage: Thread-local storage fits specific scenarios where thread-specific state must persist across function calls without explicit parameter passing. Request-scoped data in web applications represents the primary use case—storing user authentication, request identifiers, or transaction contexts that the entire request handler stack needs to access.
Temporary caching within thread execution contexts also benefits from thread-local storage. Threads performing repeated operations can maintain caches that live for the thread's duration, avoiding contention that shared caches would introduce. Format parsers, date formatters, and other stateful objects that are expensive to create but not thread-safe become viable when each thread maintains its own instance.
When to Avoid Thread-Local Storage: Thread-local storage introduces hidden state that makes code harder to test and reason about. Functions that depend on thread-local variables become harder to understand because their behavior depends on external context not visible in the function signature. This violates principles of explicit dependencies and function purity.
Thread pool environments create additional complications. Application servers and background job processors reuse threads across multiple requests or jobs. Thread-local variables set during one request persist when the thread handles the next request, causing data leakage between unrelated operations. Frameworks that use thread pools require careful cleanup of thread-local storage between operations.
Memory leaks emerge easily with thread-local storage. Storing references to large objects or maintaining caches without size limits causes memory consumption to grow unbounded in long-lived threads. Thread-local storage survives longer than individual request processing might suggest, particularly in thread pool scenarios.
Alternatives to Thread-Local Storage: Explicit parameter passing remains the clearest approach for providing context to functions. While more verbose, parameters make dependencies visible and testable. Dependency injection frameworks formalize this pattern, managing object lifecycles and scopes without hidden global state.
Context objects passed through call chains provide a middle ground. Rack middleware passes an env hash through the request cycle, containing all request-specific data. This approach maintains explicitness while avoiding the need to modify every function signature as new context requirements emerge.
Immutable context objects combined with functional programming patterns eliminate many concurrency concerns entirely. When functions receive all required context as immutable data and produce new immutable results, thread-local storage becomes unnecessary. This approach proves particularly effective in languages with strong support for immutable data structures.
Trade-off Analysis: Thread-local storage optimizes convenience over explicitness. Code becomes more concise but less transparent. Testing becomes more complex because test setup must initialize thread-local state. This trade-off proves worthwhile when:
- Many functions deep in the call stack need access to the same context
- The context represents framework-level concerns rather than application logic
- The application architecture already commits to thread-per-request concurrency
- Performance requirements preclude parameter passing overhead for high-frequency operations
The trade-off proves unfavorable when:
- The application uses thread pools or fiber-based concurrency
- Function behavior should remain testable in isolation
- Code maintainability and clarity take priority over convenience
- Debugging and tracing require understanding function dependencies
Integration with Concurrency Models: Different concurrency models interact with thread-local storage in distinct ways. Thread-per-request models work naturally with thread-local storage since each request executes in a dedicated thread. Event-driven architectures using event loops and callbacks break thread-local storage assumptions because a single thread handles many concurrent operations.
Fiber-based concurrency creates ambiguity about whether "thread-local" means thread-scoped or fiber-scoped. Ruby addresses this by providing both thread-local storage accessible across fibers and true thread variables isolated from fibers. Applications using fibers must choose the appropriate scope for each piece of state.
Actor-based concurrency systems render thread-local storage largely irrelevant since actors encapsulate state and communicate through message passing. The actor model's design explicitly avoids shared state, making thread-local storage an anti-pattern in that context.
Common Patterns
Registry Pattern: Maintaining a thread-local registry of services or configuration objects that functions throughout the call stack can access.
class ServiceRegistry
def self.register(name, service)
registry[name] = service
end
def self.lookup(name)
registry[name] || raise("Service not registered: #{name}")
end
def self.clear
Thread.current[:service_registry] = {}
end
private
def self.registry
Thread.current[:service_registry] ||= {}
end
end
# Framework initialization
ServiceRegistry.clear
ServiceRegistry.register(:logger, Logger.new(STDOUT))
ServiceRegistry.register(:cache, RedisCache.new)
# Application code accesses services
class OrderProcessor
def process(order)
logger = ServiceRegistry.lookup(:logger)
cache = ServiceRegistry.lookup(:cache)
logger.info("Processing order #{order.id}")
cached_data = cache.get("order:#{order.id}")
end
end
Context Stack Pattern: Maintaining a stack of contexts in thread-local storage to support nested operations that each establish their own context.
class ContextStack
def self.push(context)
stack.push(context)
end
def self.pop
stack.pop
end
def self.current
stack.last
end
def self.with_context(context)
push(context)
yield
ensure
pop
end
private
def self.stack
Thread.current[:context_stack] ||= []
end
end
# Nested context usage
ContextStack.with_context(user: admin_user) do
# Admin context
ContextStack.with_context(impersonating: regular_user) do
# Nested context for impersonation
actual_user = ContextStack.current[:user] # admin_user
impersonated = ContextStack.current[:impersonating] # regular_user
end
# Back to admin context
end
Lazy Initialization Pattern: Creating thread-local objects on first access to avoid initialization overhead for threads that never use the object.
class ThreadLocalFormatter
def self.instance
Thread.current[:formatter] ||= begin
formatter = DateTimeFormatter.new
formatter.configure(
timezone: ENV['TIMEZONE'],
format: :iso8601
)
formatter
end
end
end
# First access initializes, subsequent accesses reuse
100.times do
formatted = ThreadLocalFormatter.instance.format(Time.now)
end
Automatic Cleanup Pattern: Registering cleanup handlers to ensure thread-local storage gets cleared when threads terminate or between operations.
class ThreadLocalManager
def self.register_cleanup(&block)
cleanups << block
end
def self.cleanup
cleanups.each(&:call)
Thread.current[:cleanup_handlers] = []
end
private
def self.cleanups
Thread.current[:cleanup_handlers] ||= []
end
end
# Registering resources that need cleanup
class DatabaseConnection
def self.for_thread
Thread.current[:db_conn] ||= begin
conn = establish_connection
ThreadLocalManager.register_cleanup do
conn.close
Thread.current[:db_conn] = nil
end
conn
end
end
end
# Framework ensures cleanup
Thread.new do
conn = DatabaseConnection.for_thread
perform_work
ensure
ThreadLocalManager.cleanup
end
Default Value Pattern: Providing sensible defaults for thread-local storage when values haven't been explicitly set.
class Configuration
DEFAULTS = {
timeout: 30,
retry_count: 3,
log_level: :info
}
def self.get(key)
config = Thread.current[:config] ||= {}
config.fetch(key, DEFAULTS[key])
end
def self.set(key, value)
config = Thread.current[:config] ||= {}
config[key] = value
end
def self.with_overrides(overrides)
previous = Thread.current[:config]
Thread.current[:config] = (previous || {}).merge(overrides)
yield
ensure
Thread.current[:config] = previous
end
end
# Use defaults or override
Configuration.get(:timeout) # => 30
Configuration.with_overrides(timeout: 60) do
Configuration.get(:timeout) # => 60
end
Configuration.get(:timeout) # => 30
Common Pitfalls
Thread Pool Contamination: Thread pools reuse threads across multiple operations, causing thread-local variables set during one operation to persist into the next. This creates subtle bugs where data from one request appears in another.
# Problematic code
class UserContext
def self.current_user=(user)
Thread.current[:current_user] = user
end
def self.current_user
Thread.current[:current_user]
end
end
# In a threaded web server with connection pooling
def handle_request(request)
user = authenticate(request)
UserContext.current_user = user
process_request(request)
# Missing cleanup—user persists in thread-local storage
end
# Next request in same thread sees previous user
def handle_next_request(unauthenticated_request)
# Bug: UserContext.current_user returns previous request's user
if UserContext.current_user
# Incorrectly authorized because thread-local wasn't cleared
access_protected_resource
end
end
# Solution: Always clean up
def handle_request(request)
user = authenticate(request)
UserContext.current_user = user
process_request(request)
ensure
UserContext.current_user = nil # Critical cleanup
end
Memory Leaks in Long-Lived Threads: Thread-local storage persists for the thread's entire lifetime. In application servers with long-lived worker threads, thread-local variables accumulate memory without bounds if not managed carefully.
# Memory leak example
class RequestCache
def self.cache
Thread.current[:request_cache] ||= {}
end
def self.memoize(key)
cache[key] ||= yield
end
end
# Each request adds to the cache, never clearing
1000.times do |i|
RequestCache.memoize("user_#{i}") do
User.find(i) # Cached forever in thread-local storage
end
end
# Solution: Bounded cache with cleanup
class BoundedRequestCache
MAX_SIZE = 100
def self.memoize(key)
cache = Thread.current[:request_cache] ||= {}
if cache.size >= MAX_SIZE
cache.clear # Prevent unbounded growth
end
cache[key] ||= yield
end
end
Testing Complexity: Thread-local storage creates hidden dependencies that complicate testing. Tests must set up thread-local state before each test and clean up afterward, or tests interfere with each other.
# Difficult to test
class OrderProcessor
def process(order)
user = UserContext.current_user # Hidden dependency
order.processed_by = user.id
order.save
end
end
# Test requires thread-local setup
RSpec.describe OrderProcessor do
before do
UserContext.current_user = build(:user)
end
after do
UserContext.current_user = nil
end
it "processes order" do
processor = OrderProcessor.new
processor.process(order)
# Test depends on thread-local state being set correctly
end
end
# Better design with explicit dependencies
class OrderProcessor
def process(order, user:)
order.processed_by = user.id
order.save
end
end
# Test is simpler and clearer
RSpec.describe OrderProcessor do
it "processes order" do
user = build(:user)
processor = OrderProcessor.new
processor.process(order, user: user)
# No thread-local setup required
end
end
Fiber Confusion: Ruby's thread-local storage using Thread.current hash access is actually fiber-local, causing unexpected behavior when mixing threads and fibers.
# Unexpected behavior with fibers
Thread.current[:value] = "main fiber"
Fiber.new do
Thread.current[:value] = "other fiber"
end.resume
puts Thread.current[:value] # => "other fiber" (modified by fiber!)
# Use thread variables for true thread-local storage
Thread.current.thread_variable_set(:value, "main fiber")
Fiber.new do
Thread.current.thread_variable_set(:value, "other fiber")
end.resume
puts Thread.current.thread_variable_get(:value) # => "main fiber" (unchanged)
Race Conditions in Initialization: Lazy initialization of thread-local storage can create race conditions if the initialization code isn't thread-safe or if multiple threads initialize the same conceptual resource.
# Problematic lazy initialization
class ConnectionManager
def self.connection
Thread.current[:connection] ||= Database.connect
end
end
# If Database.connect isn't thread-safe, multiple threads
# might corrupt shared state during initialization
# Safe approach: Ensure initialization is thread-safe
class ConnectionManager
def self.connection
Thread.current[:connection] ||= begin
# Use thread-safe initialization
connection = Database.connect
connection.set_isolation_level(:read_committed)
connection
end
end
end
Forgetting Cleanup in Exception Paths: Exception handling can skip thread-local cleanup, leaving stale state in thread pools.
# Cleanup skipped on exception
def process_request(request)
RequestContext.set(:user, authenticate(request))
handle_request(request)
RequestContext.clear # Never reached if handle_request raises
end
# Proper cleanup with ensure
def process_request(request)
RequestContext.set(:user, authenticate(request))
handle_request(request)
ensure
RequestContext.clear # Always executes
end
Debugging Difficulties: Thread-local storage makes debugging harder because inspecting variables requires examining thread-specific state that may not be visible in standard debugging tools.
# Hard to debug
def process_data
cache = ThreadLocalCache.instance
# Debugger may not show thread-local cache contents easily
result = cache.get(key)
end
# Add explicit debugging support
class ThreadLocalCache
def self.debug_info
{
thread_id: Thread.current.object_id,
cache_size: instance.size,
cache_keys: instance.keys
}
end
end
# Debug by inspecting thread-local state explicitly
puts ThreadLocalCache.debug_info.inspect
Reference
Ruby Thread-Local Storage API
| Method | Purpose | Scope |
|---|---|---|
| Thread.current[:key] | Store and retrieve fiber-local data | Current thread and its fibers |
| Thread.current[:key] = value | Set fiber-local variable | Current thread and its fibers |
| Thread.current.thread_variable_set | Set true thread-local variable | Current thread only, not fibers |
| Thread.current.thread_variable_get | Get true thread-local variable | Current thread only, not fibers |
| Thread.current.thread_variables | List all thread variable keys | Current thread only |
| Thread.current.thread_variable? | Check if thread variable exists | Current thread only |
Storage Scope Comparison
| Storage Type | Accessible from Fibers | Persists Across Fiber Switches | Use Case |
|---|---|---|---|
| Thread.current[:key] | Yes | Yes | Request-scoped data in fiber-based apps |
| Thread.current.thread_variable_set | No | N/A | True thread-local isolation |
| Instance variables on Thread.current | Yes | Yes | Complex thread-local objects |
| Fiber local storage | Fiber only | No | Fiber-specific state |
Common Pitfalls and Solutions
| Pitfall | Symptom | Solution |
|---|---|---|
| Thread pool contamination | Data leaks between requests | Clear thread-locals in ensure blocks |
| Memory leaks | Growing memory usage | Implement bounded caches, cleanup on thread exit |
| Fiber confusion | Unexpected value changes | Use thread_variable_set for true thread-locals |
| Testing interference | Flaky tests | Reset thread-locals in test setup/teardown |
| Hidden dependencies | Hard to test and debug | Prefer explicit parameter passing |
| Race conditions | Intermittent initialization bugs | Ensure initialization code is thread-safe |
Design Decision Matrix
| Requirement | Thread-Local Storage | Parameter Passing | Context Object |
|---|---|---|---|
| Explicitness | Low | High | Medium |
| Testability | Difficult | Easy | Easy |
| Framework-level context | Excellent | Poor | Good |
| Thread pool safety | Requires cleanup | Safe | Safe |
| Fiber compatibility | Depends on API | Safe | Safe |
| Memory efficiency | Risk of leaks | Efficient | Efficient |
| Debugging clarity | Poor | Excellent | Good |
Cleanup Patterns
| Pattern | Code Template | When to Use |
|---|---|---|
| Ensure block | begin; set_context; work; ensure; clear; end | Single operation cleanup |
| Middleware | before: set; after: clear | Request-scoped cleanup |
| Resource wrapper | with_context { work } | Scoped resource management |
| Thread finalizer | Thread.new { work; ensure cleanup } | Thread termination cleanup |
| Periodic cleanup | Every N operations, clear cache | Long-running threads |
Framework Integration
| Framework | Thread-Local Support | Cleanup Mechanism |
|---|---|---|
| Rails | ActiveSupport::CurrentAttributes | Automatic per-request |
| Rack | Middleware-based | Manual middleware |
| Sidekiq | Per-job isolation | Automatic job cleanup |
| Puma | Thread pool with cleanup | Manual ensure blocks |
| RequestStore gem | Request-scoped storage | Automatic middleware |