Threads
A thread is the smallest unit of scheduling in an operating system. Multiple threads within the same process share that process’s memory space. Python provides thread support through the threading module.
Relationship Between Processes and Threads
- Process: The basic unit of resource allocation, with its own independent memory space.
- Thread: The basic unit of execution and scheduling, more lightweight than a process, with lower creation and context-switching overhead.
- Every process has at least one main thread and can create multiple child threads. All threads within the same process share memory, making communication convenient but requiring synchronization control.
Python’s GIL
CPython has a Global Interpreter Lock (GIL) that allows only one thread to execute Python bytecode at a time. This means:
- Multiple threads cannot achieve true parallel computation (CPU-bound tasks) using multiple CPU cores.
- However, multiple threads can achieve concurrency: when one thread is waiting for I/O, other threads can run (I/O-bound tasks).
- CPU-bound tasks should use
multiprocessing(multiple processes) orconcurrent.futures.ProcessPoolExecutorinstead.
--disable-gil). True multi-core parallelism may become available in the future.Creating Threads
import threading
import time
def worker(name: str, delay: float) -> None:
print(f"[{name}] Started")
time.sleep(delay)
print(f"[{name}] Finished")
# Method 1: Thread object
t1 = threading.Thread(target=worker, args=("Thread A", 1))
t2 = threading.Thread(target=worker, args=("Thread B", 2))
t1.start()
t2.start()
# join() waits for the thread to finish
t1.join()
t2.join()
print("All threads finished")Subclassing Thread
import threading
class DownloadTask(threading.Thread):
def __init__(self, url: str):
super().__init__()
self.url = url
def run(self) -> None:
"""This method is executed when the thread starts."""
import time
print(f"Downloading: {self.url}")
time.sleep(1)
print(f"Completed: {self.url}")
tasks = [
DownloadTask("https://example.com/file1.zip"),
DownloadTask("https://example.com/file2.zip"),
]
for t in tasks:
t.start()
for t in tasks:
t.join()Daemon Threads
A daemon thread exits automatically when the main thread ends, and does not prevent the program from exiting. Suitable for background tasks (log reporting, heartbeat detection, etc.):
import threading
import time
def heartbeat() -> None:
while True:
print("♥ Heartbeat")
time.sleep(2)
# Set as a daemon thread; it terminates automatically when the main thread ends
t = threading.Thread(target=heartbeat, daemon=True)
t.start()
time.sleep(5)
print("Main thread ending, daemon thread will also exit")Thread Safety: Lock
When multiple threads modify shared variables, a lock (Lock) must be used to prevent race conditions:
import threading
counter = 0
lock = threading.Lock()
def increment(n: int) -> None:
global counter
for _ in range(n):
with lock: # Equivalent to lock.acquire() ... lock.release()
counter += 1
threads = [threading.Thread(target=increment, args=(10000,)) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Final count: {counter}") # 50000 (correct result)Without a lock, counter may end up less than 50000 (lost updates).
RLock — Reentrant Lock
The same thread can acquire an RLock multiple times, avoiding deadlocks:
import threading
rlock = threading.RLock()
def outer():
with rlock:
print("Outer lock acquired")
inner() # Same thread acquires the lock again; RLock allows this, Lock would deadlock
def inner():
with rlock:
print("Inner lock acquired")
t = threading.Thread(target=outer)
t.start()
t.join()Semaphore
Controls the maximum number of threads that can access a resource simultaneously (e.g., connection pools, concurrent request limits):
import threading
import time
# Allow at most 3 threads in at once
sem = threading.Semaphore(3)
def access_resource(name: str) -> None:
with sem:
print(f"{name} is using the resource")
time.sleep(1)
print(f"{name} released the resource")
threads = [threading.Thread(target=access_resource, args=(f"Thread {i}",)) for i in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()Event — Thread Signaling
import threading
import time
event = threading.Event()
def producer() -> None:
print("Producer: preparing data...")
time.sleep(2)
event.set() # Send signal
print("Producer: data ready")
def consumer() -> None:
print("Consumer: waiting for data...")
event.wait() # Block until signal is received
print("Consumer: starting to process data")
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)
t2.start()
t1.start()
t1.join()
t2.join()Queue — Thread-Safe Queue
queue.Queue has built-in locking and is the preferred way to safely pass data between threads:
import threading
import queue
import time
task_queue: queue.Queue = queue.Queue(maxsize=10)
def producer(q: queue.Queue, n: int) -> None:
for i in range(n):
item = f"Task {i}"
q.put(item) # Automatically blocks when the queue is full
print(f"Produced: {item}")
time.sleep(0.1)
q.put(None) # Send termination signal
def consumer(q: queue.Queue) -> None:
while True:
item = q.get() # Automatically blocks when the queue is empty
if item is None:
break
print(f"Consumed: {item}")
q.task_done()
t1 = threading.Thread(target=producer, args=(task_queue, 5))
t2 = threading.Thread(target=consumer, args=(task_queue,))
t1.start()
t2.start()
t1.join()
t2.join()ThreadPoolExecutor — Thread Pool (Recommended)
concurrent.futures.ThreadPoolExecutor is the higher-level, easier-to-use thread pool interface:
from concurrent.futures import ThreadPoolExecutor, as_completed
import time
def fetch(url: str) -> str:
time.sleep(0.5) # Simulate I/O
return f"{url} → 200 OK"
urls = [f"https://api.example.com/item/{i}" for i in range(10)]
with ThreadPoolExecutor(max_workers=4) as executor:
# map: preserves order, concise
results = list(executor.map(fetch, urls))
print(results)
# submit + as_completed: process whichever finishes first
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(fetch, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
try:
result = future.result()
print(result)
except Exception as e:
print(f"{url} failed: {e}")Thread-Local Storage (threading.local)
Each thread has its own independent copy that does not interfere with others:
import threading
local_data = threading.local()
def worker(value: int) -> None:
local_data.value = value # Each thread stores independently
import time
time.sleep(0.1)
print(f"Thread {threading.current_thread().name}: {local_data.value}")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()