σμ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.2·~1 hrAdvanced

An interleaving explorer: finding bugs by enumerating schedules

Skill positionModule 1 of 4 · Topic 2 of 2
1.2 An interleaving explorer: finding bugs by enumerating schedules
—Lab score— / 100Total XP—Streak—

5 min readCode examples: 4

  1. 01How many schedules are there?
  2. 02Threads as generators
  3. 03Explore, then ask a question
  4. 04What you will build

Trying schedules by hand finds a bug only if you guess the right one. An explorer runs the program under every schedule, lists every outcome and hands you the schedule that broke it. For two threads counting the up days of ten SPY returns there are 210 schedules, and only 10 of them give the right count.

01

How many schedules are there?

A schedule is an order of turns, and thread t takes exactly lengths[t] of them. To count the orders, put all the turns in a row (the sum of the lengths of them), which can be arranged in that many factorial ways; then divide by the factorial of each thread's turns, because swapping two turns of the same thread does not give a new schedule. This is the multinomial coefficient. It grows fast: two threads of 2 turns each have only 6 schedules, three threads of 3 turns have 1,680, and two threads of 10 turns have 184,756. That is why nobody can check a concurrent program by trying schedules at random and hoping.

main.pyPython
def count_schedules(lengths):    total = factorial(sum(lengths))    for n in lengths:        total //= factorial(n)    return totalprint(count_schedules([2, 2]))print(count_schedules([3, 3, 3]))print(count_schedules([10, 10]))
6
1680
184756
02

Threads as generators

In the last lab a thread was a list of steps. Real code is better written as ordinary Python, so from now on a thread is a generator that takes the shared dictionary and runs yield before every access to shared data. One call of next() then runs the thread up to its next access and no further, which makes one turn exactly one step that another thread can interleave with. run_threads primes every generator (runs it to its first yield), gives a turn to the thread named by each entry of the schedule, and when the schedule ends lets the unfinished threads finish in thread order. all_schedules lists every order of turns by choosing which thread goes first and recursing on what is left. Below, the six schedules of two threads that increment once each.

main.pyPython
def all_schedules(lengths):    if sum(lengths) == 0:        return [[]]    out = []    for tid, n in enumerate(lengths):        if n:            rest = list(lengths)            rest[tid] -= 1            out += [[tid] + tail for tail in all_schedules(rest)]    return outdef run_threads(make_threads, schedule, shared=None):    shared = dict(shared or {})    threads = make_threads(shared)    alive = [True] * len(threads)    def turn(tid):        try:            next(threads[tid])        except StopIteration:            alive[tid] = False    for tid in range(len(threads)):        turn(tid)    for tid in schedule:        if alive[tid]:            turn(tid)    while any(alive):        for tid in range(len(threads)):            if alive[tid]:                turn(tid)    return sharedtwo = lambda shared: [increments(1)(shared), increments(1)(shared)]for schedule in all_schedules([2, 2]):    print(schedule, run_threads(two, schedule))
[0, 0, 1, 1] {'x': 2}
[0, 1, 0, 1] {'x': 1}
[0, 1, 1, 0] {'x': 1}
[1, 0, 0, 1] {'x': 1}
[1, 0, 1, 0] {'x': 1}
[1, 1, 0, 0] {'x': 2}

Two of the six schedules end at 2, the other four at 1. The two good ones are those where one thread does both its steps before the other starts. In the other four both threads read before either writes, which is exactly the lost update of the last lab, now found without guessing.

03

Explore, then ask a question

explore runs the program under every schedule and counts how many schedules give each final state. The key is a sorted tuple of (name, value) pairs so that equal states are the same key. find_schedule asks the question a bug report asks: which schedule breaks my invariant? It returns the first one, and because run_threads is deterministic you can replay it, print it, or paste it into a test. If it returns None, the invariant holds under every schedule, which is a proof for this small program, not a hope.

main.pyPython
def explore(make_threads, lengths, shared=None):    outcomes = {}    for schedule in all_schedules(lengths):        key = tuple(sorted(run_threads(make_threads, schedule, shared).items()))        outcomes[key] = outcomes.get(key, 0) + 1    return outcomesdef find_schedule(make_threads, lengths, check, shared=None):    for schedule in all_schedules(lengths):        if not check(run_threads(make_threads, schedule, shared)):            return schedule    return Noneprint(explore(two, [2, 2]))print(find_schedule(two, [2, 2], lambda shared: shared["x"] == 2))
{(('x', 2),): 2, (('x', 1),): 4}
[0, 1, 0, 1]
The space grows factorially

Brute-force enumeration works for a few turns per thread and then explodes. Real explorers prune: they remember states already seen and skip schedules that only reorder steps that cannot affect each other. The idea stays the same, so what you build here is the right mental model.

main.pyPython
returns = [float(r) for r in load_bars()["close"].pct_change().dropna()][:10]ups = [sum(1 for r in returns[start::2] if r > 0) for start in (0, 1)]lengths = [2 * k for k in ups]many = lambda shared: [increments(k)(shared) for k in ups]print(ups, count_schedules(lengths))print({dict(key)["x"]: n for key, n in explore(many, lengths).items()})
[2, 3] 210
{5: 10, 4: 70, 3: 104, 2: 26}
What the real data saysthe first 10 SPY returns, two threads counting up days
Schedules210for 2 and 3 up days
Right answer10schedules end at x = 5
Lose updates200of 210 schedules
Worst case2x at the end, of 5
Schedules by final value of x
26
104
70
10

The right answer, x = 5, appears in only 10 of 210 schedules, about 5%. A test that runs the program once, or a hundred times on a real machine where the threads rarely overlap exactly like this, would pass almost every time and still ship a bug. The explorer cannot miss it, and find_schedule hands you the first culprit: [0, 0, 0, 1, 0, 1, 1, 1, 1, 1].

DefinitionInvariant

A statement about the data that must hold whatever the schedule. A concurrent program is correct when its invariant holds under every schedule, and exploring all schedules is the way to check it for a small case.

04

What you will build

The lab follows the lecture: the number of schedules, the list of all of them, the machine that runs generator threads under a schedule, the explorer that counts outcomes, the search for the first schedule that breaks an invariant, and the study of two threads counting SPY up days.

  • Function · questionBuilds on
  • count_schedules(lengths)How many schedules does the program have?Plain arithmetic
  • all_schedules(lengths)Which are they, in order?Plain arithmetic
  • run_threads(make_threads, schedule, shared)What does one schedule leave behind?Plain arithmetic
  • explore(make_threads, lengths, shared)Which outcomes exist, and how often?all_schedulesrun_threads
  • find_schedule(make_threads, lengths, check)Which schedule breaks the invariant first?all_schedulesrun_threads
  • study(bars)How many schedules of SPY counting go wrong?count_schedulesexplorefind_schedule

Key takeaways

  • Count the schedules first. The multinomial coefficient grows factorially: 6 schedules for two threads of 2 turns, 184,756 for two threads of 10.
  • A yield marks every shared access. Writing threads as generators makes a turn one access, so the explorer can try every interleaving of ordinary Python code.
  • An invariant plus all schedules is a proof. Only 10 of 210 schedules gave the right count; find_schedule returns the first one that does not, ready to replay.
Quick check
Two threads take 3 turns each. How many different schedules do they have?
Pick an answer.

Next topic: the explorer shows that the bug is real; now we repair it. Atomics and read-modify-write adds the operations a processor can do in one indivisible step, such as fetch-and-add and compare-and-swap, and you check with the explorer that the counter is right under every schedule.

Lab · what's graded

6 tasks · 100 pts
  • How many schedules10 pts
  • Every schedule15 pts
  • Run the threads25 pts
  • Count every outcome20 pts
  • The first bad schedule10 pts
  • The study on the SPY table20 pts
Explorer · lab-lf-02
▾ lab-lf-02
▾ data
…
bash · in-browser
learner@quantcraft:~/lab-lf-02$
Python not loaded yetPythonLn 1, Col 1Spaces: 4UTF-8

Reset this topic?

Everything in lab-lf-02 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