Haskell STM: Designing Composable Concurrency with Transactional Memory
STM replaces lock-based synchronization with composable atomic blocks that retry on conflict, eliminating deadlocks and providing automatic rollback. This note covers GHC 8.10–9.8 runtime behavior, trust boundaries, operational checks, failure modes, and when to choose alternatives.
01 Jan 2026, 11:03 UTC

Problem: Coordinating Shared Mutable State Without Locks
In a concurrent Haskell service, multiple threads mutate shared counters, queues, and caches. Traditional MVar or IORef with atomicModifyIORef forces manual lock ordering, risking deadlocks and making composition difficult. STM (Software Transactional Memory) replaces explicit locks with composable atomic blocks that automatically retry on conflict, giving you isolation and rollback without managing lock hierarchies.
Core Model: Optimistic Transactions in the GHC Runtime
STM exposes atomically :: STM a -> IO a. Inside an STM block you read and write transactional variables (TVar, TMVar, TChan, TQueue, TBQueue). The GHC runtime (versions 8.10–9.8) logs each transaction’s read‑set and write‑set, validates at commit, and retries the whole block if validation fails. Contention appears as repeated retries rather than thread blocking, and the work‑stealing scheduler distributes the retries across capabilities.
Key composability comes from retry (abort and wait for a watched variable to change) and orElse ((<|>)) which chains alternative transaction paths. This lets you express protocols such as “take from queue A, or if empty take from queue B” without nested locks.
Trust and Data Boundaries
- Single‑process shared memory only. STM does not provide durability, distributed consensus, or isolation from
unsafePerformIO/FFI code that mutatesTVars outside transactions. - Foreign calls.
unsafeFFI insideatomicallyis undefined behavior;safeFFI blocks the capability, negating STM’s concurrency benefits. - Process boundaries. Data crossing processes must be serialized and coordinated externally (e.g., via a message broker or an STM‑backed database like
acid-state).
Operational Checks
- GC pressure. STM logs allocate; monitor GC pause times with
+RTS -Tor the eventlog. - Retry rates. Compile with
-eventlogand run+RTS -l -N, then open the.eventlogin ThreadScope. High retry spikes indicate contention hotspots. - Transaction size. Keep transactions small and read‑heavy. Validation cost grows with log size; transactions exceeding L1/L2 cache capacity degrade sharply.
- Strictness. Use
modifyTVar'or strict fields inTVarpayloads to avoid space leaks from accumulated thunks.
Failure Modes
- Livelock. Under high contention, transactions repeatedly invalidate each other. No fairness knobs exist in the runtime; a thread that continuously retries can starve others.
- Unbounded retry. If the
retrycondition never becomes true (e.g., waiting on aTVarthat no other thread updates), the thread spins forever. - Space leaks. Lazy
TVarcontents accumulate thunks; enforce strictness withmodifyTVar'orStrictData. - Nested
atomically. Callingatomicallyinside anotheratomicallyraisesBlockedIndefinitelyOnSTM; it cannot be used to compose larger atomic units.
When to Change the Design
| Condition | Alternative |
|---|---|
| Latency tails must be bounded | Lock‑free data structures or bounded retry with fallback to a non‑STM path |
| Transactions need I/O side effects | Hand off I/O to dedicated threads via TQueue; keep STM pure |
| State must survive process restarts | Add a persistent log or switch to acid-state / external database |
| High contention on large transactions | Shard state, reduce transaction scope, or use MVar for coarse‑grained coordination |
Example: Bounded Buffer with Backpressure
The following pattern shows a bounded buffer that blocks producers when full and consumers when empty, using only STM combinators.
import Control.Concurrent.STM
import Control.Concurrent.STM.TQueue
import Control.Monad (void)
newBoundedBuffer :: Int -> STM (TQueue a, TVar Int)
newBoundedBuffer cap = do
q <- newTQueue
slots <- newTVar cap
pure (q, slots)
writeBuffer :: TQueue a -> TVar Int -> a -> STM ()
writeBuffer q slots x = do
available <- readTVar slots
if available > 0
then do writeTQueue q x
writeTVar slots (available - 1)
else retry -- wait until a slot is freed
readBuffer :: TQueue a -> TVar Int -> STM a
readBuffer q slots = do
x <- readTQueue q -- retries if empty
modifyTVar' slots (+1)
pure x
Producers call atomically . writeBuffer, consumers call atomically . readBuffer. The retry in writeBuffer composes with orElse if you need alternative sources.
Verification Steps
- Compile for profiling.
ghc -O2 -threaded -eventlog -rtsopts YourModule.hs(requires write permission in build directory). - Run with eventlog.
./YourModule +RTS -l -N4(needs+RTSpermissions; run on a machine with at least 4 cores). - Analyze in ThreadScope. Open
YourModule.eventlog; look for frequent “STM retry” markers and GC pauses. - Benchmark alternatives. Use
criterionto compareTVar,MVar, andIORef + atomicModifyIORefunder varying thread counts. Example:benchTVar :: Int -> Benchmark benchTVar n = bench ("TVar " ++ show n) $ whio $ do v <- newTVarIO 0 replicateConcurrently_ n $ atomically $ modifyTVar' v (+1) - Strictness check. Compile with
-fstrict-dicts -funbox-strict-fields -ddump-simpland verify no thunks remain in transaction logs. - Run STM test suite.
cabal test stm(requirescabaland thestmpackage source) to confirm semantics match documented behavior.
Limitations
- No fairness guarantees; starvation possible under asymmetric contention.
- Performance drops when transaction logs exceed cache capacity.
- Cannot directly perform I/O or call unsafe FFI inside transactions.
- No built‑in persistence; external tooling required for durability.
Practical verification: after deploying a change, watch the ThreadScope retry chart for 10‑minute windows. A flat, low retry rate with stable GC pauses indicates the design fits the workload.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.