Threading Proficient¶
When you'd use this
Threads, locks, race conditions, synchronization primitives and the GIL.
Run I/O-bound work concurrently (downloads, requests) with threads — but mind the GIL, which limits CPU-bound parallelism.
When to use threads¶
A core question explored in Threading: When to use threads.
| Workload | Use threads? | Why |
|---|---|---|
| Network I/O (HTTP, DB) | Yes | GIL released during I/O |
| File I/O | Yes | GIL released during disk ops |
| CPU computation | No | GIL prevents parallel execution |
| Shared-memory concurrency | Yes | Simpler than multiprocessing |
Basic threading¶
Start a thread to run a function concurrently — good for overlapping I/O waits.
import threading
import time
def download(url, delay):
print(f" [{threading.current_thread().name}] Starting {url}")
time.sleep(delay) # simulate network I/O
print(f" [{threading.current_thread().name}] Done {url}")
return f"Data from {url}"
# Sequential — slow
start = time.perf_counter()
for url in ["api/users", "api/posts", "api/comments"]:
download(url, 1)
print(f"Sequential: {time.perf_counter() - start:.2f}s") # ~3s
# Threaded — fast
start = time.perf_counter()
threads = []
for url in ["api/users", "api/posts", "api/comments"]:
t = threading.Thread(target=download, args=(url, 1), name=f"Worker-{url}")
threads.append(t)
t.start()
for t in threads:
t.join() # wait for all to finish
print(f"Threaded: {time.perf_counter() - start:.2f}s") # ~1s
ThreadPoolExecutor (recommended API)¶
Submit tasks to a managed pool and collect results — simpler than managing threads by hand.
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def fetch_data(url):
time.sleep(1) # simulate I/O
return f"Result from {url}"
urls = [f"https://api.example.com/page/{i}" for i in range(10)]
# Submit tasks and collect results
with ThreadPoolExecutor(max_workers=5) as executor:
# Method 1: map (ordered results)
results = list(executor.map(fetch_data, urls))
print(results)
# Method 2: submit (futures — unordered, more control)
futures = {executor.submit(fetch_data, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
try:
result = future.result(timeout=5)
print(f" {url}: {result}")
except Exception as exc:
print(f" {url} failed: {exc}")
Race conditions¶
What goes wrong when threads touch shared state without coordination — the bug locks exist to prevent.
import threading
# UNSAFE — race condition
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1 # READ → ADD → WRITE (not atomic!)
threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Expected: 4,000,000")
print(f"Actual: {counter:,}") # likely < 4,000,000!
# Race: thread A reads 5, thread B reads 5, both write 6 (lost update)
Locks¶
Serialize access to shared state so only one thread is in the critical section at a time.
import threading
counter = 0
lock = threading.Lock()
def safe_increment():
global counter
for _ in range(1_000_000):
with lock: # acquire → release (context manager)
counter += 1 # only one thread at a time
threads = [threading.Thread(target=safe_increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"Result: {counter:,}") # exactly 4,000,000
RLock (re-entrant lock)¶
rlock = threading.RLock()
def recursive_function(n):
with rlock: # same thread can acquire multiple times
if n > 0:
recursive_function(n - 1)
# Regular Lock would deadlock here — RLock handles it
Other synchronization primitives¶
Events, conditions, semaphores, and barriers for coordinating threads beyond a plain lock.
Semaphore — limit concurrent access¶
import threading
import time
# Only 3 threads can access the resource simultaneously
semaphore = threading.Semaphore(3)
def limited_access(name):
with semaphore:
print(f" {name} acquired (at {time.strftime('%H:%M:%S')})")
time.sleep(2)
print(f" {name} released")
threads = [threading.Thread(target=limited_access, args=(f"Worker-{i}",)) for i in range(10)]
for t in threads: t.start()
for t in threads: t.join()
# Only 3 "acquired" messages at a time
Event — signal between threads¶
import threading
import time
ready = threading.Event()
def producer():
print(" Producer: preparing data...")
time.sleep(2)
print(" Producer: data ready!")
ready.set() # signal consumers
def consumer(name):
print(f" {name}: waiting for data...")
ready.wait() # blocks until set()
print(f" {name}: got data, processing!")
threads = [
threading.Thread(target=producer),
threading.Thread(target=consumer, args=("Consumer-1",)),
threading.Thread(target=consumer, args=("Consumer-2",)),
]
for t in threads: t.start()
for t in threads: t.join()
Condition — complex signaling¶
import threading
import time
from collections import deque
buffer = deque(maxlen=5)
condition = threading.Condition()
def producer():
for i in range(20):
with condition:
while len(buffer) >= 5:
condition.wait() # buffer full — wait
buffer.append(i)
print(f" Produced: {i} (buffer: {list(buffer)})")
condition.notify_all() # wake consumers
time.sleep(0.1)
def consumer(name):
while True:
with condition:
while len(buffer) == 0:
condition.wait() # buffer empty — wait
item = buffer.popleft()
print(f" {name} consumed: {item}")
condition.notify_all() # wake producer
time.sleep(0.2)
Barrier — synchronize N threads at a point¶
import threading
barrier = threading.Barrier(3) # wait for 3 threads
def worker(name):
print(f" {name}: phase 1 done")
barrier.wait() # blocks until all 3 arrive
print(f" {name}: phase 2 starting")
threads = [threading.Thread(target=worker, args=(f"W{i}",)) for i in range(3)]
for t in threads: t.start()
for t in threads: t.join()
# All "phase 2" messages appear after all "phase 1" messages
Thread-safe data structures¶
Use queue.Queue and friends to pass data between threads without manual locking.
import queue
import threading
# queue.Queue is thread-safe (built-in locking)
q = queue.Queue(maxsize=10)
def producer():
for i in range(20):
q.put(i) # blocks if full
q.put(None) # sentinel
def consumer():
while True:
item = q.get() # blocks if empty
if item is None:
break
print(f" Processing: {item}")
q.task_done()
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t1.start(); t2.start()
t1.join(); t2.join()
Daemon threads¶
Background threads that don't block program exit — for fire-and-forget helpers.
import threading
import time
def background_task():
while True:
print(" Background: working...")
time.sleep(1)
# Daemon thread dies when main thread exits
t = threading.Thread(target=background_task, daemon=True)
t.start()
time.sleep(3)
print("Main thread done — daemon will be killed")
# No need to join() daemon threads
Thread-local storage¶
Give each thread its own copy of a value to avoid sharing entirely.
import threading
# Each thread gets its own copy of data
local_data = threading.local()
def worker(name):
local_data.name = name # unique per thread
time.sleep(0.1)
print(f" Thread {threading.current_thread().name}: local_data.name = {local_data.name}")
threads = [threading.Thread(target=worker, args=(f"Worker-{i}",)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()
# Each thread sees its own name — no interference
Common threading patterns¶
Worker pools, producer/consumer, and periodic tasks done safely.
Thread pool with result collection¶
from concurrent.futures import ThreadPoolExecutor, as_completed
def process_item(item):
# Expensive I/O operation
import time
time.sleep(0.5)
return item * 2
items = list(range(100))
results = []
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_item = {executor.submit(process_item, item): item for item in items}
for future in as_completed(future_to_item):
item = future_to_item[future]
try:
result = future.result()
results.append(result)
except Exception as e:
print(f" Item {item} failed: {e}")
print(f"Processed {len(results)} items")
The GIL's impact on threads¶
Why threads overlap I/O but can't speed up CPU-bound Python.
import threading
import time
def cpu_bound():
"""CPU work — GIL prevents parallelism."""
total = 0
for i in range(10_000_000):
total += i
# Single thread
start = time.perf_counter()
cpu_bound()
print(f"1 thread: {time.perf_counter() - start:.2f}s") # ~0.7s
# 4 threads — NOT faster! (GIL contention)
start = time.perf_counter()
threads = [threading.Thread(target=cpu_bound) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(f"4 threads: {time.perf_counter() - start:.2f}s") # ~2.8s (SLOWER!)
# Lesson: use multiprocessing for CPU-bound work
Practice Exercises¶
- Build a web scraper that downloads 50 pages concurrently with ThreadPoolExecutor (limit 10 workers).
- Implement a thread-safe counter class with
increment(),decrement()andvalueproperty. - Build a producer-consumer system with 3 producers and 5 consumers using
queue.Queue. - Demonstrate the race condition — then fix it with a Lock, then again with an atomic operation.
- Implement a connection pool using a Semaphore that limits to N simultaneous connections.
- Benchmark threads vs sequential for I/O-bound and CPU-bound work — measure the difference.
💬 Discussion
Have a question about this topic? Found an error? Share your thoughts below.