Skip to content

Choosing the Right Tool

A decision framework for Python concurrency.

Quick Decision Tree

What type of work?
├── I/O-bound (network, disk, database)?
│   │
│   ├── High concurrency (1000s of connections)?
│   │   └── asyncio
│   │
│   ├── Moderate concurrency (10-100)?
│   │   ├── New code? → asyncio
│   │   └── Existing sync code? → threading
│   │
│   └── Legacy code can't use async?
│       └── threading
├── CPU-bound (computation)?
│   │
│   ├── Pure Python?
│   │   └── multiprocessing
│   │
│   ├── NumPy/Pandas/C extensions?
│   │   └── threading (GIL released in C)
│   │
│   └── Heavy computation, need scaling?
│       └── multiprocessing or distributed (Celery, Dask)
└── Mixed (I/O + CPU)?
    └── asyncio + run_in_executor (ProcessPoolExecutor)

Comparison Table

Aspect asyncio threading multiprocessing
Best for I/O-bound I/O-bound CPU-bound
Concurrency High (10K+) Moderate (100s) Limited by CPUs
Memory Low Medium High
Startup Fast Fast Slow
Communication Easy Shared memory Serialization
GIL impact None Limited None
Debugging Medium Hard Medium

When to Use asyncio

Good Fit

# HTTP requests
async def fetch_all_users():
    async with aiohttp.ClientSession() as session:
        tasks = [fetch_user(session, id) for id in user_ids]
        return await asyncio.gather(*tasks)

# Database queries
async def get_user_data(user_id: int):
    user, posts, comments = await asyncio.gather(
        db.get_user(user_id),
        db.get_posts(user_id),
        db.get_comments(user_id),
    )
    return {"user": user, "posts": posts, "comments": comments}

# WebSocket handling
async def websocket_handler(websocket):
    async for message in websocket:
        response = await process_message(message)
        await websocket.send(response)

Indicators

  • Network requests (HTTP, WebSocket, TCP)
  • Database queries
  • File I/O (with aiofiles)
  • Many concurrent connections
  • Event-driven architecture

When to Use Threading

Good Fit

# Parallel file downloads with requests (sync library)
from concurrent.futures import ThreadPoolExecutor
import requests

def download(url):
    return requests.get(url).content

with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(download, urls))

# Blocking I/O in existing sync codebase
def process_files(paths):
    with ThreadPoolExecutor() as executor:
        return list(executor.map(process_file, paths))

Indicators

  • Can't use async (legacy code, sync libraries)
  • Moderate number of concurrent tasks
  • Blocking I/O operations
  • Need shared memory access

When to Use Multiprocessing

Good Fit

# CPU-intensive data processing
from concurrent.futures import ProcessPoolExecutor

def process_image(path):
    img = load_image(path)
    return apply_filters(img)

with ProcessPoolExecutor() as executor:
    results = list(executor.map(process_image, image_paths))

# Parallel computation
def compute_chunk(data):
    return [x ** 2 + 3 * x + 1 for x in data]

with ProcessPoolExecutor() as executor:
    results = list(executor.map(compute_chunk, data_chunks))

Indicators

  • Pure Python computation
  • Need true parallelism
  • CPU usage is bottleneck
  • Tasks are independent

Mixed Workloads

I/O with CPU-bound Processing

import asyncio
from concurrent.futures import ProcessPoolExecutor

def cpu_intensive(data):
    # Heavy computation
    return process(data)

async def fetch_and_process(url):
    # I/O: fetch data
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            data = await response.json()

    # CPU: process in separate process
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, cpu_intensive, data)

    return result

Async with Sync Libraries

import asyncio

def sync_database_call():
    # Using a sync database driver
    return db.execute("SELECT * FROM users")

async def main():
    # Run sync code in thread pool
    result = await asyncio.to_thread(sync_database_call)
    return result

Performance Considerations

Task Granularity

# Too fine-grained (overhead > benefit)
async def bad():
    results = []
    for i in range(1000000):
        result = await process_single_item(i)  # Tiny task, big overhead
        results.append(result)

# Better: Batch operations
async def good():
    chunks = [items[i:i+1000] for i in range(0, len(items), 1000)]
    results = await asyncio.gather(*[process_chunk(c) for c in chunks])

Pool Sizing

import multiprocessing

# CPU-bound: match CPU count
cpu_workers = multiprocessing.cpu_count()

# I/O-bound: can exceed CPU count
io_workers = 20  # Or more for high-latency I/O

# Mixed: experiment to find optimal
# Start with CPU count, adjust based on profiling

Memory Usage

# multiprocessing: Each process copies data
# Solution: Use shared memory or files

# threading: Shared memory, but beware of GIL

# asyncio: Single process, most memory efficient
# But watch for memory leaks in long-running tasks

Common Mistakes

Using Threading for CPU-Bound Work

# Wrong: Threading won't parallelize CPU work
def cpu_work():
    return sum(i ** 2 for i in range(10_000_000))

# This is NOT faster due to GIL
with ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(lambda _: cpu_work(), range(4)))

# Right: Use multiprocessing
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(lambda _: cpu_work(), range(4)))

Blocking in Async Code

# Wrong: Blocks the event loop
async def bad():
    time.sleep(1)  # Blocks!
    result = requests.get(url)  # Blocks!

# Right: Use async alternatives
async def good():
    await asyncio.sleep(1)
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.json()

Not Awaiting Coroutines

# Wrong: Coroutine never runs
async def bad():
    fetch_data()  # Warning: coroutine never awaited!

# Right: Await the coroutine
async def good():
    result = await fetch_data()

Summary

Situation Use Example
HTTP requests asyncio aiohttp, httpx
Database queries asyncio asyncpg, databases
File downloads asyncio or threading aiohttp or requests
Image processing multiprocessing Pillow, OpenCV
Data crunching multiprocessing pandas, numpy
Background jobs Celery, RQ Email, reports
Legacy sync code threading Existing libraries
Web server asyncio FastAPI, Starlette

Rule of thumb: - Start with asyncio for I/O - Use multiprocessing for CPU - Use threading only when async isn't feasible