Concurrency Models
Source: Fluent Python, 2nd Edition — Chapter 20 by Luciano Ramalho (through the GIL and spinner examples)
Pike: concurrency is structure — dealing with lots of things at once. Parallelism is execution — doing lots of things at once. Parallelism is a special case of concurrency. A 4-core laptop running 200 processes is concurrent, not 200-way parallel. “Concurrency Is Not Parallelism (It’s Better).”
This book chapter is Python’s three standard-library models: threading, multiprocessing, asyncio. Web-scale Python (YouTube, Instagram, …) also used queues, app servers, and C extensions — that “big picture” is the next slice. No prior concurrent-programming course assumed.
Jargon
Concurrency / multitasking: many pending tasks; a one-core CPU still counts if the OS interleaves them.
Parallelism: overlapping computation (cores, GPU, cluster).
Process: a running program with private memory. Talks via bytes (pipes, sockets, mmap), not live Python objects. Can spawn isolated children.
Thread: a path inside a process. They share objects — easy and dangerous (corruption without locks).
Contention: fighting for a lock, disk, or CPU time.
Lock / mutex: hold it while you mutate shared structure so other polite threads wait.
Ten points: processes, threads, GIL
- One interpreter = one process. More Python processes:
multiprocessing,ProcessPoolExecutor. External programs:subprocess. - One main thread + GC. More Python threads:
threading,ThreadPoolExecutor. - GIL (global interpreter lock): only one thread runs Python bytecode at a time. Not in the language spec — CPython and PyPy have it; Jython 2.7 did not.
- The eval loop drops the GIL about every 5 ms so another thread can run.
- You cannot toggle the GIL from pure Python. C extensions can drop it for long work.
- Stdlib syscalls drop it: disk, network,
time.sleep. So do many NumPy/SciPy andzlib/bz2bits. - Extensions can start non-Python threads (no GIL). They must not mutate Python objects;
array/ NumPy buffers are OK. - Network threads: “great at doing nothing” (Beazley) — they wait on I/O with the GIL released.
- Two CPU-heavy Python threads hurt vs one sequential loop.
- CPU-heavy Python on many cores → processes.
threading docs: I/O-bound yes; compute → multiprocessing / ProcessPoolExecutor.
Concurrent hello world: a spinner
While slow() thinks for 3 s, animate \|/- on one line (\r). Then Answer: 42. Three APIs, same story.
Threads
spin loops itertools.cycle until done.wait(0.1) is true. slow is time.sleep(3) — blocks this thread, releases the GIL. Python has no “kill thread”; you signal (Event.set) and join.
Event: flag starts false; wait blocks until set (or timeout → False).
Thread(target=spin, args=(msg, done)).start() then slow() then done.set(); join().
Processes
Process and multiprocessing.Event look like Thread / threading.Event. Underneath: a new interpreter, own GIL, objects pickled across the boundary. The Event is a C semaphore. Type checkers see Event as a factory returning synchronize.Event.
asyncio
One thread, an event loop queues coroutines (same idea as the taxi Simulator.run, but for I/O and timers). Any blocking call stalls everyone.
asyncio.run(supervisor())— from syncmain, blocks until done.asyncio.create_task(spin(...))— schedule; returns a Task already queued; does not await.await slow()— transfer untilslowfinishes.
await asyncio.sleep(.1) yields; time.sleep(.1) freezes the loop. Task.cancel() raises CancelledError at the current await. You never construct Task by hand.
Invoking coro() only builds a coroutine object; the loop .sends it (Chapter 19).
Experiment: replace await asyncio.sleep(3) with time.sleep(3). Spinner Task is pending, never runs; hang 3 s; answer. Control never reached spin.
Side by side
| Threads | asyncio |
|---|---|
Thread + start | Task from create_task (already scheduled) |
| call a function | await a coroutine |
Event to stop | task.cancel() |
| preemptive | only at await — default “lock” is the single flow |
Cancelling a coroutine is safe because it can only die at await; handle CancelledError to clean up. A thread can be sliced mid-statement.
GIL and sleep
1 / 2time.sleep in a Python thread
Pick an answer to see if you are right.
wrap currently joins without wait. Join is enough if the worker always finishes, but the exercise is the Event: wait then join (or wait is enough if you join too). Put n on box in the worker.
GIL vs a real CPU hog
is_prime (odd trial division up to isqrt) on a ~3 s prime:
- multiprocessing: spinner in a child → keeps spinning while the parent tests.
- threading: spinner does keep spinning. Surprising if you over-blame the GIL: the hog is interrupted ~every 5 ms; the spinner wakes 10 Hz,
waits, gives the GIL back. Two hungry threads would still lose to sequential code. - asyncio: calling
is_primewith noawaitis liketime.sleep: spinner Task never runs; freeze; then the answer.
Network I/O in a well-written library drops the GIL (threads) or awaits (asyncio); the spinner lives. CPU Python does not.
Spinner vs is_prime
1 / 2is_prime(huge) in the asyncio slow coroutine (no await inside is_prime)
Pick an answer to see if you are right.