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

  1. Sequential, concurrent and parallel
  2. threading.Thread: the first thread
  3. The GIL, explained honestly
  4. Race conditions: the counter that loses sales
  5. Lock: a one-thread-only zone
  6. concurrent.futures.ThreadPoolExecutor: the recommended API
  7. Processes for CPU: multiprocessing and ProcessPoolExecutor
  8. 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 day

sales_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 += 1

While 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 pattern
  • submit(f, *args) returns a future: a receipt for a result that will arrive.
  • as_completed hands 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 with waits for every task to finish before exiting: __exit__ acting as a collective join().

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's with): the main program carries on and can finish with queries half-done.
  • Sharing mutable state without a Lock: counters, lists or the catalog touched from several threads are latent race conditions. Even better than the Lock: have each task return its result and let the main thread combine them — futures exist for that.
  • Missing if __name__ == "__main__": with ProcessPoolExecutor: 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 call result() inside a try.
  • Tip: measure before and after with the @timed of 08-02 or the stopwatch of 08-04. If the concurrent version doesn't clearly win, go back to sequential.

Exercises

  1. Write fetch_all(title) that queries the three distributors with ThreadPoolExecutor.map and returns the cheapest one as a (distributor, price) tuple. Use the lesson's fetch_price (with its time.sleep(2)) and check with time.perf_counter() that the total is around 2 s.

  2. 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))
    
  3. Two threads restock the same Book with book.stock += n. Explain the race condition step by step (with concrete values: initial stock 4, two restocks of 3) and fix it with a Lock, protecting the smallest amount of code possible.

Solutions

  1. 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 M3
    

    Three overlapped 2 s waits ≈ 2 s total; min(key=...) picks the cheap one.

  2. It prints the correct results, but takes the same as (or longer than) sequential: crunch_statistics is pure CPU and the GIL only lets one thread advance at a time — the 4 workers take turns, not parallelism. The word to change: ThreadPoolExecutorProcessPoolExecutor (remembering the if __name__ == "__main__":).

  3. Race: thread A reads stock = 4; the system switches to B, which also reads 4, adds 3 and writes 7; 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 operation
    

    The with lock: wraps only the critical line: any prior work (validating n, 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved