Python Concurrency Explained: Threads, Processes, and asyncio

Python gives you three concurrency models, and each solves a different bottleneck. Threads and asyncio handle waiting (network, disk). Processes handle computing (math, parsing, image work). Pick the wrong one and your code runs slower than the sequential version.

Quick Takeaways

  • Use asyncio for thousands of concurrent network calls. It has the lowest overhead per task.
  • Use threading for I/O-bound work when you rely on blocking libraries like requests or legacy database drivers.
  • Use multiprocessing for CPU-bound work. Separate processes bypass the GIL and use every core.
  • Python 3.13 and later offer an optional free-threaded build, but the default interpreter still ships with the GIL.
Model Best For Parallel CPU Execution Memory Overhead Typical Scale
threading Blocking I/O No (GIL) Low (~8 MB stack reserved per thread) Dozens to hundreds
multiprocessing CPU-bound tasks Yes High (separate interpreter per process) Number of cores
asyncio Non-blocking I/O No (single thread) Very low (~KBs per task) Thousands to tens of thousands

Concurrency vs. Parallelism

Concurrency means managing multiple tasks in overlapping time periods. Parallelism means executing multiple tasks at the same instant on different CPU cores.

A single-core machine can be concurrent but never parallel. Python threads and asyncio give you concurrency. Only multiprocessing (and free-threaded builds) give you true parallelism for Python bytecode.

I/O-Bound vs. CPU-Bound Work

Diagnose the bottleneck before choosing a tool.

  • I/O-bound: the program waits on something external. Examples: HTTP requests, file reads, database queries.
  • CPU-bound: the program waits on the processor. Examples: hashing, matrix math, compression, parsing huge files.

A quick profile settles it. If CPU usage sits near 100% on one core, you are CPU-bound. If CPU usage is low while runtime is high, you are I/O-bound.

The Global Interpreter Lock (GIL)

The GIL is a mutex inside CPython that allows only one thread to execute Python bytecode at a time. It protects the interpreter’s reference-counting memory management from race conditions.

Consequences:

  • Two threads running pure Python loops do not run in parallel.
  • The GIL is released during blocking I/O calls and inside many C extensions (NumPy, hashlib on large buffers).
  • That release is why threads still speed up I/O-bound programs.

Proving the GIL Effect

import time
from threading import Thread

def count_down(n: int) -> None:
    """Pure-Python CPU work: holds the GIL the entire time."""
    while n > 0:
        n -= 1

N = 50_000_000

# Sequential baseline
start = time.perf_counter()
count_down(N)
count_down(N)
print(f"Sequential: {time.perf_counter() - start:.2f}s")

# Two threads
start = time.perf_counter()
t1 = Thread(target=count_down, args=(N,))
t2 = Thread(target=count_down, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Threaded:   {time.perf_counter() - start:.2f}s")

On a standard GIL build, the threaded version runs about as long as the sequential one, and often slightly longer because of lock contention and context switching. Exact numbers vary by machine.

Threading: Concurrent I/O with Blocking Libraries

The threading module creates OS-level threads that share memory. The modern way to use it is concurrent.futures.ThreadPoolExecutor, which manages a worker pool for you.

Basic Thread Pool

import time
from concurrent.futures import ThreadPoolExecutor, as_completed

import requests

URLS = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
]

def fetch(url: str) -> tuple[str, int]:
    """Blocking HTTP call. The GIL is released while waiting on the socket."""
    response = requests.get(url, timeout=10)
    return url, response.status_code

start = time.perf_counter()

with ThreadPoolExecutor(max_workers=4) as executor:
    futures = [executor.submit(fetch, url) for url in URLS]
    for future in as_completed(futures):
        url, status = future.result()  # Re-raises any exception from the worker
        print(f"{status} <- {url}")

print(f"Elapsed: {time.perf_counter() - start:.2f}s")

Four one-second requests finish in roughly one second total instead of four. as_completed() yields futures as they finish, not in submission order.

Race Conditions and Locks

Threads share memory, so unsynchronized writes corrupt data. The += operator is not atomic: it reads, adds, then writes.

import threading

counter = 0
lock = threading.Lock()

def increment_safe(times: int) -> None:
    global counter
    for _ in range(times):
        with lock:  # Only one thread enters this block at a time
            counter += 1

threads = [threading.Thread(target=increment_safe, args=(100_000,)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # 400000, deterministic with the lock

Without the with lock: guard, the final value is often lower than 400,000 and changes between runs. Prefer queue.Queue for passing data between threads, since it handles locking internally.

Multiprocessing: True Parallelism for CPU-Bound Work

The multiprocessing module launches separate Python interpreters. Each has its own memory space and its own GIL. Data crossing process boundaries must be pickled (serialized), which adds overhead.

ProcessPoolExecutor

import time
from concurrent.futures import ProcessPoolExecutor

def is_prime(n: int) -> bool:
    """CPU-bound primality test, O(sqrt(n))."""
    if n < 2:
        return False
    if n % 2 == 0:
        return n == 2
    i = 3
    while i * i <= n:
        if n % i == 0:
            return False
        i += 2
    return True

NUMBERS = [112_272_535_095_293] * 8

if __name__ == "__main__":  # Required on Windows and macOS (spawn start method)
    start = time.perf_counter()
    with ProcessPoolExecutor() as executor:  # Defaults to os.cpu_count() workers
        results = list(executor.map(is_prime, NUMBERS))
    print(results)
    print(f"Elapsed: {time.perf_counter() - start:.2f}s")

On an 8-core machine, this runs close to 8x faster than a sequential loop. The if __name__ == "__main__" guard is mandatory. Without it, child processes re-import the module and spawn processes recursively.

Sharing State Between Processes

Processes do not share variables. Use one of these instead:

  • multiprocessing.Queue or Pipe for message passing.
  • multiprocessing.shared_memory for large arrays without pickling cost.
  • Manager objects for shared dicts and lists (slower, proxy-based).

Keep inter-process traffic small. Pickling a 500 MB object to every worker can erase the speedup.

asyncio: Cooperative Concurrency on One Thread

asyncio runs a single-threaded event loop. Tasks voluntarily yield control at each await point. No preemption occurs, so you avoid most race conditions. The tradeoff: one blocking call freezes every task.

Core Vocabulary

Term Meaning
Coroutine Function defined with async def. Calling it returns a coroutine object, not a result.
await Suspends the current coroutine until the awaited operation completes.
Task A coroutine scheduled on the event loop via asyncio.create_task().
Event loop The scheduler that switches between ready tasks.

Concurrent HTTP with aiohttp

import asyncio
import time

import aiohttp

URLS = ["https://httpbin.org/delay/1"] * 50

async def fetch(session: aiohttp.ClientSession, url: str) -> int:
    async with session.get(url) as response:
        await response.read()
        return response.status

async def main() -> None:
    timeout = aiohttp.ClientTimeout(total=30)
    async with aiohttp.ClientSession(timeout=timeout) as session:
        # gather() schedules all coroutines at once and preserves result order
        statuses = await asyncio.gather(*(fetch(session, u) for u in URLS))
    print(f"{len(statuses)} requests, statuses: {set(statuses)}")

start = time.perf_counter()
asyncio.run(main())
print(f"Elapsed: {time.perf_counter() - start:.2f}s")

Fifty one-second requests complete in a little over one second, using one thread.

Structured Concurrency with TaskGroup

Since Python 3.11, asyncio.TaskGroup is the recommended way to run related tasks. If one task fails, the group cancels the rest and raises an ExceptionGroup.

import asyncio

async def worker(name: str, delay: float) -> str:
    await asyncio.sleep(delay)  # Non-blocking sleep
    return f"{name} done"

async def main() -> None:
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(worker("A", 1.0))
        t2 = tg.create_task(worker("B", 0.5))
    # Leaving the block guarantees both tasks have finished
    print(t1.result(), t2.result())

asyncio.run(main())

Limiting Concurrency with a Semaphore

Launching 10,000 simultaneous requests can exhaust file descriptors or trigger rate limits. Cap it.

import asyncio

semaphore = asyncio.Semaphore(100)  # At most 100 tasks inside the guarded block

async def limited_fetch(session, url):
    async with semaphore:
        async with session.get(url) as response:
            return await response.text()

Calling Blocking Code from asyncio

Never call time.sleep() or requests.get() inside a coroutine. Offload it to a thread:

import asyncio
import time

def blocking_task() -> str:
    time.sleep(2)  # Blocking call, safe inside a worker thread
    return "finished"

async def main() -> None:
    result = await asyncio.to_thread(blocking_task)  # Python 3.9+
    print(result)

asyncio.run(main())

Choosing the Right Model

Scenario Best Choice Reason
10,000 concurrent API calls asyncio Lowest per-task memory cost
Scraping 50 pages with requests ThreadPoolExecutor Works with existing blocking code
Resizing 5,000 images ProcessPoolExecutor CPU-bound, needs multiple cores
Web server handling mixed load asyncio + process pool Async for I/O, processes for heavy compute
Calling a C library that releases the GIL ThreadPoolExecutor Threads run in parallel inside C code
Simple script, 3 slow downloads ThreadPoolExecutor Least code to write

Combining Models

Run CPU-heavy work from async code with loop.run_in_executor():

import asyncio
from concurrent.futures import ProcessPoolExecutor

def crunch(n: int) -> int:
    return sum(i * i for i in range(n))

async def main() -> None:
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        results = await asyncio.gather(
            loop.run_in_executor(pool, crunch, 10_000_000),
            loop.run_in_executor(pool, crunch, 20_000_000),
        )
    print(results)

if __name__ == "__main__":
    asyncio.run(main())

The event loop stays responsive while worker processes burn CPU.

Free-Threaded Python (No-GIL Builds)

Python 3.13 introduced an experimental free-threaded build (PEP 703), and later releases continue to mature it. In that build, threads can execute Python bytecode in parallel. Key points:

  • It is a separate interpreter build (often named python3.13t), not the default.
  • Many C extensions need updates to be thread-safe on it.
  • Single-threaded performance can be somewhat lower than the standard build.
  • Check your dependencies before adopting it in production.

For now, treat multiprocessing as the portable answer for CPU-bound parallelism, and verify the status of free-threading in the release notes of your target Python version.

Best Practices and Refactoring

Anti-pattern 1: Blocking Call Inside a Coroutine

Bad code:

import asyncio
import requests

async def get_user(user_id: int) -> dict:
    # Blocks the entire event loop for the duration of the request
    return requests.get(f"https://api.example.com/users/{user_id}").json()

async def main():
    users = await asyncio.gather(*(get_user(i) for i in range(100)))

This code runs sequentially. Each requests.get() freezes the loop.

Good code:

import asyncio
import aiohttp

async def get_user(session: aiohttp.ClientSession, user_id: int) -> dict:
    async with session.get(f"https://api.example.com/users/{user_id}") as resp:
        resp.raise_for_status()
        return await resp.json()

async def main():
    async with aiohttp.ClientSession() as session:
        users = await asyncio.gather(*(get_user(session, i) for i in range(100)))

Anti-pattern 2: Threads for CPU-Bound Work

Bad code:

from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=8) as ex:
    results = list(ex.map(heavy_math, data))  # GIL serializes this

Good code:

from concurrent.futures import ProcessPoolExecutor

if __name__ == "__main__":
    with ProcessPoolExecutor() as ex:
        results = list(ex.map(heavy_math, data, chunksize=100))

The chunksize argument batches items per worker and cuts pickling overhead.

Anti-pattern 3: Unbounded Task Creation

Bad code:

tasks = [asyncio.create_task(fetch(u)) for u in million_urls]
await asyncio.gather(*tasks)

Good code:

sem = asyncio.Semaphore(200)

async def bounded(url):
    async with sem:
        return await fetch(url)

results = await asyncio.gather(*(bounded(u) for u in million_urls))

For very large inputs, process URLs in batches or use a worker queue with asyncio.Queue so you never hold a million coroutine objects at once.

Anti-pattern 4: Swallowing Exceptions

Bad code:

executor.submit(risky_job)  # Exception is silently stored in the Future, never read

Good code:

future = executor.submit(risky_job)
try:
    future.result(timeout=30)
except Exception as exc:
    logger.exception("risky_job failed: %s", exc)

Checklist

  • Set timeouts on every network call.
  • Use context managers (with, async with) so pools and sessions close cleanly.
  • Prefer concurrent.futures over raw Thread and Process objects.
  • Keep shared mutable state to a minimum. Pass messages instead.
  • Benchmark with time.perf_counter() before and after. Concurrency adds complexity, so measure the gain.

Common Edge Cases

  • Windows and macOS spawn: child processes re-import your module. Guard entry points with if __name__ == "__main__":.
  • Unpicklable arguments: lambdas, open file handles, and locks cannot cross process boundaries. Use top-level functions and plain data.
  • Deadlocks: acquiring two locks in different orders across threads can freeze a program. Always acquire locks in a consistent order.
  • Cancellation in asyncio: catching asyncio.CancelledError without re-raising it breaks task cancellation. Clean up in a finally block and let it propagate.
  • Daemon threads: they die abruptly at interpreter exit and may skip cleanup. Avoid them for work that must finish.

FAQ

What is the difference between threading and multiprocessing in Python?

Threading runs multiple threads inside one process with shared memory, but the GIL lets only one execute Python bytecode at a time. Multiprocessing runs separate processes with separate memory and separate GILs, enabling true parallel CPU execution at the cost of higher memory use and serialization overhead.

Is asyncio faster than threading?

For high-volume I/O, usually yes. An asyncio task costs a few kilobytes, while an OS thread reserves megabytes of stack address space and incurs context-switch costs. For a few dozen concurrent calls, the speed difference is negligible, and threads are simpler when you depend on blocking libraries.

Does Python really have no true multithreading because of the GIL?

Python threads are real OS threads, but in the default build the GIL prevents parallel execution of Python bytecode. Threads still overlap during I/O and in C extensions that release the lock. Free-threaded builds (PEP 703) remove this limit but are not the default interpreter.

When should I use asyncio, threads, or processes?

Use asyncio for large-scale network I/O with async-compatible libraries. Use threads for moderate I/O with blocking libraries. Use processes for CPU-heavy work. Combine them when a workload has both traits, such as an async web service that offloads computation to a process pool.

Hot this week

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Topics

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Robot Computers in 2026: Raspberry Pi 5, Jetson, and Industrial PCs Compared

Raspberry Pi 5 vs NVIDIA Jetson vs industrial PCs for ROS 2 robots. See specs, latency math and a decision matrix, then pick your compute.

AI in Robotics: Programmed Behavior vs Machine Learning (and When You Need Each)

Programmed control or machine learning? Compare PID, FSMs, and RL with ROS2 code, benchmarks, and a decision framework. Pick the right one.

What Is ROS 2? A Beginner’s Guide to the Robot Operating System in 2026

Learn what ROS 2 is, how nodes, topics, and DDS work, and build your first publisher and subscriber. Start your ROS 2 project today.

Related Articles

Popular Categories