LWN: 要用volatile_if() 来保护control dependency吗?

共 4432字,需浏览 9分钟

 ·

2021-07-07 20:10

关注了就能看到更多这么棒的文章哦~

Protecting control dependencies with volatile_if()

By Jonathan Corbet
June 18, 2021
DeepL assisted translation
https://lwn.net/Articles/860037/

正如 Linus Torvalds 最近所说,内存排序问题(memory ordering issue)是 "the rocket science of CS"。理解 memory ordering 对于编写易于扩展的代码来说越来越有必要了,所以内核开发者经常发现自己不得不成为火箭科学家(来研究 memory ordering)。与 control dependency 相关的情况则更是一种特别棘手的问题。最近的关于如何让 control dependency 能强制实现的讨论就展示了这个领域所出现的各种困难。

Control dependencies

C 语言是在简单的单处理器计算机时代设计出来的。当时某个开发者写了一系列的 C 语句之后,他们可以确定这些语句会按照写出来的先后顺序来执行。但几十年后,情况变得更加复杂了,代码很可能被编译器和 CPU 进行了改头换面的优化,执行的代码与最初写的代码几乎没有相似之处。如果编译器(或处理器)认为对代码调整顺序之后执行的结果是等效的,那么它就会可能会对代码进行 reorder(重新排序),甚至删除一些代码。这种 reorder 对单线程代码的影响(在没有出现错误的情况下)仅仅是会使其运行得更快。不过,当有多个线程同时运行时就可能会有意外情况了。其中一个线程观察到的多个事件的发生顺序,可能会与其他线程所看到的并不相同,这可能会导致各种混乱。

在必须要保证多个处理器上看到各个操作的顺序时,开发人员通常会使用 barrier 来确保 reorder 动作并不会导致错误。然而,在有些情况下,开发者可以假设事件的发生顺序会是正确的,因为完全不可能会出错,这些情况就是通常所说的 "dependency"。总共有三类 dependency(依赖关系),在 LWN 最近发表的 lockless pattern 系列文章中有过介绍。例如,下面就是一个简单的 data dependency(数据依赖关系):

int x = READ_ONCE(a);
WRITE_ONCE(b, x + 1);

这里,无论怎么进行 reorder,都不可能在对 a 进行 read 操作之前完成对 b 的写入操作,因为编译器和 CPU 都还不知道应该写入什么数值。write 进去的数据完全依赖于前面 read 的结果,这种 dependency 关系会阻止这两个操作被 reorder。当然,这要隐含一个假设,就是编译器不认为它已经知道 a 的值是什么,也就是它之前一定没有读取过这个 a,这就是为什么这里要使用 READ_ONCE()。lockless patterns 系列文章的第二篇就详细介绍了 READ_ONCE()和 WRITE_ONCE()。

control dependency 则要复杂一些。例如下面这样的代码:

if (READ_ONCE(a))
WRITE_ONCE(b, 1);

对 a 的 read 操作和对 b 的 write 操作之间没有 data dependency,但只有当 a 的值为非零时才会进行后面的 write 操作,因此对 a 的 read 操作必定是发生在 write 之前。这种由条件分支(conditional branch)所确保的顺序关系就是 control dependency。一般来说,要建立 control dependency 的话,必须满足三个条件:

  • 先对某个位置(上面的例子中的 a)进行读取

  • 根据读出的值来决策后续代码分支(conditional branch)

  • 在一个或多个代码分支中会对另一个地址进行 write 操作

当满足这些条件时,这里的先读后写就是一个 control dependency,这两个操作之间就不会进行 reorder。

The evil optimizing compiler

如果事情能这样发展就好了。问题是,虽然硬件是这样工作的,但 C 语言本身却并不认可 control dependency,或者说,正如著名的 memory-barriers.txt 这个文档所说:"编译器并不理解 control dependency。因此,需要开发者来确保编译器不会破坏你的代码"。虽然历史上似乎没有出现很多次对有 control dependency 关系的代码进行了过度优化而导致代码出错的情况,但这仍是开发者会感到担心的事情。因此,Peter Zijlstra 提出了一个叫做 volatile_if()的机制。

这个机制试图解决什么样的问题呢?以 Paul McKenney 在讨论中提供的一段代码为例:

if (READ_ONCE(A)) {
WRITE_ONCE(B, 1);
do_something();
} else {
WRITE_ONCE(B, 1);
do_something_else();
}

这段代码中,对 A 的 read 和对 B 的 write 之间有一个 control dependency 关系,而在条件语句的每一个分支中都有这个写入操作,虽然它们写入的值是相同的,但并不应该影响 control dependency 的存在。因此人们可能会认为,这里的 read 和 write 操作不可能被 reorder。但是实际上编译器很可能会对代码进行 reorder,使其看起来像下面这样:

tmp = READ_ONCE(A);
WRITE_ONCE(B, 1);
if (tmp)
do_something();
else
do_something_else();

这段代码看起来跟上面的代码效果相同,但是实际上对从 A 读出的值进行判断的操作就不再发生在对 B 的 write 操作之前。这就打破了 control dependency,如果某个 CPU 格外积极,它就可能会把 write 操作移到 read 之前,从而产生一个微妙的错误。

由于 C 语言不能识别 control dependency,因此很难避免这种错误,哪怕连开发者都能意识到这个问题的情况下。一个确定的解决方案就是用 acquire 语义来对 A 进行 read 操作,用 release 语义来对 B 进行 write 操作,正如 lockless patterns 系列文章中所说,但 acquire 和 release 操作在某些体系架构上开销可能会很大。其实很多情况下并不需要付出这个代价。

volatile_if()

Zijlstra 在他的提议中指出,一个好的解决方案是在 if 语句中添加一个限定词,表明这里存在一个依赖关系:

volatile if (READ_ONCE(A)) {
/* ... */

编译器就会根据这个提示,来确保生成一个 conditional branch,并确保 branch 内部的代码不会被移出来到 branch 之外。然而,这需要编译器开发者的配合。正如 Segher Boessenkool 所指出的,除非 C 语言的标准委员会同意了在语句(statement)上加上 volatile 这样的限定符,否则这不可能实现。既然做不到,那么 Zijlstra 就提出了一个神奇的宏:

volatile_if(condition) {
/* true case */
} else {
/* false case */
}

他提供了若干体系架构上的具体实现,通常依赖手写的汇编代码来写出所需的 conditional branch 指令,从而让 CPU 能看到这个 control dependency。

后续讨论主要集中在两个话题上:volatile_if()的实现细节,以及它是否真的有价值。在实现细节方面,Torvalds 提出了一个更简单的方法:

#define barrier_true() ({ barrier(); 1; })
#define volatile_if(x) if ((x) && barrier_true())

barrier() 这个宏不会生成任何指令,它只是一个空语句,会作为汇编代码送给编译器。Torvalds 说,这样做也会命令编译器生成 conditional branch,因为它只能在 branch 的 "true" 的这个分支里被调用。但事实证明这里并不那么简单,需要按照 Jakub Jelinek 建议的思路重新定义 barrier() 之后,才能使这个方案真正起到效果。

但是 Torvalds 也想知道为什么开发人员需要开始担心这个问题,因为他认为这个问题不会在实际代码中表现出来:

再说一遍,语义(semantics)确实很重要,我不认为编译器会真的破坏我们的这个基本假设:"根据最基本的因果关系,哪怕没有 memory barrier,load->conditional->store 也是能保证最基本的顺序的",因为你不能随意生成会被别人看到的预测性地(speculative)store 操作。

而且,事实上,这种问题就算实际发生了,也是很难找到证据的。他后来确实看到了一个可能真的存在的问题(https://lwn.net/ml/linux-kernel/CAHk-=whDrTbYT6Y=9+XUuSd5EAHWtB9NBUvQLMFxooHjxtzEGA@mail.gmail.com/),但他仍明确表示他认为现在内核中没有任何代码会受此影响。

讨论(最终)结束了,对于是否需要 volatile_if() 并没有得出任何最终结论。经验告诉我们,对编译器的优化保持警惕,这通常是个好主意。即使现在并不会对 mainline 合入一个能够明确标记出 control dependency 的机制,当未来的编译器版本(如果)产生问题的时候,这个机制也会是一个备用手段。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~



浏览 38
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报