CMPUT-274 · Lesson 10 of 12 · 29 min
Concurrency II: synchronization, deadlock and message passing
Lesson 9 showed that races arise from shared mutable state. There are two broad remedies. The first keeps the shared state but controls access to it, so that only one thread at a time can perform a critical sequence of operations: this is synchronization with locks, condition variables and related primitives. The second removes the shared state altogether: threads or processes own their data and communicate by sending messages through channels or mailboxes. This lesson covers both, along with the new hazard that locking introduces, deadlock. The implementation of these primitives inside an operating system, with atomic instructions and kernel scheduling, is covered in CMPUT-379.
Critical sections and mutual exclusion
A critical section is a piece of code that accesses shared state and must not run in more than one thread at a time. Mutual exclusion guarantees that it does not. A lock, also called a mutex, is the basic tool: a thread acquires the lock before entering the critical section and releases it afterwards, and any other thread that tries to acquire it waits until it is free. With the read, add and write of `count += 1` inside a critical section, no other thread can slip in between them, so no update is lost.
import sys, threading
sys.setswitchinterval(1e-6) # switch threads very often to expose the race
def run(n_threads, n_incr, use_lock):
count = 0
lock = threading.Lock()
def work():
nonlocal count
for _ in range(n_incr):
if use_lock:
with lock: # acquire ... release, even if an exception occurs
count += 1
else:
tmp = count # read
for _ in range(5): pass # widen the window between read and write
count = tmp + 1 # write
ts = [threading.Thread(target=work) for _ in range(n_threads)]
for t in ts: t.start()
for t in ts: t.join()
return count
expected = 4 * 50_000
with_lock = run(4, 50_000, True)
without = run(4, 50_000, False)
print("expected:", expected)
print("with lock:", with_lock, "| correct:", with_lock == expected)
print("without lock lost updates:", without < expected)expected: 200000 with lock: 200000 | correct: True without lock lost updates: True
The unlocked run reliably loses updates here because the artificial delay between read and write gives other threads time to interleave; in real code the window is smaller, so the bug appears less often, which makes it worse, not better. The `with lock:` form is important. It releases the lock however the block exits, including by an exception, so a failure inside the critical section cannot leave the lock held forever and freeze every other thread.
Locks have costs. Waiting threads do no useful work, so a heavily contended lock serializes the program and removes the benefit of concurrency. Holding a lock while doing slow work, such as I/O, makes this worse. The guideline is to keep critical sections short and to protect the smallest amount of state that keeps the invariant. A common design is to make each lock guard one clearly identified group of data, documented next to the data, so that everyone knows which lock to take.
Worked problem
Finding the critical section
Two threads run `if stock > 0: stock -= 1; sold += 1` to sell tickets, starting from stock = 1. What can go wrong, and what must the critical section contain?
Show solution
- Thread A checks stock > 0 and sees 1. Before A decrements, thread B also checks and sees 1.
- Both decrement: stock becomes −1 and two tickets are sold although only one existed.
- This is a check-then-act race: the decision is based on a value that may change before the action.
- The check and the update must form one critical section: with lock: if stock > 0: stock -= 1; sold += 1.
Answer: The lock must cover both the test and the update. Locking only the decrement still lets both threads pass the test, so the oversell remains.
Condition variables and coordination
Sometimes a thread must wait not for a lock but for a condition: a queue to become non-empty, a buffer to have space, a task to finish. A condition variable lets a thread release a lock and sleep until another thread signals that the condition may now hold. Higher-level tools are built from locks and condition variables: semaphores limit how many threads can use a resource at once, events let one thread signal others, and barriers make a group of threads wait until all have arrived. In Python, the thread-safe `queue.Queue` packages the most common pattern, producer and consumer, so that you rarely need raw condition variables.
| Primitive | Purpose | Python |
|---|---|---|
| Lock (mutex) | Mutual exclusion for a critical section | threading.Lock |
| Reentrant lock | A lock the same thread may acquire repeatedly | threading.RLock |
| Condition variable | Wait for a condition while releasing a lock | threading.Condition |
| Semaphore | Allow at most N threads into a section | threading.Semaphore |
| Event | One thread signals; others wait for the signal | threading.Event |
| Barrier | Threads wait until all have reached a point | threading.Barrier |
| Thread-safe queue | Pass items between threads with blocking get and put | queue.Queue |
Deadlock
Locks solve races but introduce a new failure: deadlock, in which two or more threads each wait for a lock held by another, so none can proceed. The classic example transfers money between accounts. If one thread transfers from account A to B by locking A then B, and another transfers from B to A by locking B then A, each can acquire its first lock and then wait forever for the second. The program does not crash; it simply stops making progress, which can be harder to diagnose than a crash.
Edward Coffman and colleagues identified four conditions that must all hold for deadlock to occur. Mutual exclusion: a resource is held by one thread at a time. Hold and wait: a thread holds one resource while waiting for another. No pre-emption: a resource cannot be forcibly taken away. Circular wait: there is a cycle of threads, each waiting for a resource held by the next. Preventing any one condition prevents deadlock. In practice, the easiest to break is circular wait: if every thread acquires locks in the same global order, a cycle is impossible.
import itertools
def philosophers(n, ordered):
"""Simulate n dining philosophers taking forks step by step in every round-robin start order.
Each philosopher needs forks i and (i+1) % n. Returns True if some schedule deadlocks."""
def needs(i):
a, b = i, (i + 1) % n
return (min(a, b), max(a, b)) if ordered else (a, b) # ordered: lower-numbered fork first
for start in itertools.permutations(range(n)):
owner = {} # fork -> philosopher
holding = {i: [] for i in range(n)}
# each philosopher, in this order, tries to pick up its first fork
for i in start:
f = needs(i)[0]
if f not in owner:
owner[f] = i; holding[i].append(f)
# then everyone tries the second fork; deadlock if nobody can get both
can_eat = [i for i in range(n)
if len(holding[i]) == 1 and needs(i)[1] not in owner]
if all(len(holding[i]) == 1 for i in range(n)) and not can_eat:
return True, start
return False, None
for ordered in (False, True):
dead, example = philosophers(5, ordered)
label = "global fork order" if ordered else "left fork first"
print(f"{label:>17}: deadlock possible = {dead}" + (f", e.g. start order {example}" if dead else ""))left fork first: deadlock possible = True, e.g. start order (0, 1, 2, 3, 4) global fork order: deadlock possible = False
In the dining philosophers problem, introduced by Dijkstra, five philosophers sit at a round table with one fork between each pair, and each needs both neighbouring forks to eat. If every philosopher picks up the left fork first, all five can hold one fork and wait forever for the other: a circular wait. The simulation finds such a schedule. Numbering the forks and always taking the lower-numbered fork first means the last philosopher reaches for fork 0 before fork 4, which breaks the cycle, and the simulation confirms that no schedule deadlocks.
Worked problem
Fixing the transfer deadlock
transfer(a, b, amt) locks a.lock, then b.lock, then moves money. Two threads call transfer(x, y, 10) and transfer(y, x, 5) at the same time. Show how they can deadlock and fix it.
Show solution
- Thread 1 locks x; thread 2 locks y.
- Thread 1 now waits for y, held by thread 2, and thread 2 waits for x, held by thread 1: a circular wait.
- Fix: order the locks by a unique key, for example account number: first, second = sorted([a, b], key=lambda acc: acc.number).
- Both threads now lock the lower-numbered account first, so one acquires both locks while the other waits, and no cycle can form.
Answer: Acquire locks in a consistent global order, such as by account number. Other options are a timeout on lock acquisition with retry, or a single coarse lock for all transfers, which is simpler but limits concurrency.
Deadlock has relatives. In livelock, threads keep reacting to each other without progress, like two people in a corridor who both step aside in the same direction repeatedly. In starvation, a thread waits indefinitely because others keep getting the resource first. Priority inversion occurs when a low-priority thread holds a lock that a high-priority thread needs, while medium-priority threads keep the low-priority one from running; it famously caused repeated resets on the Mars Pathfinder lander in 1997.
Message passing
Message passing avoids shared state entirely. Each concurrent activity owns its data, and activities interact only by sending messages, values copied or transferred from one to another. Because nobody else can touch an activity's data, there is nothing to lock. Two styles dominate. In the communicating sequential processes style, from Tony Hoare's 1978 work and used by Go, activities communicate through channels: typed pipes into which one side sends and from which the other receives. In the actor model, from Carl Hewitt's work in 1973 and used by Erlang and Akka, each actor has a mailbox; it processes one message at a time, and in response can update its private state, send messages and create new actors.
import threading, queue
tasks, results = queue.Queue(), queue.Queue()
STOP = object() # sentinel value tells workers to finish
def worker(wid):
while True:
item = tasks.get()
if item is STOP:
break
n = item
results.put((n, wid, sum(range(n + 1)))) # send the answer back as a message
workers = [threading.Thread(target=worker, args=(w,)) for w in range(3)]
for w in workers: w.start()
for n in [10, 100, 1000, 5, 50]:
tasks.put(n) # producer sends work
for _ in workers:
tasks.put(STOP)
for w in workers: w.join()
answers = sorted(results.get() for _ in range(results.qsize()))
for n, _, total in answers:
print(f"sum 0..{n} = {total}")
print("no shared variables were mutated; only queues were used")sum 0..5 = 15 sum 0..10 = 55 sum 0..50 = 1275 sum 0..100 = 5050 sum 0..1000 = 500500 no shared variables were mutated; only queues were used
The workers never touch a shared variable. Work arrives through one queue and answers leave through another, and the queues handle all the locking internally. The sentinel object `STOP` tells each worker to finish, a common idiom for shutting down a pool. Because the workers finish in an unpredictable order, the main thread sorts the answers before printing, so the output does not depend on scheduling. The same structure works across processes with `multiprocessing.Queue` and across machines with message brokers.
package main
import (
"fmt"
"sort"
)
func worker(id int, jobs <-chan int, results chan<- [2]int) {
for n := range jobs { // receive until the channel is closed
sum := 0
for i := 1; i <= n; i++ {
sum += i
}
results <- [2]int{n, sum} // send the result back
}
}
func main() {
jobs := make(chan int)
results := make(chan [2]int)
for w := 1; w <= 3; w++ {
go worker(w, jobs, results) // goroutines: lightweight concurrent functions
}
inputs := []int{10, 100, 1000, 5, 50}
go func() {
for _, n := range inputs {
jobs <- n
}
close(jobs)
}()
var out [][2]int
for range inputs {
out = append(out, <-results)
}
sort.Slice(out, func(i, j int) bool { return out[i][0] < out[j][0] })
for _, r := range out {
fmt.Printf("sum 1..%d = %d\n", r[0], r[1])
}
}sum 1..5 = 15 sum 1..10 = 55 sum 1..50 = 1275 sum 1..100 = 5050 sum 1..1000 = 500500
Go builds channels into the language and makes concurrent functions, goroutines, so cheap that programs routinely start thousands of them. Its motto puts the paradigm in a sentence: do not communicate by sharing memory; instead, share memory by communicating. Erlang takes the idea further: actors are isolated processes inside the runtime, and if one crashes, a supervisor actor is notified and can restart it, a philosophy called let it crash that has kept telephone switches running for years at a time.
| Approach | Shared mutable state? | Main hazards | Examples |
|---|---|---|---|
| Locks on shared memory | Yes, protected by locks | Races if a lock is missed; deadlock; contention | Java synchronized, Python threading, C pthreads |
| Channels (CSP) | No; values pass through channels | Deadlock if all wait on channels; goroutine leaks | Go, Rust std::sync::mpsc, Clojure core.async |
| Actors | No; each actor owns its state | Mailbox overload; message ordering assumptions | Erlang, Elixir, Akka |
| Immutable data + pure functions | No shared mutation at all | Copying costs; coordination still needed for effects | Haskell, Clojure, map–reduce systems |
Common mistakes
MistakeLocking only the write in a check-then-act sequence.
InsteadThe check and the action together form the critical section; lock both, or another thread can change the state between them.
MistakeAcquiring locks in different orders in different places.
InsteadDefine a global lock order and always acquire locks in that order to prevent circular wait.
MistakeCalling acquire and release manually and forgetting release on an error path.
InsteadUse with lock: so the lock is released however the block exits.
MistakeHolding a lock during slow I/O.
InsteadKeep critical sections short; copy the data you need, release the lock, then do slow work.
MistakeAssuming message passing cannot deadlock.
InsteadTwo activities each waiting to receive from the other still deadlock; design protocols so that someone always sends.
Summary and key terms
- A critical section accesses shared state and must run in one thread at a time; locks provide mutual exclusion.
- Check-then-act sequences must be locked as a whole.
- Condition variables, semaphores, events, barriers and thread-safe queues coordinate threads beyond simple exclusion.
- Deadlock needs mutual exclusion, hold and wait, no pre-emption and circular wait; a global lock order breaks circular wait.
- Livelock, starvation and priority inversion are related liveness failures.
- Message passing avoids shared state: CSP uses channels, the actor model uses mailboxes.
- Go's motto summarizes the approach: share memory by communicating rather than communicating by sharing memory.
- critical section
- Code that accesses shared state and must not be executed by more than one thread at a time.
- mutual exclusion
- Ensuring that only one thread at a time executes a critical section.
- lock (mutex)
- A synchronization object that one thread at a time can hold.
- condition variable
- A primitive that lets a thread wait for a condition while releasing an associated lock.
- semaphore
- A counter-based primitive that allows a limited number of threads to proceed.
- deadlock
- A state in which a set of threads each wait for a resource held by another, so none can proceed.
- circular wait
- A cycle of threads each waiting for a resource held by the next.
- livelock
- Threads continually change state in response to each other without making progress.
- channel
- A typed conduit through which concurrent activities send and receive values.
- actor
- A concurrent entity with private state and a mailbox that processes one message at a time.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Rewrite `count += 1` in a thread so that it is safe.
Show answer
`with lock: count += 1`, where lock is a threading.Lock shared by all threads.
Why is `with lock:` preferable to calling acquire and release?
Show answer
It releases the lock automatically even if an exception occurs inside the block.
Name the four Coffman conditions for deadlock.
Show answer
Mutual exclusion, hold and wait, no pre-emption and circular wait.
Which Coffman condition does a global lock order break?
Show answer
Circular wait.
Two threads both need locks L1 and L2. Thread A takes L1 then L2; how should thread B take them?
Show answer
Also L1 then L2, following the same order.
What is the difference between deadlock and livelock?
Show answer
In deadlock threads are blocked and do nothing; in livelock they keep running and responding to each other but make no progress.
Why does the queue-based worker pool need no explicit lock?
Show answer
Workers share no mutable variables; the queues are internally synchronized.
What is a sentinel value in a producer–consumer queue?
Show answer
A special item, such as STOP or None, that tells a consumer to stop.
Contrast channels and actors in one sentence.
Show answer
With channels, activities send through named conduits that any activity holding them can use; with actors, messages are addressed to a specific actor's mailbox.
Can two goroutines deadlock with channels? Give an example.
Show answer
Yes: if each waits to receive from a channel that only the other would send on, both block forever.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.