Choosing Haskell's STM for Safe Concurrent State Management
Learn how Haskell's Software Transactional Memory (STM) lets you manage shared state safely and composably, with a worked bank‑transfer example and practical adoption tips.
05 Sept 2025, 10:30 UTC

The problem: shared mutable state in concurrent Haskell
When you need multiple Haskell threads to read and update the same data, naïve use of IORef or MVar often leads to race conditions, deadlocks, or hard‑to‑reason‑about locking code. The programmer must manually enforce ordering, handle retries, and ensure that every possible interleaving is safe—a task that grows quickly as the program scales.
Why STM is a practical engineering decision
Haskell’s Software Transactional Memory (STM) provides a composable, lock‑free abstraction for shared state. Instead of managing locks yourself, you describe what a transaction should do; the runtime guarantees that the transaction either commits atomically or is retried automatically when a conflict occurs. This shifts the burden from low‑level synchronization to a higher‑level, declarative model.
Worked example: a bank transfer
The following code shows a minimal bank with two accounts represented by TVar Int. The transfer function reads both balances, checks sufficient funds, and writes the new balances inside an atomically block. If another transaction modifies either account concurrently, the STM runtime retries the whole block.
import Control.Concurrent.STM
import Control.Concurrent (forkIO, threadDelay)
import Control.Monad (replicateM_)
main :: IO ()
main = do
-- initialise two accounts with 100 each
accA <- newTVarIO 100
accB <- newTVarIO 100
-- spawn a few concurrent transfers
replicateM_ 10 $ forkIO $ do
transfer accA accB 10
threadDelay 50000 -- small pause to increase interleaving
-- let the threads finish
threadDelay 2000000
finalA <- readTVarIO accA
finalB <- readTVarIO accB
putStrLn $ "Account A: " ++ show finalA
putStrLn $ "Account B: " ++ show finalB
putStrLn $ "Total: " ++ show (finalA + finalB)
transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amount = do
fromBal <- readTVar from
toBal <- readTVar to
if fromBal < amount
then retry -- not enough funds, wait for a change
else do
writeTVar from (fromBal - amount)
writeTVar to (toBal + amount)
To build and run the example:
- Save the code to
Bank.hs. - Compile with the threaded runtime:
ghc -threaded Bank.hs -o bank(requires GHC ≥ 8.0; newer versions give better STM performance). - Execute with two capabilities:
./bank +RTS -N2. - Check that the final balances sum to 200 and that no negative balances appear.
If you add a conflicting transaction that repeatedly withdraws from the same account, you will see the retry cause the operation to pause until the conflicting transaction commits, preserving correctness without deadlock.
Trade‑offs and limitations
- Performance overhead. STM may incur retries under high contention, which can degrade throughput compared to hand‑tuned lock‑based code. For low‑to‑moderate contention workloads (e.g., configuration caches, modest‑size shared queues) the overhead is usually acceptable.
- Lazy evaluation pitfalls. Values read from a
TVar are lazy; if you do not force them inside the transaction, a later retry can cause the same thunk to be evaluated multiple times. Use!patterns orControl.DeepSeqto force strictness when needed. - Runtime dependence. STM relies on GHC’s runtime system; older GHC releases (< 9.0) lack some performance improvements and bug fixes present in newer versions.
To verify that retries are happening as expected, you can enable RTS statistics: ./bank +RTS -N2 -s. Look for the "SPARKS" and "GC" sections; a noticeable increase in "GC" time may indicate many retries under contention.
Getting started in your project
- Add
Control.Concurrent.STMto your imports. - Replace
MVarorIORefused for shared state withTVar. - Wrap each critical section in
atomically. - Use
retryfor conditional waiting andorElse(<|>) to compose alternatives. - Compile with
-threadedand test with+RTS -N<cores>to exercise concurrency.
By adopting STM, you gain a clear, composable model for concurrent state that reduces the chance of race‑related bugs while keeping the code readable. Evaluate the contention profile of your specific workload; if contention stays modest, STM is often the most practical choice for safe concurrent Haskell programming.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.