比较并交换问的是“值还是我读到的那个吗”,却问不了“它是不是被改过又改回来了”。在无锁栈上,这个盲区就是 ABA 问题:交换基于对栈的过期认识而成功,导致同一个值被返回两次。一个 pop 对阵一个不断折腾栈的线程,共 3,021 种调度,其中 21 种会破坏普通栈,而带标签的栈一种也不会被破坏。
用比较并交换实现的栈
无锁栈把节点放在共享字典里:节点 i 有值 val{i} 和指向下一个节点的链接 next{i},head 指向栈顶(空栈为 -1)。压入节点时,读 head,让新节点的链接指向它,再把 head 从读到的值交换成新节点;交换失败说明有人改了 head,于是重来。弹出时,读 head,读栈顶节点的链接和值,再把 head 从读到的节点交换成它的链接。赢的那次 pop 把值记到 popped 里。栈的每个操作都是生成器,线程用 yield from 执行它,它同时会交还返回值。
def make_stack(values, tagged=False): top = 0 if values else -1 shared = {"head": (top, 0) if tagged else top, "popped": ()} for i, value in enumerate(values): shared["val%d" % i] = value shared["next%d" % i] = i + 1 if i + 1 < len(values) else -1 return shareddef push(shared, node, value, tagged=False): shared["val%d" % node] = value while True: yield old = shared["head"] yield shared["next%d" % node] = old[0] if tagged else old yield new = (node, old[1] + 1) if tagged else node if compare_and_swap(shared, "head", old, new): returndef pop(shared, tagged=False): while True: yield old = shared["head"] idx = old[0] if tagged else old if idx == -1: return None yield nxt = shared["next%d" % idx] value = shared["val%d" % idx] yield new = (nxt, old[1] + 1) if tagged else nxt if compare_and_swap(shared, "head", old, new): shared["popped"] = shared["popped"] + (value,) return valuedef alone(shared): def thread(): for _ in range(3): print("popped", (yield from pop(shared))) return [thread()]final, alive = run_prefix(alone, [0] * 20, make_stack([10, 20]))print(final["head"], final["popped"])
popped 10 popped 20 popped None -1 (10, 20)
单独运行时它就是个普通的栈:pop 依次返回 10、20,然后是 None,head 回到 -1。一切取决于:当另一个线程在一次 pop 的三次读取与它的交换之间干活时会发生什么。
ABA 调度
陷阱是这样的。受害者线程开始一次 pop:它读到 head = 0(节点 A)和链接 next0 = 1(节点 B),在交换之前被暂停。然后折腾线程弹出 A,弹出 B,再把节点 0 以新值 99 压回去,于是 head 又是 0,下面却什么也没有。受害者醒来。它的比较并交换只问 head 是否仍是 0,答案是“是”,于是交换成功,把已经不在栈里的节点 1 装成了栈顶。我们沿着调度 [0, 0]、折腾线程的九个回合、再到受害者走一遍,并用 conserved 检查结果:弹出的值加上栈里剩下的值,必须恰好等于放进去的值。
def walk(shared, tagged=False): values = [] idx = shared["head"][0] if tagged else shared["head"] while idx != -1 and len(values) < 10: values.append(shared["val%d" % idx]) idx = shared["next%d" % idx] return valuesdef conserved(shared, expected, tagged=False): return sorted(list(shared["popped"]) + walk(shared, tagged)) == sorted(expected)def aba_program(tagged=False): def make_threads(shared): def victim(): yield from pop(shared, tagged) def churner(): yield from pop(shared, tagged) yield from pop(shared, tagged) yield from push(shared, 0, 99, tagged) return [victim(), churner()] return make_threadsprefix = [0, 0] + [1] * 9 + [0]final, alive = run_prefix(aba_program(), prefix, make_stack([10, 20]))print(final)print("still on the stack:", walk(final), "| conserved:", conserved(final, [10, 20, 99]))
{'head': 1, 'popped': (10, 20, 10), 'val0': 99, 'next0': -1, 'val1': 20, 'next1': -1}
still on the stack: [20] | conserved: False受害者返回了 10,而折腾线程早已弹出过它(popped: (10, 20, 10)),head 是节点 1,可值 20 也已被弹出,所以 20 既在栈里又在 popped 里,新值 99 则丢了。conserved 说 False。一次丢失更新、一个重复的值、一个本不该存在的节点:全都来自一次对其所比较的值而言“正确”的交换。
一个值被读作 A,随后变成 B,又在比较并交换检查它之前变回 A。交换看到 A 就成功了,尽管该值周围的状态已经变了。当节点可以被释放且其地址可以被重用时就会发生。
给变化计数的标签
补救办法是让头指针自带历史。在带标签指针版本里,head 是一对 (节点, 标签),每次成功的 push 或 pop 都把它换成 (新节点, 标签 + 1)。比较并交换现在同时比较两个字段,所以折腾线程留下的 head (0, 3) 不是受害者读到的 (0, 0)。受害者的交换失败,记一次重试,重新读到真实的栈,并弹出 99。同样的调度,有了标签就能正确结束。
final, alive = run_prefix(aba_program(True), prefix, make_stack([10, 20], True))print(final["head"], "victim alive:", alive == [0])final, alive = run_prefix(aba_program(True), prefix + [0] * 6, make_stack([10, 20], True))print(final["popped"], walk(final, True), "| conserved:", conserved(final, [10, 20, 99], True))
(0, 3) victim alive: True (10, 20, 99) [] | conserved: True
64 位指针很少用得上全部位,所以标签可以放在高 16 位里,或者用双倍宽度的比较并交换同时交换指针和计数器。回绕的标签理论上可能重复;另一种办法是安全内存回收,它阻止节点在还可能有人读它时被重用,这是后面一个主题的内容。
values = [int(round(float(c) * 100)) for c in load_bars()["close"].iloc[:2]]for tagged in (False, True): outcomes = explore_all(aba_program(tagged), make_stack(values, tagged)) corrupt = sum(n for key, n in outcomes.items() if not conserved(dict(key), values + [99], tagged)) print("tagged" if tagged else "plain ", sum(outcomes.values()), "schedules,", corrupt, "corrupt")
plain 3021 schedules, 21 corrupt tagged 3021 schedules, 0 corrupt
3,021 种里的 21 种不到 1%。在真机上跑压力测试可能跑上几天也碰不到一次,却在生产环境里失败一回。探索器把它们全部列出,而标签把它们全部消除。
你要实现什么
实验沿着本讲走:共享字典里的栈、带比较并交换循环的 push 和 pop(普通版与带标签版)、对栈的遍历、守恒检查、受害者与折腾线程的 ABA 程序,以及用两个 SPY 收盘价对两种栈做全调度研究。
- 函数 · 问题依赖
- make_stack(values, tagged)共享栈是什么样子?简单算术
- push(shared, node, value, tagged)节点如何压入?简单算术
- pop(shared, tagged)值如何弹出?简单算术
- walk(shared, tagged)栈上有什么?简单算术
- conserved(shared, expected, tagged)有没有丢失或重复?walk
- aba_program(tagged)ABA 陷阱是什么样子?poppush
- study(bars)标签挽救了多少种调度?make_stackaba_programconserved
下一个主题:栈只有一个头。队列有两端,每端都有许多线程。多生产者多消费者队列会构建一个有界队列:生产者和消费者用 fetch-and-add 领取票号,每个槽位带一个序号,并用探索器在所有调度上检验它。
实验:评分内容
6 个任务 · 100 分- 共享栈10 分
- 压入15 分
- 弹出20 分
- 遍历与检查20 分
- ABA 程序10 分
- 基于 SPY 表的研究25 分