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!

Key insight: The GIL is released during I/O operations (network, disk, sleep), allowing other threads to run. This makes threading perfect for I/O-bound workloads but ineffective for CPU-bound pure Python code.

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.

About the outputs on this page: every output here is copied from a real run, but for most of them yours will not match line for line, and that is not a mistake. When several threads print, the scheduler decides the order, so the transcript shifts between runs and between machines. Read them as one example of what happened, not as a promise. Where a block can make a stable promise it does: a couple of them sort their results specifically so they can.

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
#         pass
Expected 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/checkpoints
Expected 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 block
Expected 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

Do This
  • Use ThreadPoolExecutor for 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 Queue for communication
  • Set daemon=True for background threads
  • Handle exceptions in thread functions
  • Use concurrent.futures for cleaner code
  • Join threads to wait for completion
  • Use Event for signaling between threads
Avoid This
  • 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