py4u blog

Multithreading in Python: A Comprehensive Guide

Multithreading is a programming technique that allows multiple threads of execution to run concurrently within a single process. In Python, multithreading is particularly useful for I/O-bound tasks, where threads can wait for external resources without blocking the entire program. This blog post will provide a comprehensive guide to multithreading in Python, covering everything from basic concepts to advanced synchronization techniques.

2026-10

Table of Contents#

  1. Introduction
  2. Understanding Threads
  3. Python's Global Interpreter Lock (GIL)
  4. The threading Module
  5. Creating and Managing Threads
  6. Synchronization Mechanisms
  7. Thread Communication
  8. Common Patterns and Best Practices
  9. When to Use Multithreading
  10. Real-World Examples
  11. References

Understanding Threads#

A thread is the smallest unit of execution that can be scheduled by an operating system. Threads within the same process share the same memory space, which makes communication between threads more efficient compared to inter-process communication.

Key characteristics of threads:

  • Share the same memory space
  • Lightweight compared to processes
  • Suitable for I/O-bound tasks
  • Subject to Python's Global Interpreter Lock (GIL)

Python's Global Interpreter Lock (GIL)#

The Global Interpreter Lock (GIL) is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes simultaneously. This means that in CPython (the standard Python implementation), only one thread can execute Python code at a time.

Implications of the GIL:

  • CPU-bound tasks don't benefit from multithreading in Python
  • I/O-bound tasks can still benefit as threads release the GIL during I/O operations
  • For CPU-bound parallelism, consider using multiprocessing instead

The threading Module#

Python's threading module provides a high-level interface for working with threads. It's built on top of the lower-level _thread module but offers more features and better abstraction.

import threading
import time

Creating and Managing Threads#

Method 1: Using the Thread Class#

def simple_worker(thread_id, delay):
    """A simple worker function that simulates work with a delay"""
    print(f"Thread {thread_id} starting")
    time.sleep(delay)
    print(f"Thread {thread_id} finished")
 
# Create and start threads
threads = []
for i in range(3):
    thread = threading.Thread(target=simple_worker, args=(i, 2))
    threads.append(thread)
    thread.start()
 
# Wait for all threads to complete
for thread in threads:
    thread.join()
 
print("All threads completed")

Method 2: Subclassing Thread#

class CustomThread(threading.Thread):
    def __init__(self, thread_id, delay):
        super().__init__()
        self.thread_id = thread_id
        self.delay = delay
        self.result = None
    
    def run(self):
        """Main thread execution logic"""
        print(f"Custom Thread {self.thread_id} starting")
        time.sleep(self.delay)
        self.result = f"Thread {self.thread_id} completed after {self.delay} seconds"
        print(self.result)
 
# Usage
custom_threads = []
for i in range(3):
    thread = CustomThread(i, 2)
    custom_threads.append(thread)
    thread.start()
 
for thread in custom_threads:
    thread.join()
 
print("All custom threads completed")

Thread Lifecycle Management#

def thread_lifecycle_example():
    def worker():
        print(f"Thread {threading.current_thread().name} is running")
        time.sleep(1)
        print(f"Thread {threading.current_thread().name} is finishing")
    
    # Create a daemon thread (will be terminated when main thread exits)
    daemon_thread = threading.Thread(target=worker, name="DaemonThread", daemon=True)
    
    # Create a regular thread
    regular_thread = threading.Thread(target=worker, name="RegularThread")
    
    print(f"Daemon thread alive: {daemon_thread.is_alive()}")
    print(f"Regular thread alive: {regular_thread.is_alive()}")
    
    daemon_thread.start()
    regular_thread.start()
    
    print(f"Daemon thread alive after start: {daemon_thread.is_alive()}")
    print(f"Regular thread alive after start: {regular_thread.is_alive()}")
    
    # Only wait for regular thread
    regular_thread.join()
    print("Main thread continuing...")
 
thread_lifecycle_example()

Synchronization Mechanisms#

1. Locks#

Locks prevent race conditions by ensuring only one thread can access a critical section at a time.

class BankAccount:
    def __init__(self, initial_balance=0):
        self.balance = initial_balance
        self.lock = threading.Lock()
    
    def deposit(self, amount):
        with self.lock:  # Context manager automatically acquires and releases lock
            print(f"Depositing {amount}")
            new_balance = self.balance + amount
            time.sleep(0.1)  # Simulate processing time
            self.balance = new_balance
            print(f"New balance: {self.balance}")
    
    def withdraw(self, amount):
        with self.lock:
            if self.balance >= amount:
                print(f"Withdrawing {amount}")
                new_balance = self.balance - amount
                time.sleep(0.1)  # Simulate processing time
                self.balance = new_balance
                print(f"New balance: {self.balance}")
                return True
            return False
 
def account_worker(account, operations):
    for op, amount in operations:
        if op == 'deposit':
            account.deposit(amount)
        elif op == 'withdraw':
            account.withdraw(amount)
 
# Test the bank account with multiple threads
account = BankAccount(1000)
operations = [('deposit', 100), ('withdraw', 200), ('deposit', 50)]
 
threads = []
for i in range(3):
    thread = threading.Thread(target=account_worker, args=(account, operations))
    threads.append(thread)
    thread.start()
 
for thread in threads:
    thread.join()
 
print(f"Final balance: {account.balance}")

2. RLocks (Reentrant Locks)#

RLocks allow the same thread to acquire the lock multiple times.

class RLockExample:
    def __init__(self):
        self.rlock = threading.RLock()
        self.value = 0
    
    def recursive_method(self, n):
        with self.rlock:
            if n <= 0:
                return self.value
            self.value += 1
            return self.recursive_method(n - 1)
 
def rlock_worker(example, n):
    result = example.recursive_method(n)
    print(f"Thread {threading.current_thread().name} result: {result}")
 
example = RLockExample()
threads = []
for i in range(3):
    thread = threading.Thread(target=rlock_worker, args=(example, 3), name=f"Thread-{i}")
    threads.append(thread)
    thread.start()
 
for thread in threads:
    thread.join()

3. Semaphores#

Semaphores control access to a shared resource with limited capacity.

class ConnectionPool:
    def __init__(self, max_connections):
        self.semaphore = threading.Semaphore(max_connections)
        self.connections = []
    
    def get_connection(self, thread_id):
        with self.semaphore:
            print(f"Thread {thread_id} acquired connection")
            # Simulate database operation
            time.sleep(2)
            print(f"Thread {thread_id} released connection")
 
def connection_worker(pool, thread_id):
    pool.get_connection(thread_id)
 
# Only 2 connections available in the pool
pool = ConnectionPool(2)
threads = []
 
for i in range(5):
    thread = threading.Thread(target=connection_worker, args=(pool, i))
    threads.append(thread)
    thread.start()
 
for thread in threads:
    thread.join()

4. Conditions#

Conditions allow threads to wait for certain conditions to be met.

class BoundedBuffer:
    def __init__(self, capacity):
        self.buffer = []
        self.capacity = capacity
        self.lock = threading.Lock()
        self.not_empty = threading.Condition(self.lock)
        self.not_full = threading.Condition(self.lock)
    
    def put(self, item):
        with self.lock:
            while len(self.buffer) == self.capacity:
                self.not_full.wait()  # Wait until not full
            
            self.buffer.append(item)
            self.not_empty.notify()  # Notify that buffer is not empty
    
    def get(self):
        with self.lock:
            while len(self.buffer) == 0:
                self.not_empty.wait()  # Wait until not empty
            
            item = self.buffer.pop(0)
            self.not_full.notify()  # Notify that buffer is not full
            return item
 
def producer(buffer, items):
    for item in items:
        print(f"Producing: {item}")
        buffer.put(item)
        time.sleep(0.1)
 
def consumer(buffer, count):
    for _ in range(count):
        item = buffer.get()
        print(f"Consuming: {item}")
        time.sleep(0.2)
 
buffer = BoundedBuffer(5)
producer_thread = threading.Thread(target=producer, args=(buffer, range(10)))
consumer_thread = threading.Thread(target=consumer, args=(buffer, 10))
 
producer_thread.start()
consumer_thread.start()
 
producer_thread.join()
consumer_thread.join()

5. Events#

Events allow threads to wait for signals from other threads.

def event_example():
    event = threading.Event()
    
    def waiter():
        print("Waiter: waiting for event...")
        event.wait()  # Wait until event is set
        print("Waiter: event received!")
    
    def setter():
        time.sleep(2)  # Simulate work
        print("Setter: setting event")
        event.set()  # Signal the event
    
    waiter_thread = threading.Thread(target=waiter)
    setter_thread = threading.Thread(target=setter)
    
    waiter_thread.start()
    setter_thread.start()
    
    waiter_thread.join()
    setter_thread.join()
 
event_example()

Thread Communication#

Using Queues for Safe Communication#

import queue
import random
 
def producer(q, producer_id, num_items):
    """Producer thread that puts items into the queue"""
    for i in range(num_items):
        item = f"Item-{producer_id}-{i}"
        q.put(item)
        print(f"Producer {producer_id} produced: {item}")
        time.sleep(random.uniform(0.1, 0.5))
    q.put(None)  # Sentinel value to signal completion
 
def consumer(q, consumer_id):
    """Consumer thread that takes items from the queue"""
    while True:
        item = q.get()
        if item is None:
            q.put(None)  # Put back for other consumers
            break
        print(f"Consumer {consumer_id} consumed: {item}")
        # Simulate processing time
        time.sleep(random.uniform(0.2, 0.7))
        q.task_done()  # Mark task as done
 
# Create a thread-safe queue
q = queue.Queue()
 
# Create producer and consumer threads
producers = []
consumers = []
 
for i in range(2):
    producer_thread = threading.Thread(target=producer, args=(q, i, 5))
    producers.append(producer_thread)
    producer_thread.start()
 
for i in range(3):
    consumer_thread = threading.Thread(target=consumer, args=(q, i))
    consumers.append(consumer_thread)
    consumer_thread.start()
 
# Wait for all producers to finish
for producer in producers:
    producer.join()
 
# Add sentinel for each consumer
for _ in consumers:
    q.put(None)
 
# Wait for all consumers to finish
for consumer in consumers:
    consumer.join()
 
print("All producers and consumers finished")

Common Patterns and Best Practices#

1. Thread Pool Pattern#

from concurrent.futures import ThreadPoolExecutor
import requests
 
def download_url(url):
    """Download a URL and return the content length"""
    try:
        response = requests.get(url, timeout=10)
        return f"URL: {url}, Length: {len(response.content)}"
    except Exception as e:
        return f"URL: {url}, Error: {e}"
 
def thread_pool_example():
    urls = [
        'https://httpbin.org/delay/1',
        'https://httpbin.org/delay/2',
        'https://httpbin.org/delay/3',
        'https://httpbin.org/delay/1',
        'https://httpbin.org/delay/2'
    ]
    
    # Using ThreadPoolExecutor for managing thread pool
    with ThreadPoolExecutor(max_workers=3) as executor:
        # Submit all tasks
        futures = [executor.submit(download_url, url) for url in urls]
        
        # Collect results as they complete
        for future in futures:
            result = future.result()
            print(result)
 
thread_pool_example()

2. Producer-Consumer with Priority#

class PriorityTaskQueue:
    def __init__(self):
        self.queue = queue.PriorityQueue()
        self.workers = []
    
    def add_task(self, priority, task):
        self.queue.put((priority, task))
    
    def worker(self, worker_id):
        while True:
            priority, task = self.queue.get()
            if task is None:  # Poison pill
                self.queue.task_done()
                break
            print(f"Worker {worker_id} processing task with priority {priority}: {task}")
            time.sleep(1)  # Simulate work
            self.queue.task_done()
    
    def start_workers(self, num_workers):
        for i in range(num_workers):
            worker_thread = threading.Thread(target=self.worker, args=(i,))
            self.workers.append(worker_thread)
            worker_thread.start()
    
    def stop_workers(self):
        # Send poison pill to each worker
        for _ in self.workers:
            self.queue.put((0, None))
        
        for worker in self.workers:
            worker.join()
 
# Usage
task_queue = PriorityTaskQueue()
task_queue.start_workers(2)
 
# Add tasks with different priorities
tasks = [(2, "Low priority task"), (0, "High priority task"), 
         (1, "Medium priority task"), (0, "Another high priority")]
 
for priority, task in tasks:
    task_queue.add_task(priority, task)
 
# Wait for all tasks to be processed
task_queue.queue.join()
task_queue.stop_workers()

Best Practices Summary#

  1. Use locks sparingly: Only protect critical sections
  2. Prefer with statements: For automatic lock acquisition/release
  3. Use thread-safe data structures: Like queue.Queue
  4. Avoid shared state: When possible, use message passing
  5. Use daemon threads carefully: They can be terminated abruptly
  6. Handle exceptions in threads: Uncaught exceptions can terminate the thread silently
  7. Use ThreadPoolExecutor: For managing thread lifecycles
  8. Profile your code: Ensure multithreading actually improves performance

When to Use Multithreading#

Suitable for I/O-bound tasks:#

  • Network requests
  • File I/O operations
  • Database queries
  • Web scraping
  • API calls

Not suitable for CPU-bound tasks:#

  • Mathematical computations
  • Image processing
  • Data analysis
  • Machine learning training

Use multiprocessing for CPU-bound tasks:#

from multiprocessing import Pool
import math
 
def cpu_intensive_task(n):
    """CPU-intensive task - better suited for multiprocessing"""
    return sum(math.sqrt(i) for i in range(n))
 
def compare_approaches():
    numbers = [1000000] * 4
    
    # Multithreading (not effective for CPU-bound tasks)
    start_time = time.time()
    with ThreadPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(cpu_intensive_task, numbers))
    thread_time = time.time() - start_time
    
    # Multiprocessing (better for CPU-bound tasks)
    start_time = time.time()
    with Pool(processes=4) as pool:
        results = pool.map(cpu_intensive_task, numbers)
    process_time = time.time() - start_time
    
    print(f"Multithreading time: {thread_time:.2f}s")
    print(f"Multiprocessing time: {process_time:.2f}s")
 
compare_approaches()

Real-World Examples#

Example 1: Web Scraper with Rate Limiting#

import requests
from urllib.parse import urljoin
from bs4 import BeautifulSoup
import time
 
class ThreadedWebScraper:
    def __init__(self, base_url, max_workers=5, delay=1):
        self.base_url = base_url
        self.visited = set()
        self.lock = threading.Lock()
        self.delay = delay
        self.max_workers = max_workers
        self.results = []
    
    def scrape_page(self, url):
        """Scrape a single page and extract links"""
        try:
            time.sleep(self.delay)  # Rate limiting
            response = requests.get(url, timeout=10)
            soup = BeautifulSoup(response.content, 'html.parser')
            
            # Extract page title
            title = soup.title.string if soup.title else "No title"
            
            # Extract all links
            links = []
            for link in soup.find_all('a', href=True):
                absolute_url = urljoin(self.base_url, link['href'])
                links.append(absolute_url)
            
            with self.lock:
                self.results.append({
                    'url': url,
                    'title': title,
                    'links': links
                })
                self.visited.add(url)
            
            return links
        
        except Exception as e:
            print(f"Error scraping {url}: {e}")
            return []
    
    def scrape_site(self, start_url, max_pages=50):
        """Scrape the website using multiple threads"""
        to_visit = queue.Queue()
        to_visit.put(start_url)
        self.visited.add(start_url)
        
        def worker():
            while len(self.visited) < max_pages and not to_visit.empty():
                try:
                    url = to_visit.get(timeout=1)
                    new_links = self.scrape_page(url)
                    
                    # Add new links to queue
                    for link in new_links:
                        if (link.startswith(self.base_url) and 
                            link not in self.visited and 
                            len(self.visited) < max_pages):
                            to_visit.put(link)
                
                except queue.Empty:
                    break
        
        # Create and start worker threads
        threads = []
        for i in range(self.max_workers):
            thread = threading.Thread(target=worker)
            threads.append(thread)
            thread.start()
        
        # Wait for all threads to complete
        for thread in threads:
            thread.join()
        
        return self.results
 
# Usage example (commented out to avoid actual web requests)
# scraper = ThreadedWebScraper('https://httpbin.org')
# results = scraper.scrape_site('https://httpbin.org', max_pages=10)
# print(f"Scraped {len(results)} pages")

Example 2: Real-time Data Processor#

import json
import random
from datetime import datetime
 
class DataProcessor:
    def __init__(self):
        self.data_queue = queue.Queue()
        self.processed_data = []
        self.running = False
        self.lock = threading.Lock()
    
    def data_generator(self, num_samples):
        """Simulate a data source generating JSON data"""
        for i in range(num_samples):
            timestamp = datetime.now().isoformat()
            data_point = {
                'id': i,
                'timestamp': timestamp,
                'value': random.uniform(0, 100),
                'sensor_id': random.randint(1, 5)
            }
            self.data_queue.put(json.dumps(data_point))
            time.sleep(0.1)  # Simulate data generation interval
        
        # Signal end of data
        self.data_queue.put(None)
    
    def data_processor(self, processor_id):
        """Process data points from the queue"""
        while self.running or not self.data_queue.empty():
            try:
                data_json = self.data_queue.get(timeout=1)
                if data_json is None:
                    self.data_queue.put(None)  # Put back for other processors
                    break
                
                # Parse and process data
                data = json.loads(data_json)
                data['processed_by'] = processor_id
                data['processed_at'] = datetime.now().isoformat()
                data['value_squared'] = data['value'] ** 2
                
                # Simulate processing time
                time.sleep(0.05)
                
                with self.lock:
                    self.processed_data.append(data)
                
                print(f"Processor {processor_id} processed: {data['id']}")
            
            except queue.Empty:
                continue
    
    def run_processing(self, num_samples=100, num_processors=3):
        """Run the complete data processing pipeline"""
        self.running = True
        self.processed_data = []
        
        # Start data generator
        generator_thread = threading.Thread(target=self.data_generator, args=(num_samples,))
        generator_thread.start()
        
        # Start processor threads
        processors = []
        for i in range(num_processors):
            processor_thread = threading.Thread(target=self.data_processor, args=(i,))
            processors.append(processor_thread)
            processor_thread.start()
        
        # Wait for generator to finish
        generator_thread.join()
        
        # Wait for processors to finish
        self.running = False
        for processor in processors:
            processor.join()
        
        return self.processed_data
 
# Usage
processor = DataProcessor()
results = processor.run_processing(num_samples=50, num_processors=2)
print(f"Processed {len(results)} data points")

References#

  1. Python threading documentation
  2. Python Global Interpreter Lock
  3. Queue module documentation
  4. Concurrent.futures documentation
  5. Python Threading Best Practices

This comprehensive guide should provide you with a solid foundation for working with multithreading in Python. Remember to always test your threaded code thoroughly and consider the specific requirements of your application when choosing between threading, multiprocessing, or asynchronous programming.