Table of Contents#
- Introduction
- Understanding Threads
- Python's Global Interpreter Lock (GIL)
- The
threadingModule - Creating and Managing Threads
- Synchronization Mechanisms
- Thread Communication
- Common Patterns and Best Practices
- When to Use Multithreading
- Real-World Examples
- 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 timeCreating 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#
- Use locks sparingly: Only protect critical sections
- Prefer
withstatements: For automatic lock acquisition/release - Use thread-safe data structures: Like
queue.Queue - Avoid shared state: When possible, use message passing
- Use daemon threads carefully: They can be terminated abruptly
- Handle exceptions in threads: Uncaught exceptions can terminate the thread silently
- Use
ThreadPoolExecutor: For managing thread lifecycles - 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#
- Python threading documentation
- Python Global Interpreter Lock
- Queue module documentation
- Concurrent.futures documentation
- 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.