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.
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.
forkIOcreates lightweight green threads (a few hundred bytes each) scheduled cooperatively by GHC's runtime, not the OS.- Compiling with
-threadedand running with+RTS -Nlets 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
asynclibrary'swaitproperly re-throws them to the caller. concurrentlyandracefrom Control.Concurrent.Async provide structured, exception-safe patterns for running tasks in parallel.
Practice what you learned
1. What makes GHC's forkIO threads 'lightweight' compared to OS threads?
2. What is required to actually run Haskell green threads in parallel across multiple CPU cores, not just interleaved on one?
3. What does `retry` do inside an STM transaction?
4. Why is a classic MVar deadlock possible but an equivalent STM transaction typically isn't?
5. What advantage does Control.Concurrent.Async's `wait` have over a raw forkIO'd thread?
Was this page helpful?
You May Also Like
Lazy Evaluation in Haskell
How Haskell's non-strict evaluation model defers computation until results are needed, enabling infinite structures but requiring care around space leaks.
Error Handling in Haskell
Learn how Haskell represents failure explicitly using Maybe, Either, and exceptions, and when to reach for each approach.
Testing Haskell Code
Learn how to write unit tests, property-based tests, and doctest examples for Haskell code using HUnit, Hspec, and QuickCheck.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics