Concurrency
Haskell has a substantial concurrency story built on lightweight threads: GHC’s runtime multiplexes green threads onto a small pool of OS threads, admitting tens of thousands of concurrent threads at low cost. The principal primitives — forkIO, MVar, STM, Chan, async — admit a range of concurrency styles, from low-level shared-memory primitives to high-level async-task composition. Software Transactional Memory (STM) is one of Haskell’s distinguishing contributions: a composable, lock-free concurrency mechanism that admits atomic operations on multiple variables. Combined with pure code’s automatic thread-safety (immutable values cannot be raced), Haskell’s concurrency story is one of the most elaborate among mainstream languages.
This page covers forkIO, MVar, STM, async, parallelism, the Java memory model equivalents, and the conventions for using each.
Lightweight threads with forkIO
forkIO from Control.Concurrent starts a lightweight thread:
import Control.Concurrent
main = do
forkIO $ do
putStrLn "Hello from the new thread"
putStrLn "Hello from the main thread"
threadDelay 1000000 -- 1 second; let the new thread complete
The forkIO returns immediately with a ThreadId; the new thread runs concurrently. Lightweight threads are cheap — the GHC runtime can manage thousands of them — and they multiplex onto a small pool of OS threads (typically one per CPU core).
The principal operations:
forkIO :: IO () -> IO ThreadId
killThread :: ThreadId -> IO ()
threadDelay :: Int -> IO () -- delay in microseconds
myThreadId :: IO ThreadId
yield :: IO () -- hint a context switch
The lightweight-thread model is similar to Erlang’s processes or Go’s goroutines; the difference from OS threads is the cost — millions of lightweight threads fit in memory; OS threads are limited to thousands.
forkIO versus async
For most concurrent work, the async library (treated below) is preferable to raw forkIO — it provides a typed Async a value, exception handling, and cancellation. Raw forkIO is appropriate when:
- Fire-and-forget work is genuinely intended.
- The thread’s result is not needed.
- The thread’s lifecycle is managed through some other mechanism.
For “spawn work, get the result”, async is the conventional choice.
MVar for synchronisation
MVar (mutable variable) is a primitive synchronisation device — a box that may be empty or hold a value:
import Control.Concurrent
newMVar :: a -> IO (MVar a)
newEmptyMVar :: IO (MVar a)
takeMVar :: MVar a -> IO a -- blocks if empty
putMVar :: MVar a -> a -> IO () -- blocks if full
readMVar :: MVar a -> IO a -- read without taking
modifyMVar_ :: MVar a -> (a -> IO a) -> IO () -- atomic update
modifyMVar :: MVar a -> (a -> IO (a, b)) -> IO b
MVar admits two principal patterns:
Mutex (one-element MVar)
withMutex :: MVar () -> IO a -> IO a
withMutex mvar action = do
takeMVar mvar
result <- action `finally` putMVar mvar ()
return result
main = do
lock <- newMVar ()
forkIO $ withMutex lock (criticalSection 1)
forkIO $ withMutex lock (criticalSection 2)
threadDelay 1000000
The pattern admits exclusive access to a critical section.
Channel-style communication
producer :: MVar Int -> IO ()
producer mvar = do
forM_ [1..10] $ \n -> do
putMVar mvar n
threadDelay 100000
consumer :: MVar Int -> IO ()
consumer mvar = forever $ do
n <- takeMVar mvar
putStrLn ("got " ++ show n)
The pattern admits one-direction communication between threads.
modifyMVar for atomic updates
import Control.Concurrent
main = do
counter <- newMVar (0 :: Int)
let increment = modifyMVar_ counter (return . (+ 1))
mapConcurrently_ (const increment) [1..1000]
n <- readMVar counter
print n
modifyMVar_ admits a function a -> IO a to atomically update the value; modifyMVar admits returning an additional result.
MVar discipline
The principal pitfalls:
- Deadlock: two threads each waiting on the other’s
MVar. The conventional defence is to acquireMVars in a fixed order. - Unbounded blocking: an
MVaroperation may block forever; the conventional defence is timeouts (Control.Concurrent.timeout). - Lost updates:
takeMVar/putMVaris not atomic if the thread is interrupted between them. UsemodifyMVarfor atomic updates.
MVar is a low-level primitive; for shared state, STM (treated below) is often preferable.
STM: Software Transactional Memory
STM is one of Haskell’s distinguishing contributions: a composable, lock-free concurrency mechanism with atomic blocks:
import Control.Concurrent.STM
newTVar :: a -> STM (TVar a)
readTVar :: TVar a -> STM a
writeTVar :: TVar a -> a -> STM ()
atomically :: STM a -> IO a
retry :: STM a
orElse :: STM a -> STM a -> STM a
The STM monad admits operations on TVar (transactional variables); atomically runs an STM action as a single atomic transaction:
import Control.Concurrent.STM
transferAccount :: TVar Int -> TVar Int -> Int -> STM ()
transferAccount from to amount = do
fromBalance <- readTVar from
when (fromBalance < amount) retry -- block until sufficient
writeTVar from (fromBalance - amount)
toBalance <- readTVar to
writeTVar to (toBalance + amount)
main = do
accountA <- atomically (newTVar 1000)
accountB <- atomically (newTVar 0)
atomically (transferAccount accountA accountB 100)
The mechanism’s contract:
- Atomicity: the entire
STMblock runs atomically; if any step fails or is incompatible with concurrent transactions, the block is rolled back and retried. - Composability: small
STMactions compose into larger ones; the composition remains atomic. - No locks: the runtime uses optimistic concurrency; conflicting transactions retry rather than wait.
- Pure within
STM: theSTMmonad cannot perform IO. The mechanism prevents side effects that cannot be rolled back.
retry and orElse
retry blocks until any of the read variables changes:
waitForCondition :: TVar Bool -> STM ()
waitForCondition tvar = do
done <- readTVar tvar
if done then return () else retry
main = do
flag <- newTVarIO False
forkIO $ do
threadDelay 1000000
atomically (writeTVar flag True)
atomically (waitForCondition flag)
putStrLn "done"
orElse admits an alternative if the first action retries:
takeFromEither :: TVar (Maybe a) -> TVar (Maybe a) -> STM a
takeFromEither v1 v2 = takeFrom v1 `orElse` takeFrom v2
where
takeFrom v = do
m <- readTVar v
case m of
Nothing -> retry
Just x -> do
writeTVar v Nothing
return x
The mechanism is the foundation of the Control.Concurrent.STM.TBQueue, TQueue, TChan and similar synchronised data structures in the standard libraries.
When to use STM
The conventional contemporary advice:
- Use STM for shared state across threads; the composability and atomicity admit clean code.
- Use MVar for simple producer-consumer patterns when STM would be overkill.
- Use atomic counters (e.g.,
Data.Atomics) for frequent simple increments where the overhead of STM matters.
STM has a substantial overhead (transaction tracking, rollback), but for non-trivial concurrent algorithms it is substantially easier to write correctly than lock-based code.
async for tasks
The async library provides a typed task abstraction:
import Control.Concurrent.Async
async :: IO a -> IO (Async a)
wait :: Async a -> IO a
waitCatch :: Async a -> IO (Either SomeException a)
cancel :: Async a -> IO ()
withAsync :: IO a -> (Async a -> IO b) -> IO b
mapConcurrently :: Traversable t => (a -> IO b) -> t a -> IO (t b)
mapConcurrently_ :: Foldable t => (a -> IO b) -> t a -> IO ()
race :: IO a -> IO b -> IO (Either a b)
concurrently :: IO a -> IO b -> IO (a, b)
The principal operations:
import Control.Concurrent.Async
main = do
-- Run several tasks concurrently:
[r1, r2, r3] <- mapConcurrently fetch ["url1", "url2", "url3"]
-- Race two tasks:
result <- race (slowOperation 5) (timeoutAfter 1000000)
-- Concurrent and gather both:
(a, b) <- concurrently (queryDatabase userId) (loadConfig)
The async library is the conventional contemporary choice for concurrent IO; it provides exception handling, cancellation, and composition that raw forkIO does not.
withAsync for scoped tasks
import Control.Concurrent.Async
main = withAsync backgroundTask $ \task -> do
-- main work
waitOrCancel task
withAsync ensures the background task is cancelled when the main work exits (even on exception). The pattern is the conventional structured-concurrency idiom.
Cancellation
async admits clean cancellation:
main = do
task <- async slowOperation
threadDelay 1000000
cancel task -- raises an async exception in the task
result <- waitCatch task
case result of
Left e -> putStrLn ("cancelled: " ++ show e)
Right v -> putStrLn ("got: " ++ show v)
The cancellation is cooperative: it raises an AsyncCancelled exception in the task, which the task may catch (through the standard bracket/finally mechanisms) to clean up.
Channels
For typed channel-based communication, the standard library provides several channel types:
| Type | Module | Notes |
|---|---|---|
Chan | Control.Concurrent.Chan | Unbounded; basic |
MVar | Control.Concurrent.MVar | One-element; basic |
TChan | Control.Concurrent.STM.TChan | STM-based |
TQueue | Control.Concurrent.STM.TQueue | STM-based; faster than TChan |
TBQueue | Control.Concurrent.STM.TBQueue | STM-based; bounded |
import Control.Concurrent.STM
import Control.Concurrent.STM.TQueue
main = do
queue <- atomically newTQueue
forkIO $ forM_ [1..10] $ \n ->
atomically (writeTQueue queue n)
forkIO $ forever $ do
n <- atomically (readTQueue queue)
putStrLn ("got " ++ show n)
threadDelay 1000000
The STM-based channels admit composing channel operations with other STM actions atomically.
Pure parallelism
For pure computations (no side effects), Haskell admits parallelism without explicit concurrency:
import Control.Parallel
import Control.Parallel.Strategies
main = do
let results = parMap rdeepseq fib [40, 41, 42, 43]
print results
fib :: Int -> Int
fib 0 = 0
fib 1 = 1
fib n = fib (n - 1) + fib (n - 2)
The parMap from Control.Parallel.Strategies admits parallel evaluation of pure expressions. The rdeepseq strategy ensures each result is fully evaluated.
The principal primitives:
par— sparks parallel evaluation; the result is the second argument’s value.pseq— sequencing primitive (similar toseqbut with stronger guarantees).Strategy a— a functiona -> Eval athat evaluates a value.parMap,parTraverse— parallel maps.
The mechanism is the conventional Haskell tool for deterministic parallelism — no race conditions, no synchronisation, just multiple cores working on a pure computation.
Lock-free patterns
For frequently-updated shared counters, Data.IORef.atomicModifyIORef' and Data.Atomics:
import Data.IORef
main = do
counter <- newIORef (0 :: Int)
let increment = atomicModifyIORef' counter (\n -> (n + 1, ()))
mapConcurrently_ (const increment) [1..10000]
n <- readIORef counter
print n
atomicModifyIORef' is faster than MVar-based counters and avoids the rollback overhead of STM. The conventional choice for simple atomic updates.
Thread-local storage
Haskell does not have built-in thread-local storage in the conventional sense (the IORef per-thread idiom is built on top of Concurrent.MVar.ThreadLocal from concurrent packages). For most use cases, the Reader monad with explicit threading of state is the conventional Haskell substitute.
The Java Memory Model equivalent
Haskell’s memory model:
- Pure values are immutable; reads from a pure value produce the same result regardless of when they happen.
MVaroperations include implicit memory barriers;takeMVar/putMVarhappen atomically.STMtransactions are atomic with respect to other STM transactions.IORefdoes not provide implicit synchronisation;atomicModifyIORef'is the synchronised form.- Asynchronous exceptions are delivered when the runtime decides;
maskadmits delaying delivery.
The combination is substantially simpler than the Java Memory Model — most of Haskell’s data is immutable by default, and the synchronisation primitives carry their own guarantees. The cases where memory-model details matter are typically also cases where STM or higher-level primitives are the right tool.
Common patterns
Producer-consumer
import Control.Concurrent
import Control.Concurrent.STM
import Control.Concurrent.STM.TBQueue
main = do
queue <- atomically (newTBQueue 100)
-- Producer:
forkIO $ forM_ items $ \item ->
atomically (writeTBQueue queue item)
-- Consumer:
forever $ do
item <- atomically (readTBQueue queue)
process item
The TBQueue admits bounded buffering — producers block if the queue is full, consumers block if empty.
Worker pool
import Control.Concurrent
import Control.Concurrent.Async
main = do
let workItems = [1..100]
results <- mapConcurrently process workItems
mapM_ print results
mapConcurrently admits “process each item in a separate task, collect results”. For substantial workloads, a bounded pool is conventional through libraries like pooled-io.
Timeout
import Control.Concurrent.Async
import Control.Concurrent (threadDelay)
withTimeout :: Int -> IO a -> IO (Maybe a)
withTimeout micros action = do
result <- race (threadDelay micros) action
case result of
Left () -> return Nothing
Right a -> return (Just a)
The race admits “first to complete wins”; the timeout is one alternative.
Structured concurrency with withAsync
main = do
contents <- withAsync slowFetch $ \task -> do
liftIO checkProgress
wait task
print contents
-- task is cancelled if main exits, even on exception
The pattern admits “spawn a background task that is cancelled with the main work”.
STM-based event loop
import Control.Concurrent.STM
import Control.Concurrent.STM.TVar
main = do
state <- atomically (newTVar EmptyState)
-- Worker thread:
forkIO $ forever $ atomically $ do
s <- readTVar state
case nextAction s of
Just (action, newState) -> do
writeTVar state newState
return action
Nothing -> retry
-- Update from main:
atomically (modifyTVar state addEvent)
The pattern admits coordination through STM transactions; the retry blocks the worker until the state changes.
A note on the conventional Haskell discipline
The principal contemporary Haskell concurrency advice:
- Use
asyncfor tasks — the typedAsync ais substantially safer thanforkIOdirectly. - Use
STMfor shared state — composable, atomic, no deadlocks. - Use
TBQueue/TQueuefor channels — STM-based queues are the conventional choice. - Use pure parallelism for pure computations —
parMap,Eval,Strategy. - Use
withAsyncfor structured concurrency — guarantees cleanup even on exception. - Avoid
forkIOdirectly — useasyncinstead. - Avoid
MVarfor non-trivial state — useSTM. - Avoid raw
IOReffor shared state — useatomicModifyIORef'orSTM.
The combination is one of the language’s distinguishing strengths. Haskell’s concurrency story — lightweight threads + STM + immutability-by-default + a typed task abstraction — admits substantial concurrent programs with relatively few deadlocks and race conditions compared with imperative alternatives.