σμ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 Academy路线图学习路线
Ctrl K
AO
+1.2k 位学习者
登录加入 ↗
路线图学习路线
AO
+1.2k 位学习者
登录
  • 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 Academy路线图学习路线
Ctrl K
AO
+1.2k 位学习者
登录加入 ↗
路线图学习路线
AO
+1.2k 位学习者
登录
  • English
  • Français
  • Español
  • العربية
  • Português
  • Deutsch
  • 日本語
  • 中文
Lock-free 学习路线›模块 1 · 竞态与探索器›主题 1.2·~1小时高级

交错探索器:通过枚举调度来发现缺陷

路线进度模块 1/4 · 主题 2/2
1.2 交错探索器:通过枚举调度来发现缺陷
—实验得分— / 100总 XP—连续天数—

阅读约 5 分钟4 个代码示例

  1. 01一共有多少种调度?
  2. 02把线程写成生成器
  3. 03先探索,再提问
  4. 04你要实现什么

靠手工试调度,只有猜中那一个才能发现缺陷。探索器会在所有调度下运行程序,列出每一种结果,并把让它出错的调度交给你。两个线程数 SPY 十个收益率里的上涨日,共有 210 种调度,其中只有 10 种给出正确的计数。

01

一共有多少种调度?

调度是一串回合的顺序,线程 t 恰好占 lengths[t] 个回合。要数有多少种顺序,把所有回合排成一行(长度之和),排法数是该数的阶乘;再除以每个线程回合数的阶乘,因为交换同一线程的两个回合并不产生新的调度。这就是多项式系数。它增长得很快:两个各 2 回合的线程只有 6 种调度,三个各 3 回合的线程有 1,680 种,两个各 10 回合的线程有 184,756 种。这就是没人能靠随机试调度再碰运气来检验并发程序的原因。

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

把线程写成生成器

上一个实验里线程是一串步骤。真实的代码用普通 Python 写更自然,所以从现在起线程是一个生成器,它接收共享字典,并在每次访问共享数据之前执行 yield。于是调用一次 next() 就让线程推进到下一次访问,不再多走,所以一个回合恰好是一步,别的线程可以和它交错。run_threads 先给每个生成器“预热”(运行到第一个 yield),再给调度中每一项所指的线程一个回合,调度结束后让未完成的线程按线程顺序跑完。all_schedules 通过选择谁先走、再对剩余部分递归,列出所有回合顺序。下面是两个各递增一次的线程的六种调度。

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}

六种调度里有两种以 2 结束,另外四种以 1 结束。好的两种是一个线程做完两步之后另一个才开始。其余四种里,两个线程都在对方写之前读了,这正是上一个实验里的丢失更新,只是这次不用靠猜就找到了。

03

先探索,再提问

explore 在所有调度下运行程序,统计每种最终状态由多少种调度产生。键是由 (名字, 值) 对排序得到的元组,这样相同的状态就是同一个键。find_schedule 问的是缺陷报告会问的问题:哪种调度破坏了我的不变量?它返回第一个,由于 run_threads 是确定性的,你可以重放它、打印它或把它贴进测试。如果返回 None,不变量在所有调度下都成立,这对这个小程序是证明,而不是希望。

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]
空间呈阶乘增长

暴力枚举在每个线程只有几个回合时可行,之后就会爆炸。真正的探索器会剪枝:记住见过的状态,跳过只是重排互不影响的步骤的调度。思路不变,所以你在这里搭的就是正确的思维模型。

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}
真实数据怎么说SPY 的前 10 个收益率,两个线程数上涨日
调度210上涨日 2 天和 3 天
正确结果10种调度以 x = 5 结束
丢失更新200共 210 种调度
最坏情形2最终 x(正确为 5)
按最终 x 值统计的调度数
26
104
70
10

正确结果 x = 5 只在 210 种调度中的 10 种出现,约 5%。只运行一次,或者在真实机器上运行一百次(线程很少恰好这样重叠)的测试,几乎每次都会通过,却仍然会交付一个缺陷。探索器不会漏掉它,而 find_schedule 会把第一个罪魁调度交给你:[0, 0, 0, 1, 0, 1, 1, 1, 1, 1]。

定义不变量

关于数据的一条断言,无论调度如何都必须成立。并发程序在其不变量于所有调度下都成立时才是正确的,对小规模情形,探索所有调度就是检验它的办法。

04

你要实现什么

实验沿着本讲走:调度的数量、全部调度的列表、在调度下运行生成器线程的机器、统计结果的探索器、查找第一个破坏不变量的调度,以及用两个线程数 SPY 上涨日的研究。

  • 函数 · 问题依赖
  • count_schedules(lengths)这个程序有多少种调度?简单算术
  • all_schedules(lengths)按顺序有哪些?简单算术
  • run_threads(make_threads, schedule, shared)一种调度会留下什么?简单算术
  • explore(make_threads, lengths, shared)有哪些结果,各出现多少次?all_schedulesrun_threads
  • find_schedule(make_threads, lengths, check)哪种调度最先破坏不变量?all_schedulesrun_threads
  • study(bars)数 SPY 的调度里有多少会出错?count_schedulesexplorefind_schedule

要点

  • 先数一数调度。 多项式系数呈阶乘增长:两个各 2 回合的线程 6 种,两个各 10 回合的线程 184,756 种。
  • 每个 yield 标出一次共享访问。 把线程写成生成器,一个回合就是一次访问,探索器因而能尝试普通 Python 代码的所有交错。
  • 不变量加上全部调度就是证明。 210 种调度里只有 10 种给出正确计数;find_schedule 返回第一个不对的,可直接重放。
快速检查
两个线程各占 3 个回合。它们一共有多少种不同的调度?
请选择一个答案。

下一个主题:探索器证明了缺陷是真实的,现在来修复它。原子操作与读-改-写加入处理器能在一个不可分割的步骤里完成的操作,比如 fetch-and-add 和 compare-and-swap,并用探索器检验计数器在所有调度下都正确。

实验:评分内容

6 个任务 · 100 分
  • 调度的数量10 分
  • 每种调度15 分
  • 运行线程25 分
  • 统计每种结果20 分
  • 第一个出错的调度10 分
  • 基于 SPY 表的研究20 分
资源管理器 · lab-lf-02
▾ lab-lf-02
▾ data
…
bash · 浏览器内
learner@quantcraft:~/lab-lf-02$
Python 尚未加载PythonLn 1, Col 1Spaces: 4UTF-8

要重置此主题吗?

lab-lf-02 中的所有内容将恢复为初始文件,终端也会被清空。你当前的解答将丢失,且无法撤销。

将被重置
  • main.py → 初始代码
  • 终端历史与测试输出
  • 未提交的更改
将被保留
  • 实验最高得分
  • 你的 XP 与连续天数
  • 讲解进度与笔记