Site

Threads — Python Threading and Concurrency

Tutorial S8  •  Python / Learn

S8.0 What This Teaches

This tutorial covers threading and concurrency in Python:

S8.1 Creating Threads

The 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

A daemon thread runs in the background and is killed automatically when the main thread exits. Set 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
Use daemon threads for background services (logging, monitoring) that should not prevent the program from exiting. Never use them for work that must complete.

S8.3 Race Conditions

When two threads read and modify shared state without coordination, the result is non-deterministic. This is a race condition:
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
Even simple operations like x += 1 involve multiple bytecode instructions. The GIL reduces (but does not eliminate) the window for races on CPython.

S8.4 Lock and RLock

A 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
Use 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

CPython uses a Global Interpreter Lock (GIL) that prevents more than one thread from executing Python bytecode at the same time. This means:
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.Queue and 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.Lock and verify the correct result.
  • Use concurrent.futures.ThreadPoolExecutor to simulate downloading 5 URLs concurrently (use time.sleep for fake latency). Print each result as it arrives using as_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

The GIL limits threads to one active at a time for Python code. CPU-intensive work (image processing, number crunching) gets no parallelism from threads. Use 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

TermMeaning
ThreadLightweight unit of execution sharing memory with its creator
daemon threadThread that exits automatically when the main thread exits
race conditionBug caused by uncoordinated access to shared mutable state
LockMutual-exclusion primitive; only one thread holds it at a time
RLockReentrant lock; same thread can acquire multiple times without deadlock
queue.QueueThread-safe FIFO; the standard way to pass data between threads
GILGlobal Interpreter Lock; prevents parallel execution of Python bytecode in CPython
ProcessPoolExecutorRuns callables in separate processes, bypassing the GIL