S8.0 What This Teaches
- Creating and joining threads with the
threadingmodule - Daemon threads and thread lifecycle
- Race conditions and shared mutable state
LockandRLockfor mutual exclusion- Thread-safe communication with
queue.Queue - The Global Interpreter Lock (GIL) and when threads help
- When to prefer
multiprocessingorconcurrent.futures
S8.1 Creating Threads
threading.Thread class wraps a callable. Call start()
to launch the thread and join() to wait for it to finish:
# Threads - creating and joining threads.
import threading
import time
def worker(name: str, delay: float):
time.sleep(delay)
print(f"{name} done after {delay}s")
t1 = threading.Thread(target=worker, args=("A", 0.5))
t2 = threading.Thread(target=worker, args=("B", 0.2))
t1.start()
t2.start()
t1.join()
t2.join()
print("all done")
join() blocks the calling thread until the target thread exits.
Without join(), the main thread may exit before the workers finish.
S8.2 Daemon Threads
daemon=True before starting:
import threading, time
def heartbeat():
while True:
print("ping")
time.sleep(1)
monitor = threading.Thread(target=heartbeat, daemon=True)
monitor.start()
time.sleep(2.5)
print("main exiting") # daemon thread dies here automatically
S8.3 Race Conditions
import threading
counter = 0
def increment(n: int):
global counter
for _ in range(n):
counter += 1 # read-modify-write is NOT atomic
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # expected 500_000, but often less due to lost updates
x += 1 involve multiple bytecode
instructions. The GIL reduces (but does not eliminate) the window for races
on CPython.
S8.4 Lock and RLock
Lock ensures only one thread executes a critical section at a time.
Always use it as a context manager to guarantee release even on exceptions:
import threading
counter = 0
lock = threading.Lock()
def safe_increment(n: int):
global counter
for _ in range(n):
with lock: # acquire on enter, release on exit
counter += 1
threads = [threading.Thread(target=safe_increment, args=(100_000,)) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # always 500_000
RLock (reentrant lock) when the same thread may acquire the lock
multiple times - for example, in recursive functions that need synchronization.
S8.5 Thread-Safe Queue
queue.Queue is the standard way to pass data between threads.
It handles all locking internally and supports producer-consumer patterns:
import threading
import queue
def producer(q: queue.Queue, items: list):
for item in items:
q.put(item)
q.put(None) # sentinel to signal completion
def consumer(q: queue.Queue):
while True:
item = q.get()
if item is None:
break
print(f"processed {item}")
q.task_done()
q = queue.Queue()
p = threading.Thread(target=producer, args=(q, [1, 2, 3, 4, 5]))
c = threading.Thread(target=consumer, args=(q,))
p.start()
c.start()
p.join()
c.join()
S8.6 The Global Interpreter Lock
- Threads do NOT run in parallel for CPU-bound work (no true parallelism)
- Threads DO run concurrently for I/O-bound work - I/O releases the GIL
- Use
multiprocessingorconcurrent.futures.ProcessPoolExecutorfor CPU-bound parallelism
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
def cpu_work(n):
return sum(range(n))
# ThreadPoolExecutor - good for I/O-bound tasks
with ThreadPoolExecutor(max_workers=4) as ex:
results = list(ex.map(lambda url: fetch(url), urls))
# ProcessPoolExecutor - good for CPU-bound tasks (bypasses GIL)
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(cpu_work, [10_000_000] * 4))
S8.7 Example - All Together
# Threads - worker pool with a Queue and Lock for safe result collection.
import threading
import queue
results = []
results_lock = threading.Lock()
def worker(task_queue: queue.Queue):
while True:
item = task_queue.get()
if item is None:
task_queue.put(None) # pass sentinel to next worker
break
result = item * item
with results_lock:
results.append(result)
task_queue.task_done()
tasks = queue.Queue()
for n in range(1, 11):
tasks.put(n)
tasks.put(None) # one sentinel triggers chain shutdown
workers = [threading.Thread(target=worker, args=(tasks,)) for _ in range(3)]
for w in workers: w.start()
for w in workers: w.join()
print(sorted(results)) # [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
S8.8 Exercise
Exercise
- Write a producer that puts 20 integers into a
queue.Queueand three consumers that each square the integers and print results. Use a sentinel value to stop the consumers cleanly. - Demonstrate a race condition by incrementing a shared counter from 10 threads
without a lock. Then fix it with
threading.Lockand verify the correct result. - Use
concurrent.futures.ThreadPoolExecutorto simulate downloading 5 URLs concurrently (usetime.sleepfor fake latency). Print each result as it arrives usingas_completed().
S8.9 Common Mistakes
Forgetting join() - silent data loss
t = threading.Thread(target=worker)
t.start()
print("done") # main exits before worker finishes!
# Fix: always join threads you care about
t.start()
t.join()
print("done")
Using threads for CPU-bound work
multiprocessing or ProcessPoolExecutor instead.
Not using Queue for cross-thread data
# Dangerous: sharing a list without locking
shared = []
def bad_append(x): shared.append(x) # list.append is thread-safe by accident; dict access is not
# Correct: use Queue
q = queue.Queue()
def safe_put(x): q.put(x)
S8.10 Key Terms
| Term | Meaning |
|---|---|
| Thread | Lightweight unit of execution sharing memory with its creator |
| daemon thread | Thread that exits automatically when the main thread exits |
| race condition | Bug caused by uncoordinated access to shared mutable state |
| Lock | Mutual-exclusion primitive; only one thread holds it at a time |
| RLock | Reentrant lock; same thread can acquire multiple times without deadlock |
| queue.Queue | Thread-safe FIFO; the standard way to pass data between threads |
| GIL | Global Interpreter Lock; prevents parallel execution of Python bytecode in CPython |
| ProcessPoolExecutor | Runs callables in separate processes, bypassing the GIL |