Beautiful Concurrency
Simon Peyton Jones called it Beautiful Concurrency for a reason: once effects are values (Chapter 7), a transaction can simply be thrown away and retried if it conflicts — turning one of programming's hardest problems into a small, composable idea.
The problem locks were never good at
Say two threads need to transfer money between two bank accounts, safely, at the same time. The traditional answer is locks: acquire a lock on both accounts, do the transfer, release the locks. It sounds simple. In practice, it’s one of the most reliable sources of subtle, non-reproducible bugs in all of software engineering.
// C++: the classic lock-ordering hazard
void transfer(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lock1(from.mutex);
std::lock_guard<std::mutex> lock2(to.mutex); // deadlock if another
from.balance -= amount; // thread locks these
to.balance += amount; // two accounts in
} // the opposite order
If one thread calls transfer(a, b, ...) while another calls transfer(b, a, ...) at the same moment, each can end up holding one lock while waiting forever for the other — a deadlock. The fix (acquire locks in a globally agreed order, every time, everywhere in the codebase) is a discipline, not something the compiler can check for you. Forget it once, anywhere in a large codebase, and you have a bug that might not show up for months.
The same hazard shows up wherever locks do, regardless of language. Java’s synchronized keyword is a higher-level way to grab an object’s built-in lock, but it inherits the identical risk the moment two locks are involved:
// Java: the same lock-ordering hazard, with nicer syntax
void transfer(Account from, Account to, int amount) {
synchronized (from) { // deadlock if another thread
synchronized (to) { // locks these two accounts
from.balance -= amount; // in the opposite order
to.balance += amount;
}
}
}
synchronized (from) { ... } is doing exactly what std::lock_guard did above — acquiring from’s intrinsic lock for the duration of the block, and releasing it automatically when the block exits. The syntax is friendlier and the lock can’t accidentally be left held (Java handles that for you), but the actual bug is untouched: nesting synchronized (to) inside synchronized (from) deadlocks against a thread doing the reverse, for precisely the same reason the C++ version does. Cleaner lock syntax was never the missing ingredient — the missing ingredient is not using locks in the first place.
Software Transactional Memory
Haskell’s answer, popularized in Simon Peyton Jones’s talk and essay “Beautiful Concurrency,” is Software Transactional Memory (STM): instead of locking anything, you mark a block of code as a single transaction, and the runtime handles the rest.
import Control.Concurrent.STM
transfer :: TVar Int -> TVar Int -> Int -> STM ()
transfer from to amount = do
fromBal <- readTVar from
writeTVar from (fromBal - amount)
toBal <- readTVar to
writeTVar to (toBal + amount)
runTransfer :: TVar Int -> TVar Int -> Int -> IO ()
runTransfer from to amount = atomically (transfer from to amount)
TVar is a transactional mutable cell. atomically runs an STM action as if it were the only thing happening in the entire program: either the whole transaction commits, or — if another thread’s transaction touched the same TVars in a conflicting way — it’s silently thrown away and retried automatically, from the start, with no explicit error handling required at the call site.
Figure: Lock-based code pays its cost up front — Thread B blocks the moment it needs a locked resource, and the wrong lock order anywhere in the program risks deadlock. STM pays its cost only on an actual conflict: both threads run optimistically, and only the loser of a genuine race gets thrown away and retried.
Why purity is what makes this safe
Here is the payoff of everything Chapter 7 built up to: a transaction can be silently discarded and re-run only because Haskell’s type system guarantees an STM action can’t do anything irreversible. You cannot putStrLn inside an STM block — it simply doesn’t typecheck, because putStrLn :: String -> IO (), not STM (). The same purity that separates IO from ordinary functions in Chapter 7 is precisely what makes “just throw this away and try again” a safe strategy rather than a catastrophic one: there’s no way for a discarded transaction to have already printed to the screen, sent an email, or launched a missile.
It’s tempting to assume STM is “just locks, but automatic.” It isn’t — it’s a fundamentally different strategy (optimism instead of mutual exclusion), and it inherits different failure modes. A transaction that keeps conflicting with others can retry indefinitely under contention, a phenomenon called livelock, which STM doesn’t eliminate — it trades deadlock’s failure mode for a different one, generally far easier to reason about, but not a free lunch.
Composable blocking: retry and orElse
The genuinely startling part of Peyton Jones’s original pitch is how composable STM turns out to be. retry explicitly abandons the current transaction and waits until one of the TVars it read changes, then tries again:
withdraw :: TVar Int -> Int -> STM ()
withdraw account amount = do
balance <- readTVar account
if balance < amount
then retry -- not enough funds: wait and retry
else writeTVar account (balance - amount)
No condition variables, no manually notifying other threads when a balance changes — retry simply blocks until the transaction might succeed differently, because something it depended on has changed. And orElse composes two transactions, trying the second only if the first calls retry:
withdrawFromEither :: TVar Int -> TVar Int -> Int -> STM ()
withdrawFromEither a b amount =
withdraw a amount `orElse` withdraw b amount
withdrawFromEither tries to withdraw from account a; if that would need to retry, it falls through to trying b instead — a composable “try this, or else that” that would require careful, error-prone, hand-rolled coordination logic in a lock-based design, and here is two ordinary function calls and one operator.
This composability is exactly why Peyton Jones called the underlying paper “Composable Memory Transactions” (with Tim Harris, Maurice Herlihy, and Simon Marlow): lock-based code famously does not compose — combining two individually-correct lock-using functions into a bigger atomic operation usually requires rewriting both. orElse and atomically compose by construction, no rewriting needed.
Where this leaves you
STM is not the only concurrency tool Haskell offers — lightweight forkIO threads, the async library, and explicit MVars all have their place — but it’s the one that most directly demonstrates this book’s throughline: a discipline (purity) that looked, in Chapter 7, like mere bookkeeping turns out, here, to unlock something genuinely difficult in most other languages almost for free.
STM-style optimistic concurrency has spread well beyond Haskell — Clojure’s refs and software transactional memory, and the general database notion of optimistic concurrency control (attempt a transaction, roll back and retry on conflict, rather than locking rows up front) are close cousins of exactly this idea. Databases got there first, historically; Haskell’s contribution was showing the same idea works cleanly for in-memory concurrent programming too, once the type system can guarantee a transaction is safe to discard.
STM is, like IO and Maybe before it, a Monad — readTVar, writeTVar, and retry all chain together with the same >>= from Chapter 11, inside the same do-notation. Once you’ve internalized that a monad is just “a context you can sequence computations inside,” recognizing STM as one more instance, rather than a bespoke concurrency feature bolted onto the language, is exactly the payoff this book’s whole climb has been building toward.