100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Concurrency in Haskell

Learn how Haskell's lightweight threads, MVar, STM, and the async library make concurrent and parallel programming safer and more composable.

Practical HaskellAdvanced11 min readJul 10, 2026
Analogies

Lightweight Threads with forkIO

GHC provides lightweight 'green threads' via Control.Concurrent.forkIO, scheduled cooperatively by the GHC runtime system itself rather than the operating system, with only a few hundred bytes of overhead each -- cheap enough that spawning tens of thousands of them is routine. By default these green threads are multiplexed onto a single OS thread; compiling with -threaded and running the resulting binary with +RTS -N (or +RTS -N4 for exactly four capabilities) lets the runtime actually schedule them across multiple OS cores in true parallel.

🏏

Cricket analogy: GHC's green threads being so lightweight you can spawn thousands is like a franchise cricket league running thousands of local gully-cricket matches simultaneously on cheap rented grounds, versus the OS-thread equivalent of booking thousands of full international stadiums.

Sharing State Safely: MVar and STM

MVar is a mutable, lockable box holding at most one value -- a takeMVar blocks if it's empty and a putMVar blocks if it's already full, making it a simple building block for shared mutable state or a one-item channel. Software Transactional Memory (atomically, TVar, retry, orElse from Control.Concurrent.STM) instead lets you compose reads and writes across multiple shared variables into a single indivisible transaction, which the runtime automatically re-attempts if it conflicts with another transaction, making composable, deadlock-resistant coordination far easier to get right than manual locking.

🏏

Cricket analogy: An MVar acting as a single-slot lockable box is like a team having exactly one physical match ball -- only one fielder can hold it at a time -- whereas STM's atomically coordinating multiple TVars is like a review system that lets the third umpire check bat, ball, and stump-mic data together as one indivisible decision.

haskell
import Control.Concurrent
import Control.Concurrent.STM
import Control.Concurrent.Async
import Control.Exception (SomeException, try)
import Control.Monad (forM_)

-- MVar: simple shared counter guarded by a lock
incrementCounter :: MVar Int -> IO ()
incrementCounter mv = modifyMVar_ mv (return . (+1))

-- STM: atomic bank transfer across two TVars, never partially applied
transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amount = do
  fromBal <- readTVar from
  if fromBal < amount
    then retry
    else do
      writeTVar from (fromBal - amount)
      toBal <- readTVar to
      writeTVar to (toBal + amount)

main :: IO ()
main = do
  counter <- newMVar 0
  forM_ [1..1000] $ \_ -> forkIO (incrementCounter counter)

  alice <- newTVarIO 100
  bob   <- newTVarIO 0
  result <- try (atomically (transfer alice bob 30)) :: IO (Either SomeException ())
  case result of
    Left e  -> putStrLn ("Transfer failed: " ++ show e)
    Right _ -> putStrLn "Transfer succeeded"

  -- Structured concurrency: run two tasks, exceptions propagate properly
  (r1, r2) <- concurrently (readTVarIO alice) (readTVarIO bob)
  print (r1, r2)

Structured Concurrency with the async Library

Control.Concurrent.Async builds structured concurrency patterns on top of forkIO: async spawns a computation and returns a handle, wait blocks for its result, concurrently runs two actions in parallel and waits for both, and race runs two actions and returns as soon as either finishes, cancelling the other. Crucially, wait re-throws any exception the child thread encountered, which fixes a sharp edge of raw forkIO, where an exception thrown inside a forked thread is otherwise lost silently unless you install your own handler.

🏏

Cricket analogy: concurrently running two forked computations and waiting for both, propagating an exception from either back to the caller, is like a match director running the pitch inspection and the weather check in parallel but immediately calling off play if either one reports a problem.

A raw forkIO thread that throws an unhandled exception dies silently -- the exception is not automatically propagated to the parent thread or printed anywhere unless you install a handler, so a crashed worker thread can leave your program running in a subtly broken state; prefer Control.Concurrent.Async's async/wait, which properly re-throws a child thread's exception when you wait on it.

Deadlocks and the Value of STM's Composability

The classic deadlock happens when one thread locks MVar A then tries to take MVar B while another thread locks MVar B first and then tries to take MVar A -- both threads block forever, each waiting on a lock the other holds. STM sidesteps this class of bug: a transaction doesn't hold exclusive locks while running, and if it calls retry because a condition isn't met yet, the runtime simply re-runs the whole transaction automatically once one of the TVars it read changes, with orElse letting you supply a fallback transaction to try if the first one would retry. The tradeoff is that side effects like IO must not appear inside an atomically block, since a transaction may be re-run speculatively.

🏏

Cricket analogy: Two threads each holding one MVar and blocking waiting for the other's, deadlocking permanently, is like two fielders each holding one half of a damaged stump and each waiting for the other to hand it over first, whereas STM's automatic retry is like a third umpire simply re-running the whole review the moment any input changes.

Compile with -threaded and run with +RTS -N (or +RTS -N4 for exactly 4 capabilities) to let GHC's runtime actually schedule green threads across multiple OS cores in parallel, not just interleaved on one; use Control.Concurrent.getNumCapabilities at runtime to check how many capabilities are actually available.

  • forkIO creates lightweight green threads (a few hundred bytes each) scheduled cooperatively by GHC's runtime, not the OS.
  • Compiling with -threaded and running with +RTS -N lets green threads run in true parallel across multiple OS cores.
  • MVar provides a simple, lockable single-slot box for shared mutable state, but composing multiple MVars can deadlock.
  • STM (atomically, TVar, retry, orElse) coordinates updates across multiple shared variables as one indivisible transaction.
  • STM transactions automatically retry when blocked instead of deadlocking, but must avoid IO/side effects inside atomically.
  • Raw forkIO threads silently swallow unhandled exceptions; the async library's wait properly re-throws them to the caller.
  • concurrently and race from Control.Concurrent.Async provide structured, exception-safe patterns for running tasks in parallel.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#HaskellStudyNotes#ConcurrencyInHaskell#Concurrency#Haskell#Lightweight#Threads#StudyNotes#SkillVeris#ExamPrep