Threading & Concurrency
Master threads, locks, the GIL, thread pools, and concurrent.futures.
Threading in Python
Threading allows multiple tasks to run concurrently within a single process. While Python's Global Interpreter Lock (GIL) prevents true parallel execution of Python code, threading is excellent for I/O-bound operations like network requests, file I/O, and database queries where threads spend most of their time waiting.
When to Use Threading:
- ✓ I/O-bound tasks: Network calls, file operations, database queries
- ✓ Concurrent operations: Handling multiple connections simultaneously
- ✓ Responsive UIs: Keep interface responsive during background work
- ✗ CPU-bound tasks: Use multiprocessing instead (covered in next lesson)
Understanding the Global Interpreter Lock (GIL)
The GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously. This is Python's most misunderstood feature.
❌ GIL Hurts CPU-Bound Tasks
Only one thread executes Python code at a time
Thread 1: [███░░░░░░]
Thread 2: [░░░███░░░]
Thread 3: [░░░░░░███]Threads take turns, no speedup for pure Python computation
✓ GIL Released During I/O
GIL automatically released during blocking I/O
Thread 1: [░░I/O░░░░]
Thread 2: [░░I/O░░░░]
Thread 3: [░░I/O░░░░]All threads can wait for I/O simultaneously!
Those three demos replay a real run of the benchmark below, so you can check the claim rather than take it. Run it yourself and the ratios should look similar even though the absolute times will not.
# gil_bench.py - measure the three claims instead of assuming them
import os
import threading
import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
def cpu_work(n=6_000_000):
"""Pure Python arithmetic: needs the GIL the whole way through"""
total = 0
for i in range(n):
total += i
return total
def io_work(_=None):
"""Sleeping releases the GIL, exactly like waiting on a socket"""
time.sleep(1)
def timed(fn):
start = time.perf_counter()
fn()
return time.perf_counter() - start
def cpu_sequential(workers=4):
for _ in range(workers):
cpu_work()
def cpu_threaded(workers=4):
ts = [threading.Thread(target=cpu_work) for _ in range(workers)]
for t in ts:
t.start()
for t in ts:
t.join()
def cpu_processes(workers=4):
with ProcessPoolExecutor(max_workers=workers) as ex:
list(ex.map(cpu_work, [6_000_000] * workers))
def io_sequential(n=5):
for _ in range(n):
io_work()
def io_threaded(n=5):
with ThreadPoolExecutor(max_workers=n) as ex:
list(ex.map(io_work, range(n)))
if __name__ == "__main__":
print(f"cores: {os.cpu_count()}")
seq = timed(cpu_sequential)
thr = timed(cpu_threaded)
proc = timed(cpu_processes)
print("CPU-bound, 4 tasks")
print(f" sequential {seq:5.2f}s")
print(f" 4 threads {thr:5.2f}s ({seq / thr:.2f}x vs sequential)")
print(f" 4 processes {proc:5.2f}s ({seq / proc:.2f}x vs sequential)")
io_seq = timed(io_sequential)
io_thr = timed(io_threaded)
print("I/O-bound, 5 x 1s sleep")
print(f" sequential {io_seq:5.2f}s")
print(f" 5 threads {io_thr:5.2f}s ({io_seq / io_thr:.2f}x vs sequential)")Expected Output:
cores: 20 CPU-bound, 4 tasks sequential 0.41s 4 threads 0.39s (1.05x vs sequential) 4 processes 0.12s (3.30x vs sequential) I/O-bound, 5 x 1s sleep sequential 5.00s 5 threads 1.00s (4.99x vs sequential)
"The GIL" means the default build, not every Python
Everything above describes the standard CPython build, which is what you get from python.org or your package manager. CPython 3.13 added a second, free-threaded build (PEP 703) that runs without the GIL, and 3.14 moved it from experimental to officially supported. It is a separate binary you have to install on purpose, so the advice here holds for the interpreter you are almost certainly running. From 3.13 on you can settle it at runtime with sys._is_gil_enabled(), which returns True on a normal build. On a free-threaded build, threads do run CPU-bound Python in parallel and the first benchmark above would tell a different story.
Basic Threading
Creating and starting threads with the threading module.
import threading
import time
def worker():
"""Function to run in a thread"""
print(f"Worker started in thread: {threading.current_thread().name}")
time.sleep(2)
print(f"Worker finished in thread: {threading.current_thread().name}")
# Create thread
thread = threading.Thread(target=worker)
# Start thread (non-blocking)
thread.start()
print("Main thread continues...")
# Wait for thread to complete
thread.join()
print("Thread completed!")
# Multiple threads
threads = []
for i in range(5):
t = threading.Thread(target=worker, name=f"Worker-{i}")
threads.append(t)
t.start()
# Wait for all threads
for t in threads:
t.join()
print("All threads completed!")Expected Output:
Worker started in thread: Thread-1 (worker) Main thread continues... Worker finished in thread: Thread-1 (worker) Thread completed! Worker started in thread: Worker-0 Worker started in thread: Worker-1 Worker started in thread: Worker-2 Worker started in thread: Worker-3 Worker started in thread: Worker-4 Worker finished in thread: Worker-0 Worker finished in thread: Worker-1 Worker finished in thread: Worker-2 Worker finished in thread: Worker-3 Worker finished in thread: Worker-4 All threads completed!
The five workers all wake from sleep(2) at nearly the same moment, so the order of the "finished" lines is up to the scheduler. Two batches of 20 runs on one machine produced the order above 18 and 19 times; the odd runs out reordered some of the "finished" lines among themselves. Tidy output is the common case here, not a guarantee.
Thread Synchronization: Locks and More
When multiple threads access shared data, you need synchronization primitives to prevent race conditions and data corruption.
Lock: Basic Mutual Exclusion
import threading
import time
# Shared resource
counter = 0
lock = threading.Lock()
def increment_without_lock():
"""Race condition - unsafe!"""
global counter
for _ in range(20000):
temp = counter
time.sleep(0) # hands the GIL to another thread, mid-update
temp += 1
counter = temp
# While this thread is paused above, another one reads the same
# 'counter'. Both then write back the same value, so one increment
# is silently lost.
def increment_with_lock():
"""Thread-safe with lock"""
global counter
for _ in range(20000):
with lock: # Acquire lock
temp = counter
time.sleep(0) # other threads still cannot enter: lock is held
temp += 1
counter = temp
# Lock automatically released
# Demonstrate race condition
counter = 0
threads = [threading.Thread(target=increment_without_lock) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Without lock: {counter}")
# With proper locking
counter = 0
threads = [threading.Thread(target=increment_with_lock) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"With lock: {counter}")
# Remove the time.sleep(0) and the unlocked version will very likely print
# 160000 too. That does not mean it is safe. Python only switches threads at
# certain points, and this short read-modify-write usually finishes before
# one arrives. Nothing guarantees it: a slower expression, an I/O call, or a
# different Python version moves the switch into the middle and increments
# start disappearing.
#
# Decide whether you need a lock by asking whether the operation is atomic.
# Reading a shared variable, changing it, and writing it back is three
# separate steps, so it is not atomic, so it needs a lock.Expected Output:
Without lock: 20006 With lock: 160000
The first number is different every time. Five consecutive runs here printed 20006, 20005, 20004, 20000 and 20008 without the lock, against 160,000 actual increments: eight threads did 20,000 increments each and almost exactly one thread's worth survived. The second line is 160000 on every one of those runs, which is the point: the locked version is not merely closer, it is exact, and the unlocked one is not merely wrong, it is differently wrong each time you look.
RLock: Reentrant Lock
import threading
# RLock can be acquired multiple times by same thread
rlock = threading.RLock()
def recursive_function(n):
"""Can acquire same lock multiple times"""
with rlock:
if n > 0:
print(f"Level {n}")
recursive_function(n - 1) # Reacquires same lock
return n
# This works with RLock
thread = threading.Thread(target=recursive_function, args=(5,))
thread.start()
thread.join()
# Would deadlock with regular Lock!
# lock = threading.Lock()
# with lock:
# with lock: # Deadlock! Same thread trying to acquire same lock
# passExpected Output:
Level 5 Level 4 Level 3 Level 2 Level 1
Semaphore: Limit Concurrent Access
import threading
import time
# Allow maximum 3 threads to access resource simultaneously
semaphore = threading.Semaphore(3)
def access_limited_resource(worker_id):
"""Only 3 workers can be here at once"""
print(f"Worker {worker_id} waiting...")
with semaphore:
print(f"Worker {worker_id} accessing resource")
time.sleep(2) # Simulate work
print(f"Worker {worker_id} done")
# Start 10 workers, but only 3 can access resource at once
threads = []
for i in range(10):
t = threading.Thread(target=access_limited_resource, args=(i,))
threads.append(t)
t.start()
for t in threads:
t.join()
# Use case: Database connection pool
class ConnectionPool:
def __init__(self, max_connections=5):
self.semaphore = threading.Semaphore(max_connections)
self.connections = []
def acquire(self):
self.semaphore.acquire()
# Get or create connection
return "connection"
def release(self, conn):
# Return connection to pool
self.semaphore.release()Expected Output:
Worker 0 waiting... Worker 0 accessing resource Worker 1 waiting... Worker 1 accessing resource Worker 2 waiting... Worker 2 accessing resource Worker 3 waiting... Worker 4 waiting... Worker 5 waiting... Worker 6 waiting... Worker 7 waiting... Worker 8 waiting... Worker 9 waiting... Worker 0 done Worker 1 done Worker 3 accessing resource Worker 4 accessing resource Worker 2 done Worker 5 accessing resource Worker 3 done Worker 4 done Worker 6 accessing resource Worker 7 accessing resource Worker 5 done Worker 8 accessing resource Worker 6 done Worker 7 done Worker 8 done Worker 9 accessing resource Worker 9 done
Event: Thread Signaling
import threading
import time
event = threading.Event()
def waiter():
"""Wait for signal"""
print("Waiter: Waiting for event...")
event.wait() # Blocks until event is set
print("Waiter: Event received! Proceeding...")
def setter():
"""Send signal after delay"""
print("Setter: Working...")
time.sleep(3)
print("Setter: Setting event")
event.set() # Wake up all waiting threads
# Start waiter first
waiter_thread = threading.Thread(target=waiter)
waiter_thread.start()
# Start setter
setter_thread = threading.Thread(target=setter)
setter_thread.start()
waiter_thread.join()
setter_thread.join()
# Use cases:
# - Coordinate startup sequence
# - Signal completion of initialization
# - Implement barriers/checkpointsExpected Output:
Waiter: Waiting for event... Setter: Working... Setter: Setting event Waiter: Event received! Proceeding...
ThreadPoolExecutor: High-Level Threading
concurrent.futures provides a high-level interface for managing thread pools, making it much easier to work with multiple threads.
Basic ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
def download_url(url):
"""Download a URL"""
response = requests.get(url)
return len(response.content)
urls = [
"https://example.com",
"https://python.org",
"https://github.com",
] * 5 # 15 URLs total
# Method 1: map() - simple and ordered
with ThreadPoolExecutor(max_workers=5) as executor:
results = executor.map(download_url, urls)
for url, size in zip(urls, results):
print(f"{url}: {size} bytes")
# Method 2: submit() - more control
with ThreadPoolExecutor(max_workers=5) as executor:
# Submit all tasks
futures = [executor.submit(download_url, url) for url in urls]
# Get results as they complete
for future in as_completed(futures):
try:
size = future.result()
print(f"Downloaded {size} bytes")
except Exception as e:
print(f"Error: {e}")
# Automatic cleanup - threads are joined when exiting with blockExpected Output:
https://example.com: 559 bytes https://python.org: 53119 bytes https://github.com: 575856 bytes https://example.com: 559 bytes https://python.org: 53119 bytes https://github.com: 575856 bytes https://example.com: 559 bytes https://python.org: 53119 bytes https://github.com: 575856 bytes https://example.com: 559 bytes https://python.org: 53119 bytes https://github.com: 575861 bytes https://example.com: 559 bytes https://python.org: 53119 bytes https://github.com: 575861 bytes Downloaded 559 bytes Downloaded 559 bytes Downloaded 53119 bytes Downloaded 53119 bytes Downloaded 559 bytes Downloaded 559 bytes Downloaded 53119 bytes Downloaded 575856 bytes Downloaded 575856 bytes Downloaded 53119 bytes Downloaded 559 bytes Downloaded 575856 bytes Downloaded 53119 bytes Downloaded 575856 bytes Downloaded 575856 bytes
Real-World: Concurrent API Requests
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time
def fetch_user_data(user_id):
"""Fetch user data from API"""
try:
response = requests.get(
f"https://jsonplaceholder.typicode.com/users/{user_id}",
timeout=5
)
response.raise_for_status()
return response.json()
except Exception as e:
print(f"Error fetching user {user_id}: {e}")
return None
# Sequential (slow)
start = time.time()
results = []
for user_id in range(1, 11):
results.append(fetch_user_data(user_id))
sequential_time = time.time() - start
print(f"Sequential: {sequential_time:.2f}s")
# Concurrent with ThreadPoolExecutor (fast!)
start = time.time()
with ThreadPoolExecutor(max_workers=10) as executor:
# Submit all requests
future_to_id = {
executor.submit(fetch_user_data, user_id): user_id
for user_id in range(1, 11)
}
results = []
for future in as_completed(future_to_id):
user_id = future_to_id[future]
try:
data = future.result()
if data:
results.append(data)
print(f"✓ User {user_id}: {data['name']}")
except Exception as e:
print(f"✗ User {user_id} failed: {e}")
concurrent_time = time.time() - start
print(f"Concurrent: {concurrent_time:.2f}s")
print(f"Speedup: {sequential_time / concurrent_time:.1f}x faster!")Expected Output:
Sequential: 1.32s ✓ User 8: Nicholas Runolfsdottir V ✓ User 1: Leanne Graham ✓ User 9: Glenna Reichert ✓ User 3: Clementine Bauch ✓ User 6: Mrs. Dennis Schulist ✓ User 10: Clementina DuBuque ✓ User 5: Chelsey Dietrich ✓ User 4: Patricia Lebsack ✓ User 2: Ervin Howell ✓ User 7: Kurtis Weissnat Concurrent: 0.18s Speedup: 7.5x faster!
Four runs against the live API took 1.32s, 1.41s, 1.46s and 2.91s sequentially against 0.18s, 0.18s, 0.19s and 0.38s concurrently: between 7.5x and 7.8x. The absolute times depend on your latency to that host, so the fourth run being twice as slow as the first changed almost nothing about the ratio. The order shifts too: as_completed yields whichever request finished first, so the ten user lines come out differently every run.
Advanced: Timeout and Cancellation
from concurrent.futures import ThreadPoolExecutor, TimeoutError
import time
def slow_task(n):
time.sleep(n)
return f"Task {n} completed"
with ThreadPoolExecutor(max_workers=3) as executor:
# Submit tasks
future1 = executor.submit(slow_task, 1)
future2 = executor.submit(slow_task, 5)
future3 = executor.submit(slow_task, 10)
# Wait with timeout
try:
result = future1.result(timeout=2)
print(result)
except TimeoutError:
print("Task 1 timed out")
# Only a task still sitting in the queue can be cancelled. There are
# three workers and three tasks, so this one is already running and
# cancel() returns False. Submit a fourth task and that one cancels.
if future3.cancel():
print("Task 3 cancelled successfully")
else:
print("Task 3 already running, can't cancel")
# Check task status
print(f"Future2 done: {future2.done()}")
print(f"Future2 running: {future2.running()}")
print(f"Future2 cancelled: {future2.cancelled()}")
# Wait for result
result = future2.result()
print(result)Expected Output:
Task 1 completed Task 3 already running, can't cancel Future2 done: False Future2 running: True Future2 cancelled: False Task 5 completed
Thread-Safe Data Structures
Python provides thread-safe data structures that handle synchronization internally.
import threading
import queue
import time
# Queue: Thread-safe FIFO queue
task_queue = queue.Queue(maxsize=10)
def producer(producer_id):
"""Add items to queue"""
for i in range(5):
item = f"Item-{producer_id}-{i}"
task_queue.put(item) # Thread-safe
print(f"Producer {producer_id} added {item}")
time.sleep(0.1)
def consumer(consumer_id):
"""Process items from queue"""
while True:
try:
item = task_queue.get(timeout=1) # Thread-safe
print(f"Consumer {consumer_id} processing {item}")
time.sleep(0.2)
task_queue.task_done()
except queue.Empty:
break
# Start producers and consumers
producers = [threading.Thread(target=producer, args=(i,)) for i in range(2)]
consumers = [threading.Thread(target=consumer, args=(i,)) for i in range(3)]
for p in producers:
p.start()
for c in consumers:
c.start()
for p in producers:
p.join()
for c in consumers:
c.join()
# Other queue types
priority_queue = queue.PriorityQueue() # Items sorted by priority
lifo_queue = queue.LifoQueue() # Stack (LIFO)
# PriorityQueue example
priority_queue.put((1, "High priority"))
priority_queue.put((3, "Low priority"))
priority_queue.put((2, "Medium priority"))
while not priority_queue.empty():
priority, item = priority_queue.get()
print(f"Priority {priority}: {item}")
# Thread-local storage
thread_local = threading.local()
def worker():
# Each thread has its own copy
thread_local.value = threading.current_thread().name
print(f"Thread local value: {thread_local.value}")
threads = [threading.Thread(target=worker) for _ in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()Expected Output:
Producer 0 added Item-0-0 Producer 1 added Item-1-0 Consumer 0 processing Item-0-0 Consumer 1 processing Item-1-0 Producer 0 added Item-0-1 Consumer 2 processing Item-0-1 Producer 1 added Item-1-1 Producer 0 added Item-0-2 Consumer 0 processing Item-1-1 Producer 1 added Item-1-2 Consumer 1 processing Item-0-2 Consumer 2 processing Item-1-2 Producer 0 added Item-0-3 Producer 1 added Item-1-3 Producer 1 added Item-1-4 Producer 0 added Item-0-4 Consumer 0 processing Item-0-3 Consumer 1 processing Item-1-3 Consumer 2 processing Item-1-4 Consumer 1 processing Item-0-4 Priority 1: High priority Priority 2: Medium priority Priority 3: Low priority Thread local value: Thread-6 (worker) Thread local value: Thread-7 (worker) Thread local value: Thread-8 (worker)
Threading Best Practices
- Use
ThreadPoolExecutorfor most cases - Use threading for I/O-bound operations
- Always use locks when accessing shared data
- Use context managers (
with) for locks - Use thread-safe
Queuefor communication - Set
daemon=Truefor background threads - Handle exceptions in thread functions
- Use
concurrent.futuresfor cleaner code - Join threads to wait for completion
- Use
Eventfor signaling between threads
- Using threads for CPU-bound tasks
- Accessing shared data without locks
- Creating too many threads (use a pool)
- Forgetting to join threads
- Using global variables without locks
- Nested locks (can cause deadlock)
- Ignoring the GIL's limitations
- Manual thread management when a pool works
- Daemon threads for critical tasks
- Expecting parallel CPU computation
Common Threading Patterns
Pattern 1: Worker Pool
from concurrent.futures import ThreadPoolExecutor
import time
def process_item(item):
"""Process a single item"""
time.sleep(0.1)
return item * 2
items = list(range(100))
# Process all items using thread pool
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(process_item, items))
print(f"Processed {len(results)} items")Expected Output:
Processed 100 items
Pattern 2: Producer-Consumer
import threading
import queue
import time
def producer_consumer_pattern():
work_queue = queue.Queue()
def producer():
for i in range(20):
work_queue.put(i)
time.sleep(0.1)
# Signal completion
work_queue.put(None)
def consumer():
while True:
item = work_queue.get()
if item is None:
break
# Process item
print(f"Processing {item}")
time.sleep(0.2)
work_queue.task_done()
prod_thread = threading.Thread(target=producer)
cons_thread = threading.Thread(target=consumer)
prod_thread.start()
cons_thread.start()
prod_thread.join()
cons_thread.join()
producer_consumer_pattern()Expected Output:
Processing 0 Processing 1 Processing 2 Processing 3 Processing 4 Processing 5 Processing 6 Processing 7 Processing 8 Processing 9 Processing 10 Processing 11 Processing 12 Processing 13 Processing 14 Processing 15 Processing 16 Processing 17 Processing 18 Processing 19
Pattern 3: Parallel Downloads
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
def download_file(url):
"""Download and return file size"""
try:
response = requests.get(url, timeout=10)
return len(response.content)
except Exception:
return 0
# Real URLs that return different amounts of data. Pointing 50 requests at
# example.com/fileN.jpg would "work", but every response is the same 559-byte
# placeholder page, so the sizes below would tell you nothing.
urls = [
f"https://jsonplaceholder.typicode.com/posts/{i}"
for i in range(1, 11)
]
with ThreadPoolExecutor(max_workers=10) as executor:
future_to_url = {executor.submit(download_file, url): url for url in urls}
for future in as_completed(future_to_url):
url = future_to_url[future]
size = future.result()
print(f"{url}: {size} bytes")Expected Output:
https://jsonplaceholder.typicode.com/posts/1: 292 bytes https://jsonplaceholder.typicode.com/posts/3: 283 bytes https://jsonplaceholder.typicode.com/posts/4: 270 bytes https://jsonplaceholder.typicode.com/posts/10: 217 bytes https://jsonplaceholder.typicode.com/posts/5: 225 bytes https://jsonplaceholder.typicode.com/posts/2: 278 bytes https://jsonplaceholder.typicode.com/posts/6: 288 bytes https://jsonplaceholder.typicode.com/posts/9: 246 bytes https://jsonplaceholder.typicode.com/posts/7: 225 bytes https://jsonplaceholder.typicode.com/posts/8: 260 bytes
Pattern 4: Rate-Limited Requests
from concurrent.futures import ThreadPoolExecutor
import threading
import time
class RateLimiter:
"""Limit operations to N per second"""
def __init__(self, max_per_second):
self.max_per_second = max_per_second
self.lock = threading.Lock()
self.last_call = 0
def wait(self):
with self.lock:
now = time.time()
time_since_last = now - self.last_call
min_interval = 1.0 / self.max_per_second
if time_since_last < min_interval:
sleep_time = min_interval - time_since_last
time.sleep(sleep_time)
self.last_call = time.time()
rate_limiter = RateLimiter(max_per_second=5)
def api_call(item):
rate_limiter.wait()
# Make actual API call
print(f"Processing {item}")
return item
items = range(20)
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(api_call, items))
# Only 5 requests per second despite 10 workers. Note the print order below:
# the limiter controls the rate, not which worker goes next, so the items
# come out shuffled. results is still in order, because map() puts it back.Expected Output:
Processing 0 Processing 1 Processing 11 Processing 12 Processing 13 Processing 14 Processing 15 Processing 16 Processing 17 Processing 18 Processing 19 Processing 2 Processing 3 Processing 4 Processing 5 Processing 6 Processing 7 Processing 8 Processing 9 Processing 10
Deadlock Prevention
Deadlocks occur when threads wait for each other indefinitely. Here's how to avoid them.
import threading
import time
# BAD: Can cause deadlock
lock_a = threading.Lock()
lock_b = threading.Lock()
def thread1_bad():
with lock_a:
time.sleep(0.1)
with lock_b: # Waiting for lock_b
print("Thread 1")
def thread2_bad():
with lock_b:
time.sleep(0.1)
with lock_a: # Waiting for lock_a - DEADLOCK!
print("Thread 2")
# This really does deadlock. Daemon threads plus a join timeout let the
# demo report the hang rather than become it.
t1 = threading.Thread(target=thread1_bad, daemon=True)
t2 = threading.Thread(target=thread2_bad, daemon=True)
t1.start()
t2.start()
t1.join(timeout=2)
t2.join(timeout=2)
print(f"Opposite order -> still stuck after 2s: {t1.is_alive() and t2.is_alive()}")
# GOOD: Always acquire locks in same order. Fresh locks, because the two
# above are still held by the wedged threads and are never coming back.
lock_x = threading.Lock()
lock_y = threading.Lock()
def thread1_good():
with lock_x:
time.sleep(0.1)
with lock_y:
print("Thread 1")
def thread2_good():
with lock_x: # Same order as thread1
time.sleep(0.1)
with lock_y:
print("Thread 2")
t3 = threading.Thread(target=thread1_good)
t4 = threading.Thread(target=thread2_good)
t3.start()
t4.start()
t3.join(timeout=2)
t4.join(timeout=2)
print(f"Same order -> both finished: {not (t3.is_alive() or t4.is_alive())}")
# BETTER: Use timeout, so a thread that cannot get the lock gives up
# instead of waiting forever
lock_held = threading.Lock()
lock_held.acquire() # pretend someone else is holding it
def thread_with_timeout():
acquired = lock_held.acquire(timeout=1)
if not acquired:
print("Could not acquire lock, aborting")
return
try:
# Do work
pass
finally:
lock_held.release()
t5 = threading.Thread(target=thread_with_timeout)
t5.start()
t5.join()
# BEST for re-entry: RLock can be acquired again by the same thread
rlock = threading.RLock()
def recursive_function(n):
with rlock: # Can acquire multiple times
if n > 0:
recursive_function(n - 1)
return n
recursive_function(3)
print("RLock recursion returned without deadlocking")
# Deadlock prevention strategies:
# 1. Always acquire locks in same order
# 2. Use timeout on lock acquisition
# 3. Use RLock when needed
# 4. Minimize critical sections
# 5. Use higher-level primitives (Queue, etc.)Expected Output:
Opposite order -> still stuck after 2s: True Thread 1 Thread 2 Same order -> both finished: True Could not acquire lock, aborting RLock recursion returned without deadlocking
The first line is the deadlock actually happening: two threads take the same pair of locks in opposite orders, each ends up holding what the other needs, and two seconds later both are still alive and going nowhere. They never recover, which is why the rest of the example uses a fresh pair of locks. Acquiring in a consistent order removes the cycle entirely; a timeout does not prevent the cycle but at least lets a thread give up and report instead of hanging forever.
Key Takeaways
- Threading is perfect for I/O-bound tasks - network, files, databases
- The GIL prevents parallel Python execution - but releases during I/O
- Use ThreadPoolExecutor for simplicity - high-level, easy to use
- Always synchronize shared data - use locks, queues, or thread-safe structures
- Avoid deadlocks - acquire locks in consistent order
- Use daemon threads for background work - they terminate with main program
- Queue is your friend - thread-safe communication between threads
- Don't use threading for CPU-bound tasks - use multiprocessing instead
Practice Exercises
Exercise 1: Web Scraper
Build a multi-threaded web scraper using ThreadPoolExecutor that fetches 50 web pages concurrently. Track success/failure rates, handle timeouts, and measure the speedup compared to sequential fetching. Use a semaphore to limit concurrent connections to 10.
Exercise 2: Thread-Safe Counter
Create a thread-safe counter class with increment, decrement, and get methods. Test it with 10 threads each performing 100,000 operations. Verify the final count is correct. Compare performance with and without locks.
Exercise 3: Producer-Consumer System
Implement a producer-consumer system using Queue where 3 producer threads generate random tasks and 5 consumer threads process them. Add metrics tracking (tasks produced, consumed, queue size) and implement graceful shutdown.
Additional Resources
- Official Docs: docs.python.org/3/library/threading.html
- concurrent.futures: docs.python.org/3/library/concurrent.futures.html
- Understanding the GIL: realpython.com/python-gil
- Book: "Python Concurrency with asyncio" by Matthew Fowler
- Threading vs Multiprocessing: Know when to use each
- David Beazley's talks: Understanding the Python GIL
What's Next?
You've learned threading! Now let's explore multiprocessing to achieve true parallelism and bypass the GIL for CPU-bound tasks.
- Process Pools - Manage multiple processes for parallel execution
- Shared Memory - Share data efficiently between processes
- IPC - Inter-process communication with queues and pipes