Skip to content

asyncio vs threading vs multiprocessing: When to Use Each in Python

The Problem

I tried to speed up image processing by spawning 10 threads. Each thread processed 100 images. After running the code, I measured the time and found… no improvement at all. Actually slightly slower due to context-switching overhead.

What I Expected vs What Happened
EXPECTED:
Single thread: 60 seconds
10 threads: ~6 seconds (10x faster)
ACTUAL:
Single thread: 60 seconds
10 threads: 62 seconds (slower!)

The issue? Python’s Global Interpreter Lock (GIL). Threads in Python don’t run truly parallel for CPU-bound work. The GIL ensures only one thread executes Python bytecode at a time, making threads useless for CPU-heavy operations.

Then I tried multiprocessing for a simple HTTP request batch. I spawned 20 processes to fetch URLs. The result: processes took longer to start than the actual requests. Process spawn overhead killed the performance gains.

Wrong Tool, Wrong Outcome
Using threading for CPU work:
Result: Context-switch overhead, no parallelism
Using multiprocessing for simple IO:
Result: Process spawn overhead > request time

I was picking the wrong concurrency model for my workload.

The Solution

The rule is simple: match your concurrency model to your task type.

Decision Flowchart
What is your task type?
┌───────────────┴───────────────┐
│ │
IO-bound CPU-bound
(network/file/db) (math/image/data)
│ │
│ │
┌──────────┴──────────┐ │
│ │ │
│ │ │
Do you have No async Use multiprocessing
async libraries? library? │
│ │ │
│ │ │
Use asyncio Use threading True parallelism
│ │ (bypasses GIL)
│ │ │
Cooperative Pseudo-parallel High
multitasking (GIL limited) overhead
Low overhead Medium overhead

asyncio: Best for IO-bound with Async Libraries

Use asyncio when your IO operations have async library support. This is the lowest overhead option for high-concurrency IO work.

async_fetch.py
import asyncio
import aiohttp
async def fetch_urls(urls):
"""Fetch multiple URLs concurrently with minimal overhead."""
async with aiohttp.ClientSession() as session:
tasks = [session.get(url) for url in urls]
responses = await asyncio.gather(*tasks)
return [await r.text() for r in responses]
# Run 100 concurrent requests with single thread
urls = ["https://api.example.com/data/" + str(i) for i in range(100)]
asyncio.run(fetch_urls(urls))

Why asyncio works well:

asyncio Characteristics
Parallelism: Cooperative (single thread)
Memory: Shared (no process isolation)
Overhead: Very low (just task switching)
Best for: Network, database, file operations with async libs
Examples: aiohttp, asyncpg, aiomysql, aiofiles, FastAPI

The event loop manages all tasks on a single thread. When one task waits for IO, it yields control to the next. No thread creation, no context switching, no GIL contention.

threading: IO-bound Without Async Support

Use threading when you need IO concurrency but the library doesn’t have an async version. The requests library is a classic example.

threaded_fetch.py
import threading
import requests # blocking library, no async version
def fetch_url(url, results, index):
"""Fetch URL in a thread. GIL releases during IO wait."""
results[index] = requests.get(url).text
def fetch_all(urls):
"""Fetch URLs using threads."""
results = [None] * len(urls)
threads = [
threading.Thread(target=fetch_url, args=(url, results, i))
for i, url in enumerate(urls)
]
for t in threads:
t.start()
for t in threads:
t.join()
return results
urls = ["https://api.example.com/data/" + str(i) for i in range(100)]
fetch_all(urls)

Key limitation: threading is NOT for CPU-bound work.

threading Characteristics
Parallelism: Pseudo-parallel (GIL limits CPU work)
Memory: Shared (easy data sharing)
Overhead: Medium (thread creation + context switching)
Best for: Blocking IO without async library support
Limitation: GIL blocks true parallelism for CPU work

The GIL releases during IO operations (network, file, sleep). Threads work for IO because while one thread waits for network response, another can run. But for CPU work, GIL never releases, so threads just add overhead.

multiprocessing: CPU-bound Work

Use multiprocessing for CPU-intensive operations. Each process has its own Python interpreter and GIL, so true parallelism is possible.

parallel_process.py
from multiprocessing import Pool
import numpy as np
def process_image(img_path):
"""Heavy computation - runs in separate process, bypasses GIL."""
# Load and process image with numpy
data = np.load(img_path)
# CPU-intensive operations
filtered = np.convolve(data, np.ones(100)/100, mode='valid')
return filtered.mean()
def batch_process(image_paths):
"""Process images using multiple processes."""
with Pool(processes=4) as pool:
results = pool.map(process_image, image_paths)
return results
# Process 1000 images with 4 parallel processes
paths = [f"/data/image_{i}.npy" for i in range(1000)]
batch_process(paths)
multiprocessing Characteristics
Parallelism: True parallel (multiple interpreters, multiple GILs)
Memory: Separate (process isolation, no shared memory)
Overhead: High (process creation + IPC)
Best for: Math, image processing, data analysis, ML training
Benefit: Bypasses GIL completely

Each process runs independently with its own memory space. No GIL contention. But creating processes is expensive, and sharing data requires serialization (pickling).

Comparison Table

ModelUse CaseParallelismMemoryOverheadGIL Impact
asyncioIO-bound asyncCooperativeSharedLowNot applicable
threadingIO-bound blockingPseudo (GIL limited)SharedMediumBlocks CPU
multiprocessingCPU-boundTrue parallelSeparateHighBypasses

Why This Matters

Choosing wrong wastes resources. Here’s what happens with mismatched choices:

Performance Impact of Wrong Choices
Thread for CPU work:
Input: 4 CPU cores, 100 math operations
Expected: 4x faster
Actual: Same speed + context-switch overhead
Reason: GIL blocks parallel execution
Process for simple HTTP:
Input: 20 HTTP requests, 50ms each
Expected: Faster concurrent execution
Actual: Slower than sequential
Reason: Process spawn (100ms+) > request time (50ms)

The right choice gives you performance without unnecessary overhead:

Correct Choices and Results
asyncio for async IO (100 requests):
Sequential: 5000ms
asyncio: 100ms (50x faster, minimal overhead)
threading for blocking IO (100 requests with requests lib):
Sequential: 5000ms
threading: ~500ms (10x faster, GIL releases during IO)
multiprocessing for CPU work (4 cores, 1000 images):
Single process: 120s
multiprocessing: 30s (4x faster, true parallelism)

Common Mistakes

I’ve made all these mistakes. Here’s what to avoid:

Mistake 1: Threads for CPU-bound work

mistake_threads_cpu.py
# WRONG: Using threads for CPU-heavy computation
import threading
def compute(data):
# Heavy math - GIL blocks parallelism
result = sum(x ** 2 for x in data)
return result
threads = [threading.Thread(target=compute, args=(data,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
# Result: Same speed, wasted thread overhead

Mistake 2: Multiprocessing for trivial IO

mistake_process_io.py
# WRONG: Spawning processes for simple HTTP requests
from multiprocessing import Process
def fetch(url):
import requests
return requests.get(url).text # Takes 50ms
# Process spawn takes 100ms+, so total > sequential
processes = [Process(target=fetch, args=(url,)) for url in urls]
# Total time: spawn_time + request_time > just doing sequentially

Mistake 3: Mixing async with blocking calls

mistake_blocking_in_async.py
# WRONG: Blocking call in async function
import asyncio
import requests # blocking!
async def bad_fetch(url):
# This blocks the entire event loop!
return requests.get(url).text
async def main():
# All 100 "concurrent" tasks run sequentially
# because requests.get blocks the event loop
tasks = [bad_fetch(url) for url in urls]
await asyncio.gather(*tasks)
# Result: No concurrency at all!

Mistake 4: Too many threads/processes

Resource Exhaustion
Creating 1000 threads:
Memory: ~8MB per thread stack = 8GB total
Context switching: Massive overhead
Result: Slower than fewer threads
Creating 100 processes:
Memory: Each process duplicates Python runtime
Spawn time: Minutes to start all processes
Result: System hangs, no work done

Practical Guidelines

Quick Decision Guide
1. Check your task: IO-bound or CPU-bound?
- IO: network, file, database, sleep
- CPU: math, image, data processing, compression
2. For IO-bound:
- Have async library? Use asyncio
- No async library? Use threading
- Limit threads to ~10-20 for most cases
3. For CPU-bound:
- Use multiprocessing
- Set processes = number of CPU cores
- Consider process pool for repeated work
4. Avoid:
- Threads for CPU work (GIL blocks it)
- Processes for tiny IO tasks (spawn overhead)
- Blocking calls in async functions (blocks loop)
- Too many threads/processes (resource exhaustion)

The GIL exists because Python’s memory management is not thread-safe. Reference counting requires atomic operations, which would be slow without GIL. Python 3.14’s free-threading mode (PEP 779) removes the GIL but requires careful testing.

GIL Background
Purpose: Protect reference counting from race conditions
Effect: Only one thread executes Python bytecode at a time
Exception: GIL releases during IO operations
Future: Python 3.14 offers GIL-free mode (experimental)

asyncio is built on coroutines and the event loop. A coroutine yields control with await, allowing other tasks to run. This cooperative multitasking avoids preemptive context switching overhead.

asyncio Internals
Event loop: Single thread managing multiple tasks
Coroutine: Function that can pause and resume
await: Yield point where control transfers to loop
gather: Run multiple coroutines concurrently

multiprocessing uses OS-level processes. Each process is a separate Python interpreter with its own GIL. Communication requires serialization through pipes or queues.

multiprocessing Internals
Process: OS-level process with own memory
IPC: Inter-process communication via pipes/queues
Pickling: Serialization for data transfer
Pool: Worker processes for parallel mapping

Summary

In this post, I explained why my threading approach failed for CPU-heavy image processing. The GIL blocked actual parallelism, making threads useless for CPU-bound work. Then I overcorrected with multiprocessing for simple HTTP requests, adding unnecessary spawn overhead.

The solution: match your concurrency model to your workload:

  • asyncio: IO-bound tasks with async library support (network, database, file operations)
  • threading: IO-bound tasks without async libraries (blocking IO operations)
  • multiprocessing: CPU-bound tasks (math, image processing, data analysis)

The decision flowchart guides you through the choice. Avoid common mistakes: threads for CPU work, processes for trivial IO, blocking calls in async, and resource exhaustion from too many workers.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments