Using Haskell STM for Safe Concurrent State: An Architecture Note
An architecture note on using Haskell's STM and TVar for composable, lock-free concurrent state: requirements, minimal design, trust boundaries, operational checks, failure modes, and when to switch approaches.
06 Aug 2025, 08:45 UTC

Requirements
The system needs a way to share mutable state between many lightweight threads while guaranteeing that updates appear atomic and composable. Lock-based approaches are undesirable because they complicate reasoning about deadlocks and make it hard to build complex operations from simpler ones. The desired properties are:
- Atomicity: a group of reads and writes either all take effect or none do.
- Composability: small STM actions can be combined with
orElseor sequenced with>>=to form larger transactions. - Blocking waits: the ability to retry a transaction until a condition becomes true without busy-spinning.
- Deadlock-freedom: the runtime must guarantee that no set of threads can block each other forever.
Smallest Suitable Design
The core of Haskell's Software Transactional Memory (STM) is the TVar type, a mutable cell that can be read and written only inside the STM monad. A minimal design therefore consists of:
- Define the shared state as one or more
TVarvalues, e.g.TVar Intfor a counter. - Wrap all interactions with those
TVars in functions that returnSTM a. Keep the pure logic outside the monad; only thereadTVar,writeTVar, andretryprimitives appear inside. - Expose a public API that returns results in
IOby atomically executing the transaction withatomically :: STM a -> IO a. The API never exports theTVarconstructor or the rawTVarvalues.
Example (illustrative only; not verified against a specific GHC or stm package version):
module Counter (newCounter, increment, getValue) where
import Control.Concurrent.STM
newCounter :: Int -> IO (TVar Int)
newCounter = newTVarIO
increment :: TVar Int -> STM ()
increment tvar = do
v <- readTVar tvar
writeTVar tvar (v + 1)
getValue :: TVar Int -> STM Int
getValue = readTVar
The caller uses atomically to run the transaction:
import Counter
import Control.Concurrent.STM
main :: IO ()
main = do
c <- newCounter 0
atomically $ increment c
v <- atomically $ getValue c
print v
Note that this sketch still returns the raw TVar from newCounter; in a stricter design, hide it behind an opaque handle type so callers cannot compose transactions outside your invariants.
Trust and Data Boundaries
The TVar itself is trusted internal state; the only way external code can affect it is through the exported functions (increment, getValue, etc.). By keeping the TVar constructors out of the module's export list, callers cannot bypass invariants such as "the counter must never be negative". Any additional validation (for example, rejecting negative values) lives inside the STM transaction, guaranteeing that the check and the update are performed atomically.
Operational Checks
To confirm the STM subsystem behaves as expected under load, monitor three aspects:
- Transaction retry rate: Run the program with the RTS flag
+RTS -sstm(requires linking with-threadedand, depending on GHC version,-rtsopts) to obtain statistics showing how many transactions were retried. A rising retry ratio under steady load may indicate contention that could lead to livelock. - Long-running transactions: Instrument the code to record the wall-clock time spent inside
atomically. If a transaction exceeds a configurable threshold (for example, 10 ms), log it for investigation; long transactions increase the chance of conflicts. - Starvation detection: Wrap calls to
atomicallywith a timeout usingSystem.Timeout.timeout. If a timeout fires, the waiting thread receivesNothingand can report an error instead of blocking indefinitely.
Failure Modes
Exceptions thrown inside an STM transaction abort it; depending on the exception, the transaction may be retried or the exception rethrown in IO. Any IO action smuggled in via unsafeIOToSTM can leave the system inconsistent if the transaction later retries, because the IO effect may already have happened. Avoid unsafeIOToSTM for anything that is not idempotent.
Although STM is deadlock-free by design, high contention can produce livelock: threads repeatedly retry because each transaction sees a conflicting update from another. Observed symptoms are high retry rates with little forward progress. Reducing the granularity of TVars (more, smaller cells instead of one large one) or introducing back-off can mitigate this.
Memory leaks may occur if a TVar holds unevaluated thunks that are never forced. Ensure values written to a TVar are evaluated to normal form (using bang patterns or seq) before the transaction commits.
Conditions That Would Change the Design
If the application requires extremely low latency and STM retry overhead becomes prohibitive, consider replacing TVar with MVar or atomic primitives. This reintroduces the need for explicit lock ordering and careful deadlock avoidance, so treat it as a last resort backed by measurements.
If state must be shared across multiple OS processes or network nodes, STM's single-heap assumption breaks down. In that scenario, move to a replicated state machine or a distributed consensus protocol, where each node maintains local state and coordinates via message passing.
Verifying the Result
Two practical checks: first, inspect the module's export list to confirm only functions, not TVar constructors, are exposed. Second, run a stress test with many concurrent threads performing random updates and assert that your invariants hold and the program terminates; combine this with +RTS -sstm output to confirm retry rates stay within expected bounds under load.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.