无锁栈从不阻塞,但它不能把弹出的节点直接释放:另一个线程可能刚读过栈顶,正要读这个节点的 next。释放过的内存可能被复用或取消映射,所以这次读取就是释放后使用。有两种经典的办法。危险指针让线程声明自己正在用的节点;纪元让线程声明自己何时进入。两者都是等到没人可能还握着它时,才释放已摘下的节点。
立刻释放:不安全的弹出
模型就是 ABA 主题里的栈。线程读栈顶、读节点,然后用一次 compare-and-swap 换掉栈顶。不安全的弹出在交换成功的同一轮里释放节点。被释放的节点谁都不能碰,每当有线程读到它,read_node 就记一次释放后使用。两个线程各弹一次只有 20 种调度,探索器立刻找出坏的那些:一个线程读了栈顶,另一个弹出并释放了该节点,第一个线程随后去读它。
def pop_unsafe(shared, tid): while True: yield idx = shared["head"] if idx == -1: return None yield nxt, value = read_node(shared, idx) yield if compare_and_swap(shared, "head", idx, nxt): shared["popped"] = shared["popped"] + (value,) free_node(shared, idx) return valuedef two_pops(shared): return [pop_unsafe(shared, 0), pop_unsafe(shared, 1)]outcomes = explore_all(two_pops, make_heap([9369, 9305]))hits = sum(n for key, n in outcomes.items() if dict(key)["uaf"])print(sum(outcomes.values()), "schedules,", hits, "of them read a freed node")
20 schedules, 6 of them read a freed node
危险指针:先声明,再检查
危险指针把释放变成一次“对话”。线程在读节点之前,先把它的编号写进自己的槽位。光这样还不够,因为在读栈顶和写槽位之间,节点可能已被弹出并释放,所以线程要验证:重新读一次栈顶,如果变了就重来。弹出成功后,线程不直接释放节点,而是放进自己的退役列表并扫描:把每个线程的槽位读一遍,只释放没有任何槽位提到的退役节点。留在列表里的节点等下一次扫描。
def pop_hazard(shared, tid): while True: yield idx = shared["head"] if idx == -1: shared["hp%d" % tid] = -1 return None yield shared["hp%d" % tid] = idx yield if shared["head"] != idx: continue yield nxt, value = read_node(shared, idx) yield if compare_and_swap(shared, "head", idx, nxt): shared["popped"] = shared["popped"] + (value,) shared["hp%d" % tid] = -1 shared["retired%d" % tid] = shared["retired%d" % tid] + (idx,) break yield hazards = tuple(shared["hp%d" % t] for t in range(shared["threads"])) yield keep = [] for node in shared["retired%d" % tid]: if node in hazards: keep.append(node) else: free_node(shared, node) shared["retired%d" % tid] = tuple(keep) return valuedef two_pops(shared): return [pop_hazard(shared, 0), pop_hazard(shared, 1)]outcomes = explore_all(two_pops, make_heap([9369, 9305]))hits = sum(n for key, n in outcomes.items() if dict(key)["uaf"])waiting = max(sum(len(dict(key)["retired%d" % t]) for t in (0, 1)) for key in outcomes)print(sum(outcomes.values()), "schedules,", hits, "read a freed node, up to", waiting, "node waiting at the end")
12122 schedules, 0 read a freed node, up to 1 node waiting at the end
每个线程对“正在读的那一个节点”的声明:读之前写入,读之后验证。只有没有任何声明提到的退役节点才会被释放。
纪元:声明自己何时进入
基于纪元的回收声明得少得多。只有一个全局的纪元计数器。线程进入一次操作时把当前纪元写进自己的槽位,离开时写 -1,从不声明具体节点。在纪元 e 退役的节点,只可能被在纪元 e 或更早进入的线程握着,所以当操作内部的每个线程声明的纪元都大于 e 时,释放它是安全的。代价是:一个在里面待很久的线程,会拖住它进入之后退役的所有节点,而危险指针只拖住它点名的那一个节点。
def pop_epoch(shared, tid): yield shared["ep%d" % tid] = shared["epoch"] while True: yield idx = shared["head"] if idx == -1: shared["ep%d" % tid] = -1 return None yield nxt, value = read_node(shared, idx) yield if compare_and_swap(shared, "head", idx, nxt): shared["popped"] = shared["popped"] + (value,) shared["ep%d" % tid] = -1 shared["retired%d" % tid] = shared["retired%d" % tid] + ((idx, shared["epoch"]),) shared["epoch"] = shared["epoch"] + 1 break announced = [] for t in range(shared["threads"]): yield if shared["ep%d" % t] != -1: announced.append(shared["ep%d" % t]) yield keep = [] for node, epoch in shared["retired%d" % tid]: if announced and epoch >= min(announced): keep.append((node, epoch)) else: free_node(shared, node) shared["retired%d" % tid] = tuple(keep) return valuedef two_pops(shared): return [pop_epoch(shared, 0), pop_epoch(shared, 1)]outcomes = explore_all(two_pops, make_heap([9369, 9305]))hits = sum(n for key, n in outcomes.items() if dict(key)["uaf"])waiting = max(sum(len(dict(key)["retired%d" % t]) for t in (0, 1)) for key in outcomes)print(sum(outcomes.values()), "schedules,", hits, "read a freed node, up to", waiting, "node waiting at the end")
9012 schedules, 0 read a freed node, up to 1 node waiting at the end
两种方案在某些调度结束时都会留下等待中的退役节点:探索器把它们报告为 pending。之后的扫描会释放它们,所以什么也没丢。纪元真正的危险是线程在操作内部停住:它永远不声明更新的纪元,于是它之后退役的节点永远不会被释放,内存不断增长。危险指针不受影响,因为停住的线程只拖住一个节点。
安全是用调度数换来的,因为每一次声明、验证和扫描都是另一个线程可以插入的新步骤:20 变成了 12,122 和 9,012。这正是探索器在这里有用的原因:它不需要知道方案为什么正确,只是把每一种调度都检查一遍。
你要实现什么
实验沿着本讲走:带每线程一组槽位的共享状态、不安全的弹出、危险指针弹出、纪元弹出、两个不变量(没有释放后使用、没有丢失)的检查、探索某方案全部调度的验证器,以及在 SPY 收盘价上对三种方案的研究。
- 函数 · 问题依赖
- make_heap(values, threads)共享什么?简单算术
- pop_unsafe(shared, tid)弹出并立刻释放简单算术
- pop_hazard(shared, tid)声明、验证、退役、扫描简单算术
- pop_epoch(shared, tid)进入、带纪元退役、回收简单算术
- violations(final)哪些不变量被破坏了?简单算术
- verify(scheme, values, pops)方案在所有调度下都安全吗?explore_allviolations
- study(bars)哪种方案在 SPY 栈上存活?verify
下一个主题:这只是个栈,而且是玩具版。毕业项目无锁的行情数据扇出会把整个技能用起来:SPY 的行情从一个生产者经由无锁结构流向多个消费者,探索器在所有调度下证明它的不变量。
实验:评分内容
7 个任务 · 100 分- 共享状态5 分
- 立刻释放10 分
- 危险指针20 分
- 纪元20 分
- 不变量10 分
- 验证器15 分
- 基于 SPY 表的研究20 分