---
title: "0.4.6 Concurrency: threading, multiprocessing and asyncio"
description: "Parallel price downloads with threads, simulations across processes, several exchanges with asyncio, and what changes with GIL-free Python 3.14."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/python-concurrency-threading-multiprocessing-asyncio/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/python-concurrency-threading-multiprocessing-asyncio.png"
---

# 0.4.6 · 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.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.4 · Intermediate Python
- Duration: 55 min
- Prerequisites: [0.4.3 · Iterators, generators, decorators and context managers](https://pythonparatrading.com/en/python-trading-course/level-0/python-iterators-generators-decorators/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/)

## By the end of this lesson

- Tell I/O-bound tasks from CPU-bound tasks, and choose between threads, processes and asyncio.
- Download data in parallel with `ThreadPoolExecutor`, keep the order of the results and protect shared state with a `Lock`.
- Split a simulation across processes with `multiprocessing` and explain the `forkserver` start method of Python 3.14.
- Query several exchanges at once with `asyncio.gather` and timeouts, and explain what the GIL and free-threading mode are.

## 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:

```python
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)
```
```output
[('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:

```python
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)
```
```output
True
at least twice as fast: True
```

The 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:

```python
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))
```
```output
ORO 171.44
XYZ error: KeyError('XYZ')
TECH 77.82
```

One 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:

```python
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}")
```
```output
79,137,500.00 79,137,500.00
```

With 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:

```python
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])
```
```output
['-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`.[^1]

- `fork` copies 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.
- `forkserver` starts 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.
- `spawn` starts 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**:

```python
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)
```
```output
default: forkserver
True
```

Same 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:

```python skip title="simular.py"
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:

```python
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}")
```
```output
('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.0
```

`beta` 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:

```python
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)
```
```output
('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`.

<aside class="callout" data-kind="aviso">

In asyncio, nothing may **block** the loop. A `time.sleep(1)`, a request with `requests` or a long computation
inside a coroutine stops all the others: a websocket stops reading messages and the broker may close the connection
for inactivity. Waits go with `await` and an asynchronous library (`httpx`, `aiohttp`, `websockets`); blocking code
is sent to a thread with `await asyncio.to_thread(funcion, ...)`.

</aside>

## 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.[^2] 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.[^3]
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:

```python
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())
```
```output
(3, 14, 8)
free-threading build: False
GIL active: True
```

The course uses the regular build, with the GIL. To try the other one, uv installs both variants:[^4]

```bash
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 on
```

With free-threading, code that shares state between threads needs `Lock`s 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
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.

[^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`.
[^2]: PEP 703, status *Final*. The Steering Council accepted it “with clear proviso: that the rollout be gradual and
break as little as possible”.
[^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).
[^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/).

## Exercises

1. Repeat the downloads with `max_workers=2`. Do the results change? What do you expect to happen to the total time?

**Solution.**

```python
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)
```
```output
True
slower than with 4 threads: True
```

The 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.

2. An exchange accepts at most two simultaneous requests. With `asyncio.Semaphore(2)`, query `alfa`, `beta` and
   `gamma` without exceeding two at a time and check that the result is the same as before.

**Solution.**

```python
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)
```
```output
True
```

The 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.

3. With the process pool, run `peor_drawdown` for seeds 1 to 4 with 500 years instead of 2,000 (use
   `pool.starmap` with `(semilla, anios)` pairs). Why is the worst drawdown less extreme?

**Solution.**

```python
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])
```
```output
['-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.

## Self-assessment

1. You need the last price of 200 symbols from a REST API. Which tool fits best?

   - A. `multiprocessing`, with one process per symbol
   - B. A sequential loop: Python cannot do two things at once
   - C. Threads or asyncio: the task is waiting for the network, and while one thread waits the others can send their requests
   - D. NumPy

   *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.

2. In what order does `ThreadPoolExecutor.map(f, simbolos)` return the results?

   - A. In the order of `simbolos`, even if the tasks finish in a different order
   - B. In the order in which the tasks finish
   - C. At random
   - D. Sorted alphabetically

   *Answer A: In the order of `simbolos`, even if the tasks finish in a different order.* `map` preserves the order of the input. `as_completed`, in contrast, delivers the futures as they finish, an order that can change on every run.

3. In Python 3.14 on Linux, what is the default start method of `multiprocessing`?

   - A. `fork`
   - B. `spawn`
   - C. It depends on the CPU
   - D. `forkserver`

   *Answer D: `forkserver`.* Python 3.14 changed the default from `fork` to `forkserver` on Linux and the other Unix systems except macOS, which stays on `spawn`. The function sent to another process must be importable from a module; `fork` has to be requested with `get_context("fork")`.

4. What does free-threading mode (the `3.14t` build) do?

   - A. Runs asyncio coroutines in parallel
   - B. Removes the GIL, so that several threads can run Python code in parallel on several cores
   - C. Replaces multiprocessing on every platform
   - D. Speeds up single-threaded code

   *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.map` and `asyncio.gather` return 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 `multiprocessing` starts with `forkserver` on Linux: the functions must live in an importable module and the script must run under `if __name__ == "__main__":`.
- asyncio handles thousands of connections in a single thread, as long as nothing blocks the loop: a `time.sleep` inside a coroutine stops everything.

## Resources

### Documentation

- [Python 3.14 documentation: concurrent.futures](https://docs.python.org/3.14/library/concurrent.futures.html) · docs.python.org
- [Python 3.14 documentation: multiprocessing (start methods)](https://docs.python.org/3.14/library/multiprocessing.html#contexts-and-start-methods) · docs.python.org
- [Python 3.14 documentation: asyncio](https://docs.python.org/3.14/library/asyncio.html) · docs.python.org
- [PEP 703: Making the Global Interpreter Lock Optional in CPython](https://peps.python.org/pep-0703/) · peps.python.org
- [PEP 779: Criteria for supported status for free-threaded Python](https://peps.python.org/pep-0779/) · peps.python.org
- [Python 3.14: Python support for free threading (HOWTO)](https://docs.python.org/3.14/howto/free-threading-python.html) · docs.python.org

Previous lesson: [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/)

Next lesson: [0.5.1 · NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators](https://pythonparatrading.com/en/python-trading-course/level-0/numpy-arrays-and-vectorization/)
