๐Ÿงต Threading 101

What a thread actually is, why Python threading is weird (hello, GIL), and how the machinery works โ€” before you touch a single pattern.

What Is a Thread?

A thread is an independent path of execution inside a single process. Every process starts with one (the main thread). You can spawn more, and they all share the same memory: the same variables, the same objects, the same open files.

Key Insight: Threads share memory by default. That's their superpower (cheap communication โ€” just read a variable) and their curse (two threads touching the same variable at once = corruption). Almost everything in threading is about managing that shared memory safely.

Threads vs Processes in One Picture

flowchart TB subgraph P["One Process"] MEM["Shared memory
(variables, objects, files)"] T1["Thread 1"] --- MEM T2["Thread 2"] --- MEM T3["Thread 3"] --- MEM end

Why Use Threads?

Same core reason as async: most programs spend their time waiting โ€” on the network, the disk, a database. While one thread waits, another can run. You get overlap without rewriting everything into async/await.

Scenario: Download 3 files, each takes 1 second of network wait.

Single-threaded: 1s + 1s + 1s = 3 seconds.

Three threads: all three wait at the same time โ‰ˆ 1 second.
import threading, time

def download(name):
    print(f"{name} start")
    time.sleep(1)          # simulate network wait
    print(f"{name} done")

threads = [threading.Thread(target=download, args=(f"file-{i}",)) for i in range(3)]
for t in threads: t.start()   # kick them all off
for t in threads: t.join()    # wait for all to finish
# total โ‰ˆ 1 second, not 3

The Catch: The GIL

CPython has a Global Interpreter Lock โ€” a single lock that lets only one thread execute Python bytecode at a time. Even on a 16-core machine, your Python threads take turns; they do not run Python code truly in parallel.

flowchart LR GIL{{"GIL
(one holder at a time)"}} T1["Thread 1"] -->|acquire| GIL T2["Thread 2"] -.waits.-> GIL T3["Thread 3"] -.waits.-> GIL GIL -->|"releases on I/O or every few ms"| T2
So when do threads actually help? The GIL is released during I/O (network, disk, time.sleep) and inside many C extensions (NumPy). So:
  • I/O-bound work โ†’ threads help a lot (they overlap the waiting).
  • Pure-Python CPU work โ†’ threads do not speed it up (they fight over the GIL). Use multiprocessing instead.

This is the single most important thing to understand about Python threading. There's a whole deep-dive on the GIL โ€” read it after this.

The Core Danger: Race Conditions

Because threads share memory and can be paused anywhere (the OS switches them preemptively, unlike async's cooperative await), two threads updating the same value can corrupt it.

counter = 0

def increment():
    global counter
    for _ in range(1_000_000):
        counter += 1     # NOT atomic: read, add, write โ€” can be interrupted mid-way

threads = [threading.Thread(target=increment) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)   # Expected 2,000,000 โ€” but you'll get less. Lost updates!
Why: counter += 1 is three steps under the hood (read โ†’ add โ†’ store). A thread can be paused between them, another thread reads the stale value, and one update is lost. The fix is a Lock (Pattern 2).

Threads vs Async: The Key Difference

Both give concurrency on I/O. The difference is who decides when to switch:

ThreadsAsync
SwitchingPreemptive โ€” the OS interrupts anywhere, anytimeCooperative โ€” only at await
Race conditionsEverywhere โ€” need locksRare โ€” switches are predictable
Cost per unitHeavy (~MBs of stack, OS-scheduled)Cheap (thousands of tasks fine)
Works with blocking libsโœ… Yes (requests, etc.)โŒ Needs async libraries
Rule of thumb: a few dozen blocking I/O tasks with existing sync libraries โ†’ threads. Thousands of concurrent connections you can rewrite โ†’ async. CPU-heavy number crunching โ†’ neither, use processes. There's a full comparison guide.

The Building Blocks You'll Meet

Start high-level: in real code, reach for ThreadPoolExecutor and queue.Queue first. Raw Thread + Lock is worth understanding, but the high-level tools prevent most bugs.

The One-Paragraph Recap

A thread is a lightweight worker inside your process that shares all memory with the others. Threads let I/O-bound work overlap because the GIL is released while waiting โ€” but they can't speed up pure-Python CPU work, and they can be interrupted anywhere, so shared state needs locks. You create them with threading.Thread, coordinate them with queues and locks, and in practice run them through a ThreadPoolExecutor. Master the GIL and race conditions and the rest is just applying that safely.