Papyrus wants to modernize: before restocking, Ana checks each title's price with three distributors. Each query takes about 2 seconds (network, slow servers), and the program sits idle waiting: 3 queries × 2 s = 6 seconds staring at the cursor. The same happens when downloading the covers of new books. The key observation is that during those 2 seconds Python does nothing: it waits. Concurrency is about exploiting those waits to make progress on other tasks. In this lesson: threads, the famous GIL told honestly, race conditions (with a sales counter that corrupts itself live), and processes for when the problem isn't waiting but computing. One note before we start: we'll simulate the queries with time.sleep() — it's the standard way to learn concurrency without depending on real servers; real HTTP requests arrive in module 10.
Contents
- Sequential, concurrent and parallel
threading.Thread: the first thread- The GIL, explained honestly
- Race conditions: the counter that loses sales
Lock: a one-thread-only zoneconcurrent.futures.ThreadPoolExecutor: the recommended API- Processes for CPU:
multiprocessingandProcessPoolExecutor - The decision table (including the "you don't need concurrency" option)
Sequential, concurrent and parallel
Three words that get mixed up and don't mean the same thing:
- Sequential: one task after another. Query A, wait; query B, wait; query C.
- Concurrent: several tasks in progress at once, even if only one advances at any given instant — like Ana serving Julia while Omar's receipt is printing. It exploits the waits.
- Parallel: several tasks running literally at the same time on different CPU cores. It exploits the hardware.
gantt
dateFormat s
axisFormat %S s
title Querying 3 distributors (2 s each)
section Sequential (6 s)
Distributor A :a1, 0, 2s
Distributor B :a2, after a1, 2s
Distributor C :a3, after a2, 2s
section Concurrent with threads (~2 s)
Distributor A :b1, 0, 2s
Distributor B :b2, 0, 2s
Distributor C :b3, 0, 2s
The three waits overlap: the total drops from the sum (6 s) to the maximum (2 s).
threading.Thread: the first thread
A thread is a line of execution inside the same process: it shares memory (the same catalog, the same variables) with the other threads.
import threading
import time
def fetch_price(distributor: str, title: str) -> None:
print(f"→ querying {title} at {distributor}...")
time.sleep(2) # simulates the network wait (I/O)
print(f"← {distributor} responds")
start = time.perf_counter()
threads = [
threading.Thread(target=fetch_price, args=(d, "Faust"))
for d in ("Distributor A", "Distributor B", "Distributor C")
]
for t in threads:
t.start() # launches the thread: the function starts running "in parallel"
for t in threads:
t.join() # waits for that thread to finish
print(f"Total: {time.perf_counter() - start:.1f} s") # Total: 2.0 s (not 6.0)Two API details: target receives the function without calling it (the function object, as with the decorators of 08-02) and the arguments go separately in args; and join() blocks until the thread finishes — without the join() calls, the main program could finish before the queries do.
The GIL, explained honestly
Now for the conversation many tutorials dodge. CPython (the Python you use) has the GIL (Global Interpreter Lock): a global lock that allows only one thread to execute Python bytecode at any instant. Even if your laptop has 8 cores, two Python threads don't execute Python code at the same time.
So why did the previous example take 2 s and not 6? Because the GIL is released during I/O operations: while a thread waits in time.sleep(), on a disk read or for a network response, it releases the GIL and another thread advances. The waits do overlap; the computation doesn't.
| Type of task | Do threads help? | Why |
|---|---|---|
| I/O (network, disk, waits) | Yes | The GIL is released while waiting; the waits overlap |
| Pure CPU (computation, big loops) | No (even slightly worse) | The GIL lets only one thread advance; the rest queue up |
Demonstration with pure CPU — summing 10 million numbers in 2 threads takes the same or longer than in one:
def compute(): # pure CPU: no waits to overlap
return sum(range(10_000_000))
# 1 thread: ~0.4 s · 2 threads with threading: ~0.4 s as well (or more, due to overhead!)Honest conclusion: in Python, threads are for waiting on several things at once, not for computing faster. For parallel computation there's another tool (processes, below). Side note: as of 2026 there are experimental GIL-free CPython builds, but the Python you have installed works as just described.
Race conditions: the counter that loses sales
Sharing memory between threads has a price. Suppose each query records sales in a global counter:
sales_counter = 0
def record_sales(n: int) -> None:
global sales_counter
for _ in range(n):
sales_counter += 1 # looks atomic... and it is NOT
threads = [threading.Thread(target=record_sales, args=(100_000,)) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(sales_counter) # expected: 200000 · obtained: 200000... or 173942, depending on the daysales_counter += 1 is actually three steps: read the value, add 1, write it back. If thread A reads 50, the system switches to thread B, which also reads 50, adds and writes 51... and then A writes its 51: a sale has been lost. This is a race condition: the result depends on the haphazard order in which the threads interleave. It's the worst kind of bug — it doesn't fail every time, it leaves no trace in papyrus.log, and it vanishes when you try to reproduce it.
Lock: a one-thread-only zone
The solution is a lock that turns read-add-write into an indivisible operation. And the way you use it will ring a bell from the previous lesson — a Lock is a context manager:
sales_counter = 0
lock = threading.Lock()
def record_sales(n: int) -> None:
global sales_counter
for _ in range(n):
with lock: # __enter__ acquires; __exit__ ALWAYS releases
sales_counter += 1While one thread is inside the with lock:, any other that arrives waits at the door. Result: 200000, every time. The cost: that zone is sequential again — the bigger the protected block, the less concurrency remains. Protect the bare minimum.
ThreadPoolExecutor: the recommended API
Creating threads by hand is fine for learning, but the modern API is concurrent.futures: a pool (team) of reusable threads you hand tasks to. What's more — and this is something raw Threads don't do — it collects return values and re-raises exceptions where you can catch them with the M7 rings:
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch_price(distributor: str, title: str) -> tuple[str, float]:
time.sleep(2) # simulated I/O, remember
prices = {"Distributor A": 19.90, "Distributor B": 21.50,
"Distributor C": 20.10}
return distributor, prices[distributor]
distributors = ["Distributor A", "Distributor B", "Distributor C"]
# Option 1 — map: results in SUBMISSION order (API almost identical to M3's map())
with ThreadPoolExecutor(max_workers=3) as pool: # another context manager (08-04)
for dist, price in pool.map(lambda d: fetch_price(d, "Faust"),
distributors):
print(f"{dist}: {price} EUR")
# Option 2 — submit + as_completed: results AS they arrive
with ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(fetch_price, d, "Faust") for d in distributors]
for future in as_completed(futures):
try:
dist, price = future.result() # the exception re-surfaces here, if there was one
print(f"{dist}: {price} EUR")
except OSError:
logger.exception("a distributor failed") # the 07-05 patternsubmit(f, *args)returns a future: a receipt for a result that will arrive.as_completedhands them over as they finish — the fast distributor doesn't wait for the slow one.future.result()returns the return value or re-raises the exception the thread suffered: nothing dies in silence.- The pool's
withwaits for every task to finish before exiting:__exit__acting as a collectivejoin().
Processes: real parallelism for CPU
And when the bottleneck really is computation? Imagine Papyrus's annual reports/ demands processing heavy statistics per year. A process is a complete, independent Python, with its own memory and its own GIL — so several processes really do compute in parallel on different cores:
from concurrent.futures import ProcessPoolExecutor
def generate_heavy_report(year: int) -> tuple[int, float]:
total = sum(i * i for i in range(20_000_000)) # pure CPU (simulated)
return year, total
if __name__ == "__main__": # MANDATORY with processes on Windows
with ProcessPoolExecutor() as pool: # by default, one process per core
for year, total in pool.map(generate_heavy_report, [2023, 2024, 2025, 2026]):
print(f"report {year} ready")The same API as ThreadPoolExecutor — only the class changes — and that's its elegance: you test with threads, and if the problem turns out to be CPU-bound, you change one word. The costs of processes: starting them is slow, they don't share memory (arguments and return values are serialized to travel between processes, in something resembling M6's json — no cheerfully sharing the catalog), and M3's if __name__ == "__main__": stops being optional: without it, on Windows every child process re-imports the module and spawns processes without end.
The decision table
| Your situation | Tool | Why |
|---|---|---|
| Lots of network/disk waits (queries, cover downloads) | ThreadPoolExecutor |
The GIL is released while waiting; the waits overlap |
| Heavy computation (heavy reports, statistics) | ProcessPoolExecutor |
Each process has its own GIL: real parallelism |
| Thousands of simultaneous connections | asyncio (next lesson) |
Threads don't scale to those numbers |
| It's quick, runs once, or the code is simple | Nothing: sequential | Concurrency adds subtle bugs; don't pay for it needlessly |
The last row is serious: 90% of Papyrus's code — selling a book, saving the catalog, the daily close — is and should stay sequential. Concurrency is added where a measured wait hurts, not by default.
Common Mistakes and Tips
- Using threads to speed up pure computation: the GIL prevents it; you'll get the same time with more complexity. Computation → processes.
- Forgetting
join()(or the pool'swith): the main program carries on and can finish with queries half-done. - Sharing mutable state without a
Lock: counters, lists or thecatalogtouched from several threads are latent race conditions. Even better than theLock: have each task return its result and let the main thread combine them — futures exist for that. - Missing
if __name__ == "__main__":withProcessPoolExecutor: on Windows it produces a startup error (or a cascade of processes). It's the number one mistake with processes. - Ignoring futures' exceptions: if you never call
future.result(), the thread's exception dies in silence — the sin of 07-04 in its concurrent version. Always callresult()inside atry. - Tip: measure before and after with the
@timedof 08-02 or thestopwatchof 08-04. If the concurrent version doesn't clearly win, go back to sequential.
Exercises
-
Write
fetch_all(title)that queries the three distributors withThreadPoolExecutor.mapand returns the cheapest one as a(distributor, price)tuple. Use the lesson'sfetch_price(with itstime.sleep(2)) and check withtime.perf_counter()that the total is around 2 s. -
Without running it: what does this code print (approximately) and why? Which single word would you change to fix it?
def crunch_statistics(data): return sum(x * x for x in data) # pure CPU with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(crunch_statistics, four_big_blocks)) -
Two threads restock the same
Bookwithbook.stock += n. Explain the race condition step by step (with concrete values: initial stock 4, two restocks of 3) and fix it with aLock, protecting the smallest amount of code possible.
Solutions
-
from concurrent.futures import ThreadPoolExecutor import time def fetch_all(title: str) -> tuple[str, float]: distributors = ["Distributor A", "Distributor B", "Distributor C"] start = time.perf_counter() with ThreadPoolExecutor(max_workers=3) as pool: results = list(pool.map(lambda d: fetch_price(d, title), distributors)) print(f"queried in {time.perf_counter() - start:.1f} s") # ~2.0 s return min(results, key=lambda r: r[1]) # the sorted key from M3Three overlapped 2 s waits ≈ 2 s total;
min(key=...)picks the cheap one. -
It prints the correct results, but takes the same as (or longer than) sequential:
crunch_statisticsis pure CPU and the GIL only lets one thread advance at a time — the 4 workers take turns, not parallelism. The word to change:ThreadPoolExecutor→ProcessPoolExecutor(remembering theif __name__ == "__main__":). -
Race: thread A reads
stock = 4; the system switches to B, which also reads4, adds 3 and writes7; A wakes up and writes its result,7. Final stock: 7 instead of 10 — a restock of Faust has evaporated without a trace. Fix:lock = threading.Lock() def restock_concurrent(book, n: int) -> None: with lock: book.stock += n # ONLY the read-add-write operationThe
with lock:wraps only the critical line: any prior work (validatingn, logging) stays outside so as not to over-sequentialize.
Conclusion
Threads overlap waits (the GIL is released on I/O), processes parallelize computation (each with its own GIL), concurrent.futures gives both the same API with futures that carry results and exceptions, and sharing memory demands a Lock — which turned out to be a context manager from lesson 08-04 working overtime. And the most underrated option is still on the table: not using concurrency when there's no wait that hurts. But an awkward question remains: to wait on 3 queries we set up 3 threads, with their Lock, their potential races and their per-thread cost. What if there are 500 queries? 500 threads? There's another model where a single thread manages all the waits by taking turns between tasks — with no locks and no races — and it's called asyncio. It's the module's last lesson, and it closes Papyrus's tour of the advanced topics.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
