关注了就能看到更多这么棒的文章哦~
Zapping pointers out of thin air
By Daroc Alden
October 15, 2024
Kangrejos 2024
Gemini-1.5-flash translation
https://lwn.net/Articles/993484/
Paul McKenney 在今年的 Kangrejos 上做了一场与 Rust 无直接关系的演讲。他谈论了自己与其他许多贡献者合作,在改进 C++ 中处理微妙的并发性问题方面所做的工作。尽管他提醒说,他的演讲只是一篇概述,不能替代阅读相关论文,但他希望 C++ 社区正在进行的工作能引起在场的 Rust 开发人员的兴趣,并可能为该语言的未来工作提供参考。McKenney 的演讲,正如他的风格一样,充满了奇怪的多线程行为的微妙例子。感兴趣的读者可以参考 他的幻灯片 来尝试理解。
生命周期末尾指针清除
他从生命周期结束时的指针清除( lifetime-end pointer zapping)开始,这是一个比较晦涩的话题,所以他举了一个相关的例子。假设一个 C++ 程序员编写了一个带有两个操作函数的堆栈:push 和 clear。他们希望这个堆栈是多线程的,所以想法是多个线程可以向这个堆栈 push 数据,多个线程可以 clear 它,但只有一个 clear 线程会“获胜”,并获取所有 push 的数据。模拟这种情况的自然方法是使用链表和原子比较和交换 (CAS, compare-and-swap) 操作来交换指向列表头部元素的指针。
所以假设堆栈的顶部指向对象 A,一个线程试图 push 第二个对象 B。该线程将指向 A 的指针放入 B 中,但顶部仍然指向 A。然后,该线程被抢占,另一个线程清除了整个堆栈。它处理这些条目,并释放 A。现在 A 在空闲列表中,但 B 仍然指向它。然后第二个线程 分配并 push 对象 C。 malloc()
实现将与 A 相同的内存块分配给 C,所以现在 B 指向 C — 其实只是我们这样认为而已。第一个线程继续执行,使用比较和交换操作(CAS)看到相同的指针 (指向 A 或 C),并且 B 的 push 成功。可以查看链接的幻灯片,以获取该过程的图形描述。
但这并不是合理行为。McKenney 说,从 B 到 C 的指针就是一个“僵尸指针”的例子 — 它是一个在汇编代码中合法的指针,类型也匹配,但它在 C++ 中是不允许的。C 或 C++ 中的指针不仅仅是内存中特定位置的地址,它们还具有 来源。一旦 A 被释放,B 指向 A 的指针就失效了 — 即使 C 最终被放回同一个位置。因此,如果编译器可以证明 A 将被释放,而 B 将继续保留对它的引用,那么它就知道任何试图通过该指针进行读取都是未定义的行为,并且可以假设这种情况不会发生从而自由地进行优化。此外,虽然这个堆栈的简单例子是 "ABA 可容忍的", 但并非所有数据结构都能很好地处理这类问题。
Björn Baron 同意,这个问题也会出现在 Rust 中。尽管 Rust 的来源规则(provenance rule)尚未完全确定,但指向已释放分配的指针仍然是未定义的行为。他说,有些人讨论过添加编译器内在函数(intrinsic)来帮助标注这种情况。McKenney 说,这与他在解决这个问题时的经验一致 — 大量讨论,进展却很小。
一个问题是编译器开发者非常喜欢来源(provenance),因为它允许进行其他情况下不可能进行的优化。跟踪来源很复杂。来源可以被擦除,例如通过将指针转换为整数。然而,这不是解决生命周期末尾指针清除的完全方案。McKenney 说,去年 Davis Herring 提出了“天使来源(angelic provenance)”,这需要编译器选择使程序有效的来源,如果有不止一种可能的解释的话。这有助于从总体上简化围绕来源的推理,但对他的例子没有帮助。
一个可能的解决方案是让 atomic<T*>
值 (C++ 的原子指针) 擦除来源,包括被成功 CAS 操作引用的指针。这项提议已经提交给了 C++ 委员会,初步审查结果为正面。Benno Lossin 说,由于 Rust 正在尝试开发自己的来源规则,所以 Rust 开发人员可能需要考虑使用类似的 API 来移除来源。Alice Ryhl 提到,Miri (Rust 的未定义行为分析器) 在今年早些时候实际上已经找到了 一个问题,类似于 McKenney 在 Rust 标准库的队列实现中提出的堆栈问题。
无中生有
接下来,McKenney 讨论了无中生有 (OOTA, out-of-thin-air) 循环。他举了一个涉及两个进程和两个变量 (最初都为零) 的例子。假设一个进程使用 松弛 读取(relaxed read)从 X 读取的值 42。然后它将该值存储到 Y 中。另一个进程对 Y 进行松弛读取 — 读到了 42 — 然后将其存储到 X 中,供第一个进程读取。如果两个变量最初都是零,那么 42 从哪里来的呢?在 C++ 术语中,它是“无中生有”的。该值不必是 42;这样的循环可以产生任何值,这对于推理程序来说是一个主要问题,因此我们希望能够证明它不会发生。“循环”(cycle)这个名字的由来是,如果你绘制一个图表来表示值的来源,它会形成一个循环 — X 从 Y 获取它,而 Y 从 X 获取它。
OOTA 循环在直觉上是不可能的,事实上在真实硬件上也不会发生 — 但对为什么它不会发生进行形式化的描述却出人意料地难以融入现有的内存模型。而这一点很重要,因为语言的内存模型是确定哪些程序有效的一部分,因此也决定了哪些优化是有效的。McKenney 说,C 语言委员会最终放弃了,只是定义了内存模型,然后加了一句话说“也不要这样做”。当尝试在 2000 年左右对其进行形式化时,Java 也做了同样的事情,最终放弃了。
肯特大学的研究人员有一个 很有希望的理论模型,该模型考虑了编译器优化,最终可以解决这个问题。但他表示,他们的模型是理论性的,尚未准备就绪。McKenney 对为什么这在现实生活中不是问题有一个更简单的解释:读取需要时间,指令执行也需要时间。他说,为了形成一个 OOTA 循环,这些步骤中的至少一项必须在现实世界中发生时间倒退。要么读取必须以某种方式获取未来的值,要么执行读取和存储必须花费负的时间。打破这种情况的唯一方法是硬件预测(speculation)失误 (但糟糕的硬件可以发生任何事情),或者编译器让这些操作的执行时间为零 (例如通过将其优化掉)。
这与现有内存模型不匹配的原因是,它们没有时间概念 — 只有因果关系的概念。因此,用这种无时间形式语言表达 OOTA 循环不可能的原因很困难。
Baron 建议,也许 CPU 可以预测会有一个存储操作,然后检查这个猜测是否正确。他不知道有任何 CPU 实际上这样做,但理论上是可能的。McKenney 很高兴有人在未经提示的情况下提到了这一点 —“我还没有付钱给那个人,”他笑着说。
McKenney 解释说,事件之间实际上存在两种链接:时间链接和非时间链接(temporal and atemporal links)。一个 store-to-load 的链接是时间链接;当执行存储操作时,该存储操作需要时间才能在整个系统中传播。信息在现实世界中传播需要时间流逝。但 store-to-store 链接是非时间链接 — 一个存储操作可以“胜过(win)”另一个存储操作,即使它发生的时间更早,因为 CPU 缓存没有使用全局的时钟。McKenney 在实际硬件上进行了实验,结果表明,获胜的存储操作可以在它覆盖的存储操作之前超过一微秒的时间前发生。最后,load-to-store 链接也是非时间链接,它们没有告诉你事件顺序的信息,因为你可能会获得旧值。
所以,在真实的计算机上,你可以依赖的唯一能够确定事件在现实世界中发生的实际顺序的事件类型是存储到加载链接,其中一个 CPU 存储一个值,另一个 CPU 加载它。这与投机预测有什么关系呢?好吧,假设一个 CPU 决定对一个值进行投机预测。它需要在某个时刻检查投机是否正确。在这样做的时候,它需要执行一个 load 操作。如果该 load 操作看到一个不同的值,那么它需要放弃投机预测。但如果它看到的是符合预测的话,它仍然不能花费负的时间,因为它最终还是需要执行该加载操作。这意味着,即使有投机,OOTA 循环仍然是不可能的,因为投机实际上并不能消除加载操作 — 只能将依赖于该加载操作的工作提前到更早的时间。
这听起来很令人欣慰,但对编译器开发者来说,有什么启示吗?OOTA 由于基于物理原因而在真实硬件上无法发生的事实,对于必须依赖 C 语言规范提供的内存模型的编译器开发者来说毫无意义。最终,McKenney 对编译器人员有一个启示:不要发明非易失性原子访问(non-volatile atomic access)。现在已经违反了发明易失性原子访问时的原则,但为什么发明对非易失性原子的访问会导致问题,这一点尚不清楚;避免这样做可以防止 OOTA 循环。
原因是语义依赖(semantic dependencies)。C 抽象机(abstract machine)可能没有真正的时钟概念,但它确实有数据依赖概念。这足以防止 OOTA 循环,但编译器被允许生成多余的 load 操作 (例如为了减少寄存器压力)。McKenney 展示了一个例子,其中添加对非易失性原子布尔值的第二次加载操作使一系列优化能够实际消除两个其他变量之间的数据依赖关系。在依赖关系被消除后,编译器就可以重新排列剩余的语句来产生一个无中生有的值。
因此,McKenney 关于避免 OOTA 循环的最终建议很简单,就是 不要这样做 。发明、复制或重新利用非易失性原子加载是导致 OOTA 循环的必要步骤,因此,如果编译器不这样做,那么它就不必担心这类错误。C++ 委员会是否会接受 McKenney 的 关于该主题的论文,我们拭目以待 — 但由于这个问题困扰了 C++ 社区一段时间,所以这件事似乎很有可能发生。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
欢迎分享、转载及基于现有协议再创作~
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~