Skip to main content

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​

  1. One interpreter = one process. More Python processes: multiprocessing, ProcessPoolExecutor. External programs: subprocess.
  2. One main thread + GC. More Python threads: threading, ThreadPoolExecutor.
  3. 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.
  4. The eval loop drops the GIL about every 5 ms so another thread can run.
  5. You cannot toggle the GIL from pure Python. C extensions can drop it for long work.
  6. Stdlib syscalls drop it: disk, network, time.sleep. So do many NumPy/SciPy and zlib/bz2 bits.
  7. Extensions can start non-Python threads (no GIL). They must not mutate Python objects; array / NumPy buffers are OK.
  8. Network threads: “great at doing nothing” (Beazley) — they wait on I/O with the GIL released.
  9. Two CPU-heavy Python threads hurt vs one sequential loop.
  10. 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 sync main, blocks until done.
  • asyncio.create_task(spin(...)) — schedule; returns a Task already queued; does not await.
  • await slow() — transfer until slow finishes.

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​

Threadsasyncio
Thread + startTask from create_task (already scheduled)
call a functionawait a coroutine
Event to stoptask.cancel()
preemptiveonly 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.

Quiz

GIL and sleep

1 / 2

time.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:

  1. multiprocessing: spinner in a child → keeps spinning while the parent tests.
  2. 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.
  3. asyncio: calling is_prime with no await is like time.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.

Quiz

Spinner vs is_prime

1 / 2

is_prime(huge) in the asyncio slow coroutine (no await inside is_prime)

Pick an answer to see if you are right.