σμ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 学习路线›模块 2 · 原子操作、顺序与保证›主题 2.1·~55分钟高级

原子操作与读-改-写

路线进度模块 2/4 · 主题 1/3
2.1 原子操作与读-改-写
—实验得分— / 100总 XP—连续天数—

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

  1. 01一个回合,没有间隙
  2. 02比较并交换与重试循环
  3. 03回合数会变化时如何探索
  4. 04你要实现什么

丢失更新的原因是读和写是中间有间隙的两个回合。原子操作把间隙堵上:读、改、写在一个不可分割的步骤里完成。有了它,计数器在所有调度下都正确:调度从 28 种变成 4 种,而且没有一种出错。

01

一个回合,没有间隙

在我们的机器里,一个回合从一个 yield 走到下一个 yield,所以写在两个 yield 之间的内容不会被交错。因此不含 yield 的函数就表现为原子操作。最常见的是fetch-and-add:它读出旧值、写入 old + n、返回 old,全部在一个回合内。调用它的计数线程整个增量只有一个 yield 而不是两个,于是两个线程再也不会读到同一个旧值。

main.pyPython
def fetch_add(shared, name, n):    old = shared.get(name, 0)    shared[name] = old + n    return olddef atomic_increments(k):    def thread(shared):        for _ in range(k):            yield            fetch_add(shared, "x", 1)    return threadtwo = lambda shared: [atomic_increments(1)(shared), atomic_increments(1)(shared)]for prefix in ([0, 1], [1, 0]):    print(prefix, run_prefix(two, prefix))
[0, 1] ({'x': 2}, [])
[1, 0] ({'x': 2}, [])

两种顺序都以 {'x': 2} 结束,没有线程剩下。每次增量只有一个回合,程序只有 [0, 1] 和 [1, 0] 两种调度(研究里增量为 1 和 3 时共 4 种),没有一种会丢更新。

02

比较并交换与重试循环

fetch-and-add 只会做加法。其他情形需要通用工具比较并交换(CAS)。其模式是一个循环:读出旧值,据此算出新值,请 CAS 仅在这期间没人改过该值时才写入。CAS 失败说明有人先动了,于是重新读取再试。下面是两个 CAS 计数器:线程 1 在线程 0 写入之前读取,它的 CAS 失败一次、记一次重试,第二次尝试成功。更新不会丢,因为过期的写入会被拒绝。

main.pyPython
def compare_and_swap(shared, name, expected, new):    if shared.get(name, 0) != expected:        return False    shared[name] = new    return Truedef cas_increments(k):    def thread(shared):        for _ in range(k):            while True:                yield                old = shared.get("x", 0)                yield                if compare_and_swap(shared, "x", old, old + 1):                    break                fetch_add(shared, "retries", 1)    return threadcas = lambda shared: [cas_increments(1)(shared), cas_increments(1)(shared)]print(run_prefix(cas, [0, 1, 0, 1]))print(run_prefix(cas, [0, 1, 0, 1, 1, 1]))
({'x': 1, 'retries': 1}, [1])
({'x': 2, 'retries': 1}, [])

[0, 1, 0, 1] 之后共享字典是 {'x': 1, 'retries': 1},线程 1 还活着:它读到 0,但线程 0 已经存入了 1,所以它的比较并交换被拒绝。再给线程 1 接下来的两个回合(重新读、重新交换),最终得到 x = 2。注意代价:输的一方把活干了两遍。fetch-and-add 从不重试;CAS 则随线程冲突的次数而重试。

03

回合数会变化时如何探索

上一个主题的探索器假设每个线程占用固定的回合数 lengths。重试循环打破了这一点:输掉竞争的线程会多占回合,多少取决于调度。解决办法是把调度当作一棵树来搜索。从一段回合前缀出发,run_prefix 重放程序并告诉我们哪些线程还活着;若一个都没有,该调度已完整,统计其最终状态;否则对每个活着的线程分叉。这种深度优先搜索恰好访问每个完整调度一次。随后 values_of 从全部结果中读出某个共享名字的取值。

main.pyPython
def explore_all(make_threads, shared=None):    outcomes = {}    def visit(prefix):        final, alive = run_prefix(make_threads, prefix, shared)        if not alive:            key = tuple(sorted(final.items()))            outcomes[key] = outcomes.get(key, 0) + 1            return        for tid in alive:            visit(prefix + [tid])    visit([])    return outcomesdef values_of(outcomes, name):    return sorted({dict(key).get(name, 0) for key in outcomes})print(explore_all(cas))racy = lambda shared: [increments(1)(shared), increments(1)(shared)]print(values_of(explore_all(racy), "x"), values_of(explore_all(cas), "x"))
{(('x', 2),): 2, (('retries', 1), ('x', 2)): 4}
[1, 2] [2]
原子不等于免费

在这台机器里,每个操作都会被所有线程同时且按顺序地看到。真实的处理器对普通读写并不保证这一点,即便是原子操作也会让各个内核争抢缓存行。下一个主题会去掉第一个假设。

main.pyPython
returns = [float(r) for r in load_bars()["close"].pct_change().dropna()][:6]ups = [sum(1 for r in returns[start::2] if r > 0) for start in (0, 1)]for name, factory in (("racy", increments), ("atomic", atomic_increments), ("cas", cas_increments)):    outcomes = explore_all(lambda shared, f=factory: [f(k)(shared) for k in ups])    print(name, sum(outcomes.values()), "schedules, x in", values_of(outcomes, "x"))
racy 28 schedules, x in [1, 2, 3, 4]
atomic 4 schedules, x in [4]
cas 144 schedules, x in [4]
真实数据怎么说SPY 的前 6 个收益率,一个线程有 1 个上涨日,另一个有 3 个
有竞态的计数器2428 种调度中有错
fetch-and-add04 种调度中有错
比较并交换0144 种调度中有错
最多重试次数3CAS 计数器的重试
各计数器的调度数
fetch-and-add4
有竞态28
比较并交换144

两种修复在所有调度下都正确,区别在于代价。fetch-and-add 每次增量一个回合就完成:4 种调度。CAS 计数器有 144 种调度,因为冲突会增加回合,最坏情况下一个线程重试 3 次。这就是无锁代码的权衡:没人在锁上等待,但每场竞争的赢家会迫使输家把活重做一遍。

定义读-改-写

把共享值读出、据此算出新值、再作为一个不可分割的步骤写回的操作。fetch-and-add 和比较并交换是最常用的两个,所有无锁结构都由它们搭成。

04

你要实现什么

实验沿着本讲走:fetch-and-add、比较并交换、基于它们各自的计数线程、能处理回合数可变的线程的探索器、从全部结果中读取某个名字取值的辅助函数,以及对 SPY 上涨日的三种计数器的研究。

  • 函数 · 问题依赖
  • fetch_add(shared, name, n)加 n,之前是什么值?简单算术
  • compare_and_swap(shared, name, expected, new)值还是我读到的那个吗?简单算术
  • atomic_increments(k)没有间隙的计数器?fetch_add
  • cas_increments(k)用重试代替丢失的计数器?compare_and_swapfetch_add
  • explore_all(make_threads, shared)无论回合数多少,所有结果是什么?run_prefix
  • values_of(outcomes, name)这个名字取哪些值?简单算术
  • study(bars)三种计数器在 SPY 上有何不同?explore_allvalues_of

要点

  • 原子意味着内部没有 yield。 一个回合内的读-改-写无法被交错,因此 fetch-and-add 只剩 4 种调度,且全部正确。
  • CAS 把冲突变成重试。 过期写入被拒绝而不是丢失:144 种调度,没有错的,最多重试 3 次。
  • 回合数可变时按树来探索。 重放每个前缀,并在仍活着的线程上分叉;每个完整调度恰好访问一次。
快速检查
线程 0 和线程 1 各自用比较并交换做一次递增。两者都在对方交换之前读到 x = 0。接下来会怎样?
请选择一个答案。

下一个主题:我们一直假设每个线程立即且按顺序看到每一次写入。真实的处理器并不保证这一点。内存模型:acquire 与 release 会给机器加上存储缓冲区,演示读者在看到它所保护的数据之前就看到了标志,并用代价最小的内存序修复。

实验:评分内容

7 个任务 · 100 分
  • fetch-and-add5 分
  • 比较并交换10 分
  • 用 fetch-and-add 的计数器10 分
  • 带重试循环的计数器20 分
  • 探索所有调度20 分
  • 某个名字的取值5 分
  • 基于 SPY 表的研究30 分
资源管理器 · lab-lf-03
▾ lab-lf-03
▾ data
…
bash · 浏览器内
learner@quantcraft:~/lab-lf-03$
Python 尚未加载PythonLn 1, Col 1Spaces: 4UTF-8

要重置此主题吗?

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

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