σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
uantCraft AcademyRoadmapsSkills
Ctrl K
AO
+1.2k Learners
LoginJoin ↗
RoadmapsSkills
AO
+1.2k Learners
Login
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
σμimplied vol · KKmax(S−K,0)udβρᵢⱼfat tailsPnL · drawdownyield · TdSₜ = μSₜ dt + σSₜ dWₜC = S·N(d₁) − K·e⁻ʳᵀ·N(d₂)σₚ² = wᵀΣwd₁ = [ln(S/K) + (r + σ²⁄2)T] ⁄ σ√T∂V/∂t + ½σ²S²·∂²V/∂S² + rS·∂V/∂S − rV = 0Sharpe = (Rₚ − R_f) ⁄ σₚVaR₉₉ = μ − 2.33σρ = Cov(X,Y) ⁄ σₓσᵧE[Rₚ] = Σ wᵢ·E[Rᵢ]φ(x) = e^(−x²/2) ⁄ √2πE[Rᵢ] = R_f + βᵢ(E[Rₘ] − R_f)dXₜ = θ(μ − Xₜ)dt + σ dWₜσ²ₜ = ω + α·ε²ₜ₋₁ + β·σ²ₜ₋₁C − P = S − K·e⁻ʳᵀΔ = ∂V/∂S Γ = ∂²V/∂S²p = (e^(rΔt) − d) ⁄ (u − d)rₜ = ln(Pₜ ⁄ Pₜ₋₁)E[Xₜ₊₁ | ℱₜ] = Xₜf* = (bp − q) ⁄ bz = (x − μ) ⁄ σP(N=k) = λᵏe^(−λ) ⁄ k!Θ = −∂V/∂Tν = S·φ(d₁)·√Tmin wᵀΣw s.t. wᵀμ = μ*∫Σ∂∞λ
uantCraft AcademyRoadmapsSkills
Ctrl K
AO
+1.2k Learners
LoginJoin ↗
RoadmapsSkills
AO
+1.2k Learners
Login
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
Lock-free skill›Module 1 · Races and the explorer›Topic 1.1·~45 minIntermediate

Races and why locks exist

Skill positionModule 1 of 4 · Topic 1 of 2
1.1 Races and why locks exist
—Lab score— / 100Total XP—Streak—

5 min readCode examples: 4

  1. 01One line, three steps
  2. 02A schedule is a list of thread numbers
  3. 03A lock makes the steps one block
  4. 04What you will build

Two threads add 1 to the same counter, and the counter ends up wrong, with no error and no crash. The line x += 1 is not one step but three, and the order in which two threads interleave those steps decides the answer. On 1,085 up days of SPY, 542 updates vanish; a lock brings them all back.

01

One line, three steps

A processor cannot add to a value that lives in memory. It loads the value into a register, adds to the register, and stores the register back. So x += 1 is load x, add 1, store x. One thread alone does the three in a row and nothing can go wrong. With two threads, the machine may run a step of one, then a step of the other, in any order. To see this we write a tiny machine. A thread is a list of steps, each with its own register; the threads share a dictionary of named values. The function execute runs one step of one thread. Below we play the six steps of two threads by hand, one step each in turn.

main.pyPython
def execute(op, shared, regs, locks, tid):    kind = op[0]    if kind == "load":        regs[tid] = shared.get(op[1], 0)    elif kind == "add":        regs[tid] += op[1]    elif kind == "store":        shared[op[1]] = regs[tid]    elif kind == "lock":        if op[1] in locks:            return False        locks[op[1]] = tid    elif kind == "unlock":        del locks[op[1]]    return Trueshared, regs, locks = {"x": 0}, {0: 0, 1: 0}, {}turns = [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]1for tid, step in turns:    execute(INCREMENT[step], shared, regs, locks, tid)    print("thread", tid, INCREMENT[step], "->", shared, regs)
thread 0 ('load', 'x') -> {'x': 0} {0: 0, 1: 0}
thread 1 ('load', 'x') -> {'x': 0} {0: 0, 1: 0}
thread 0 ('add', 1) -> {'x': 0} {0: 1, 1: 0}
thread 1 ('add', 1) -> {'x': 0} {0: 1, 1: 1}
thread 0 ('store', 'x') -> {'x': 1} {0: 1, 1: 1}
thread 1 ('store', 'x') -> {'x': 1} {0: 1, 1: 1}
  1. 1Both threads load before either stores: each register holds 0.

Read the output line by line. Thread 0 loads 0, thread 1 loads 0 as well (thread 0 has not stored yet), both add 1 to a register that holds 0, and both store 1. Two threads each did their x += 1, yet x is 1, not 2. One update was lost. Nothing was wrong with either thread's code; the bug exists only in this order of steps.

DefinitionRace condition

A bug whose result depends on the order in which threads interleave their steps. The same program is right under some schedules and wrong under others, so the bug is easy to miss in testing.

02

A schedule is a list of thread numbers

In the real world the schedule is chosen by the operating system and changes from run to run, which is why a race is so hard to reproduce. In our machine we choose it. A schedule is a list of thread numbers, and run gives the thread named by each entry one turn. Two simple schedules matter. In the serial schedule thread 0 runs all its steps, then thread 1: it is what we hope for. In the round-robin schedule the threads take one step each in turn: it is the worst case for our counter, because the steps of the two threads overlap everywhere. The function lost_updates compares what the increments should have added with what the counter shows.

main.pyPython
def run(programs, schedule, shared=None):    shared = dict(shared or {})    regs = {tid: 0 for tid in range(len(programs))}    pcs = [0] * len(programs)    locks = {}    for tid in schedule:        if pcs[tid] < len(programs[tid]) and execute(programs[tid][pcs[tid]], shared, regs, locks, tid):            pcs[tid] += 1    while any(pcs[t] < len(programs[t]) for t in range(len(programs))):        progress = False        for tid in range(len(programs)):            if pcs[tid] < len(programs[tid]) and execute(programs[tid][pcs[tid]], shared, regs, locks, tid):                pcs[tid] += 1                progress = True        if not progress:            raise RuntimeError("deadlock")    return shareddef serial_schedule(programs):    return [tid for tid, program in enumerate(programs) for _ in program]def round_robin_schedule(programs):    longest = max((len(program) for program in programs), default=0)    return [tid for step in range(longest) for tid, program in enumerate(programs) if step < len(program)]def lost_updates(programs, schedule, name="x"):    expected = sum(op[1] for program in programs for op in program if op[0] == "add")    return expected - run(programs, schedule).get(name, 0)two = [INCREMENT, INCREMENT]print(serial_schedule(two), run(two, serial_schedule(two)))print(round_robin_schedule(two), run(two, round_robin_schedule(two)))print("lost:", lost_updates(two, round_robin_schedule(two)))
[0, 0, 0, 1, 1, 1] {'x': 2}
[0, 1, 0, 1, 0, 1] {'x': 1}
lost: 1

The serial schedule [0, 0, 0, 1, 1, 1] gives 2; the round-robin schedule [0, 1, 0, 1, 0, 1] gives 1 and loses one update. Notice what run does when the schedule ends early: the unfinished threads keep taking turns, one step each in thread order, so every program always finishes. Also notice that a finished thread ignores its turns, so any list of thread numbers is a valid schedule.

03

A lock makes the steps one block

A lock fixes the race by forbidding the overlap. A thread takes the lock before load, and releases it after store. A second thread that asks for the lock in between cannot get it: its turn does nothing, and it stays at the same step until the lock is free. The steps between lock and unlock therefore run as one block, and no other thread can slip in. The function counter_program builds the program of a thread that increments a counter, with or without the lock. Locks bring a new danger, shown at the end of the code: two threads that take two locks in opposite orders can each hold the lock the other needs, and neither can ever move. That is a deadlock, and our run detects it when a whole pass makes no progress.

main.pyPython
def counter_program(increments, locked=False):    program = []    for _ in range(increments):        program += ([("lock", "m")] if locked else []) + INCREMENT + ([("unlock", "m")] if locked else [])    return programsafe = [counter_program(1, locked=True), counter_program(1, locked=True)]print(counter_program(1, locked=True))print(run(safe, round_robin_schedule(safe)))ab = [[("lock", "a"), ("lock", "b"), ("unlock", "b"), ("unlock", "a")],      [("lock", "b"), ("lock", "a"), ("unlock", "a"), ("unlock", "b")]]try:    run(ab, [0, 1])except RuntimeError as error:    print("error:", error)
[('lock', 'm'), ('load', 'x'), ('add', 1), ('store', 'x'), ('unlock', 'm')]
{'x': 2}
error: deadlock
A lock is not free

A thread that waits on a lock does nothing, and the thread that holds it can be paused by the operating system at any step. On a trading hot path that means jitter you cannot bound. The rest of this skill builds structures that need no lock at all.

main.pyPython
def study(bars):    returns = [float(r) for r in bars["close"].pct_change().dropna()][:2000]    ups = [sum(1 for r in returns[start::2] if r > 0) for start in (0, 1)]    free = [counter_program(k) for k in ups]    locked = [counter_program(k, locked=True) for k in ups]    return {"up": sum(ups),            "serial": run(free, serial_schedule(free))["x"],            "racy": run(free, round_robin_schedule(free))["x"],            "locked": run(locked, round_robin_schedule(locked))["x"]}print(study(load_bars()))
{'up': 1085, 'serial': 1085, 'racy': 543, 'locked': 1085}
What the real data says2,000 SPY daily returns, two threads counting up days, round-robin schedule
Up days to count1,085of 2,000 days
Serial schedule1,085x at the end: all counted
Round robin, no lock543x at the end: 542 updates lost
Round robin, with a lock1,085x at the end: nothing lost
Final value of the counter by schedule
Serial1085
Round robin, no lock543
Round robin, with a lock1085

With no lock, the round-robin schedule keeps both threads one step apart, so they always load the same value and each pair of increments counts once: about half of the 1,085 up days are lost. A lock restores the exact total, at the price of making one thread wait.

04

What you will build

The lab follows the lecture: one step of a thread, the whole machine under a schedule, the two simple schedules, the count of lost updates, the program of a counter thread with and without a lock, and the study of two threads counting the up days of SPY.

  • Function · questionBuilds on
  • execute(op, shared, regs, locks, tid)What does one step do, and does the thread have to wait?Plain arithmetic
  • run(programs, schedule, shared)What do the threads leave behind under this schedule?execute
  • serial_schedule(programs)One thread after the other?Plain arithmetic
  • round_robin_schedule(programs)One step each in turn?Plain arithmetic
  • lost_updates(programs, schedule)How many increments vanish?run
  • counter_program(increments, locked)What does a counting thread do?Plain arithmetic
  • study(bars)How many updates does a race cost on SPY?counter_programrunlost_updates

Key takeaways

  • `x += 1` is three steps. Load, add, store: if two threads load before either stores, one update is lost, with no error to warn you.
  • A bug is a schedule. A list of thread numbers can be printed, replayed and tested; 542 of 1,085 updates vanished under one round-robin schedule.
  • A lock cures the race and adds a cost. It makes the steps one block, but a waiting thread does nothing, and two locks taken in opposite orders deadlock.
Quick check
Two threads each run load x, add 1, store x on x = 0, under the schedule [0, 1, 1, 0, 0, 1]. What is x at the end?
Pick an answer.

Next topic: you found the lost update by choosing a schedule by hand. A real program has far too many schedules to try by hand. In An interleaving explorer you enumerate every schedule of a small program, count the ones that break it, and keep the explorer as the tool for the rest of the skill.

Lab · what's graded

7 tasks · 100 pts
  • One step of a thread15 pts
  • The machine under a schedule25 pts
  • One thread after the other5 pts
  • One step each in turn10 pts
  • Lost updates10 pts
  • A counting thread10 pts
  • The study on the SPY table25 pts
Explorer · lab-lf-01
▾ lab-lf-01
▾ data
…
bash · in-browser
learner@quantcraft:~/lab-lf-01$
Python not loaded yetPythonLn 1, Col 1Spaces: 4UTF-8

Reset this topic?

Everything in lab-lf-01 goes back to the starter files and the terminal is cleared. Your current solution will be lost — this can't be undone.

Will be reset
  • main.py → starter code
  • Terminal history and test output
  • Unsubmitted changes
Will be kept
  • Best lab score
  • Your XP and streak
  • Lecture progress and notes