thread-utils-context-0.4.1.0: Garbage-collected thread local storage
Safe HaskellNone
LanguageHaskell2010

Control.Concurrent.Thread.Storage

Description

Thread-local storage for Haskell green threads.

Associates at most one value of type a with each green thread in a ThreadStorageMap. Values are automatically cleaned up by a GC finalizer when the owning thread dies.

Implementation

Internally, a ThreadStorageMap is a flat open-addressed hash table that resizes automatically when full. Keys (thread IDs) live in a MutableByteArray# with per-slot atomic CAS; values live in a GC-traced MutableArray# of IORefs. On resize, a new table is allocated at double the capacity, live entries are copied (cleaning tombstones), and the reference is swapped under an MVar lock that serializes resize operations; at most one thread performs the expensive copy-and-swap at a time while other inserters wait. In-flight readers on the old table are safe because the old arrays remain valid GC objects and the per-thread IORefs are shared between old and new tables.

Reads and writes on the hot path go directly to the per-thread IORef, with zero CAS and zero contention. CAS is only used during thread registration (once per thread lifetime) and during finalizer-driven cleanup.

Two CMM primops avoid allocation and FFI overhead on the hot path:

  • stg_getCurrentThreadId: reads StgTSO_id(CurrentTSO) directly.
  • stg_probeThreadSlot: fuses thread-ID retrieval with a multiplicative-hash linear probe of the key array.

Slot hashing

Slot assignment uses a Fibonacci/golden-ratio multiplicative hash (tid * 0x9E3779B97F4A7C15) rather than a simple bit-mask. This spreads sequential thread IDs (GHC allocates them contiguously) across different cache lines, eliminating false sharing on both the key and value arrays under multi-core contention.

Detach encoding

Thread IDs are 32-bit (StgWord32) but stored in 64-bit key slots. Bit 32 serves as a "detached" flag. When a context is detached via detach, the flag is set in the key array (a single atomic write to unboxed memory — no GC write barrier, no card-table contention). The value slot is left untouched so no MutableArray# card is dirtied. The CMM probe reports detach status via its return value, so the Haskell hot path for lookup and adjust never checks the value array for detached markers at all.

Choosing an API tier

This module exposes three tiers of API, from simplest to fastest:

High-level
attach, detach, lookup, update, adjust and their …OnThread variants. Each call resolves the thread ID internally. Fine when you make only one or two calls per operation.
Raw
getThreadId / lookupRaw / updateRaw. Pre-compute the thread-ID word once, then pass it to several operations on the same thread without repeated FFI calls.
Ref-based
ensureRefFast / lookupRefFast / readRef / writeRef / modifyRef. On the fast path (thread already registered), the entire lookup is a single CMM call plus an IORef dereference. Subsequent reads and writes are plain IORef operations with no hash-table probe at all. Use this tier in instrumentation hot loops (e.g. tracing spans).

Lifecycle

  • A value attached to a thread remains reachable at least as long as the thread is alive.
  • A value may be explicitly removed via detach at any time. The hash-table key is marked with a "detached" bit; the value slot is not overwritten. A subsequent attach on the same thread reuses the slot without registering a duplicate GC finalizer.
  • After a thread dies, its finalizer tombstones the slot. The IORef (and the value it holds) become eligible for GC once no other references remain.
  • purgeDeadThreads can be used to eagerly reclaim slots for threads that have exited but whose finalizers have not yet run. (GHC >= 9.6 only.)
Synopsis

The map type

data ThreadStorageMap a Source #

A concurrent map from green-thread IDs to values of type a.

Each thread may have at most one associated value. The table starts at an initial capacity (see newThreadStorageMap, newThreadStorageMapWith) and doubles automatically when full. Resize operations are serialized by an internal MVar lock so that at most one thread performs the expensive copy-and-swap at a time; other threads that discover a full table block on the lock and retry after the resize completes.

All read paths and ref-based hot-path operations are entirely lock-free. The MVar is only contended during table growth, which happens O(log n) times over the life of the map.

Construction

newThreadStorageMap :: MonadIO m => m (ThreadStorageMap a) Source #

Create a ThreadStorageMap with a default initial capacity derived from the number of runtime capabilities: max 128 (capabilities * 32), rounded up to the next power of two.

The table resizes automatically when full, so this is a good default for most applications.

newThreadStorageMapWith :: MonadIO m => Int -> m (ThreadStorageMap a) Source #

Create a ThreadStorageMap with at least the given number of initial slots.

The actual capacity is rounded up to the next power of two (minimum 16). The table doubles automatically when it runs out of slots. A load factor below 0.7 keeps probe chains short; resizing also cleans tombstones.

High-level API

Convenient functions that resolve the current thread's identity internally. Each call obtains the ThreadId (or numeric ID) on your behalf, which is fine for one-shot operations. If you are making multiple calls in sequence for the same thread, consider the Raw API or Ref-based API to avoid redundant work.

Lookup

lookup :: MonadIO m => ThreadStorageMap a -> m (Maybe a) Source #

Retrieve the value associated with the current thread, if any.

Uses the fused CMM probe which reads CurrentTSO.id, applies the multiplicative hash, and linearly probes the key array in a single CMM call. Returns Nothing for both absent and detached entries without touching the value array in the detached case.

lookupOnThread :: MonadIO m => ThreadStorageMap a -> ThreadId -> m (Maybe a) Source #

Retrieve the value associated with a specific thread.

Insert / replace

attach :: MonadIO m => ThreadStorageMap a -> a -> m (Maybe a) Source #

Associate a value with the current thread, replacing any previous value.

Returns the previous value, or Nothing if the thread had no entry. A GC finalizer is registered on the first call per thread so that the entry is automatically cleaned up when the thread dies.

On the hot path (value already attached), no ThreadId is allocated and no FFI call is made. myThreadId is only called on the cold first-insert path to register the GC finalizer.

attachOnThread :: MonadIO m => ThreadStorageMap a -> ThreadId -> a -> m (Maybe a) Source #

Like attach, but targets a specific thread.

Remove

detach :: MonadIO m => ThreadStorageMap a -> m (Maybe a) Source #

Remove the value associated with the current thread.

Returns the removed value, or Nothing if the thread had no entry. The slot key is marked with the detached bit (a single atomic write to unboxed memory with no GC write barrier) so it can be reused by a future attach without registering a duplicate GC finalizer.

detachFromThread :: MonadIO m => ThreadStorageMap a -> ThreadId -> m (Maybe a) Source #

Like detach, but targets a specific thread.

General update

update :: MonadIO m => ThreadStorageMap a -> (Maybe a -> (Maybe a, b)) -> m b Source #

Atomically read and update the value for the current thread.

The callback receives the current value (or Nothing) and returns a pair of the new value to store (or Nothing to remove the entry) and an arbitrary result.

Uses the fused CMM probe (stg_probeThreadSlot#). The probe reports attached/detached/absent via its return encoding, so the hot path (attached, updating the value) never checks the detached state at all.

-- Increment a counter, inserting 1 if absent:
update tsm (\old -> (Just (maybe 1 (+1) old), ()))

updateOnThread :: MonadIO m => ThreadStorageMap a -> ThreadId -> (Maybe a -> (Maybe a, b)) -> m b Source #

Like update, but targets a specific thread.

This is the most general function in the high-level API. attachOnThread and detachFromThread are implemented in terms of this.

In-place modification

adjust :: MonadIO m => ThreadStorageMap a -> (a -> a) -> m () Source #

Modify the value for the current thread in place if one is attached.

Does nothing if the thread has no entry or the entry is detached. The modification is strict (modifyIORef'). Uses the fused CMM probe.

adjustOnThread :: MonadIO m => ThreadStorageMap a -> ThreadId -> (a -> a) -> m () Source #

Like adjust, but targets a specific thread.

Raw API

Pre-compute a thread's numeric ID once and reuse it across several operations, avoiding repeated FFI calls to rts_getThreadId.

tid <- myThreadId
let !tw = getThreadId tid
lookupRaw tsm tw >>= \case ...
updateRaw tsm tid tw (\old -> ...)

The ThreadId is still required by updateRaw because it may need to register a GC finalizer on the first insert.

getThreadId :: ThreadId -> Word Source #

Extract the numeric thread ID from an existing ThreadId.

This makes a cheap FFI call to rts_getThreadId. When you already hold a ThreadId and need its numeric form for lookupRaw or updateRaw, use this. Otherwise prefer getCurrentThreadId.

getCurrentThreadId :: IO Int Source #

Read the current green thread's numeric ID directly from CurrentTSO.

This is implemented as a CMM primop, so no ThreadId box is allocated and no FFI call is made. Prefer this over getThreadId =<< myThreadId whenever you do not need the ThreadId value itself.

lookupRaw :: MonadIO m => ThreadStorageMap a -> Word -> m (Maybe a) Source #

Retrieve a value using a pre-computed thread ID (from getThreadId).

Avoids the FFI call to rts_getThreadId that lookupOnThread would make internally. Uses a CMM primop for the key-array probe.

updateRaw :: MonadIO m => ThreadStorageMap a -> ThreadId -> Word -> (Maybe a -> (Maybe a, b)) -> m b Source #

Generalized update using a pre-computed thread ID.

Behaves like updateOnThread but skips the internal getThreadId call. The ThreadId argument is still needed so a GC finalizer can be registered when a new entry is created. Uses a CMM primop for the key-array probe.

Ref-based API

The fastest tier. On the hot path (thread already registered), the operations below avoid the hash-table probe entirely by handing you the per-thread IORef directly. Subsequent reads and writes are plain IORef operations.

Typical usage in a tracing library:

-- Once per request (or per thread lifetime):
(tid, ref) <- ensureRefFast tsm Nothing

-- On every span open (hot path, no probe, no CAS):
writeRef ref (Just spanContext)

-- On every span close:
ctx <- readRef ref
writeRef ref Nothing

If you already have a ThreadId and numeric ID, use ensureRef or lookupRef. If you want the absolute fastest path and don't have a ThreadId yet, use ensureRefFast or lookupRefFast which read CurrentTSO.id and probe the key array entirely in CMM.

ensureRef :: ThreadStorageMap a -> ThreadId -> Int -> a -> IO (IORef a) Source #

Get or create the IORef for a given thread.

If the thread already has an entry, returns its IORef (read-only probe, no CAS). Otherwise, creates a new IORef initialised to def, claims a slot via CAS, and registers a GC finalizer for cleanup.

The Int argument is the numeric thread ID (e.g. from getCurrentThreadId or fromIntegral . getThreadId).

ensureRefFast :: ThreadStorageMap a -> a -> IO (Int, IORef a) Source #

Fused CMM fast path: get or create the IORef for the current thread.

Returns (threadId, ref).

Steady state (entry exists): read the table IORef, then a single CMM call reads CurrentTSO.id and linearly probes the key array, then one readArray# fetches the IORef. No ThreadId allocation, no FFI, no Maybe wrapper.

First call per thread: falls back to myThreadId, CAS-inserts a new IORef initialised to def, and registers a finalizer.

lookupRef :: ThreadStorageMap a -> Int -> IO (Maybe (IORef a)) Source #

Look up the IORef for a thread by its numeric ID (Haskell-side probe).

Use this when you already have the numeric ID but not necessarily the current thread's TSO (e.g. inspecting another thread's slot).

lookupRefFast :: ThreadStorageMap a -> IO (Int, Maybe (IORef a)) Source #

Look up the IORef for the current thread using the fused CMM probe.

Returns (threadId, Maybe (IORef a)). The numeric thread ID is returned so you can pass it to ensureRef on the slow path without a second FFI call:

(tid, mref) <- lookupRefFast tsm
ref <- case mref of
  Just r  -> pure r
  Nothing -> do
    t <- myThreadId
    ensureRef tsm t tid defaultValue

readRef :: IORef a -> IO a Source #

Read the value from a per-thread IORef.

Thin wrapper around readIORef; provided for API symmetry with writeRef and modifyRef.

writeRef :: IORef a -> a -> IO () Source #

Write a value into a per-thread IORef.

modifyRef :: IORef a -> (a -> a) -> IO () Source #

Strictly modify the value in a per-thread IORef.

Equivalent to modifyIORef'.

Monitoring

storedItems :: ThreadStorageMap a -> IO [(Int, a)] Source #

Snapshot all live entries as (threadId, value) pairs.

Intended for monitoring and debugging, e.g. verifying that entries are cleaned up after threads exit. The result is a point-in-time snapshot; concurrent mutations may or may not be reflected.

purgeDeadThreads :: MonadIO m => ThreadStorageMap a -> m () Source #

Tombstone slots belonging to threads that are no longer alive, and shrink the table if the load factor drops below 25%.

Normally, slots are cleaned up by GC finalizers attached to the owning ThreadId. This function provides an eager alternative: it calls listThreads to obtain the set of live threads and tombstones any slot whose key is not in that set.

Internally builds a flat array of live thread IDs and passes it to a C function that qsorts it, then batch-scans the key array using SIMD (NEON / SSE2) linear search for small live sets or branchless binary search (Khuong / Lemire CMOV style) for large ones. A single unsafe ccall amortises FFI overhead across the full table scan. Tombstoning (key + value slot) is done on the Haskell side to maintain GC write barriers.

After tombstoning, if the number of remaining live entries is less than 1/4 of the table capacity (and the capacity exceeds the 16-slot minimum), the table is rehashed to a smaller power-of-two size under the resize MVar lock. This prevents unbounded memory use after bursts of short-lived threads.

This is a best-effort operation: if a resize occurs concurrently, some dead entries may survive in the new table until the next purge or GC.

@since base 4.18.0 (GHC 9.6)