Skip to content
Concurrency: STM, MVar, async
step 1/7

Reading — step 1 of 7

Learn

~3 min readMonad Transformers, GADTs, Concurrency

Haskell's concurrency story is among the cleanest of any language — a combination of cheap green threads (forkIO), shared variables (MVar), and Software Transactional Memory (STM). Real World Haskell's concurrency chapter and the Parallel and Concurrent Programming in Haskell book are the references.

forkIO — lightweight threads

import Control.Concurrent

main :: IO ()
main = do
    forkIO $ do
        threadDelay 1000000   -- 1 second
        putStrLn "hello from thread"
    putStrLn "main continues"
    threadDelay 2000000        -- wait so the thread completes

forkIO is super cheap — millions of threads possible. The Haskell runtime maps them onto OS threads efficiently.

MVar — shared mutable variable with locking

import Control.Concurrent.MVar

main :: IO ()
main = do
    counter <- newMVar 0
    forkIO $ replicateM_ 1000 (modifyMVar_ counter (return . (+1)))
    forkIO $ replicateM_ 1000 (modifyMVar_ counter (return . (+1)))
    threadDelay 100000
    final <- readMVar counter
    print final         -- 2000 (eventually)
  • newMVar — create with initial value
  • takeMVar / putMVar — atomic take and put (blocks if empty/full)
  • modifyMVar_ — atomic update; safe for compound operations
  • readMVar — read without removing

Blocking semantics:

  • takeMVar on empty MVar blocks until something arrives
  • putMVar on full MVar blocks until something is taken

MVar is essentially a one-cell channel — useful for synchronization too.

STM — Software Transactional Memory

The killer feature: composable atomic operations.

import Control.Concurrent.STM

main :: IO ()
main = do
    balance1 <- newTVarIO 100
    balance2 <- newTVarIO 50
    
    -- Atomic transfer:
    atomically $ do
        b1 <- readTVar balance1
        when (b1 < 30) retry        -- block until balance1 has 30+
        writeTVar balance1 (b1 - 30)
        b2 <- readTVar balance2
        writeTVar balance2 (b2 + 30)

The atomically block runs as a transaction:

  • If conflicting changes happen elsewhere, the transaction aborts and retries
  • retry blocks until SOME relevant variable changes (e.g., until balance1 has enough money)
  • Whole compound operation is atomic — no halfway states visible

Why STM is great:

  • Compositional — combine smaller transactions into bigger ones with do
  • No deadlocks — no explicit locks
  • Optimistic — no blocking unless retry is hit
  • Type system enforces purity (no IO inside atomically)

async — futures with cancellation

The async library provides a Promise-like interface:

import Control.Concurrent.Async

main :: IO ()
main = do
    a <- async (heavyComputation 1)
    b <- async (heavyComputation 2)
    
    resultA <- wait a
    resultB <- wait b
    print (resultA + resultB)
  • async :: IO a -> IO (Async a) — start an action concurrently
  • wait :: Async a -> IO a — block for result
  • cancel :: Async a -> IO () — kill the async
  • concurrently :: IO a -> IO b -> IO (a, b) — run two in parallel
  • race :: IO a -> IO b -> IO (Either a b) — first one wins, other is cancelled

Recommended over raw forkIO + MVar for most concurrent code.

Picking your concurrency tool

NeedTool
Lightweight async tasksasync library
Compositional atomic state changesSTM
Single shared variable with simple updateMVar
Many small communicating threadsTQueue (STM-based queue)
CPU parallelism (pure data)parallel package's par

Comparison with other languages

  • vs Go's goroutines — similar lightweight thread model; STM cleaner than Go's channels for compound transactions
  • vs Java's threads — Haskell threads cheaper, no memory model surprises (immutability default)
  • vs Erlang processes — Erlang processes are isolated (no shared state); Haskell shares via MVar/STM. Different trade-offs.

Common mistakes

  • MVar for compound updates — atomic for individual ops, but multi-step logic can race. Use modifyMVar for read-modify-write.
  • STM that does IO — illegal; the transaction may retry, so side effects are forbidden inside atomically.
  • Forgetting to wait — async with no wait/cancel can leak. Use withAsync for scope-bounded async.
  • forkIO without resource cleanup — children don't auto-die when parent does. async + withAsync solve this.
  • Heavy lock contention with MVar — for many threads contending one variable, STM scales better.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…

Concurrency: STM, MVar, async — Haskell Advanced