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.

A lock makes the increment a critical section; without it, updates are 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)
Output
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
  1. Thread A checks stock > 0 and sees 1. Before A decrements, thread B also checks and sees 1.
  2. Both decrement: stock becomes −1 and two tickets are sold although only one existed.
  3. This is a check-then-act race: the decision is based on a value that may change before the action.
  4. 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.

Common synchronization primitives
PrimitivePurposePython
Lock (mutex)Mutual exclusion for a critical sectionthreading.Lock
Reentrant lockA lock the same thread may acquire repeatedlythreading.RLock
Condition variableWait for a condition while releasing a lockthreading.Condition
SemaphoreAllow at most N threads into a sectionthreading.Semaphore
EventOne thread signals; others wait for the signalthreading.Event
BarrierThreads wait until all have reached a pointthreading.Barrier
Thread-safe queuePass items between threads with blocking get and putqueue.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.

A wait-for graph. Thread 1 holds lock A and waits for lock B. Thread 2 holds lock B and waits for lock A. The arrows form a cycle. A second panel shows both threads acquiring A before B, and the cycle is gone.
Circular wait causes deadlock; a global lock order breaks the cycle
Simulating the dining philosophers: left-fork-first can deadlock; a global order cannot
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 ""))
Output
  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
  1. Thread 1 locks x; thread 2 locks y.
  2. Thread 1 now waits for y, held by thread 2, and thread 2 waits for x, held by thread 1: a circular wait.
  3. Fix: order the locks by a unique key, for example account number: first, second = sorted([a, b], key=lambda acc: acc.number).
  4. 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.

Producer–consumer with thread-safe queues: workers share no mutable state
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")
Output
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.

The same worker pool in Go with goroutines and channels (compiled and run with Go)
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])
	}
}
Output
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.

Strategies for safe concurrency
ApproachShared mutable state?Main hazardsExamples
Locks on shared memoryYes, protected by locksRaces if a lock is missed; deadlock; contentionJava synchronized, Python threading, C pthreads
Channels (CSP)No; values pass through channelsDeadlock if all wait on channels; goroutine leaksGo, Rust std::sync::mpsc, Clojure core.async
ActorsNo; each actor owns its stateMailbox overload; message ordering assumptionsErlang, Elixir, Akka
Immutable data + pure functionsNo shared mutation at allCopying costs; coordination still needed for effectsHaskell, 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.

  1. 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.

  2. 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.

  3. Name the four Coffman conditions for deadlock.

    Show answer

    Mutual exclusion, hold and wait, no pre-emption and circular wait.

  4. Which Coffman condition does a global lock order break?

    Show answer

    Circular wait.

  5. 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.

  6. 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.

  7. Why does the queue-based worker pool need no explicit lock?

    Show answer

    Workers share no mutable variables; the queues are internally synchronized.

  8. 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.

  9. 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.

  10. 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.

Take the quiz