WEEK 2

Concurrency & Async in Python

A live trading system does a dozen things at once on a single thread — and exactly one blocking call is enough to freeze all of them, mid-session, while the market moves.

Scoring begins this week Deliverable: async pipeline diagram + non-blocking bot A bot that blocks its loop misses fills
Part 1

1What is concurrency — and what is it not?

Concurrency is structure: many tasks in progress, interleaved. Parallelism is execution: many tasks running in the same instant. One chef juggling six pans is concurrent. Six chefs at six stoves are parallel. Python's asyncio gives you the first and never the second — and for a trading bot, the first is almost always what you actually needed.

I/O-bound: the CPU is idle, waiting

A socket read. A Yahoo poll. Waiting for an OrderAck. The process is parked doing nothing while bytes are in flight. Ninety-five percent of what your bot does all session is this — which is why interleaving wins: while one task waits, another runs, on the same core.

→ asyncio. Thousands of concurrent waits, one thread, no locks.

CPU-bound: the core is pinned

Fitting a model over 5 years of ticks. Sweeping 400 parameter combinations. Nobody is waiting on anything — the core is genuinely busy. Interleaving buys you nothing here, because there is no idle time to fill.

→ processes. Real cores, separate memory, no GIL.

Processes

Separate memory, real parallelism, sidesteps the GIL. Best for CPU-bound work. Costs: heavyweight to start, and you need IPC or shared files to move data. multiprocessing

Threads

Shared memory, pre-emptively scheduled by the OS. Fine for blocking I/O because the GIL is released during the wait. No speedup at all for CPU-bound Python. Costs: races and locks. threading

Async / asyncio

One thread, cooperative scheduling, thousands of tasks. Best for high-volume I/O. Cost: nothing may block the loop, ever. async/await

The GIL, in one paragraph

CPython holds a Global Interpreter Lock: only one thread executes Python bytecode at a time. Four threads crunching numbers therefore take as long as one thread doing all four jobs — the lock serialises them. But the GIL is released around blocking calls: a socket read, a disk read, an HTTP request. That is why threads still help for I/O, and why the arena's one remaining thread is the blocking yfinance poller.

# 4 threads, CPU-bound — NO speedup (the GIL serialises them)
from threading import Thread
def crunch(n):
    while n: n -= 1        # pure Python bytecode
ts = [Thread(target=crunch, args=(10**7,)) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]

Coroutines: functions that can pause

An async def function is a coroutine. Calling it does not run it — you get a coroutine object. Only await (or the loop, via a Task) drives it, and every await is a point where it may hand control back so something else can run.

async def fetch_quote(symbol):        # a coroutine
    await asyncio.sleep(0.2)          # pause here; loop runs others
    return {'sym': symbol, 'px': 185.5}

q = fetch_quote('AAPL')  # NOT run — a coroutine object
q = await fetch_quote('AAPL')  # now it runs

await asyncio.gather(              # ~3s total, not 6s
    worker('feed', 3), worker('quote', 2), worker('order', 1))

Interactive · the event loop, one step at a time

Four coroutines from a real trader: a feed reader, your strategy, an order/fill handler and a heartbeat. Press Step to advance the loop by one tick. Watch tasks move ready → running → waiting and back. Only one box is ever running: that is the whole idea, and the whole danger.

READY QUEUE RUNNING (one, always) WAITING ON I/O LOOP TIMELINE — who held the thread
t = 0
loop ticks
0
thread busy
0%
ticks handled
0

What just happened

Every green await is a decision to give up the thread. A coroutine that never awaits is a coroutine that has taken the whole system hostage.
Part 2

2How do you choose? Run the workload and see

The decision is not a matter of taste — it falls out of two numbers: how much time each unit of work spends computing, and how much it spends waiting. Set those two sliders and the four models below stop being an argument and become a bar chart.

Presets:

Workload

Verdict

—

profile
I/O-bound
best speedup
1×
The model is deliberately crude: CPU work is serialised by the GIL for threads and asyncio, waits overlap, and processes pay a real spawn cost (~15 ms each). Crude is enough — the shape of the answer never changes.

AlgoArena is async all the way down

  • Every process is an asyncio program. Exchange, brokers, traders — all networking is async over WebSockets, no blocking calls on the loop.
  • on_tick is a loop callback. The engine reads the socket, decodes the message and calls your hook. Return fast.
  • gather, not threads. Concurrent tasks are coroutines the loop interleaves: feed, orders, housekeeping.
  • Catch ConnectionClosed. Sockets drop. Your bot must reconnect, not crash — this is graded.

The one thread we keep, and why

yfinance is blocking HTTP with no async API. Put it on the loop and every quote, order and fill in the process stops for the duration of the request. So the broker runs it on its own thread and hands prices to the async side through shared state.

# broker/broker.py — yfinance is blocking HTTP
import threading, yfinance as yf

def poll_yahoo(symbols, out):      # runs on its OWN thread
    while True:
        px = yf.download(symbols)  # BLOCKING — cannot be on the loop
        out.update(px)             # hand to the async side

threading.Thread(target=poll_yahoo, daemon=True).start()
# the async quoting logic reads 'out'; the loop never blocks

The pipeline you will diagram this week

   [ yfinance thread ]        ══════ ONE asyncio event loop ══════
    (blocking HTTP)
          │  prices        ┌─────────┐   asyncio.Queue   ┌─────────┐
          └─────────────▼ │   feed    │ ───────────────►  │ strategy  │
                             │  (async)  │      ticks        │ on_tick() │
  WebSocket in ──────────► └─────────┘                   └─────┬─────┘
                                                                 │ Signal
  WebSocket out ◀────────  orders / acks / fills  ◀──────┘

One loop, many tasks: the feed, the strategy and the socket I/O are concurrent coroutines. The only thread in the picture is the blocking yfinance poller.

Part 3

3Why does this matter in trading?

Because a frozen loop is not a slow bot — it is a blind bot. While your coroutine sits inside time.sleep() or requests.get(), nothing reads the socket. Book updates queue up in the kernel, your view of the market goes stale, your resting orders get picked off at prices you would never have quoted, and the fills you do get land on decisions made a second ago. Scoring starts this week; this is where the points go.

Interactive · one blocking call, live

The tape below is a market feed pushing a tick every 100 ms. Both buttons "take 1.5 seconds to compute a signal". One of them awaits. Try the other one.

AAPL · incoming ticks (one every 100 ms)
ticks received
0
ticks missed
0
worst staleness
0 ms

State

Loop is running. Both tasks are alive.

# WRONG — freezes EVERY task on the loop
async def on_tick(...):
    time.sleep(1.5)                 # blocks all tasks
    r = requests.get(url)         # blocking HTTP

# RIGHT — yield, or offload to a thread
async def on_tick(...):
    await asyncio.sleep(1.5)        # non-blocking
    loop = asyncio.get_running_loop()
    r = await loop.run_in_executor(None, requests.get, url)

Interactive · back-pressure, or how a queue saves you

In a fast market the feed produces faster than your strategy consumes. What happens next is a design decision, and all three answers below are used in production systems. Set the rates, then switch the policy.

PRODUCER asyncio.Queue CONSUMER QUEUEING DELAY (what your strategy sees as "the market")
Policy:
depth
0
delay
0 ms
stalls / drops
0
—

Races: why async is safer, and where it still is not

On one loop, one task runs at a time and only yields at an await — so a plain read-modify-write between awaits is atomic, and most of the classic thread-race bugs simply cannot happen. What can still happen: state changing across an await. If you read a position, await a send, then write the position back, something else ran in between and you have just clobbered it.

  • asyncio.Lock — guard a critical section that spans awaits.
  • threading.Lock — required for genuinely shared data across real threads.
  • Better: do not share mutable state at all. Hand data between tasks through an asyncio.Queue.

This week's code: offload, do not block

import asyncio
from arena import Trader, Signal

class MyBot(Trader):
    async def on_tick(self, market, portfolio):
        ask = market.best_ask('AAPL')
        if ask is None:
            return None
        loop = asyncio.get_running_loop()
        # offload a blocking signal calc — never block the loop
        score = await loop.run_in_executor(None, heavy_calc, ask)
        if score > 0:
            return Signal('AAPL', 'buy', 1, ask)
        return None

Verify it by watching fills keep arriving while heavy_calc runs. If they stop, you did not offload — you moved the freeze.

Part 4 · deck slides 13–23

4Threads and processes: what the loop hands work to

The loop is the default, but not the whole system. A blocking library with no async API goes on a thread; CPU-heavy work goes to a process. Here is what each one really buys you, measured. Every number marked measured comes from the course’s week2_demos/*.py and Quizzes 2 and 3, run on 2026-10-09 (10-core M-series Mac, Python 3.14.7). Your laptop will give different absolute times and the same ratios.

4a · Why threads exist, and what the GIL means

Threads exist so you can wait in parallel. CPython has one Global Interpreter Lock per interpreter, so one thread executes Python bytecode at any instant. A thread that blocks (a socket read, an HTTP call, time.sleep, and many C kernels in numpy, zlib or hashlib) releases the GIL, so the others run while it waits.

  • The interpreter switches threads every 5 ms (sys.getswitchinterval()). That is pre-emptive and can land mid-function.
  • The GIL protects the interpreter, not your data. It does not make a three-step update atomic (4b).
  • The free-threaded 3.13t/3.14t builds remove it. They are not the default build you run.

gil.py measured

4 tasksserial4 threadsspeedup
wait: time.sleep(0.5)2.02 s0.51 s4.0×
crunch: while n: n -= 10.91 s0.93 snone
def crunch(n):                  # CPU-bound: pure Python bytecode
    while n: n -= 1
def wait(s):                    # I/O-bound: blocks with the GIL released
    time.sleep(s)
def threaded(fn, arg):
    ts = [threading.Thread(target=fn, args=(arg,)) for _ in range(4)]
    [t.start() for t in ts]; [t.join() for t in ts]

Interactive · slide from I/O-bound to CPU-bound

Each task takes 0.5 s: first it waits (I/O, GIL released), then it computes (GIL held). The lanes are a real scheduling simulation, not a formula. Threads take tasks from a shared list, only one thread holds the GIL at a time, the holder gives it up after 5 ms of compute, and each handoff costs 0.1 ms. Move the mix to the right and watch the extra threads turn red: they are queueing for the lock.

1 thread—
N threads—

Timeline · N threads

holds the GIL (computing) waiting on I/O (GIL released) ready, but queued for the GIL
I/O-boundCPU-bound
speedup
—
GIL handoffs
—
—

4b · The race condition: Quiz 2’s bank account

Alice deposits $1 and Bob withdraws $1, at the same time, on the same account. The balance should end where it started. deposit is three steps: read the ledger, write the receipt, write the ledger. If a thread switch lands between the read and the write, the other thread’s updates are overwritten by a stale value. The GIL allows this: it makes one bytecode atomic, and this invariant is three statements long. Even balance += 1 is three bytecodes (load, add, store).

def deposit(self, amount):
    new = self.balance + amount      # 1. read the ledger
    self._write_receipt(new)         # 2. a thread switch can land here
    self.balance = new               # 3. write: overwrites the other's
def safe_deposit(self, amount):
    with self.lock:                  # 1-2-3 as one critical section
        new = self.balance + amount
        self._write_receipt(new); self.balance = new

What Quiz 2 measured measured

  • Full pq2, 10 million each way: +2,478,125 · +1,421,558 · +1,271,067. Expected 0.
  • bank_race.py, 1 million each way: +218,338 · +120,729 · −210,327. A different wrong answer every run, which is what makes it a race.
  • With the Lock: +0, every time. The run went from 1.3 s to 2.1 s, so put the lock around the three lines and not around the whole loop.
  • The trap: withdraw() calls deposit(). If both take a plain Lock, Bob deadlocks on his first withdrawal. Take the lock in one place, or use an RLock.
  • “I ran it three times and it was fine” is not a test. Races can pass by luck.

Interactive · run the race

Each run has Alice depositing $1 and Bob withdrawing $1, 1,000 times each. In this model a thread switch can land after any step, with a 2% chance per step (the real interpreter switches every 5 ms). On the left is the first point in the last run where the interleaving mattered. On the right are the final balances over every run so far.

Interleaving · last run

runs
0
last balance
—
exactly $0
—
worst drift
—

Final balance over all runs

Press Run once or Run 1000 times.

4c · Threads vs processes: Quiz 3’s dot product

Two vectors of 20 million floats and a dot product, which is pure arithmetic. Each worker rebuilds its own slice from (seed, n), so the 160 MB of lists never has to be sent to it. Solid bars are measured. Hatched bars are a model fitted to those measurements, so treat them as the shape of the curve and not as data.

sequential (measured; dashed line) measured model
each worker gets:
sequential
1.85 s
this config
—
vs sequential
—
—

Pickling: how work reaches another process

Processes share nothing. A child receives your function by name and your data by pickle, and results come back the same way. A module-level function pickles to its module and its name, nothing else, and the child re-imports the module to find it.

pickles
  • module-level functions
  • ints, floats, str, tuples, lists, dicts
  • numpy arrays (copied)
PicklingError
  • lambdas, nested functions
  • sockets, open files
  • locks, event loops
def square(x):                     # module level: pickled BY NAME
    return x * x
if __name__ == "__main__":          # REQUIRED: spawn re-imports this file
    pickle.dumps(square)    # b'...__main__...square...'
    pickle.dumps(lambda x: x * x)     # PicklingError
    ctx = mp.get_context("spawn")  # choose explicitly
    with ctx.Pool(2) as pool:
        pool.map(square, range(6))  # [0, 1, 4, 9, 16, 25]

Start methods: how the child process begins

  • spawn starts a fresh interpreter that re-imports your module. It is the default on macOS (since 3.8) and on Windows. pickling.py printed darwin 3.14.7 spawn.
  • fork copies the parent instantly, including any lock another thread was holding at that moment. It was the Linux default up to 3.13, and 3.12+ warns if threads exist.
  • forkserver is the Linux default from 3.14.
  • The __main__ guard is not optional under spawn. Without it every child re-runs your Pool(...) line. On 2026-10-09 a four-line script with no guard produced 724 tracebacks in 8 s and never finished. What you see is a script that hangs, not a clear error.
  • Pick the method with mp.get_context(...) so the code behaves the same on your Mac and on the Linux server. “Works on my Mac, hangs on Linux” is often fork copying a lock that another thread held.
  • A pool takes about 0.1–0.3 s to start. Create it once and reuse it; never start one per tick.

4d · Which model where, in a trading system

Choose per component, based on where its time goes. Most of a trading system waits, and only a small part of it computes.

ComponentWhere the time goesUseWhy
Feed handler (WS in)waiting on the socket, 1000s of msgsasyncioone loop, no locks: the arena’s listen()
Reference price pollblocking HTTP, no async API (yfinance)a threadthe broker’s yahoo-feed thread
Signal, light (z-score)microseconds of numpyinline in on_ticka process round-trip costs more than the math
Signal, heavy (fit, backtest)CPU, secondsprocess poolown GIL, own core; results come back pickled
Order gateway (WS out, acks)waiting; ORDER mattersasyncio, one taskone writer keeps the sequence; never split it
Logs / disk / DB writesblocking I/Oto_thread or a writer threadkeeps the loop clean
Default: asyncio. Use a thread when a library blocks. Use a process when the CPU is the bottleneck, and only after you have measured that it is.

Thread ↔ loop handoff: handoff.py

import asyncio, threading, time
prices = {}                                # broker: self.state.yahoo_prices
def heavy_calc():                          # blocking work the LOOP asks for
    time.sleep(0.3); return 42
async def main():
    loop = asyncio.get_running_loop()
    q = asyncio.Queue()
    def poll():                            # the "yahoo-feed" thread
        for i in range(3):
            time.sleep(0.1)                # yf.Ticker(...): blocking HTTP
            prices["AAPL"] = 100.0 + i     # PULL: plain dict write
            loop.call_soon_threadsafe(q.put_nowait, i)   # PUSH: wake loop
    threading.Thread(target=poll, daemon=True, name="yahoo-feed").start()
    t0 = time.perf_counter()
    score = await asyncio.to_thread(heavy_calc)          # loop → thread
    got = [await q.get() for _ in range(3)]
    print(score, got, prices, f"{time.perf_counter() - t0:.2f}s")
asyncio.run(main())
# → 42 [0, 1, 2] {'AAPL': 102.0} 0.31s

Three safe crossings

  • PULL (what the broker does): the thread writes state.yahoo_prices[sym] = price and the loop reads it at the next requote. A single dict assignment is atomic under the GIL, so a float needs no lock.
  • PUSH: loop.call_soon_threadsafe(q.put_nowait, x) is the only safe way for a thread to put something into the loop. asyncio.Queue is not thread-safe, so never call it directly from a thread.
  • Loop → thread: await asyncio.to_thread(fn) runs fn on the loop’s default ThreadPoolExecutor and returns the result to your coroutine. It is the modern way to write run_in_executor(None, fn).
  • 0.31 s: the 0.3 s calculation and the 0.3 s of polls ran at the same time instead of one after the other.
to_thread does not make slow code fast. It moves the slowness off the loop. If that code is CPU-bound pure Python, it still holds the GIL and still slows the loop down, so send it to a process instead.
Part 5

5Five ways this goes wrong

Four of these five will cost you fills in a live session. Open them.

time.sleep() or requests.get() inside a coroutine

The canonical mistake, and it is silent: no exception, no warning, just a bot that is inexplicably worse than it was in the backtest. Any blocking call inside async def stops the entire loop — feed, orders, heartbeat, everything.

It hides in innocent places: a pandas.read_csv on a big file, a print() to a slow terminal, a synchronous yfinance call you added "just to check something".

Fix: await asyncio.sleep() for delays, an async HTTP client for requests, and await loop.run_in_executor(None, fn, *args) for anything blocking you cannot replace.

Forgetting to await, and getting a coroutine object

result = self.compute_signal() where compute_signal is async def gives you a coroutine object, not a number. It is truthy, so if result: is always true, and Python only mutters "RuntimeWarning: coroutine was never awaited" if you happen to be watching stderr.

The mirror-image bug: asyncio.create_task(coro) and then dropping the reference. The task can be garbage-collected mid-flight and its exception disappears with it.

Fix: await it, or keep the task in a set. Run with PYTHONASYNCIODEBUG=1 during development and treat the warnings as errors.

An unbounded queue as a memory leak with extra steps

asyncio.Queue() with no maxsize never applies back-pressure. A fast feed and a slow strategy means the queue grows until the process dies — and long before it dies, your strategy is acting on ticks that are ten seconds old.

Stale is worse than missing. A book snapshot from 8 seconds ago is not "some information"; it is actively wrong information you are about to send an order against.

Fix: bound it (maxsize=100) so put() waits, or coalesce — keep only the newest snapshot per symbol and throw the rest away.

Reaching for threads to make CPU-bound work faster

Four threads fitting four models is exactly as slow as one thread fitting four models, because the GIL lets only one of them execute bytecode at a time. You paid for locks, races and debugging and got nothing.

The exception that confuses everyone: NumPy, pandas and scikit-learn release the GIL inside their C loops, so threads sometimes do help. That is a property of the library, not of your code — do not plan around it.

Fix: CPU-bound → multiprocessing or ProcessPoolExecutor. Threads are for blocking I/O with no async API.

Letting ConnectionClosed kill the bot

Networks blink. Wi-Fi hiccups, the exchange restarts, a laptop lid closes. An uncaught websockets.exceptions.ConnectionClosed ends your session and your score stops accumulating at whatever it was — often with a position still open.

Reconnecting badly is its own trap: a tight retry loop hammers a recovering server, and resuming without re-syncing means your book is a fiction.

Fix: catch it, reconnect with exponential backoff, re-handshake, and request a fresh snapshot rather than assuming continuity. Then flatten or re-assert your risk state.

Part 6

6Check yourself

Five questions, instant feedback, nothing recorded.

Q1Your on_tick calls a library function that blocks for 800 ms. What happens to the feed-reader coroutine on the same loop?

Q2You need to fit the same model on 40 different parameter sets. Each fit is 3 seconds of pure NumPy-free Python arithmetic. Which model?

Q3Your feed produces 400 ticks/s; your strategy consumes 90/s. Which change keeps the bot both alive and useful?

Q4Quiz 2, ten million deposits and ten million withdrawals of $1 on two threads, ended at +2,478,125. Why?

Q5Quiz 3 ran sequentially in 1.85 s. Which version is fastest, and what does it depend on?

Part 7

7Do the work

In class

  • Diagram your async pipeline: feed → asyncio.Queue → strategy → orders.
  • Make on_tick non-blocking — no time.sleep, no requests; offload heavy work.
  • Concurrency and async design sign-off, plus the Phase-2 UML kickoff.
  • Handle websockets.exceptions.ConnectionClosed gracefully.
  • Push the W2 feature branch (pipeline diagram + async bot).

Before next class

  • Read trader/trader.py — the real async loop underneath on_tick.
  • Read broker/broker.py — the yfinance polling thread.
  • Confirm your bot never blocks the loop (try PYTHONASYNCIODEBUG=1).
  • Come ready to defend the pipeline design. Scoring has begun.
Code
Get the code
Labs and homework live in your course’s LMS; the starter repo is public. Use it as a template, clone, make install.
Previous
← Week 1 · UML & OOP
The objects you are now making concurrent.
Next
Week 3 · Sockets & Protocols →
What is actually on the wire under that loop: the seven layers, UDP vs TCP, sockets, FIX/ITCH/OUCH, then WebSocket.