All chapters in this track
- 01Chapter 1: Concurrency vs. Parallelism, and Why It's Hard
- 02Chapter 2: Processes vs. Threads, Revisited for Concurrency
- 03Chapter 3: The Core Hazards: Race Conditions, Deadlock, Livelock, Starvation
- 04Chapter 4: Synchronization Primitives: Locks, Semaphores, Monitors, Condition Variables
- 05Chapter 5: Designing for Thread Safety: Immutability, Confinement, Thread-Safe Collections
- 06Chapter 6: Classic Patterns: Producer-Consumer, Reader-Writer, Thread Pools
- 07Chapter 7: Higher-Level Models: Async/Await, Event Loops, Coroutines, Actors, Channels
- 08Chapter 8: Memory Models and Visibility: Happens-Before, Volatile, Atomics
- 09Chapter 9: Lock-Free and Wait-Free Programming Basics
- 10Chapter 10: How Concurrency Differs Across Runtimes: JVM Threads, Python's GIL, Go's Goroutines, JavaScript's Event Loop
- 11Chapter 11: Debugging and Testing Concurrent Code
- 12Chapter 12: Classic Interview Problem: Producer-Consumer
- 13Chapter 13: Classic Interview Problem: Dining Philosophers
- 14Chapter 14: Classic Interview Problem: Readers-Writers
- 15Chapter 15: Concurrency in System Design and Production Debugging
Chapter 7 of 15
Chapter 7: Higher-Level Models: Async/Await, Event Loops, Coroutines, Actors, Channels
Why this matters
Raw threads and locks are powerful but low-level and error-prone — every hazard in Chapter 3 is a real, ever-present risk when working with them directly. Higher-level concurrency models exist specifically to make certain classes of these bugs harder or impossible to write, at the cost of a different programming model to learn. Understanding what each model actually changes about the underlying problem — not just its syntax — is what lets you choose correctly among them.
Async/await and the event loop
[BEGINNER] Async/await (Python's asyncio) runs many logical tasks on a single thread, using an event loop that switches between tasks specifically at explicit suspension points (await) — never preemptively, unlike OS thread scheduling (companion OS book, Chapter 4). This is directly the OS book's Chapter 9 non-blocking I/O and epoll-style readiness notification, exposed as a language-level programming model.
import asyncio
async def fetch_data(name, delay):
print(f"{name}: starting")
await asyncio.sleep(delay) # suspends THIS task, yielding control to the event loop --
# other tasks run during this wait, but THIS task is
# guaranteed not to be interrupted at any OTHER point
print(f"{name}: done")
return f"{name} result"
async def main():
results = await asyncio.gather(
fetch_data("Task A", 2),
fetch_data("Task B", 1),
)
print(results)
asyncio.run(main())
# Task A and Task B run CONCURRENTLY (interleaved), but NEVER in true parallel --
# only one runs at any literal instant, and control only switches at `await` points.
[SENIOR INSIGHT] The critical practical consequence: code between await points is guaranteed to run without interruption from other async tasks — this eliminates an entire category of race conditions that plague thread-based code, as long as shared state is never mutated across an await boundary without care. This "as long as" is exactly Chapter 1's warning that single-threaded concurrency can still have race-condition-like bugs: if a coroutine reads shared state, awaits something, and then writes back based on that stale read, another coroutine could have modified that state during the await — a check-then-act race, just triggered by await instead of thread preemption.
shared_balance = 100
async def withdraw(amount):
global shared_balance
if shared_balance >= amount:
await asyncio.sleep(0.01) # <-- another coroutine can run HERE, during this await
shared_balance -= amount # the check may now be stale
Coroutines: the building block underneath async/await
[BEGINNER] A coroutine is a function that can suspend its execution and later resume exactly where it left off, preserving its local state across the suspension — async def functions in Python are coroutines, and await is what triggers a suspension point. Coroutines are the mechanism; async/await (and the event loop) is the higher-level programming model built on top of that mechanism.
Actors: no shared state at all, only message passing
[BEGINNER] The actor model takes a fundamentally different approach: each actor is an independent unit with its own private state that no other actor can directly access — actors communicate exclusively by sending each other asynchronous messages, and each actor processes its incoming messages one at a time, sequentially, from its own private mailbox.
import queue
import threading
class Actor:
def __init__(self):
self._mailbox = queue.Queue()
self._state = 0 # PRIVATE -- no other actor ever touches this directly
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def send(self, message):
self._mailbox.put(message) # the ONLY way to interact with this actor
def _run(self):
while True:
message = self._mailbox.get()
if message == "increment":
self._state += 1 # SAFE -- only this actor's own thread ever touches _state
elif message == "print":
print(f"State: {self._state}")
counter_actor = Actor()
counter_actor.send("increment")
counter_actor.send("increment")
counter_actor.send("print") # eventually prints "State: 2" -- no lock needed anywhere
[SENIOR INSIGHT] Because an actor's state is genuinely private and only ever touched by its own single processing thread, there is no shared mutable state, and therefore no possible race condition on that state — this is thread confinement (Chapter 5) elevated into a complete, disciplined architectural style, rather than an occasional technique. The trade-off: actors communicate only via message passing, which is a real, deliberate constraint — you can't just "call a method" on another actor's state directly, the way you could with shared-memory threading; every interaction must be modeled as an asynchronous message.
Channels (CSP — Communicating Sequential Processes)
[BEGINNER] Channels (most associated with Go, but available as a library/pattern in most languages) provide a typed conduit for passing values between concurrent tasks, with the channel itself handling synchronization — conceptually similar to queue.Queue from Chapter 6, but framed as the primary way tasks communicate, rather than one tool among several, per the CSP (Communicating Sequential Processes) philosophy: "don't communicate by sharing memory; share memory by communicating."
import queue
import threading
# Python doesn't have native channels, but queue.Queue serves the same role here:
channel = queue.Queue()
def sender():
for i in range(5):
channel.put(i) # "send i on the channel"
def receiver():
for _ in range(5):
value = channel.get() # "receive from the channel"
print(f"Received: {value}")
threading.Thread(target=sender).start()
threading.Thread(target=receiver).start()
The philosophical shift channels (and the actor model) both represent: instead of multiple tasks sharing one piece of mutable memory and coordinating access to it with locks (this book's Chapters 3-6), tasks each own their own state privately and exchange copies of data through a communication mechanism — sidestepping the shared-mutable-state hazards entirely, by design, rather than managing them carefully.
Choosing among the models
| Model | Best fit | Key trade-off |
|---|---|---|
| Raw threads + locks | Fine-grained control, CPU-bound work needing true parallelism (with multiprocessing) | Full exposure to every Chapter 3 hazard; maximum flexibility, maximum risk |
| Async/await | High-concurrency I/O-bound work (many simultaneous network connections, per the companion Networking book's Chapter 9) | No thread-level races, but state mutated across await points can still race logically |
| Actor model | Systems naturally modeled as independent, communicating units (simulations, distributed systems, some UI frameworks) | No shared-state races at all, but requires committing to message-passing for every interaction |
| Channels/CSP | Structured, pipeline-like concurrent workflows | Similar benefits to actors, framed around data flow through a pipeline rather than independent stateful units |
Common mistakes
- Assuming async/await code is automatically free of race conditions — it eliminates thread-preemption-based races but not races caused by mutating shared state across an
awaitboundary, which requires the same careful analysis as thread-based code. - Using async/await for CPU-bound work expecting a speedup — async/await addresses I/O-bound waiting efficiently (many tasks overlapping their waits on one thread) but provides no parallelism for genuinely CPU-bound computation, which still needs multiple processes/cores to actually speed up.
- Mixing actor-style "never share state directly" discipline with occasional shared-memory shortcuts "just this once for performance" — this reintroduces every hazard the actor model was adopted specifically to avoid, and undermines the entire architectural guarantee.
- Choosing a higher-level model based on trendiness rather than fit — each model listed here solves a specific class of problem well and others poorly; the choice should follow from the actual workload's shape (I/O-bound vs. CPU-bound, naturally independent units vs. tightly-coupled shared state).
Discussion / interview questions
-
"Does using async/await eliminate race conditions entirely?" Strong answer: No — it eliminates races caused by unpredictable thread preemption (since code between
awaitpoints runs without interruption), but a coroutine that reads shared state, awaits something, and then acts on that now-possibly-stale state can still race with another coroutine that modified it during the await — the same check-then-act hazard, triggered differently. -
"What's the core idea behind the actor model, and how does it avoid the need for locks?" Strong answer: Each actor owns fully private state that only its own single processing thread ever touches, and all interaction happens via asynchronous message passing rather than direct shared-memory access — with no shared mutable state, there's no possible race condition on that state, eliminating the need for locks entirely (a form of thread confinement, elevated to a full architectural style).
-
"What does 'don't communicate by sharing memory; share memory by communicating' mean, in the context of channels/CSP?" Strong answer: Instead of multiple tasks accessing the same shared, lock-protected memory, each task owns its own state privately and passes copies of data to other tasks through a channel — sidestepping shared-mutable-state hazards by design, rather than managing them with synchronization primitives.
-
"When would async/await be the wrong tool for a performance problem?" Strong answer: For genuinely CPU-bound work — async/await's benefit comes from overlapping I/O waits on a single thread, providing no actual parallelism for computation; a CPU-bound bottleneck needs true parallel execution across multiple processes/cores (companion OS book's Chapter 9 I/O-bound vs. CPU-bound distinction, and this book's Chapter 10 on the GIL) to see a real speedup.
Chapter summary / cheat sheet
- Async/await: single-threaded, cooperative task-switching at explicit
awaitpoints — eliminates thread-preemption races, but shared state mutated across anawaitcan still race logically. - Coroutines: the underlying suspend/resume mechanism async/await is built on.
- Actor model: fully private per-actor state, communication only via message passing — eliminates shared-state races entirely, at the cost of committing to a message-passing architectural style for every interaction.
- Channels/CSP: "share memory by communicating" — pass data through typed conduits rather than accessing shared, lock-protected memory directly.
- Each model trades away some of raw threads' flexibility in exchange for eliminating a specific class of hazard — choose based on the workload's actual shape (I/O-bound vs. CPU-bound, naturally independent units vs. tightly-coupled state), not by trend.