Skip to content

Multithreading vs Multiprocessing: Choosing the Right Model

Overview

Python offers two concurrency models with different trade-offs: - Threading: Lightweight, shared memory, fast communication, but GIL limits CPU parallelism - Multiprocessing: Heavy processes, isolated memory, true CPU parallelism, but slow communication

Choosing the right model is critical for system performance.


Threading: Lightweight Concurrency

ThreadPoolExecutor for I/O

from concurrent.futures import ThreadPoolExecutor
import time
import requests

def fetch_url(url):
    """Fetch URL (I/O-bound)."""
    response = requests.get(url, timeout=5)
    return len(response.content)

# Sequential (5 URLs, 1 second each = 5 seconds)
urls = ['https://example.com'] * 5

start = time.time()
for url in urls:
    fetch_url(url)
sequential_time = time.time() - start

# Parallel with threads (5 URLs in parallel = 1 second)
start = time.time()
with ThreadPoolExecutor(max_workers=5) as executor:
    sizes = list(executor.map(fetch_url, urls))
threaded_time = time.time() - start

print(f"Sequential: {sequential_time:.1f}s")
print(f"Threaded: {threaded_time:.1f}s")
print(f"Speedup: {sequential_time / threaded_time:.1f}x")

Thread-Safe Data Structures

import threading
from queue import Queue
import time

# Thread-safe queue
queue = Queue(maxsize=10)

def producer():
    """Put items in queue."""
    for i in range(20):
        queue.put(i)
        print(f"Produced {i}")
        time.sleep(0.1)

def consumer():
    """Get items from queue."""
    while True:
        item = queue.get()
        if item is None:
            break
        print(f"Consumed {item}")
        queue.task_done()

# Run producer and consumer in parallel
t1 = threading.Thread(target=producer)
t2 = threading.Thread(target=consumer)

t1.start()
t2.start()

t1.join()
queue.put(None)  # Signal consumer to stop
t2.join()

Threading Caveats

import threading

# WRONG: Shared state without synchronization
counter = 0

def increment():
    global counter
    for _ in range(1000000):
        counter += 1  # RACE CONDITION!

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

print(counter)  # Expected: 2000000, Actual: ~1200000 (random!)

# CORRECT: Use lock
import threading

counter = 0
lock = threading.Lock()

def increment_safe():
    global counter
    for _ in range(1000000):
        with lock:
            counter += 1

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

print(counter)  # Correct: 2000000

Multiprocessing: True Parallelism

ProcessPoolExecutor for CPU

from concurrent.futures import ProcessPoolExecutor
import time
import math

def cpu_bound(n):
    """CPU-intensive calculation."""
    return sum(math.sqrt(i) for i in range(n))

# Sequential (slow)
start = time.time()
for _ in range(4):
    cpu_bound(10000000)
sequential_time = time.time() - start

# Parallel with processes (true parallelism, no GIL)
start = time.time()
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(cpu_bound, [10000000] * 4))
parallel_time = time.time() - start

print(f"Sequential: {sequential_time:.1f}s")
print(f"Parallel: {parallel_time:.1f}s")
print(f"Speedup: {sequential_time / parallel_time:.1f}x")
# Result: ~3-4x speedup with 4 processes

Inter-Process Communication

from multiprocessing import Process, Queue, Pipe
import time

# Queue for one-way communication
def producer(queue):
    for i in range(10):
        queue.put(i)

def consumer(queue):
    for _ in range(10):
        item = queue.get()
        print(f"Received: {item}")

# Pipe for two-way communication
def process1(conn):
    for i in range(5):
        conn.send(f"Message {i}")
        response = conn.recv()
        print(f"Got response: {response}")

def process2(conn):
    for i in range(5):
        msg = conn.recv()
        print(f"Received: {msg}")
        conn.send(f"Response {i}")

# Queue example
if __name__ == '__main__':
    q = Queue()
    p1 = Process(target=producer, args=(q,))
    p2 = Process(target=consumer, args=(q,))

    p1.start()
    p2.start()

    p1.join()
    p2.join()

Shared Memory (Dangerous)

from multiprocessing import Process, Value, Array
import time

# Shared integer
def increment_shared(shared_counter):
    for _ in range(1000000):
        with shared_counter.get_lock():
            shared_counter.value += 1

if __name__ == '__main__':
    # Create shared value
    counter = Value('i', 0)  # 'i' = integer

    # Create shared array
    data = Array('d', [1.0, 2.0, 3.0, 4.0])  # 'd' = double

    # Increment in parallel
    processes = [Process(target=increment_shared, args=(counter,)) 
                 for _ in range(2)]

    for p in processes:
        p.start()

    for p in processes:
        p.join()

    print(f"Counter: {counter.value}")  # Correct: 2000000

    # Note: Shared memory is slower than message passing!

Comparison Table

Aspect Threading Multiprocessing
CPU-Bound No (GIL) Yes (true parallelism)
I/O-Bound Yes (fast) Yes (overkill)
Memory Shared, low overhead Isolated, high overhead
Startup Fast (~1ms) Slow (~100ms)
Communication Fast (shared memory) Slow (IPC)
Synchronization Complex (locks, etc) Simpler (message passing)
Debugging Easier Harder

Hybrid Approach: Multi-GPU Inference Server

from multiprocessing import Process
from concurrent.futures import ThreadPoolExecutor
import torch
import queue

class MultiGPUServer:
    """Use processes for GPU isolation, threads for I/O."""

    def __init__(self, num_gpus=4):
        self.num_gpus = num_gpus
        self.request_queues = [queue.Queue() for _ in range(num_gpus)]
        self.result_queues = [queue.Queue() for _ in range(num_gpus)]

    def gpu_worker(self, gpu_id):
        """Run inference on specific GPU."""
        model = torch.load('model.pth')
        model = model.to(f'cuda:{gpu_id}')
        model.eval()

        while True:
            request_id, data = self.request_queues[gpu_id].get()

            if request_id is None:  # Stop signal
                break

            with torch.no_grad():
                x = torch.tensor(data, device=f'cuda:{gpu_id}')
                result = model(x)

            self.result_queues[gpu_id].put((request_id, result))

    def start_workers(self):
        """Start GPU workers in separate processes."""
        self.processes = []
        for gpu_id in range(self.num_gpus):
            p = Process(target=self.gpu_worker, args=(gpu_id,))
            p.start()
            self.processes.append(p)

    def infer(self, request_id, data):
        """Submit inference request."""
        # Round-robin load balancing
        gpu_id = request_id % self.num_gpus
        self.request_queues[gpu_id].put((request_id, data))

        # Get result
        _, result = self.result_queues[gpu_id].get()
        return result

if __name__ == '__main__':
    server = MultiGPUServer(num_gpus=4)
    server.start_workers()

    # Submit requests (can use threads to fetch data)
    results = []
    with ThreadPoolExecutor(max_workers=10) as executor:
        futures = []
        for i in range(100):
            future = executor.submit(server.infer, i, data[i])
            futures.append(future)

        for future in futures:
            results.append(future.result())

Decision Framework

Use Threading When:

  • I/O-bound (network, disk)
  • Need low latency
  • Shared state is simple
  • NumPy/PyTorch computation (GIL released)

Use Multiprocessing When:

  • CPU-bound pure Python
  • Need true parallelism
  • Can tolerate higher latency
  • Processes should be isolated

Use Async When:

  • Very high concurrency (1000s of connections)
  • Mostly I/O-bound
  • Can rewrite code as async

Performance Checklist

# Threading
def use_threading():
    # ✓ Network requests
    # ✓ Disk I/O
    # ✓ Database queries
    # ✓ DataLoader with workers
    pass

# Multiprocessing
def use_multiprocessing():
    # ✓ CPU-intensive computation
    # ✓ Multiple GPUs
    # ✓ Long-running workers
    # ✓ Process isolation
    pass

# Async
def use_async():
    # ✓ Web server
    # ✓ WebSocket connections
    # ✓ Concurrent I/O
    # ✓ High concurrency
    pass