MVar vs STM: A Practical Decision Guide for Haskell Concurrency
When building concurrent Haskell programs, choosing between MVar and STM is critical. This guide weighs constraints, compares options, and shows a hands‑on counter benchmark to help you decide.
07 May 2026, 14:56 UTC

Problem Statement
When building concurrent Haskell applications, you often need a way to synchronize access to shared state. The two most common primitives are MVar from Control.Concurrent and Software Transactional Memory (STM) from the stm package. Both are fully supported in GHC 9.x and later, but they differ in semantics, performance, and ease of use. This guide helps you decide which primitive to use for a given scenario.
Decision Constraints
- Shared State Size – How many variables need to be updated atomically?
- Contention Level – Are multiple threads frequently accessing the same data?
- Performance Sensitivity – Is micro‑second latency critical?
- Code Maintainability – Do you need composable, lock‑free updates?
- Runtime Overhead – Is the STM runtime acceptable for your deployment?
Compact Comparison Table
| Feature | MVar | STM |
|---|---|---|
| Blocking behavior | Explicit takeMVar / putMVar – can block or use tryPutMVar | Automatic retry inside atomically – no explicit blocking calls |
| Atomicity | Single variable at a time | Multiple TVars in one transaction |
| Deadlock risk | High if lock ordering is not enforced | Low – transactions are retried automatically |
| Performance overhead | Low – lightweight kernel lock | Higher – STM runtime, garbage collection of transaction logs |
| Composability | Manual composition of lock order | Composable via atomically and combinators like orElse |
| API complexity | Simple but requires careful ownership handling | Higher learning curve but reduces boilerplate |
| Runtime requirements | None – part of base | Requires stm package and STM runtime enabled |
Trade‑Off Summary
- Use MVar when: You have a single, lightweight shared value (e.g., a counter or a queue) and you need maximum throughput with minimal overhead. MVar is ideal for classic producer/consumer patterns where the lock order is trivial.
- Use STM when: You need to atomically update several related values, avoid explicit lock ordering, or write composable concurrent code. STM shines in complex coordination scenarios, such as bank account transfers or multi‑resource reservations.
- Hybrid approach: Wrap an
MVarinside aSTMtransaction usingatomically . newTVarIOif you want to combine the low‑level control of MVar with STM’s composability.
Concrete Implementation: Counter Benchmark
The following example demonstrates how to increment a shared counter from ten threads, once using MVar and once using STM. The code is intentionally simple so you can copy it into a file named CounterBench.hs and run it with ghc -O2 CounterBench.hs.
{-# LANGUAGE OverloadedStrings #-}
import Control.Concurrent
import Control.Concurrent.Async
import Control.Concurrent.MVar
import Control.Concurrent.STM
import Control.Monad
import System.CPUTime
-- | Increment a counter 1,000,000 times using MVar
mvarCounter :: IO Int
mvarCounter = do
counter <- newMVar 0
let worker = replicateM_ 1000000 $ modifyMVar_ counter (return . (+1))
asyncs <- replicateM 10 (async worker)
mapM_ wait asyncs
readMVar counter
-- | Increment a counter 1,000,000 times using STM
stmCounter :: IO Int
stmCounter = do
counter <- newTVarIO 0
let worker = replicateM_ 1000000 $ atomically $ modifyTVar' counter (+1)
asyncs <- replicateM 10 (async worker)
mapM_ wait asyncs
readTVarIO counter
-- | Helper to time an IO action
timeIt :: IO a -> IO (a, Double)
timeIt action = do
start <- getCPUTime
result <- action
end <- getCPUTime
let elapsed = fromIntegral (end - start) / 1e12 -- seconds
return (result, elapsed)
main :: IO ()
main = do
putStrLn "Running MVar counter..."
(mvarResult, mvarTime) <- timeIt mvarCounter
putStrLn $ "Result: " ++ show mvarResult ++ ", Time: " ++ show mvarTime ++ " s"
putStrLn "Running STM counter..."
(stmResult, stmTime) <- timeIt stmCounter
putStrLn $ "Result: " ++ show stmResult ++ ", Time: " ++ show stmTime ++ " s"
To run the benchmark:
ghc -O2 CounterBench.hs
./CounterBench
Both implementations should output Result: 10000000. The Time values let you compare throughput. Remember to run the benchmark on a machine with multiple cores to observe contention effects.
Validation Checklist
- Correctness – Verify that both counters end at
10,000,000(10 threads × 1,000,000 increments). - Performance – Record elapsed time for each variant. If MVar is consistently faster and contention is low, choose MVar.
- Resource Usage – Use GHC’s profiling tools (
+RTS -p -h) to inspect allocation and GC impact. STM transactions generate more heap due to transaction logs. - Maintainability – Review code complexity. If you anticipate adding more shared variables, consider STM early.
Practical Tips
- When using
MVar, pairtakeMVarandputMVarin the same thread or usetryPutMVarto avoid deadlocks. - For STM, guard against infinite retries by using
orElseortimeoutcombinators. - Profile under realistic workloads before finalizing the decision; micro‑benchmarks can be misleading if the workload differs.
- Keep the STM runtime enabled even if you use
MVarfor other parts of the program; the overhead is negligible in most cases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.