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.
await, suspension, resume.on_tick non-blocking — offload blocking work with run_in_executor and bound your queues so back-pressure works for you.Lock, and say when processes beat threads, using measured numbers.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.
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.
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.
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
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
One thread, cooperative scheduling, thousands
of tasks. Best for high-volume I/O. Cost: nothing may block the loop, ever.
async/await
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]
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))
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.
await is a decision to give up the thread. A coroutine that
never awaits is a coroutine that has taken the whole system hostage.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.
—
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.ConnectionClosed. Sockets drop. Your bot must reconnect, not crash — this is graded.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
[ 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.
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.
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.
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)
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.
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.asyncio.Queue.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.
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.
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.
sys.getswitchinterval()). That is pre-emptive and can land mid-function.| 4 tasks | serial | 4 threads | speedup |
|---|---|---|---|
| wait: time.sleep(0.5) | 2.02 s | 0.51 s | 4.0× |
| crunch: while n: n -= 1 | 0.91 s | 0.93 s | none |
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]
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.
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
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.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.
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.
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.
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]
pickling.py printed darwin 3.14.7 spawn.__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.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.Choose per component, based on where its time goes. Most of a trading system waits, and only a small part of it computes.
| Component | Where the time goes | Use | Why |
|---|---|---|---|
| Feed handler (WS in) | waiting on the socket, 1000s of msgs | asyncio | one loop, no locks: the arena’s listen() |
| Reference price poll | blocking HTTP, no async API (yfinance) | a thread | the broker’s yahoo-feed thread |
| Signal, light (z-score) | microseconds of numpy | inline in on_tick | a process round-trip costs more than the math |
| Signal, heavy (fit, backtest) | CPU, seconds | process pool | own GIL, own core; results come back pickled |
| Order gateway (WS out, acks) | waiting; ORDER matters | asyncio, one task | one writer keeps the sequence; never split it |
| Logs / disk / DB writes | blocking I/O | to_thread or a writer thread | keeps the loop clean |
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
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.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.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).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.Four of these five will cost you fills in a live session. Open them.
time.sleep() or requests.get() inside a coroutineThe 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.
await, and getting a coroutine objectresult = 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.
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.
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.
ConnectionClosed kill the botNetworks 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.
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?
asyncio.Queue → strategy → orders.on_tick non-blocking — no time.sleep, no requests; offload heavy work.websockets.exceptions.ConnectionClosed gracefully.trader/trader.py — the real async loop underneath on_tick.broker/broker.py — the yfinance polling thread.PYTHONASYNCIODEBUG=1).make install.