Databases solved concurrent access to shared data decades ago with transactions: group a set of operations together, and the database guarantees they either all happen atomically and consistently, or none of them do, even with many clients hitting the same data simultaneously. Software Transactional Memory (STM) takes that exact idea and brings it into a program’s in-memory data, letting functional languages offer a concurrency model that feels almost like database transactions for ordinary variables. This article explains what STM is, how it works internally, and how it’s used in practice in Haskell and Clojure specifically.
The Problem STM Solves
Even in a language that defaults to immutability, some state genuinely needs to be shared and mutated across concurrent threads — a bank account balance, a shared cache, a counter of active connections. The traditional answer is locks: acquire a mutex before touching the shared variable, release it after. But locks bring well-known, serious problems:
- Deadlock: two threads each hold a lock the other needs, and neither can proceed.
- Priority inversion: a low-priority thread holding a lock blocks a high-priority thread (covered in depth in the priority-scheduling article).
- Composability failure: two individually-correct, individually-lock-safe operations often can’t be safely combined into one atomic operation without redesigning their locking scheme — locks don’t compose well.
- Cognitive overhead: correct lock ordering, granularity, and scope require careful, error-prone manual reasoning that scales poorly as a codebase grows.
STM aims to give programmers atomicity, consistency, and isolation for shared memory — analogous to what ACID transactions give database users — without requiring manual lock acquisition, ordering, or release at all.
How STM Works: The Optimistic Concurrency Model
STM is built on optimistic concurrency control, the same underlying idea used in many databases’ snapshot isolation and multi-version concurrency control (MVCC) systems. Instead of preventing conflicts upfront (as locks do, by blocking access), STM lets transactions proceed optimistically, assuming no conflict will occur, and only checks for conflicts at commit time:
- A transaction begins, and reads/writes to shared transactional variables are recorded in a private, thread-local transaction log rather than being applied directly to the shared memory.
- When the transaction finishes its block of code, the runtime checks whether any of the transactional variables it read were modified by another thread’s transaction that committed in the meantime.
- If no conflict occurred, the transaction commits: its recorded writes are atomically applied to the actual shared memory, visible to everyone else.
- If a conflict did occur (another thread changed something this transaction depended on), the transaction is aborted and automatically retried from the beginning, transparently, with no code needed from the programmer to handle the retry logic.
This retry-on-conflict approach is why STM tends to perform well when contention is low (transactions rarely need to retry) but can degrade under high contention (many transactions repeatedly conflicting and retrying, wasting work) — a tradeoff worth understanding before assuming STM is a strictly superior replacement for locks in every scenario.
STM in Haskell
Haskell’s STM implementation, part of the stm package (built on GHC runtime primitives), is widely considered the cleanest realization of the idea, largely because Haskell’s type system can enforce at compile time that STM operations only happen inside a properly delimited transaction, using a dedicated STM monad distinct from ordinary IO.
import Control.Concurrent.STM
-- Transactional variables (TVar) hold shared mutable state
data Account = Account { balance :: TVar Int }
transfer :: Account -> Account -> Int -> STM ()
transfer from to amount = do
fromBal <- readTVar (balance from)
if fromBal < amount
then retry -- explicitly block until conditions change, then retry automatically
else do
writeTVar (balance from) (fromBal - amount)
toBal <- readTVar (balance to)
writeTVar (balance to) (toBal + amount)
main :: IO ()
main = do
a <- Account <$> newTVarIO 100
b <- Account <$> newTVarIO 50
atomically (transfer a b 30) -- runs the whole transaction atomically
Key details worth noting:
TVar(“transactional variable”) is the STM equivalent of a mutable reference, but it can only be read or written from within theSTMmonad, not directly fromIO— the type system prevents you from accidentally touching transactional state outside a transaction.atomicallyis the function that takes anSTMaction and actually executes it against real memory, handling the optimistic execution, conflict detection, and automatic retry loop internally.retryis a genuinely elegant feature: calling it inside a transaction explicitly aborts and blocks the current transaction until any of theTVars it read change, at which point it’s retried automatically — enabling condition-variable-like blocking behavior (e.g., “wait until there’s enough balance”) without any explicit wait/notify/condition-variable machinery.- Composability: because
transferis just an ordinarySTMaction, it can be composed with otherSTMactions into larger atomic transactions trivially — something notoriously difficult to do safely with hand-rolled locks, since combining two independently lock-safe functions into one atomic operation usually requires redesigning their locking strategy entirely.
STM in Clojure
Clojure integrates STM directly into its core state-management model via ref, dosync, alter, and ensure, built specifically to work with Clojure’s persistent, immutable data structures.
(def account-a (ref 100))
(def account-b (ref 50))
(defn transfer [from to amount]
(dosync
(alter from - amount)
(alter to + amount)))
(transfer account-a account-b 30)
refcreates a transactional reference, analogous to Haskell’sTVar.dosyncdelimits a transaction, analogous toatomically.alterapplies a pure function to the ref’s current value within the transaction; Clojure records this as an intent rather than mutating immediately.- Because Clojure’s underlying values are immutable/persistent, the STM system never has to worry about a transaction seeing a “torn” or partially-updated data structure even mid-conflict — the combination of immutability and STM is specifically what makes Clojure’s model both safe and relatively easy to reason about.
Clojure’s STM famously distinguishes between identity (a ref, which changes over time) and value (the actual immutable data a ref points to at any given moment) — a philosophical distinction Rich Hickey (Clojure’s creator) has spoken about extensively, arguing that most concurrency bugs in traditional OOP languages stem from conflating mutable objects’ identity and value into a single concept.
Nested and Composed Transactions
A major practical advantage of STM over locks is safe composability. Consider combining two independently-written, individually correct transactional functions:
withdrawSTM :: TVar Int -> Int -> STM ()
withdrawSTM acc amount = do
bal <- readTVar acc
writeTVar acc (bal - amount)
depositSTM :: TVar Int -> Int -> STM ()
depositSTM acc amount = do
bal <- readTVar acc
writeTVar acc (bal + amount)
-- Composing them into one atomic transaction requires zero extra locking logic
transferSTM :: TVar Int -> TVar Int -> Int -> STM ()
transferSTM from to amount = do
withdrawSTM from amount
depositSTM to amount
Because both withdrawSTM and depositSTM are just ordinary STM actions, sequencing them inside transferSTM automatically makes the whole thing atomic — the runtime doesn’t distinguish between “one big transaction” and “two smaller transactions run in sequence,” because it’s all just one STM computation until an outer atomically call executes it. Achieving the equivalent with locks would require either a single, coarser lock covering both operations from the start, or careful, error-prone reasoning about lock ordering between two separately-designed locking schemes.
Performance Characteristics and Tradeoffs
- Read-heavy, low-contention workloads: STM tends to perform very well, since most transactions commit on the first try with minimal overhead.
- High-contention, write-heavy workloads on the same variables: STM can suffer from repeated aborts and retries, sometimes performing worse than a well-tuned fine-grained lock, since wasted transactional work (computation discarded due to conflict) is a real cost that locks don’t have (locks simply block rather than doing wasted speculative work).
- Livelock risk: in pathological cases, multiple transactions can repeatedly conflict with each other and retry indefinitely without any of them successfully committing — GHC’s STM implementation includes safeguards, but understanding this failure mode matters for systems with many high-frequency transactions on shared hot variables.
- I/O inside transactions: STM fundamentally requires the ability to abort and retry a transaction transparently, which means transactions must generally avoid irreversible side effects (like arbitrary I/O) inside them — this is exactly why Haskell enforces the
STMmonad as distinct fromIOat the type level, preventing you from accidentally putting something irreversible (like sending a network request) inside a block that might silently retry multiple times.
Real-World Use Cases
- Financial and trading systems: STM’s strong atomicity guarantees for shared account/ledger-like state, combined with Haskell’s type-enforced separation of pure and transactional code, make it attractive for correctness-critical financial software.
- Simulation and game engines: Clojure’s STM has been used for coordinating shared simulation state across many concurrently-updating entities without hand-rolled locking.
- Caching layers: STM-backed shared caches let multiple reader/writer threads interact safely with cache invalidation logic that would be error-prone to implement correctly with manual locks, especially when cache updates need to be conditionally atomic relative to reads.
Comparison: STM vs. Locks vs. Actor Model
| Approach | Coordination Mechanism | Deadlock Risk | Composability | Best Fit |
|---|---|---|---|---|
| Locks (mutex/semaphore) | Pessimistic, blocking | High (lock ordering bugs) | Poor | Fine-grained, performance-critical, low-level code |
| STM | Optimistic, automatic retry | Low (livelock possible, not deadlock in the classic sense) | Excellent | Composable business logic over shared state |
| Actor model (message passing) | No shared memory at all | Low (protocol-level deadlock still possible) | Good, at the message-protocol level | Highly concurrent, isolated units (Erlang/Elixir style) |
Best Practices
- Keep transactions small and side-effect-free (pure, aside from
TVar/refoperations) to minimize wasted work on retry and to satisfy the type-level restrictions languages like Haskell enforce. - Avoid extremely hot, heavily-contended single
TVars/refs in high-throughput systems; consider splitting shared state into finer-grained transactional variables where the access pattern allows it, reducing unnecessary conflicts between unrelated updates. - Use STM’s blocking primitives (Haskell’s
retry,orElse) instead of manual polling loops when a transaction needs to wait for a condition — they integrate directly with the runtime’s conflict-detection machinery rather than wasting CPU busy-waiting. - Don’t assume STM eliminates all need to reason about concurrency correctness — livelock and performance degradation under contention are real failure modes that still require profiling and understanding of your workload’s actual access patterns.
- For workloads dominated by isolated, independent units of work rather than shared mutable state, consider whether the actor model (message passing) might be a better structural fit than STM in the first place.
Summary
Software Transactional Memory brings database-style atomic, isolated transactions to in-memory shared state, replacing manual lock acquisition and release with an optimistic, automatically-retried transaction model. Haskell’s STM monad and TVars enforce this discipline at the type level, guaranteeing transactional code can’t accidentally leak irreversible side effects, while Clojure’s ref/dosync system integrates the same idea directly with its persistent data structures and identity/value philosophy. STM’s biggest practical win is composability — independently-written transactional functions combine safely into larger atomic operations without redesign — a property that’s notoriously difficult to achieve reliably with hand-rolled locks, though it comes with its own contention-related performance tradeoffs that need to be understood, not ignored.
FAQs
Is STM strictly better than using locks? Not universally — STM excels at composability and correctness under moderate contention, but can underperform fine-tuned locks under very high contention on the same shared variables, due to wasted retried work.
Can STM deadlock like locks can? Classic deadlock (circular waiting on held resources) is structurally avoided, since STM transactions don’t hold exclusive locks while executing — but livelock (repeated mutual conflict and retry without progress) is a related, still-possible failure mode under pathological access patterns.
Why does Haskell separate the STM monad from IO? Because transactions can be silently aborted and retried by the runtime, any side effect that can’t be safely undone (like sending a network request or printing to a terminal) would be dangerous to run multiple times inside a retried transaction — the type system prevents this class of bug entirely by simply not allowing arbitrary IO inside STM.
Does STM require a purely functional language? No — STM implementations exist for non-functional languages too (e.g., experimental STM libraries in C++, and STM.NET for C#), but functional languages, especially those with strong immutability guarantees, make STM’s correctness properties easier to achieve and reason about, since there’s no risk of a transaction observing a partially-mutated, non-transactional data structure by accident.
References
- Harris, T., Marlow, S., Peyton Jones, S., Herlihy, M. — “Composable Memory Transactions” (the foundational Haskell STM paper, PPoPP 2005)
- Peyton Jones, S. — “Beautiful Concurrency” (accessible introduction to Haskell STM)
- Hickey, R. — Clojure documentation and talks on
ref,dosync, and the identity/value/state model Control.Concurrent.STM— Haskellstmpackage documentation on Hackage- Clojure official documentation — “Refs and Transactions”