Choosing Concurrency Abstractions in Haskell: STM, Async, and forkIO
A decision guide for Haskell developers choosing between forkIO, async, and STM for concurrency, featuring a comparison table and a concrete STM implementation.
01 Apr 2026, 16:19 UTC

The Coordination Problem
Managing concurrent execution in Haskell often leads to a choice between three distinct levels of abstraction: forkIO, async, and STM (Software Transactional Memory). The primary challenge is not starting a thread, but managing the communication and synchronization between them without introducing race conditions or deadlocks.
The core decision depends on whether you need to perform independent parallel tasks, manage shared mutable state across multiple variables, or maintain absolute control over low-level thread overhead.
Comparison of Concurrency Primitives
| Feature | forkIO | async | STM |
|---|---|---|---|
| Abstraction Level | Low (Primitive) | High (Task-based) | High (Transactional) |
| Primary Use Case | Lightweight backgrounding | Parallel I/O or computation | Consistent shared state |
| Error Handling | Manual/Silent | Propagated to parent | Atomic rollback |
| Composition | None | Composable (wait/cancel) | Highly Composable |
| Overhead | Minimal | Low | Moderate (Retry logic) |
Trade-off Analysis
forkIO: The Low-Level Primitive
forkIO spawns a lightweight Haskell thread. It is the foundation for the other two options. Use this only when you have a very specific performance requirement or are building your own concurrency primitive. Because it does not return a handle to the thread, you must implement your own mechanism (like an MVar) to signal completion or handle exceptions. Failure to do so can lead to "silent deaths" where a thread crashes without notifying the main application.
async: Managing Task Lifecycles
The async library wraps forkIO to provide a handle (an Async a value). This allows you to wait for a result or cancel the operation. It is the ideal choice for "fork-join" patterns—where you start several independent tasks and need their results to proceed. A critical risk with async is exception handling; while it propagates exceptions to the parent thread upon wait, using waitCatch is necessary if you need to handle the failure locally without crashing the parent.
STM: Atomic State Coordination
STM treats memory updates like database transactions. You wrap operations in an atomically block, ensuring that all changes to TVar (Transactional Variables) happen entirely or not at all. This eliminates common locking bugs like priority inversion or deadlocks caused by acquiring locks in the wrong order. However, STM is not for I/O; you cannot perform side effects (like printing or network calls) inside an atomically block because the runtime may retry the transaction multiple times if contention is detected.
Implementation: Coordinating State with STM
The following example demonstrates using STM to manage a shared counter and a message queue between two threads. This ensures the counter and the queue are updated as a single atomic unit.
-- Required extensions: None
-- Dependencies: stm, base
import Control.Concurrent
import Control.Concurrent.STM
import Control.Monad (replicateM_)
-- A shared state containing a counter and a list of logs
data AppState = AppState
{ counter :: TVar Int
, logs :: TVar [String]
}
-- Atomic operation: increments counter and adds a log entry
incrementAndLog :: AppState -> String -> STM ()
incrementAndLog state msg = do
curr <- readTVar (counter state)
writeTVar (counter state) (curr + 1)
currLogs <- readTVar (logs state)
writeTVar (logs state) (msg : currLogs)
main :: IO ()
main = do
-- Initialize state
s <- atomically $ AppState <-< TVar 0 <-< TVar []
-- Spawn two workers using forkIO
-- In a production app, 'async' would be preferred for better error handling
forkIO $ replicateM_ 1000 $ atomically (incrementAndLog s "Worker A")
forkIO $ replicateM_ 1000 $ atomically (incrementAndLog s "Worker B")
-- Wait briefly for workers to finish (simplified for example)
threadDelay 100000
-- Verify result
(finalCount, finalLogs) <- atomically $ do
c <- readTVar (counter s)
l <- readTVar (logs s)
return (c, l)
putStrLn $ "Final Count: " ++ show finalCount
putStrLn $ "Log entries: " ++ show (length finalLogs)
Verification and Constraints
To verify this implementation, compile with GHC (version 8.10 or later) using the -threaded flag to enable the multi-threaded runtime:
ghc -threaded -O2 Main.hs
./Main +RTS -N
Expected Result: The final count must be exactly 2000. If IORef or MVar were used without careful locking, race conditions would likely result in a count lower than 2000.
Limitations
- STM Contention: If hundreds of threads attempt to write to the same
TVarsimultaneously, performance will drop due to frequent transaction retries. - Memory:
asynchandles must be explicitly waited on or cancelled; otherwise, you may leak resources in long-running processes. - Runtime: All three methods require the
-threadedruntime flag to utilize multiple CPU cores.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.