Python Tutorial / Choose Threads, Processes or asyncio

Threads, processes and asyncio offer different ways to make progress on multiple tasks. Choose according to CPU work, external waiting and library support, then measure the actual workload rather than assuming concurrency makes it faster.

Distinguish concurrency from parallel execution

Method Model Costs and constraints
Threads Share memory in one process Synchronization and library behavior
Processes Separate memory spaces Startup, serialization and data transfer
asyncio Cooperative progress at await points Async-compatible libraries and event-loop blocking

In ordinary GIL-enabled CPython, adding threads does not directly parallelize arbitrary pure-Python CPU work. Extensions releasing the GIL and free-threaded builds change the conditions, so consider the interpreter build and libraries together.

For the relevant APIs and platform requirements, see Python: concurrent.futures.

Bounded I/O work and collection failures build on the rules demonstrated in Understand HTTP APIs, JSON Responses and Request Failures.

Compare the interfaces with one complete program

Save and run this as a .py file rather than assuming identical process behavior in a notebook or interactive session.

Save the complete code below as concurrency.py in a separate practice folder, using UTF-8. Run python concurrency.py in a terminal with your project virtual environment selected. Outside a virtual environment on macOS or Ubuntu, use python3 when that is your interpreter command.

import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def square(value):
    return value * value

async def async_square(value):
    await asyncio.sleep(0.01)
    return square(value)

async def gather_values():
    return await asyncio.gather(*(async_square(x) for x in [1, 2, 3]))

def main():
    with ThreadPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    with ProcessPoolExecutor(max_workers=2) as pool:
        print(list(pool.map(square, [1, 2, 3])))
    print(asyncio.run(gather_values()))

if __name__ == "__main__":
    main()

Expected output:

[1, 4, 9]
[1, 4, 9]
[1, 4, 9]

Respect entry points and cooperative waiting

Process startup, including on Windows, can import the main module. The main guard prevents recursive startup. Defining square at module level makes it accessible to worker processes.

An async function performing long CPU work or synchronous network calls without yielding can block the event loop. asyncio.sleep demonstrates cooperative waiting; it does not make a real HTTP request.

The GIL (Global Interpreter Lock) limits concurrent Python-bytecode execution by threads in a conventional GIL-enabled CPython build. Consider threads for blocking I/O, processes for pure-Python CPU work, and asyncio for I/O libraries that support async operations; measure the actual workload. Free-threaded builds and extensions releasing the GIL have different conditions. The square example demonstrates APIs, not a speed benchmark. Bound task counts and wait times, and inspect exceptions from Future.result. The cancellation experiment below shows finally cleanup before the caller observes cancellation.

Save the complete example as cancel.py and run python cancel.py in the same practice directory.

import asyncio
async def worker():
    try:
        await asyncio.sleep(60)
    finally:
        print("worker cleanup")
async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print("cancel confirmed")
if __name__ == "__main__":
    asyncio.run(main())

Expected output:

worker cleanup
cancel confirmed

The following checks a submitted task’s failure at result. max_workers limits concurrent workers, not the entire submission queue. A timeout limits waiting; it does not forcibly stop a running task. Submit large inputs in bounded batches and use an explicit I/O concurrency limit such as Semaphore in asyncio.

Save the complete example as future_failure.py and run python future_failure.py in the same practice directory.

from concurrent.futures import ThreadPoolExecutor
def calculate(value):
    if value < 0:
        raise ValueError("negative input")
    return value * value
with ThreadPoolExecutor(max_workers=2) as pool:
    futures = [pool.submit(calculate, value) for value in [2, -1]]
    for future in futures:
        try:
            print(future.result(timeout=5))
        except ValueError as error:
            print("failed:", error)

Expected output:

4
failed: negative input

Official behavior and conditions: Python: asyncio task cancellation · Python: concurrent.futures

Measure realistic work and design failure handling

This tiny square example illustrates APIs and is not a performance benchmark. Process startup can cost more than the work itself. Measure representative data while recording environment, worker counts and memory.

map returns results in input order, although tasks may finish in another order. Bound concurrency, synchronize shared state and design error propagation, cancellation and shutdown. Do not create unlimited tasks or rely on unsynchronized changes to a shared list. asyncio.run cannot be nested inside an already running event loop.

The API collection project gives a concrete workflow whose network and storage phases should be understood before introducing parallel requests.