Lesson 0.4.6 · 55 min
Concurrency: threading, multiprocessing, asyncio and free-threading
A trading system spends almost all its time waiting: for the network, for the broker, for the next tick. At other times it computes nonstop: simulations, optimizations. Each case has its own tool in Python, and choosing the wrong one may speed up nothing. This lesson compares them with deterministic examples and explains what changes with GIL-free Python.
- Module
- 0.4 · Intermediate Python
- Duration
- 55 min
- Prerequisites
- 0.4.3 · Iterators, generators, decorators and context managers, 0.4.5 · Professional logging and testing with pytest
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
Waiting or computing
A program can be slow for two very different reasons:
- It waits (I/O-bound, input/output): requesting prices from an API, receiving messages from a websocket, reading files, writing to a database. The processor is almost idle; the time goes into waiting for responses.
- It computes (CPU-bound): simulating thousands of paths, optimizing parameters, recomputing indicators over years of data. The processor is at 100%.
Concurrency means having several tasks in progress at once; parallelism means literally running them at the same time on several cores. For waiting, concurrency is enough: while one request waits, another is sent. For computing, parallelism is needed. Python offers three tools, and a fourth that changes the rules:
| Tool | What it creates | True parallelism of Python code | Fits |
|---|---|---|---|
threading / ThreadPoolExecutor | Threads in one process | No (with GIL); yes on 3.14t | Waiting: REST APIs, files |
multiprocessing / ProcessPoolExecutor | Processes with their own memory | Yes | Computing: simulations, optimizations |
asyncio | Coroutines in one thread | No | Many waits: websockets, several exchanges |
| Free-threading (3.14t) | Threads without GIL | Yes | Computing with shared memory |
Before all of this there is an option that does not appear in the table: vectorizing. A computation that NumPy or Polars performs on whole arrays (lessons 0.5.1 and 0.5.4) runs in C, is usually tens of times faster than a Python loop and often makes parallelism unnecessary.
All the examples in this lesson are deterministic: waits simulated with sleep, simulations with a seed and
results collected in the requested order. The order in which tasks finish, on the other hand, can change from one
run to the next, and no result should depend on it.
Threads to wait for the network
The function descargar simulates requesting the last close of an asset: it waits a quarter of a second, like an
HTTP call, and returns the December 31, 2025 closes from cartera_sint.csv. Four requests in a row
take four quarters of a second:
import time
CIERRES = {"RVAR": 164.35, "RFIJ": 102.29, "ORO": 171.44,
"TECH": 77.82}
def descargar(simbolo: str) -> tuple[str, float]:
"""Simulates an HTTP request: 0.25 s of network wait."""
time.sleep(0.25)
return simbolo, CIERRES[simbolo]
simbolos = list(CIERRES)
t0 = time.perf_counter()
secuencial = [descargar(s) for s in simbolos]
t_secuencial = time.perf_counter() - t0
print(secuencial)[('RVAR', 164.35), ('RFIJ', 102.29), ('ORO', 171.44), ('TECH', 77.82)]While descargar waits, the program does nothing. With threads, the four waits overlap.
concurrent.futures.ThreadPoolExecutor keeps a pool of threads and hands them the tasks; its map method works
like the regular map and returns the results in the order of the input, even if the tasks finish in another:
from concurrent.futures import ThreadPoolExecutor
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
en_hilos = list(pool.map(descargar, simbolos))
t_hilos = time.perf_counter() - t0
print(en_hilos == secuencial)
print("at least twice as fast:", t_hilos < t_secuencial / 2)True
at least twice as fast: TrueThe results are identical and the total time approaches that of a single download. The lesson does not print the
times because they change on every run; the comparison, with a wide margin, does not. The with closes the thread pool
on exit and waits for all the tasks to finish (lesson 0.4.3).
With a real API, the number of threads is limited by the provider, not by the computer: almost all data and broker APIs set a maximum number of requests per second, and exceeding it leads to 429 errors or a temporary ban.
Errors and order
submit launches a task and returns a future, an object that will hold the result when the task finishes.
future.result() delivers it or, if the task raised an exception, re-raises it in the thread that asks. Iterating over
the futures in the order they were launched keeps the output stable and lets you handle each error separately:
with ThreadPoolExecutor(max_workers=4) as pool:
futuros = {s: pool.submit(descargar, s)
for s in ["ORO", "XYZ", "TECH"]}
for simbolo, futuro in futuros.items():
try:
print(simbolo, futuro.result()[1])
except KeyError as e:
print(simbolo, "error:", repr(e))ORO 171.44
XYZ error: KeyError('XYZ')
TECH 77.82One failure does not bring down the other downloads. concurrent.futures.as_completed(futuros) would deliver the futures
as they finish, useful for showing progress, but in an order that can change on every run.
Shared state: Lock
Threads share the memory of the process. If several modify the same variable at once, one update can
overwrite another: exposicion += importe reads, adds and writes in separate steps, and another thread can slip in
between them. The bug is intermittent, depends on the load of the machine and almost never shows up in tests. The
protection is a threading.Lock: only one thread at a time runs the with cerrojo block:
import threading
exposicion = 0.0
cerrojo = threading.Lock()
def registrar_ejecucion(importe: float) -> None:
global exposicion
with cerrojo:
exposicion += importe
importes = [7_782.0, 17_144.0, -3_500.0, 10_229.0] * 2_500
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(registrar_ejecucion, importes))
print(f"{exposicion:,.2f}", f"{sum(importes):,.2f}")79,137,500.00 79,137,500.00With the lock, the total always matches the direct sum. The safest design, in any case, is not to share: have each task return its result and let the main thread add them up at the end, as in the downloads.
Processes to compute
Threads do not speed up computation in Python with the GIL (the Global Interpreter Lock): a lock inside the interpreter that lets only one thread at a time run Python code. Threads release it while they wait (which is why they work with the network), but not while they compute. To use several cores with pure Python code, the classic solution is several processes, each with its own interpreter, its own GIL and its own memory.
The example computation is a Monte Carlo simulation: for each seed, 2,000 simulated years of 252 sessions with normal returns, and the worst drawdown found. It is pure Python, without NumPy, on purpose:
import random
def peor_drawdown(semilla: int, anios: int = 2_000) -> float:
"""Worst drawdown over `anios` simulated years of 252 sessions."""
rng = random.Random(semilla)
peor = 0.0
for _ in range(anios):
valor = pico = 1.0
for _ in range(252):
valor *= 1 + rng.gauss(0.0004, 0.012)
pico = max(pico, valor)
peor = min(peor, valor / pico - 1)
return peor
semillas = [1, 2, 3, 4]
peores = [peor_drawdown(s) for s in semillas]
print([f"{p:.2%}" for p in peores])['-49.04%', '-44.49%', '-49.17%', '-48.19%']With a daily volatility of 1.2% (19% annualized), the worst of 2,000 simulated years loses between 44% and 49% from its peak depending on the seed: truly bad years are rare, but they exist. Each seed takes a fraction of a second; with hundreds of seeds, spreading the work across cores starts to matter.
With multiprocessing, a Pool of processes distributes the seeds and map returns the results in order. This is
where a change in Python 3.14 comes in: on Linux, the default start method went from fork to forkserver.1Note 1 · multiprocessing documentation in Python 3.14: “On POSIX platforms the default start method was changed
from fork to forkserver to retain the performance but avoid common multithreaded process incompatibilities”. On
macOS and Windows it is still spawn.
forkcopies the parent process as is, with all its functions and data. It is fast, but copying a process that has threads running can leave the child deadlocked, which is why it stopped being the default.forkserverstarts a clean, single-threaded server process at the beginning and creates the children from it. The children do not inherit the functions defined in the program: they receive them by name and import them, so they must live in an importable module.spawnstarts a new interpreter for each child. It is the default on Windows and macOS.
In a notebook, the functions defined in a cell are not in any importable file, so with forkserver
or spawn the pool fails. The way out in a notebook is to request fork explicitly with a context:
import multiprocessing as mp
print("default:", mp.get_start_method())
ctx = mp.get_context("fork")
with ctx.Pool(processes=4) as pool:
en_procesos = pool.map(peor_drawdown, semillas)
print(en_procesos == peores)default: forkserver
TrueSame results, because each simulation depends only on its seed, not on the process that runs it or on the moment it finishes. Without per-task seeds, each process would generate different numbers on every run.
In a script, the recommended form on any platform is the other one: the functions at the top level of the file
and the startup under if __name__ == "__main__":, so that the children can import the file without launching the
pool again. With ProcessPoolExecutor, the interface is the same as with threads:
from concurrent.futures import ProcessPoolExecutor
from montecarlo import peor_drawdown # the function, in its module
def main() -> None:
semillas = range(1, 33)
with ProcessPoolExecutor() as pool: # one process per core
peores = list(pool.map(peor_drawdown, semillas))
print(f"worst of {len(peores)} simulations: {min(peores):.2%}")
if __name__ == "__main__":
main()Processes have costs that threads do not: starting each one takes time, they do not share memory and everything
that goes in or out of them is serialized with pickle. They pay off when each task computes a lot and receives and
returns little, like a simulation that returns a number. Splitting a DataFrame of millions of rows across processes to
compute a sum is usually slower than doing it in one.
asyncio: many waits in a single thread
asyncio solves waiting in another way. Instead of several threads, a single thread runs an event loop that
switches between coroutines: functions defined with async def that, when they reach an await, yield control
while they wait. It is the basis of websocket clients and of the asynchronous APIs of almost every exchange, and of
systems that listen to many data streams at once (level 3).
Three fictitious exchanges, with different latencies, return the best buy (bid) and the best sell (ask) for BTC.
asyncio.gather launches the three queries at once and returns the results in the order of the arguments:
import asyncio
LIBROS = { # exchange: (latency in s, bid, ask), fictitious
"alfa": (0.30, 67_412.5, 67_415.0),
"beta": (0.10, 67_418.0, 67_421.5),
"gamma": (0.20, 67_409.0, 67_410.5),
}
async def mejor_precio(exchange: str) -> tuple[str, float, float]:
latencia, bid, ask = LIBROS[exchange]
await asyncio.sleep(latencia) # the network request
return exchange, bid, ask
async def consultar() -> list[tuple[str, float, float]]:
return await asyncio.gather(*(mejor_precio(e) for e in LIBROS))
cotizaciones = asyncio.run(consultar())
for c in cotizaciones:
print(c)
compra = min(cotizaciones, key=lambda c: c[2])
venta = max(cotizaciones, key=lambda c: c[1])
print(f"buy on {compra[0]} at {compra[2]:,.1f}, "
f"sell on {venta[0]} at {venta[1]:,.1f}")('alfa', 67412.5, 67415.0)
('beta', 67418.0, 67421.5)
('gamma', 67409.0, 67410.5)
buy on gamma at 67,410.5, sell on beta at 67,418.0beta responds first and alfa last, but gather returns the results in the requested order. The total takes as long
as the slowest query, not the sum of the three. Buying on gamma at 67,410.5 and selling on beta at 67,418 leaves
7.5 dollars per BTC before commissions, which in practice would eat all of it: it is the kind of calculation an
arbitrageur does, and the reason latency matters.
asyncio.run creates the loop, runs the coroutine and closes it: it is the entry point of an asynchronous program.
Jupyter and Marimo already have a loop running, and in them you write await consultar() directly in the cell.
A slow exchange should not delay the others. asyncio.timeout puts a time limit on a wait and raises
TimeoutError if it is exceeded:
LIBROS["delta"] = (2.0, 67_420.0, 67_423.0) # very slow
async def con_limite(exchange: str, limite: float = 0.5):
try:
async with asyncio.timeout(limite):
return await mejor_precio(exchange)
except TimeoutError:
return exchange, None, None
async def consultar_todos():
return await asyncio.gather(*(con_limite(e) for e in LIBROS))
for c in asyncio.run(consultar_todos()):
print(c)('alfa', 67412.5, 67415.0)
('beta', 67418.0, 67421.5)
('gamma', 67409.0, 67410.5)
('delta', None, None)delta is dropped after 0.5 s and the rest arrive in time. To launch tasks that must be cancelled together if one
fails, Python 3.11+ offers asyncio.TaskGroup, the structured way of doing the same as gather.
The GIL and GIL-free Python
For decades the GIL simplified the interpreter and C extensions, at the price of threads not being able to compute in parallel. PEP 703 proposed making it optional and was accepted on the condition of a gradual and reversible rollout.2Note 2 · PEP 703, status Final. The Steering Council accepted it “with clear proviso: that the rollout be gradual and break as little as possible”. Python 3.13 included an experimental build without the GIL, and in Python 3.14 that build became officially supported (PEP 779), although it remains optional: the Python installed by default has the GIL.3Note 3 · PEP 779, status Final, accepted on June 16, 2025; “What's New In Python 3.14”: “Free-threaded Python is officially supported” and “The performance penalty on single-threaded code in free-threaded mode is now roughly 5-10%” (https://docs.python.org/3.14/whatsnew/3.14.html). Making it the default is left for a future PEP.
The GIL-free build is called free-threading and its executable carries a t (python3.14t). With it, threads
run Python code in parallel, and a simulation like the one in this lesson scales with threads, without the costs of
processes. It has trade-offs: according to the release notes, single-threaded code runs between 5% and 10% slower,
and a C extension that does not declare compatibility turns the GIL back on when imported, with a warning.
From Python you can check which build you are using and whether the GIL is active:
import sys
import sysconfig
print(sys.version_info[:3])
print("free-threading build:",
bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL active:", sys._is_gil_enabled())(3, 14, 8)
free-threading build: False
GIL active: TrueThe course uses the regular build, with the GIL. To try the other one, uv installs both variants:4Note 4 · uv documentation, “Python versions”: uv installs free-threaded variants since CPython 3.13; on 3.14,
uv python install 3.14 still prefers the build with the GIL
(https://docs.astral.sh/uv/concepts/python-versions/).
uv python install 3.14t
uv run --python 3.14t python simular_hilos.py
PYTHON_GIL=1 uv run --python 3.14t python simular_hilos.py # GIL forced onWith free-threading, code that shares state between threads needs Locks more than ever: without the GIL, race
conditions that used to be rare become frequent.
What to use in each case
The decision tree, from top to bottom:
Mermaid source
flowchart TB
Q{Wait or compute?} -->|wait| N{Many at once?}
N -->|a few| T[Threads]
N -->|hundreds| A[asyncio]
Q -->|compute| V{Can it be vectorized?}
V -->|yes| NP[NumPy or Polars]
V -->|no| P[Processes]
P -.->|3.14t| H[GIL-free threads]To request prices for a few dozen symbols from a REST API, threads; to keep hundreds of websockets open, asyncio; for a computation that can be expressed with arrays, NumPy or Polars before anything else; for a pure Python computation that does not vectorize (simulations with bar-by-bar trading logic, optimizations), processes, or threads on a GIL-free build. And in every case, results that do not depend on the order in which the tasks finish.
Exercises
- Repeat the downloads with
max_workers=2. Do the results change? What do you expect to happen to the total time?
Solution
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=2) as pool:
dos_hilos = list(pool.map(descargar, simbolos))
t_dos = time.perf_counter() - t0
print(dos_hilos == secuencial)
print("slower than with 4 threads:", t_dos > t_hilos)True
slower than with 4 threads: TrueThe results do not change. With two threads, the four downloads happen in two batches: about 0.5 s, twice as long as
with four and half as long as in sequence. max_workers is the number of simultaneous requests, the parameter you
have to tune to the API limit.
- An exchange accepts at most two simultaneous requests. With
asyncio.Semaphore(2), queryalfa,betaandgammawithout exceeding two at a time and check that the result is the same as before.
Solution
async def consultar_con_limite(maximo: int = 2):
semaforo = asyncio.Semaphore(maximo)
async def una(exchange: str):
async with semaforo: # waits its turn if there are already `maximo`
return await mejor_precio(exchange)
nombres = ["alfa", "beta", "gamma"]
return await asyncio.gather(*(una(e) for e in nombres))
print(asyncio.run(consultar_con_limite()) == cotizaciones)TrueThe semaphore lets two coroutines through and makes the third wait until one finishes. It is the usual tool for respecting the request limits of an asynchronous API.
- With the process pool, run
peor_drawdownfor seeds 1 to 4 with 500 years instead of 2,000 (usepool.starmapwith(semilla, anios)pairs). Why is the worst drawdown less extreme?
Solution
with ctx.Pool(processes=4) as pool:
cortos = pool.starmap(peor_drawdown, [(s, 500) for s in semillas])
print([f"{p:.2%}" for p in cortos])['-49.04%', '-41.01%', '-43.26%', '-38.45%']starmap unpacks each tuple as arguments. With fewer simulated years there are fewer chances of hitting a bad
streak: the worst case over 500 years is at most as extreme as over 2,000, and almost always less. It is the same reason
a short backtest underestimates the drawdown a strategy can end up having.
Footnotes
-
multiprocessingdocumentation in Python 3.14: “On POSIX platforms the default start method was changed from fork to forkserver to retain the performance but avoid common multithreaded process incompatibilities”. On macOS and Windows it is stillspawn. ↩ -
PEP 703, status Final. The Steering Council accepted it “with clear proviso: that the rollout be gradual and break as little as possible”. ↩
-
PEP 779, status Final, accepted on June 16, 2025; “What's New In Python 3.14”: “Free-threaded Python is officially supported” and “The performance penalty on single-threaded code in free-threaded mode is now roughly 5-10%” (https://docs.python.org/3.14/whatsnew/3.14.html). ↩
-
uv documentation, “Python versions”: uv installs free-threaded variants since CPython 3.13; on 3.14,
uv python install 3.14still prefers the build with the GIL (https://docs.astral.sh/uv/concepts/python-versions/). ↩
Each answer is folded under its question
Self-assessment
You need the last price of 200 symbols from a REST API. Which tool fits best?
- A
multiprocessing, with one process per symbol - BA sequential loop: Python cannot do two things at once
- CThreads or asyncio: the task is waiting for the network, and while one thread waits the others can send their requests
- DNumPy
Show answer
Answer C. Threads or asyncio: the task is waiting for the network, and while one thread waits the others can send their requests
It is an I/O task: almost all the time goes into waiting for responses. During the wait the GIL is released, so the threads (or coroutines) overlap. Two hundred processes would cost far more memory with no benefit.
- A
In what order does
ThreadPoolExecutor.map(f, simbolos)return the results?- AIn the order of
simbolos, even if the tasks finish in a different order - BIn the order in which the tasks finish
- CAt random
- DSorted alphabetically
Show answer
Answer A. In the order of
simbolos, even if the tasks finish in a different ordermappreserves the order of the input.as_completed, in contrast, delivers the futures as they finish, an order that can change on every run.- AIn the order of
In Python 3.14 on Linux, what is the default start method of
multiprocessing?- A
fork - B
spawn - CIt depends on the CPU
- D
forkserver
Show answer
Answer D.
forkserverPython 3.14 changed the default from
forktoforkserveron Linux and the other Unix systems except macOS, which stays onspawn. The function sent to another process must be importable from a module;forkhas to be requested withget_context("fork").- A
What does free-threading mode (the
3.14tbuild) do?- ARuns asyncio coroutines in parallel
- BRemoves the GIL, so that several threads can run Python code in parallel on several cores
- CReplaces multiprocessing on every platform
- DSpeeds up single-threaded code
Show answer
Answer B. Removes the GIL, so that several threads can run Python code in parallel on several cores
Without the GIL, threads running pure Python code can use several cores. It has a cost: according to the 3.14 release notes, single-threaded code runs 5–10% slower, and some C extensions turn the GIL back on.
Key points
- First, the question: does the program wait (network, disk) or compute (CPU)? For waiting, threads or asyncio; for computing, NumPy, processes or, on 3.14t, GIL-free threads.
ThreadPoolExecutor.mapandasyncio.gatherreturn the results in the requested order. The completion order is not reproducible: do not depend on it.- State shared between threads is protected with a
Lock. Better still, have each task return its result and combine them at the end. - In Python 3.14
multiprocessingstarts withforkserveron Linux: the functions must live in an importable module and the script must run underif __name__ == "__main__":. - asyncio handles thousands of connections in a single thread, as long as nothing blocks the loop: a
time.sleepinside a coroutine stops everything.
Resources
Documentation
- Python 3.14 documentation: concurrent.futures
- Python 3.14 documentation: multiprocessing (start methods)
- Python 3.14 documentation: asyncio
- PEP 703: Making the Global Interpreter Lock Optional in CPython
- PEP 779: Criteria for supported status for free-threaded Python
- Python 3.14: Python support for free threading (HOWTO)