C.W.K.
Stream
Lesson 03 of 05 · published

Threading and the GIL

~18 min · threading, gil, lock, thread

Level 0Curious
0 XP0/93 lessons0/23 achievements
0/100 XP to next level100 XP to go0% complete

The GIL — what it is and why it matters

CPython has the Global Interpreter Lock — a mutex that allows only one thread to execute Python bytecode at a time. Two CPU-bound threads can't run Python in parallel; they take turns. The GIL is released during I/O, so threading is still useful for I/O-heavy work. For CPU-heavy work, you need multiprocessing (next lesson) or the new free-threaded mode in 3.13+ (experimental).

When to use threading

(1) When you need concurrent I/O but a third-party library doesn't support async (a sync database driver, a sync HTTP client). (2) When you have many blocking calls and asyncio refactor isn't viable. (3) When integrating with code that must run in a thread (some GUI frameworks, some C extensions). For modern async-aware code, threading is rarely the right first choice.

The threading API — basics

threading.Thread(target=fn, args=(...)) creates a thread. .start() runs it. .join() waits for it. threading.Lock(), threading.RLock(), threading.Event(), threading.Condition() are the synchronization primitives. queue.Queue is thread-safe and is the right way to pass data between threads.

concurrent.futures — the higher-level interface

For most threading work, concurrent.futures.ThreadPoolExecutor is the right tool. Submit tasks, get back Future objects, collect results. Cleaner than managing threads directly. ProcessPoolExecutor works the same way for processes — same API, different concurrency model.

Code

Basic threading — and the GIL constraint·python
import threading
import time

def worker(name, delay):
    print(f"{name}: starting")
    time.sleep(delay)                     # I/O-style — releases GIL
    print(f"{name}: done")

start = time.perf_counter()
threads = [
    threading.Thread(target=worker, args=(f"t{i}", 1))
    for i in range(3)
]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"elapsed: {time.perf_counter() - start:.2f}s")
# elapsed: ~1s (parallel I/O — GIL released during sleep)

# Now CPU-bound — GIL serializes
def cpu_work(n):
    total = 0
    for _ in range(n):
        total += 1
    return total

start = time.perf_counter()
threads = [threading.Thread(target=cpu_work, args=(10_000_000,)) for _ in range(3)]
for t in threads: t.start()
for t in threads: t.join()
print(f"cpu elapsed: {time.perf_counter() - start:.2f}s")
# Roughly 3x the single-thread time — GIL prevents parallelism
Lock — protecting shared state·python
import threading

counter = 0
lock = threading.Lock()

def increment(times):
    global counter
    for _ in range(times):
        with lock:                        # atomic
            counter += 1

threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)                            # 400000 — without lock, you'd see less
queue.Queue — thread-safe pipe·python
import threading
import queue

q = queue.Queue(maxsize=10)

def producer():
    for i in range(5):
        q.put(f"item-{i}")
    q.put(None)                           # done signal

def consumer():
    while True:
        item = q.get()
        if item is None:
            break
        print("got", item)
        q.task_done()

t_p = threading.Thread(target=producer)
t_c = threading.Thread(target=consumer)
t_p.start(); t_c.start()
t_p.join(); t_c.join()
ThreadPoolExecutor — easier than raw threads·python
from concurrent.futures import ThreadPoolExecutor
import time

def fetch(url):
    time.sleep(0.5)                       # simulate I/O
    return f"got {url}"

urls = ["a", "b", "c", "d", "e"]

start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
    results = list(pool.map(fetch, urls))
print(results)
print(f"{time.perf_counter() - start:.2f}s")
# All 5 ran in parallel (I/O bound) — ~0.5s total

External links

Exercise

Use ThreadPoolExecutor to fetch (simulate with time.sleep) a list of 8 URLs in parallel, with max_workers=4. Use pool.submit and collect futures, then iterate as_completed to print each result as it finishes. Verify total time is ~2 seconds (8 URLs, 4 at a time, each takes 1 second), not 8 seconds.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.