Skip to content
Codeloom
Python

Python Concurrency: Threading vs Multiprocessing vs Asyncio

Compare Python's concurrency models -- threading, multiprocessing, and asyncio -- with practical examples and performance guidance.

·6 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How the GIL affects threading in CPython
  • When to use threading vs multiprocessing vs asyncio
  • Building concurrent programs with each approach
  • Common pitfalls and how to avoid them

Prerequisites

  • Basic Python knowledge
  • Understanding of functions and classes
  • Familiarity with I/O operations

The Concurrency Landscape in Python

Python offers three main approaches to concurrency:

  • Threading: Multiple threads within a single process. Best for I/O-bound work.
  • Multiprocessing: Multiple processes, each with its own Python interpreter. Best for CPU-bound work.
  • Asyncio: Cooperative multitasking with a single thread. Best for high-concurrency I/O.

Choosing the wrong model leads to programs that are slower than their sequential counterparts. This guide will help you pick the right tool.

The GIL: The Elephant in the Room

CPython’s Global Interpreter Lock (GIL) prevents multiple threads from executing Python bytecode simultaneously. This means threading does not provide true parallelism for CPU-bound work.

import threading
import time

def cpu_heavy(n):
    """Simulate CPU-bound work."""
    total = 0
    for i in range(n):
        total += i * i
    return total

# Sequential
start = time.perf_counter()
cpu_heavy(10_000_000)
cpu_heavy(10_000_000)
print(f"Sequential: {time.perf_counter() - start:.2f}s")

# Threaded -- NOT faster due to GIL
start = time.perf_counter()
t1 = threading.Thread(target=cpu_heavy, args=(10_000_000,))
t2 = threading.Thread(target=cpu_heavy, args=(10_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threaded: {time.perf_counter() - start:.2f}s")

The threaded version will likely be the same speed or even slightly slower than sequential, because the GIL forces threads to take turns.

Note: Python 3.13 introduced an experimental free-threaded mode (--disable-gil) that removes this limitation. It is still experimental as of 2026, but worth following.

Threading: Best for I/O-Bound Work

When your program waits on network requests, file reads, or database queries, threads release the GIL during the wait. This makes threading effective for I/O-bound workloads.

import threading
import time
from concurrent.futures import ThreadPoolExecutor

def download(url):
    """Simulate downloading from a URL."""
    time.sleep(1)  # Simulates network I/O
    return f"Data from {url}"

urls = [f"https://api.example.com/data/{i}" for i in range(10)]

# Sequential: ~10 seconds
start = time.perf_counter()
results = [download(url) for url in urls]
print(f"Sequential: {time.perf_counter() - start:.2f}s")

# ThreadPoolExecutor: ~1 second
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as executor:
    results = list(executor.map(download, urls))
print(f"Threaded: {time.perf_counter() - start:.2f}s")

ThreadPoolExecutor from concurrent.futures is the modern, preferred way to use threads. It manages the thread pool, handles exceptions, and provides a clean API.

Multiprocessing: True Parallelism for CPU Work

Each process has its own Python interpreter and its own GIL. This enables genuine parallel execution on multiple CPU cores.

import time
from concurrent.futures import ProcessPoolExecutor

def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

numbers = [10_000_000] * 4

# Sequential
start = time.perf_counter()
results = [cpu_heavy(n) for n in numbers]
print(f"Sequential: {time.perf_counter() - start:.2f}s")

# Multiprocessing
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(cpu_heavy, numbers))
print(f"Multiprocessing: {time.perf_counter() - start:.2f}s")

On a 4-core machine, the multiprocessing version should be roughly 4x faster.

Sharing Data Between Processes

Processes do not share memory by default. Use multiprocessing.Queue, multiprocessing.Value, or multiprocessing.Manager for inter-process communication:

from multiprocessing import Process, Queue

def worker(queue, data):
    result = sum(x * x for x in data)
    queue.put(result)

if __name__ == "__main__":
    queue = Queue()
    data_chunks = [range(i * 1000, (i + 1) * 1000) for i in range(4)]

    processes = [Process(target=worker, args=(queue, chunk)) for chunk in data_chunks]
    for p in processes:
        p.start()
    for p in processes:
        p.join()

    total = sum(queue.get() for _ in processes)
    print(f"Total: {total}")

Always guard multiprocessing code with if __name__ == "__main__": to prevent recursive process spawning.

Asyncio: High-Concurrency I/O

Asyncio uses cooperative multitasking. A single thread runs an event loop, and tasks voluntarily yield control at await points. This avoids threading overhead and is ideal for handling thousands of concurrent connections.

import asyncio
import time

async def fetch(url):
    """Simulate an async HTTP request."""
    await asyncio.sleep(1)  # Non-blocking sleep
    return f"Data from {url}"

async def main():
    urls = [f"https://api.example.com/data/{i}" for i in range(100)]

    # Launch all requests concurrently
    tasks = [asyncio.create_task(fetch(url)) for url in urls]
    results = await asyncio.gather(*tasks)
    print(f"Fetched {len(results)} results")

start = time.perf_counter()
asyncio.run(main())
print(f"Asyncio: {time.perf_counter() - start:.2f}s")
# ~1 second for 100 concurrent requests

The key advantage of asyncio is scalability. Threading with 100 threads creates 100 OS threads with their associated memory overhead. Asyncio handles 100 tasks within a single thread.

Comparison Table

FeatureThreadingMultiprocessingAsyncio
Best forI/O-boundCPU-boundHigh-concurrency I/O
ParallelismNo (GIL)YesNo
MemorySharedSeparateShared
OverheadLowHigh (process spawn)Very low
Scalability~100s threads~10s processes~10,000s tasks
DebuggingHard (race conditions)ModerateModerate

Thread Safety and Locks

When threads share mutable state, you need synchronization:

import threading

class SafeCounter:
    def __init__(self):
        self.value = 0
        self._lock = threading.Lock()

    def increment(self):
        with self._lock:
            self.value += 1

counter = SafeCounter()
threads = [
    threading.Thread(target=counter.increment)
    for _ in range(10_000)
]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter.value)  # Always 10000

Without the lock, you would get race conditions and an incorrect count.

Practical Guidelines

  1. Default to sequential code unless you have a measured performance problem.
  2. I/O-bound with few connections: Use ThreadPoolExecutor.
  3. I/O-bound with many connections: Use asyncio.
  4. CPU-bound: Use ProcessPoolExecutor.
  5. Mixed workloads: Combine asyncio with run_in_executor for blocking calls.
import asyncio
from concurrent.futures import ProcessPoolExecutor

def cpu_work(n):
    return sum(i * i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, cpu_work, 10_000_000)
    print(result)

asyncio.run(main())

Wrapping Up

Python’s concurrency story has three distinct chapters. Threading handles I/O waits efficiently despite the GIL. Multiprocessing provides true parallelism for CPU-heavy computation. Asyncio scales to thousands of concurrent I/O operations in a single thread. Understanding the strengths and limitations of each model lets you write programs that are genuinely faster, not just more complicated.