Concurrency

Python Advanced — Concurrency
Python Advanced
Course 3 · Chapter 3 · Concurrency

⚡ Concurrency

Python has a genuinely unusual constraint most languages don't: the Global Interpreter Lock. This chapter explains what the GIL actually is and why it exists, then covers the two standard-library tools for concurrent work — threading and multiprocessing — and, critically, when each one actually helps given the GIL's constraint.

🔒 The GIL Explained

The Global Interpreter Lock (GIL) is a lock built into the standard Python interpreter (CPython) that allows only one thread to execute Python bytecode at a time — even on a machine with 8 CPU cores, only one core is ever running Python code at any given instant.

The GIL exists because CPython manages memory with simple reference counting (every object tracks how many references point to it; when that count hits zero, it's freed). Reference counting isn't thread-safe on its own — two threads incrementing the same count simultaneously could corrupt it. The GIL sidesteps that entire class of bug by simply never letting two threads run Python code at once, which is far simpler than the fine-grained locking a fully thread-safe interpreter would need.

🧵 threading

import threading import time def download(name): print(f"{name}: starting") time.sleep(2) # simulates waiting on a slow network response print(f"{name}: done") threads = [threading.Thread(target=download, args=(f"file{i}",)) for i in range(3)] for t in threads: t.start() for t in threads: t.join() # wait for every thread to finish before continuing # total time: ~2 seconds, NOT 6 — all three downloads overlap

This is where threading genuinely helps despite the GIL: time.sleep() (and real I/O like network requests or file reads) releases the GIL while waiting, letting another thread run Python code during that wait. Three 2-second downloads run in about 2 seconds total, not 6 — the threads overlap during their idle waiting time, even though only one is ever actively running Python bytecode at once.

⚠ Shared State Across Threads Still Needs a Lock

Even with the GIL, operations that take multiple steps internally (like counter += 1, which reads, adds, then writes back) aren't guaranteed atomic — two threads can interleave mid-operation and lose an update, a classic race condition. threading.Lock() (used as a context manager, tying directly back to Chapter 2's with coverage) protects a shared resource by ensuring only one thread touches it at a time: with lock: counter += 1.

🖥️ multiprocessing

import multiprocessing def compute_square(n): return n ** 2 if __name__ == "__main__": with multiprocessing.Pool(4) as pool: results = pool.map(compute_square, range(10)) print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] — computed across 4 real processes

multiprocessing sidesteps the GIL entirely by running each task in a genuinely separate operating system process — each with its own Python interpreter, its own memory space, and crucially, its own GIL. This is how Python achieves real parallelism for CPU-heavy work (like the squaring above, or any computation-bound loop), something threading alone cannot do because of the single shared GIL.

🎯 When to Use Which

I/O-Bound vs CPU-Bound Work

Work typeBottleneckRight tool
I/O-boundWaiting on network, disk, or a database — the CPU is mostly idlethreading
CPU-boundHeavy computation keeping the CPU genuinely busymultiprocessing

threading is "cheap" (threads share memory, start fast) but limited to one running at a time by the GIL — perfect for waiting, useless for parallel computation. multiprocessing is "expensive" (each process needs its own memory, starts slower, and data must be explicitly passed between processes) but gets real parallel CPU work done. Picking the wrong one for the job either wastes overhead (multiprocessing for I/O waits) or does nothing useful (threading for CPU-bound math).

Concurrency Models: Go vs Kotlin vs Python

LanguageApproach
GoGoroutines are extremely lightweight and scheduled M:N onto real OS threads by the Go runtime — no GIL equivalent, so goroutines genuinely run in parallel across cores for both I/O and CPU work.
KotlinCoroutines run on real JVM threads with cooperative scheduling — no GIL either, so CPU-bound coroutine work can genuinely use multiple cores, unlike Python threads.
Pythonthreading for I/O-bound concurrency only (GIL-limited); multiprocessing required for real CPU-bound parallelism.

The GIL is the single biggest structural difference between Python's concurrency story and Go's or Kotlin's — both of those languages simply don't have this constraint, so a single lightweight concurrency primitive covers both I/O-bound and CPU-bound cases, where Python genuinely needs two different tools for the two different problems.

The GIL

Only one thread runs Python bytecode at a time; exists to keep reference counting simple.

threading

Good for I/O-bound work — the GIL releases during waits, letting threads overlap.

multiprocessing

Separate processes, separate GILs each — real parallelism for CPU-bound work.

Locks

threading.Lock() protects shared state from race conditions between threads.

💻 Coding Challenges

Challenge 1: Timed Sequential vs Threaded Downloads

Using this chapter's download(name) function (with time.sleep(2)), time how long it takes to call it 3 times sequentially in a plain loop, versus running the same 3 calls via threading.Thread. Print both elapsed times.

Goal: See the I/O-bound threading speedup firsthand, not just read about it.

→ Solution

Challenge 2: A Race Condition, Then Fixed

Write a shared counter = 0 incremented 100,000 times across 2 threads (50,000 increments each, via counter += 1 with no lock) — print the final count (it likely won't be 100,000). Then fix it using threading.Lock() and show the count is now correct every time.

Goal: Observe a real race condition, then apply this chapter's Lock fix directly.

→ Solution

Challenge 3: Parallel Sum of Squares

Using multiprocessing.Pool, compute the square of every number from 0 to 19 in parallel across 4 processes, then sum the results and print the total.

Goal: Practice Pool.map() for genuinely CPU-parallel work.

→ Solution

🎯 What's Next

Next chapter: Async Pythonasyncio, async/await, and the event loop.