threading — Thread-based parallelism

The threading module executes multiple operations concurrently within the same program space. Threads are lighter than processes, share the same memory space, and are ideal for writing programs that perform multiple I/O-bound tasks at once.

import threading

Understanding the GIL (Crucial Concept)

Before using threading, you must understand the Global Interpreter Lock (GIL) in CPython (the standard implementation of Python).

The GIL acts as a global mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once.

1. Creating and Managing Threads

The most basic way to use threads is to define a target function and instantiate a Thread object.

import threading
import time

def download_file(filename, delay):
    print(f"[Start] Downloading {filename}...")
    time.sleep(delay) # Simulate network IO
    print(f"[Finish] {filename} downloaded!")

# 1. Create thread objects
t1 = threading.Thread(target=download_file, args=("file1.zip", 2))
t2 = threading.Thread(target=download_file, args=("file2.zip", 1))

# 2. Start the threads (places them in the OS scheduler queue)
t1.start()
t2.start()

print("Main program continues immediately without waiting...")

# 3. Join the threads (Wait for them to finish)
# Without join(), the main program could exit before threads finish
t1.join()
t2.join()

print("All downloads complete!")

Subclassing Thread

For complex threading behavior, you can subclass threading.Thread and override the run() method.

import threading
import time

class FileDownloader(threading.Thread):
    def __init__(self, filename, delay):
        super().__init__() # CRITICAL: You must call init of the superclass
        self.filename = filename
        self.delay = delay
        
    def run(self):
        # This replaces the 'target' argument
        print(f"Subclass thread downloading {self.filename}")
        time.sleep(self.delay)

worker = FileDownloader("data.csv", 1.5)
worker.start()
worker.join()

2. Daemon Threads

By default, the main Python program will not exit until all non-daemon threads have completed. Sometimes, you want background tasks (like memory monitoring, heartbeat signals, or background cleanup) that simply die when the main program stops. These are Daemon threads.

import threading
import time

def heartbeat():
    while True:
        print("<Heartbeat ping>")
        time.sleep(1)

# Set daemon=True during creation
monitor = threading.Thread(target=heartbeat, daemon=True)
monitor.start()

time.sleep(3.5)
print("Main thread exiting. The monitor will be forcefully killed now.")

3. Synchronization Primitives

Because threads share the same memory, chaos ensues if two threads try to modify the same variable simultaneously. This is called a Race Condition. threading provides several primitives to control access.

The Lock (Mutex)

A Lock guarantees that only one thread can execute a block of code at a time.

import threading

balance = 0
lock = threading.Lock()

def add_funds():
    global balance
    for _ in range(100_000):
        # BEST PRACTICE: Use context managers (with) to ensure the lock is 
        # always released, even if an exception occurs inside the block
        with lock:
            balance += 1
            
        # The above is equivalent to:
        # lock.acquire()
        # try:
        #     balance += 1
        # finally:
        #     lock.release()

threads = [threading.Thread(target=add_funds) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()

print(f"Final Balance: {balance}") # Guaranteed to be exactly 1,000,000

Reentrant Locks (RLock)

A standard Lock will block if the same thread tries to acquire it twice. An RLock allows a single thread to acquire the lock multiple times (recursively) without deadlocking itself. It must be released the same number of times it was acquired. Useful for recursive functions.

Semaphore

A Semaphore is like a lock, but it allows a specific number of threads to proceed. Perfect for rate-limiting or limiting concurrent connections to a database or API.

import threading
import time

# Allow maximum 3 concurrent connections
api_semaphore = threading.Semaphore(3)

def fetch_api(user_id):
    print(f"User {user_id} waiting to connect...")
    with api_semaphore:
        print(f"User {user_id} -> Connected! Processing...")
        time.sleep(2)
        print(f"User {user_id} <- Disconnected.")

for i in range(1, 8):
    threading.Thread(target=fetch_api, args=(i,)).start()
# You will see them process in batches of 3

Event

An Event acts as a flag for communication between threads. One thread signals an event, and other threads wait for it.

import threading
import time

event = threading.Event()

def task_waiter(name):
    print(f"{name} waiting for the green light...")
    event.wait() # Pauses thread until event is set
    print(f"{name} GO GO GO!")

def task_signaler():
    time.sleep(2)
    print("Signaler: Setting the event to True!")
    event.set()

threading.Thread(target=task_waiter, args=("Car 1",)).start()
threading.Thread(target=task_waiter, args=("Car 2",)).start()
threading.Thread(target=task_signaler).start()

4. Modern Approach: ThreadPoolExecutor

Managing individual Thread objects, keeping track of them, and gathering their return values is tedious. The concurrent.futures module provides the ThreadPoolExecutor, which abstract all of this management away into a "pool" of reusable threads.

This is the officially recommended way to do threading in modern Python code.

import concurrent.futures
import time

def fetch_content(page_num):
    time.sleep(1) # Simulate slow network
    return f"Content of page {page_num}"

# The context manager ensures threads are properly cleaned up
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
    
    # Example 1: Submit individual tasks and get Future objects
    future1 = executor.submit(fetch_content, 1)
    future2 = executor.submit(fetch_content, 2)
    
    print("Task 1:", future1.result()) # result() is a blocking call
    
    # Example 2: The map() method (Best for processing huge lists)
    pages_to_fetch = [3, 4, 5, 6, 7]
    
    # maps the function to the iterable, assigning the work to the thread pool
    results = executor.map(fetch_content, pages_to_fetch)
    
    for res in results:
        print(res)

Threading vs Asyncio

Python offers two prominent ways to handle concurrent I/O task: threading and asyncio.

Official Documentation

threading — Thread-based parallelism