Threads vs Processes vs Async in Python

Compare Python threading, multiprocessing, and asyncio side by side with benchmarks so you can choose the right concurrency model for your workload.

9 min read

Choosing between Python threading, asyncio, and multiprocessing is the most consequential decision you make when adding concurrency to a Python program. Pick the right model, and your program finishes in seconds instead of minutes. Pick the wrong one, and your program may run slower than if you had left it single-threaded.

This article compares the three models side by side with benchmarks that isolate I/O-bound and CPU-bound workloads so you can see the performance differences directly, rather than relying on rules of thumb. If you have not read the overview of Python concurrency, start there for the conceptual foundation before diving into the comparisons here.

The three models share a superficial similarity: they all let you do more than one thing at overlapping times. But the runtime mechanics are completely different. Threading creates real operating system threads that the kernel preemptively schedules.

Asyncio runs everything in one thread and uses cooperative multitasking where tasks explicitly yield control. Multiprocessing spawns entirely separate Python interpreter processes. These differences in runtime behaviour directly determine which model works for which workload, and understanding them prevents the most common concurrency mistake: using the wrong tool for the job.

Benchmark setup

The benchmarks in this article measure two distinct workloads on a machine with 4 CPU cores. The I/O benchmark simulates network or disk latency with time.sleep(), which releases the GIL. The CPU benchmark computes prime numbers with pure Python arithmetic, which holds the GIL continuously.

Every benchmark compares four implementations of the same workload: a sequential baseline, threading with ThreadPoolExecutor, asyncio with asyncio.gather, and multiprocessing with ProcessPoolExecutor.

The code for each benchmark is minimal so that the overhead of the concurrency mechanism itself is visible in the results. Real programs have additional overhead from data serialization, logging, and error handling, but isolating the core concurrency mechanism lets you see the fundamental performance characteristics before adding real-world complexity.

pythonpython
import time
import math
 
def io_work(duration):
    """Simulate an I/O operation that takes `duration` seconds."""
    time.sleep(duration)
    return duration
 
def cpu_work(limit):
    """Count primes up to `limit` to simulate CPU-bound work."""
    count = 0
    for n in range(2, limit + 1):
        is_prime = True
        for d in range(2, int(math.sqrt(n)) + 1):
            if n % d == 0:
                is_prime = False
                break
        if is_prime:
            count += 1
    return count

The io_work function represents any operation where the program waits for an external resource: a network response, a disk read, a database query. The cpu_work function represents pure computation where the program is actively using the processor.

I/O-bound benchmark: sequential vs threaded vs asyncio vs multiprocessing

The I/O workload runs 10 tasks, each sleeping for 0.1 seconds. The sequential version runs them one after another, taking roughly 1 second. A good concurrency model should run all 10 tasks concurrently and finish in approximately 0.1 seconds, the duration of the longest single task.

pythonpython
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import asyncio
 
def io_work(duration):
    time.sleep(duration)
    return duration
 
# Sequential baseline
def run_sequential(n_tasks=10, duration=0.1):
    start = time.perf_counter()
    results = [io_work(duration) for _ in range(n_tasks)]
    return time.perf_counter() - start
 
# Threading
def run_threaded(n_tasks=10, duration=0.1):
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=n_tasks) as executor:
        results = list(executor.map(io_work, [duration] * n_tasks))
    return time.perf_counter() - start

The asyncio and multiprocessing versions follow the same shape as the sequential and threaded ones above, just swapping in asyncio.gather() or a process pool to run the same io_work calls:

pythonpython
# Asyncio
async def async_io_work(duration):
    await asyncio.sleep(duration)
    return duration
 
async def run_async_tasks(n_tasks=10, duration=0.1):
    start = time.perf_counter()
    tasks = [async_io_work(duration) for _ in range(n_tasks)]
    await asyncio.gather(*tasks)
    return time.perf_counter() - start
 
def run_asyncio(n_tasks=10, duration=0.1):
    return asyncio.run(run_async_tasks(n_tasks, duration))
 
# Multiprocessing
def run_multiprocess(n_tasks=10, duration=0.1):
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=n_tasks) as executor:
        results = list(executor.map(io_work, [duration] * n_tasks))
    return time.perf_counter() - start

Typical I/O-bound results on a 4-core machine:

ApproachTime (s)Speedup vs Sequential
Sequential1.001x
Threading0.1010x
Asyncio0.1010x
Multiprocessing0.119x

Threading and asyncio both achieve near-linear speedup for I/O-bound work. The 10 tasks run concurrently, and the total time is the duration of the longest task plus a small scheduling overhead. Multiprocessing is slightly slower because spawning 10 separate processes has more overhead than spawning 10 threads or 10 asyncio tasks.

The key observation is that for I/O-bound work, threading and asyncio are both viable and perform similarly at moderate concurrency levels. The differences emerge at higher concurrency levels, which the next section explores.

I/O-bound benchmark: high concurrency

When you scale from 10 tasks to 1,000 tasks, the differences between threading and asyncio become visible. Each task still sleeps for 0.1 seconds, so the theoretical minimum time remains 0.1 seconds plus scheduling overhead.

pythonpython
def benchmark_high_concurrency():
    n_tasks = 1000
    duration = 0.1
 
    t_seq = run_sequential(n_tasks, duration)
    t_thread = run_threaded(n_tasks, duration)
    t_async = run_asyncio(n_tasks, duration)
    t_proc = run_multiprocess(n_tasks, duration)
 
    print(f"Sequential:     {t_seq:.3f}s")
    print(f"Threading:      {t_thread:.3f}s")
    print(f"Asyncio:        {t_async:.3f}s")
    print(f"Multiprocess:   {t_proc:.3f}s")

Typical results for 1,000 I/O tasks:

ApproachTime (s)Notes
Sequential100.01,000 x 0.1s
Threading0.30Thread creation overhead adds up
Asyncio0.11Minimal overhead, close to theoretical
Multiprocessing1.50Process creation overhead dominates

At 1,000 concurrent tasks, asyncio clearly outperforms threading. Each OS thread consumes memory for its stack (typically 8 MB by default on Linux) and the kernel spends CPU time context-switching between them. Asyncio tasks are lightweight coroutines that share a single thread, so creating 1,000 of them costs almost nothing.

Multiprocessing performs worst because spawning 1,000 processes is extremely expensive; in practice you would limit the process pool to the number of CPU cores and batch the work.

This benchmark explains why high-concurrency network servers, like those built with FastAPI or aiohttp, use asyncio rather than threading. When you need to handle thousands of concurrent connections, the per-connection overhead of threads becomes prohibitive.

CPU-bound benchmark: sequential vs threaded vs asyncio vs multiprocessing

The CPU workload counts prime numbers up to 10,000, repeated 8 times. On a 4-core machine, the ideal speedup from true parallelism is 4x. Less than 4x means the concurrency model is not using all cores effectively.

pythonpython
def cpu_work(limit):
    count = 0
    for n in range(2, limit + 1):
        is_prime = True
        for d in range(2, int(math.sqrt(n)) + 1):
            if n % d == 0:
                is_prime = False
                break
        if is_prime:
            count += 1
    return count
 
def run_cpu_sequential(n_tasks=8, limit=10000):
    start = time.perf_counter()
    results = [cpu_work(limit) for _ in range(n_tasks)]
    return time.perf_counter() - start

The threaded and multiprocess versions call the same cpu_work function through a thread pool and a process pool respectively, which is what makes the timing difference between them attributable to the concurrency model rather than to different work being done:

pythonpython
def run_cpu_threaded(n_tasks=8, limit=10000):
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=n_tasks) as executor:
        results = list(executor.map(cpu_work, [limit] * n_tasks))
    return time.perf_counter() - start
 
def run_cpu_multiprocess(n_tasks=8, limit=10000):
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=n_tasks) as executor:
        results = list(executor.map(cpu_work, [limit] * n_tasks))
    return time.perf_counter() - start

Typical CPU-bound results on a 4-core machine:

ApproachTime (s)Speedup vs Sequential
Sequential8.01x
Threading8.20.98x (slower!)
Multiprocessing2.13.8x

Threading is slightly slower than sequential for CPU-bound work. The GIL prevents the threads from running in parallel, and the additional cost of thread switching makes the threaded version marginally worse. Asyncio is not included here because it offers no benefit for CPU-bound work: running CPU-bound coroutines sequentially in a single thread is identical to the sequential baseline.

Multiprocessing achieves a 3.8x speedup, which is close to the theoretical 4x maximum on a 4-core machine. The small gap is due to process creation overhead and the cost of serializing results back to the main process. Each worker process runs independently on its own core with its own GIL, so the prime-counting work runs in true parallel.

This benchmark is the clearest demonstration of why you must use multiprocessing for CPU-bound Python work. Threading and asyncio are for I/O. Multiprocessing is for CPU.

Memory and startup costs

The three models have very different resource profiles that matter when your program runs in a constrained environment like a container or a serverless function.

Threads share the same memory space as the main process. Creating a thread costs roughly 1 to 2 milliseconds. Each thread needs its own stack, typically 8 MB of virtual memory, though the physical memory used is usually much smaller because the stack grows on demand.

Threads can directly read and write shared Python objects without serialization, which makes sharing data between threads fast but also requires locks to prevent data races.

Asyncio tasks are coroutine objects. Creating a task costs microseconds and a few hundred bytes of memory. There is no OS-level thread or stack allocation.

The tradeoff is that every I/O operation must use async-compatible functions. Calling time.sleep() from within an asyncio task blocks the entire event loop for every other task.

Processes have the highest startup cost. Creating a process takes tens to hundreds of milliseconds because the operating system must duplicate the parent process's memory space (via fork on Unix, or spawn on Windows and macOS by default since Python 3.8). Each process also has its own Python interpreter and memory, so sharing data requires pickling or shared memory primitives like multiprocessing.Value and multiprocessing.Array.

pythonpython
import time
import threading
import asyncio
 
def measure_thread_creation_cost():
    start = time.perf_counter()
    for _ in range(100):
        t = threading.Thread(target=lambda: None)
        t.start()
        t.join()
    return (time.perf_counter() - start) / 100 * 1000

Measuring asyncio task creation follows the same before-and-after timing approach, just swapping the 100 thread creations for 1,000 task creations since tasks are so much cheaper:

pythonpython
async def noop():
    pass
 
async def measure_task_creation_cost():
    start = time.perf_counter()
    for _ in range(1000):
        await asyncio.create_task(noop())
    return (time.perf_counter() - start) / 1000 * 1000
 
thread_cost = measure_thread_creation_cost()
task_cost = asyncio.run(measure_task_creation_cost())
 
print(f"Thread creation:     {thread_cost:.3f} ms")
print(f"Asyncio task creation: {task_cost:.6f} ms")

Typical output shows thread creation in the 1 to 2 ms range and asyncio task creation well under 0.001 ms. The difference of three orders of magnitude is why asyncio is the default choice for servers that handle tens of thousands of concurrent connections.

When to use each model: decision flowchart

Use the following questions to narrow down your choice:

  1. Is your program I/O-bound or CPU-bound? If you do not know, profile it with cProfile or timeit. Guessing leads to the wrong choice.

  2. If I/O-bound: Do you need more than a few hundred concurrent tasks? Prefer asyncio if yes, or use either threading or asyncio if you have fewer. Choose threading if your dependencies are synchronous and you cannot rewrite them, or choose asyncio if your framework already uses it (FastAPI, aiohttp, asyncpg) or you expect your concurrency needs to grow.

  3. If CPU-bound: Use multiprocessing. Threading and asyncio cannot help. If your workload is a mix of I/O and CPU, start with asyncio for the I/O layer and offload CPU work to a process pool through loop.run_in_executor().

  4. If your workload is mostly sequential with small pockets of concurrency: The simplest approach is often best. concurrent.futures.ThreadPoolExecutor gives you concurrency with minimal code changes. You can always migrate to asyncio later if your needs grow.

A common anti-pattern is reaching for asyncio because it is modern and interesting, then discovering that your core library is synchronous and blocks the event loop. Start simple, measure, and only increase complexity when the measurement justifies it. The article on how Python threading works explains the underlying mechanics that make these performance differences predictable rather than mysterious.

The role of the GIL in each model

The GIL affects each model differently, which is why the benchmarks look the way they do:

  • Threading: The GIL limits parallelism but not concurrency. Threads run Python code one at a time, but they interleave efficiently during I/O because the GIL is released before blocking calls. The GIL is the reason threading gives zero speedup for CPU work and near-linear speedup for I/O work.

  • Asyncio: The GIL is irrelevant because there is only one thread. The event loop switches between tasks cooperatively, and there is never contention for the GIL because only one task runs at a time.

  • Multiprocessing: The GIL does not apply across processes. Each process has its own interpreter and its own GIL, so there is no lock contention between processes. This is why multiprocessing achieves true parallelism for CPU-bound work.

Understanding this matrix is the key to choosing correctly. If you internalize that the GIL blocks CPU parallelism but not I/O concurrency, and that multiprocessing bypasses the GIL entirely, you already know more about Python concurrency than most developers who have been using it for years.

Rune AI

Rune AI

Key Insights

  • Threading and asyncio both speed up I/O-bound programs, but asyncio scales to more concurrent tasks with lower overhead.
  • Multiprocessing is the only model that achieves true parallelism for CPU-bound Python code because each process has its own GIL.
  • Threading creates real OS threads that the kernel preemptively schedules; asyncio uses cooperative multitasking in a single thread.
  • For I/O workloads, asyncio outperforms threading at high concurrency, but threading matches or beats asyncio at low concurrency with GIL-releasing libraries.
  • Benchmark your actual workload before choosing. Generic advice cannot replace measurement of your specific I/O patterns and data sizes.
RunePowered by Rune AI

Frequently Asked Questions

Is asyncio always faster than threading in Python?

No. Asyncio has lower overhead per task than threading because it avoids OS-level context switching, which makes it more efficient for very high numbers of concurrent I/O operations. But threading can be faster when you have fewer concurrent tasks and your libraries release the GIL during I/O. Threading also works with any synchronous library without adaptation, while asyncio requires async-compatible libraries. The difference in practice often comes down to what your dependencies support.

Why would I use multiprocessing if threading is simpler?

Use multiprocessing when your program is CPU-bound. Threading cannot speed up pure Python computation because the GIL prevents multiple threads from running Python bytecode at the same time. Multiprocessing spawns separate processes, each with its own Python interpreter and GIL, so they can run on different CPU cores simultaneously. The tradeoff is that processes are heavier to create and sharing data between them requires serialization.

Can I use threading and asyncio together in the same program?

Yes, but carefully. You can run blocking synchronous code in a thread pool from an asyncio event loop using loop.run_in_executor(). You can also run asyncio code from a thread using asyncio.run_coroutine_threadsafe(). The two models can coexist, but mixing them introduces complexity. Prefer to design your program around one concurrency model unless your dependencies genuinely require both.

Conclusion

The choice between threading, asyncio, and multiprocessing is not about which is best in the abstract. It is about which fits your workload. I/O-bound programs with moderate concurrency needs do well with threading. I/O-bound programs with thousands of concurrent tasks do better with asyncio. CPU-bound programs need multiprocessing. Benchmarks confirm that picking the wrong model can make your program slower than the single-threaded baseline, so measure before you commit.