Polyglot
Languages Haskell concurrency
Haskell § concurrency

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 acquire MVars in a fixed order.
  • Unbounded blocking: an MVar operation may block forever; the conventional defence is timeouts (Control.Concurrent.timeout).
  • Lost updates: takeMVar/putMVar is not atomic if the thread is interrupted between them. Use modifyMVar for 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 STM block runs atomically; if any step fails or is incompatible with concurrent transactions, the block is rolled back and retried.
  • Composability: small STM actions compose into larger ones; the composition remains atomic.
  • No locks: the runtime uses optimistic concurrency; conflicting transactions retry rather than wait.
  • Pure within STM: the STM monad 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:

TypeModuleNotes
ChanControl.Concurrent.ChanUnbounded; basic
MVarControl.Concurrent.MVarOne-element; basic
TChanControl.Concurrent.STM.TChanSTM-based
TQueueControl.Concurrent.STM.TQueueSTM-based; faster than TChan
TBQueueControl.Concurrent.STM.TBQueueSTM-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 to seq but with stronger guarantees).
  • Strategy a — a function a -> Eval a that 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.
  • MVar operations include implicit memory barriers; takeMVar/putMVar happen atomically.
  • STM transactions are atomic with respect to other STM transactions.
  • IORef does not provide implicit synchronisation; atomicModifyIORef' is the synchronised form.
  • Asynchronous exceptions are delivered when the runtime decides; mask admits 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 async for tasks — the typed Async a is substantially safer than forkIO directly.
  • Use STM for shared state — composable, atomic, no deadlocks.
  • Use TBQueue/TQueue for channels — STM-based queues are the conventional choice.
  • Use pure parallelism for pure computationsparMap, Eval, Strategy.
  • Use withAsync for structured concurrency — guarantees cleanup even on exception.
  • Avoid forkIO directly — use async instead.
  • Avoid MVar for non-trivial state — use STM.
  • Avoid raw IORef for shared state — use atomicModifyIORef' or STM.

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.