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.
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.
Using threading for CPU work: Result: Context-switch overhead, no parallelism
Using multiprocessing for simple IO: Result: Process spawn overhead > request timeI was picking the wrong concurrency model for my workload.
The Solution
The rule is simple: match your concurrency model to your task type.
What is your task type? │ ┌───────────────┴───────────────┐ │ │ IO-bound CPU-bound (network/file/db) (math/image/data) │ │ │ │ ┌──────────┴──────────┐ │ │ │ │ │ │ │Do you have No async Use multiprocessingasync libraries? library? │ │ │ │ │ │ │Use asyncio Use threading True parallelism │ │ (bypasses GIL) │ │ │Cooperative Pseudo-parallel Highmultitasking (GIL limited) overheadLow overhead Medium overheadasyncio: 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.
import asyncioimport 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 threadurls = ["https://api.example.com/data/" + str(i) for i in range(100)]asyncio.run(fetch_urls(urls))Why asyncio works well:
Parallelism: Cooperative (single thread)Memory: Shared (no process isolation)Overhead: Very low (just task switching)Best for: Network, database, file operations with async libsExamples: aiohttp, asyncpg, aiomysql, aiofiles, FastAPIThe 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.
import threadingimport 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.
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 supportLimitation: GIL blocks true parallelism for CPU workThe 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.
from multiprocessing import Poolimport 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 processespaths = [f"/data/image_{i}.npy" for i in range(1000)]batch_process(paths)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 trainingBenefit: Bypasses GIL completelyEach process runs independently with its own memory space. No GIL contention. But creating processes is expensive, and sharing data requires serialization (pickling).
Comparison Table
| Model | Use Case | Parallelism | Memory | Overhead | GIL Impact |
|---|---|---|---|---|---|
| asyncio | IO-bound async | Cooperative | Shared | Low | Not applicable |
| threading | IO-bound blocking | Pseudo (GIL limited) | Shared | Medium | Blocks CPU |
| multiprocessing | CPU-bound | True parallel | Separate | High | Bypasses |
Why This Matters
Choosing wrong wastes resources. Here’s what happens with mismatched 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:
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
# WRONG: Using threads for CPU-heavy computationimport 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 overheadMistake 2: Multiprocessing for trivial IO
# WRONG: Spawning processes for simple HTTP requestsfrom multiprocessing import Process
def fetch(url): import requests return requests.get(url).text # Takes 50ms
# Process spawn takes 100ms+, so total > sequentialprocesses = [Process(target=fetch, args=(url,)) for url in urls]# Total time: spawn_time + request_time > just doing sequentiallyMistake 3: Mixing async with blocking calls
# WRONG: Blocking call in async functionimport asyncioimport 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
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 donePractical Guidelines
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)Related Knowledge
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.
Purpose: Protect reference counting from race conditionsEffect: Only one thread executes Python bytecode at a timeException: GIL releases during IO operationsFuture: 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.
Event loop: Single thread managing multiple tasksCoroutine: Function that can pause and resumeawait: Yield point where control transfers to loopgather: Run multiple coroutines concurrentlymultiprocessing uses OS-level processes. Each process is a separate Python interpreter with its own GIL. Communication requires serialization through pipes or queues.
Process: OS-level process with own memoryIPC: Inter-process communication via pipes/queuesPickling: Serialization for data transferPool: Worker processes for parallel mappingSummary
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:
- 👨💻 Python asyncio Documentation
- 👨💻 Python threading Documentation
- 👨💻 Python multiprocessing Documentation
- 👨💻 Understanding the Python GIL
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments