LWN: 如何验证代码是否被编译器优化后出错?
Posted Linux News搬运工
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了LWN: 如何验证代码是否被编译器优化后出错?相关的知识,希望对你有一定的参考价值。
译者注:
本文搞不好可能是第一篇中文介绍LKMM、herd7,litmus test的文章吧?weak memory model场景data race的分析工具。
关于LKMM的简介和语法,建议看https://lwn.net/Articles/720550/ ,方便理解本文。或者至少读一下Linux kernel的tools/memory-model/目录下的README。
本文中经常提到的“普通C语言访问”,英文原文是plain C-language access,就是不加特殊保护地直接做变量读写。
WRITE_ONCE()和READ_ONCE()这类access once操作,核心就是加了volatile通知编译器。千万不要想当然的按字面意思理解为“只读一次”和“只写一次”了。。。
Calibrating your fear of big bad optimizing compilers
October 11, 2019
(Many contributors)
This article was contributed by Jade Alglave, Will Deacon, Boqun Feng, David Howells, Daniel Lustig, Luc Maranget, Paul E. McKenney, Andrea Parri, Nicholas Piggin, Alan Stern, Akira Yokosawa, and Peter Zijlstra
在此前的文章里提到过,Linux kernel代码中普通的一个类似"a=b"这样的C语言load或者store操作,在编译时编译器根据C语言标准有可能会假设这个变量不会被其他线程访问和修改过。因此编译器会进行一些出乎意料的优化操作,很有可能会让你的并发(concurrent)代码出现问题。既然目前的编译器通常不会对这种情况给出诊断信息和提示,那么最好有其他工具来改善这一部分。例如Kernel Thread Sanitizer (KTSAN)就是一例,不过它的长处(能分析Linux kernel这种数量级的大量代码)也是它的最大缺点,因为它只能使用一些近似分析技术(当然也还是很不错了)。
Quick Quiz 1: 但是完全不用担心on-stack(位于栈上的变量)或者per-CPU变量,对吗?
per-CPU变量也有一些类似的访问场景。
我们需要的其实是一个工具,能够对大量代码进行精确分析(exact analyse),不过这方面我们是没法心想事成了。因此我们改进了Linux Kernel Memory Model (LKMM)来对小段代码进行精确分析,已经在5.3 merge window内合入了Linux kernel mainline。因此我们暂时虽然还没法对大量代码进行精确分析,不过至少已经有个工具可以开始用了。
接下来会介绍如何利用LKMM的这次升级后的功能:
-
Goals and non-goals
-
A plain example
-
A less-plain example
-
Locking
-
Reference counting
-
Read-copy update (RCU)
-
Debug output
Goals and non-goals
一句话来说,LKMM的目标是帮助人们理解Linux-kernel concurrency(并发)。
上次文章的结论之一,就是我们其实没有一个完整列表,来说明哪些编译器优化可能会导致并发出错。更不用说今后可能还会有多种编译器用来编译kernel。更进一步来说,kernel在很多情况下其实依赖了一些C语言标准以外的用法,因此没法直接利用C11 memory model(内存模型)。Linux-kernel编译过程中的编译选项,编译器的实现细节,还有各种体系结构特有的代码,共同影响了LKMM的实现,反之亦然。
这些组合确实非常复杂,充满不确定性,因此LKMM无法对所有在Linux-kernel中的memory-ordering问题做出确定无疑的裁判。相反的,LKMM应该被视作顾问角色。通常来说,在一些性能需求不是那么紧迫的代码里,应该更加重视LKMM的建议。而开发者如果写了一些fastpath(快速通路,指为了提升性能而做的简化处理流程,对应的就叫slowpath)的代码,那么就可以把LKMM的warning当做是一个“这里要加倍小心”的提醒,而不用当做必须要fix的error message。
牢记这个原则,我们可以开始审查一些LKMM的test例子来分析C语言访问的处理。
A plan example
下面的代码是使用C语言进行访问以及配合read/write memory barrier的实现,是个典型的消息传递代码样例。
Litmus Test #1
1 C C-MP+p-wmb-p+p-rmb-p
2
3 {
4 }
5
6 P0(int *x0, int *x1)
7 {
8 *x0 = 1;
9 smp_wmb();
10 *x1 = 1;
11 }
12
13 P1(int *x0, int *x1)
14 {
15 int r1;
16 int r2;
17
18 r1 = *x1;
19 smp_rmb();
20 r2 = *x0;
21 }
22
23 exists (1:r1=1 /\ 1:r2=0)
这个示例可以在kernel的tools/memory-model目录利用下述命令来运行:
herd7 -conf linux-kernel.cfg /path/to/litmus/tests/C-MP+p-wmb-p+p-rmb-p.litmus
这里/path/to/litmus/tests应该替换为放置你的测试代码的具体目录。可以参考tools/memory-model目录下面的安装步骤。命令的输出信息会是下面这样:
Outcome for Litmus Test #1 (linux-kernel model)
1 Test C-MP+p-wmb-p+p-rmb-p Allowed
2 States 4
3 1:r1=0; 1:r2=0;
4 1:r1=0; 1:r2=1;
5 1:r1=1; 1:r2=0;
6 1:r1=1; 1:r2=1;
7 Ok
8 Witnesses
9 Positive: 1 Negative: 3
10 Flag data-race
11 Condition exists (1:r1=1 /\ 1:r2=0)
12 Observation C-MP+p-wmb-p+p-rmb-p Sometimes 1 3
13 Time C-MP+p-wmb-p+p-rmb-p 0.00
14 Hash=055863a755bfaf3667f1667e6d660349
第10行是最主要的一条信息:“flag data-race”,这是指Observation这一行被判定为不可靠,而第3-6行列出的状态是不可靠的。这个测试代码中有至少一处data race,也就是多个并发访问会对同一个变量进行,至少其中一个是普通的C语言访问,而另有一个store操作。
Quick Quiz 2: 不过如果针对这个变量的访问只有一个是普通C语言访问,并且只有一个store,那么还会算是data race吗?
Answer:没错,这里确实是有data race的。
普通的C语言访问可能会经过多种优化变形,例如store tearing, code reordering, invented store, store-to-load等。任何一种变形都会让你的并发流程出现意外结果。
不过,正如后续Debug output这一小节所讨论的,我们有理由相信编译器对store的优化可能比load更少。不过这完全取决于你希望你的代码在针对未来编译器时有多么可靠了。
Quick Quiz 3:为什么逃避呢?为什么不做些工作来确保state list和Observation line都是精确的?
Answer:只有能完全了解未来行为的情况下才能给出精确预测。也就是说,我们需要完全了解所有的编译器会做什么优化,包括过去、现在以及将来的。并且每个编译器的优化方式还各不相同,甚至针对同一个编译器也会使用各种不同命令行参数。因此LKMM的现实方式只能是举手报告给用户这里可能潜在有个data race。
这里x1变量会出现data race,因为它会由两处并发访问,其中至少一处是写操作。不过x1只会是0和1,所以这个变量出现data race的话可能还可以接受。不过我们如果想检查其他data race呢?
那么我们可以告诉LKMM忽略x1的data-race。一种做法是加个READ_ONCE()和WRITE_ONCE()在测试代码中(而不是你的Linux-kernel代码),最好也带着注释解释一下为什么:
Litmus Test #2
1 C C-MP+p-wmb-o+o-rmb-p
2
3 {
4 }
5
6 P0(int *x0, int *x1)
7 {
8 *x0 = 1;
9 smp_wmb();
10 WRITE_ONCE(*x1, 1); // Tolerate data race
11 }
12
13 P1(int *x0, int *x1)
14 {
15 int r1;
16 int r2;
17
18 r1 = READ_ONCE(*x1); // Tolerate data race
19 smp_rmb();
20 r2 = *x0;
21 }
22
23 exists (1:r1=1 /\ 1:r2=0)
LKMM又报出一个data race:
Outcome for Litmus Test #2 (linux-kernel model)
1 Test C-MP+p-wmb-o+o-rmb-p Allowed
2 States 3
3 1:r1=0; 1:r2=0;
4 1:r1=0; 1:r2=1;
5 1:r1=1; 1:r2=1;
6 No
7 Witnesses
8 Positive: 0 Negative: 3
9 Flag data-race
10 Condition exists (1:r1=1 /\ 1:r2=0)
11 Observation C-MP+p-wmb-o+o-rmb-p Never 0 3
12 Time C-MP+p-wmb-o+o-rmb-p 0.00
13 Hash=743f0171133035c53a5a29972b0ba0fd
这次是因为它检测到对x0的普通访问也有可能会并发进行,并且其中一个访问是write。我们可以同样使用READ_ONCE()和WRITE_ONCE()来标记这些对x0的访问,不过当然也可以用下面代码来避免x0被并发访问:
Litmus Test #3
1 C C-MP+p-wmb-o+o+ctrl-rmb-p
2
3 {
4 }
5
6 P0(int *x0, int *x1)
7 {
8 *x0 = 1;
9 smp_wmb();
10 WRITE_ONCE(*x1, 1); // Tolerate data race
11 }
12
13 P1(int *x0, int *x1)
14 {
15 int r1;
16 int r2;
17
18 r1 = READ_ONCE(*x1); // Tolerate data race
19 if (r1) {
20 smp_rmb();
21 r2 = *x0;
22 }
23 }
24
25 exists (1:r1=1 /\ 1:r2=0)
这里第19行的if条件,再加上第9行的smp_wmb(),共同目的是确保第8行和21行永远不会并发执行。运行model检查之后得到如下结果:
Outcome for Litmus Test #3 (linux-kernel model)
1 Test C-MP+p-wmb-o+o+ctrl-rmb-p Allowed
2 States 2
3 1:r1=0; 1:r2=0;
4 1:r1=1; 1:r2=1;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (1:r1=1 /\ 1:r2=0)
9 Observation C-MP+p-wmb-o+o+ctrl-rmb-p Never 0 2
10 Time C-MP+p-wmb-o+o+ctrl-rmb-p 0.00
11 Hash=01fe003cd2759d9284d40c081007c282
这次再也没有报出data race了。因此,如果我们对编译器足够敬畏,那就先确保对x1的读出值是合理的,并且针对x0加一个条件判断并保护,那么就能拿到确保正确的运行结果。
Quick Quiz4: 不过输出的“1:r1=0; 1:r2=1;”也消失了,为什么?
Answer: 如果r1是0,那么就永远不会load到r2,也就意味着r2会一直保持初始化的值0。这样就不可能出现“1:r1=0; 1:r2=1”这样的输出。
如果我们在Litmus Test #2里面用READ_ONCE()和WRITE_ONCE()标记了对x0的访问,那么所有三种结果都有可能(不过不要轻信我们的说法,直接试试看吧)
这个例子里面,因为有smp_wmb(),并且读出来的值只用过一次,因此只要利用WRITE_ONCE()和READ_ONCE()来告诉LKMM这里关于x1的data race是可以接受的,那就够了。不幸的是,很多情况下还需要做更多工作,我们接着进行试验吧。
A less-plain example
编译器在优化普通访问的时候有很大的自由度,相应的也导致了非常复杂的结果。可以参考下面的测试代码来理解:
Litmus Test #4
1 C C-read-multiuse
2
3 {
4 }
5
6 P0(int *a)
7 {
8 *a = 1;
9 }
10
11 P1(int *a, int *b, int *c)
12 {
13 int r1;
14
15 r1 = *a;
16 *b = r1;
17 *c = r1;
18 }
19
20 locations [1:r1; a; b; c]
21 exists(b=1 /\ c=0)
确实发现了一个data race:
Outcome for Litmus Test #4 (linux-kernel model)
1 Test C-read-multiuse Allowed
2 States 2
3 1:r1=0; a=1; b=0; c=0;
4 1:r1=1; a=1; b=1; c=1;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Flag data-race
9 Condition exists (b=1 /\ c=0)
10 Observation C-read-multiuse Never 0 2
11 Time C-read-multiuse 0.00
12 Hash=0cab074d9a510f141aae9026ce447828
我们可以简单修改一下来忽略这个data-race:
Litmus Test #5
1 C C-read-multiuse-drt1
2
3 {
4 }
5
6 P0(int *a)
7 {
8 WRITE_ONCE(*a, 1); // Tolerate data race
9 }
10
11 P1(int *a, int *b, int *c)
12 {
13 int r1;
14
15 r1 = READ_ONCE(*a); // Tolerate data race
16 *b = r1;
17 *c = r1;
18 }
19
20 locations [1:r1; a; b; c]
21 exists(b=1 /\ c=0)
这样一来,我们既让它忽略了这个data race,输出结果也显示没有问题了:
Outcome for Litmus Test #5 (linux-kernel model)
1 Test C-read-multiuse-drt1 Allowed
2 States 2
3 1:r1=0; a=1; b=0; c=0;
4 1:r1=1; a=1; b=1; c=1;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (b=1 /\ c=0)
9 Observation C-read-multiuse-drt1 Never 0 2
10 Time C-read-multiuse-drt1 0.00
11 Hash=96b3ae01a3c486885df1aec4d978bad9
所以我们万事大吉了,对吗?
其实不是,大家要牢记之前文章提到的编译优化的复杂性。回忆一下,编译器是会invent load的,因此上面实现的写法不好,更好的要求LKMM忽略data-race的写法应该是要重复一下对a的load操作:
Litmus Test #6
1 C C-read-multiuse-drt2
2
3 {
4 }
5
6 P0(int *a)
7 {
8 WRITE_ONCE(*a, 1); // Tolerate data race
9 }
10
11 P1(int *a, int *b, int *c)
12 {
13 int r1;
14 int r2;
15
16 r1 = READ_ONCE(*a); // Tolerate data race
17 *b = r1;
18 r2 = READ_ONCE(*a); // Tolerate data race
19 *c = r2;
20 }
21
22 locations [1:r1; a; b; c]
23 exists(b=1 /\ c=0)
这种改法也忽略了已知的data race,并且结果无异常:
Outcome for Litmus Test #6 (linux-kernel model)
1 Test C-read-multiuse-drt2 Allowed
2 States 3
3 1:r1=0; a=1; b=0; c=0;
4 1:r1=0; a=1; b=0; c=1;
5 1:r1=1; a=1; b=1; c=1;
6 No
7 Witnesses
8 Positive: 0 Negative: 3
9 Condition exists (b=1 /\ c=0)
10 Observation C-read-multiuse-drt2 Never 0 3
11 Time C-read-multiuse-drt2 0.01
12 Hash=17ff8b2e2c285776994d4488fcdcd3bb
这下工作完成了吧?
仍然没有。仔细想一下,编译器还是会reorder code的。我们没有告知编译器这里对b的store操作一定是会在对c的store操作之前发生。并且,既然代码里用了普通C语言load操作来从公共变量a里面读取数据,那么编译器就很有可能误会认为公共变量a是不会改变的。因此我们需要考虑这种情况来构建另一个data-race-tolerant测试来涵盖reorder的情况,例如下面这样:
Litmus Test #7
1 C C-read-multiuse-drt3
2
3 {
4 }
5
6 P0(int *a)
7 {
8 WRITE_ONCE(*a, 1); // Tolerate data race
9 }
10
11 P1(int *a, int *b, int *c)
12 {
13 int r1;
14 int r2;
15
16 r2 = READ_ONCE(*a); // Tolerate data race
17 *c = r2;
18 r1 = READ_ONCE(*a); // Tolerate data race
19 *b = r1;
20 }
21
22 locations [1:r1; a; b; c]
23 exists(b=1 /\ c=0)
这样仍然能解决a的data race,但是出现了新的问题:
Outcome for Litmus Test #7 (linux-kernel model)
1 Test C-read-multiuse-drt3 Allowed
2 States 3
3 1:r1=0; a=1; b=0; c=0;
4 1:r1=1; a=1; b=1; c=0;
5 1:r1=1; a=1; b=1; c=1;
6 Ok
7 Witnesses
8 Positive: 1 Negative: 2
9 Condition exists (b=1 /\ c=0)
10 Observation C-read-multiuse-drt3 Sometimes 1 2
11 Time C-read-multiuse-drt3 0.01
12 Hash=61f32f3a79e57808d348f31f5800ae1d
这个例子说明,我们需要仔细考虑编译器的各种优化策略。也说明我们需要使用多个litmus test来完整分析所有可能的输出情况。
Quick Quiz 5: 不过等一下,既然READ_ONCE()不确保顺序,那么为什么Litmus Test #5里面不会出现异常结果呢?
Answer: 首先,需要注意READ_ONCE()其实在某种意义上保证了顺序,它会让编译器不要把READ_ONCE()和其他任何标记过的访问进行调整顺序。不过,这个限制没法影响CPU。
但其实这里CPU级别的顺序保证并不是必要的,因为正如函数名称所说,READ_ONCE(a)确保只读出一次,因此会把相同的值写入b和c。因为exists语句里面的不希望出现的结果是要求b和c最终有不同的值,那么READ_ONCE()尽管没有order保证能力,也是足够了的。
那么,我们需要在kernel里避免使用普通C语言的load和store对公共变量进行访问吗?
当然不需要。其中一个原因是,多数情况下普通C语言load和store都不会导致data-race,后续几节会解释为什么。
Locking
锁,非常普及,很好用,尤其是在多CPU的系统上。因为有锁机制,C和C++起初那么多年一直不打算增加并发功能支持。所以大家通常都认为利用锁来保护好的并发代码应该不会出现普通C语言访问导致的并发问题了。例如,看一下下面这个用锁保护好的store-buffering测试:
Litmus Test #8
1 C C-SB+l-p-p-u+l-p-p-u
2
3 {
4 }
5
6 P0(int *x0, int *x1, spinlock_t *s)
7 {
8 int r1;
9
10 spin_lock(s);
11 *x0 = 1;
12 r1 = *x1;
13 spin_unlock(s);
14 }
15
16 P1(int *x0, int *x1, spinlock_t *s)
17 {
18 int r1;
19
20 spin_lock(s);
21 *x1 = 1;
22 r1 = *x0;
23 spin_unlock(s);
24 }
25
26 exists (0:r1=0 /\ 1:r1=0)
正如我们所预料的,LKMM显示上述代码执行下来确保了顺序执行,没有任何data race:
Outcome for Litmus Test #8 (linux-kernel model)
1 Test C-SB+l-p-p-u+l-p-p-u Allowed
2 States 2
3 0:r1=0; 1:r1=1;
4 0:r1=1; 1:r1=0;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (0:r1=0 /\ 1:r1=0)
9 Observation C-SB+l-p-p-u+l-p-p-u Never 0 2
10 Time C-SB+l-p-p-u+l-p-p-u 0.01
11 Hash=a1b190dd8375d869bc8826836e05f943
不过锁机制不是唯一的一个避免data-race的同步原语。
Reference counting
引用计数,它应该比锁机制应用的更加早吧。因此不出意料,它也能确保普通C语言访问公共变量安全可靠。一种方法是用atomic_dec_and_test(),这样只有把引用计数减一得到0的进程才能拥有对这些数据的访问权。下面这个有趣的测试就可以看出来效果:
Litmus Test #9
1 C C-SB+p-rc-p-p+p-rc-p-p
2
3 {
4 atomic_t rc=2;
5 }
6
7 P0(int *x0, int *x1, atomic_t *rc)
8 {
9 int r0;
10 int r1;
11
12 *x0 = 1;
13 if (atomic_dec_and_test(rc)) {
14 r0 = *x0;
15 r1 = *x1;
16 }
17 }
18
19 P1(int *x0, int *x1, atomic_t *rc)
20 {
21 int r0;
22 int r1;
23
24 *x1 = 1;
25 if (atomic_dec_and_test(rc)) {
26 r0 = *x0;
27 r1 = *x1;
28 }
29 }
30
31 exists ~((0:r0=1 /\ 0:r1=1 /\ 1:r0=0 /\ 1:r1=0) \/
32 (0:r0=0 /\ 0:r1=0 /\ 1:r0=1 /\ 1:r1=1))
起初,每个进程都有自己的变量,具体来说P0()拥有X0,P1()拥有x1。引用计数rc在第4行被初始化为2,也就是说两个进程都仍然独占他们相应的变量。每个进程在第12和24行修改了它们自己的变量,然后释放了引用计数(在第13和25行)。胜出的进程就能把rc减一得到0,然后能访问两个变量的内容了。因此它的r0和r1变量都是1。而输掉的进程,本地变量都保持为0。在第31行的exist语句验证得到如下结果:
Outcome for Litmus Test #9 (linux-kernel model)
1 Test C-SB+p-rc-p-p+p-rc-p-p Allowed
2 States 2
3 0:r0=0; 0:r1=0; 1:r0=1; 1:r1=1;
4 0:r0=1; 0:r1=1; 1:r0=0; 1:r1=0;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (not (0:r0=1 /\ 0:r1=1 /\ 1:r0=0 /\ 1:r1=0 \/ 0:r0=0 /\ 0:r1=0 /\ 1:r0=1 /\ 1:r1=1))
9 Observation C-SB+p-rc-p-p+p-rc-p-p Never 0 2
10 Time C-SB+p-rc-p-p+p-rc-p-p 0.02
11 Hash=7692409758270a77b577b11ab7cca3e3
正如我们所预料的,这里没有任何data race,胜出进程总是能够安全的读出正确数据。
Quick Quiz6:我之前见过普通C语言实现的对引用计数的加一或者减一操作,这是怎么做到的?
Answer:假如引用计数是用锁保护的,那就可以这样做。不过,假如没有锁保护的话,那么对引用计数的加一或者减一操作就必须要用kernel提供的原子操作方式了。
Read-copy update (RCU)
我们经常会在一个link list里面插入几项新的数据结构,这是一个常见的RCU使用场景。假如这些新加项里面成员预先已经都初始化好了,并且在插入之后非指针的成员从来不会被更改,那么可以直接使用普通C语言访问方式,正如下面这个测试:
Litmus Test #10
1 C C-MP+p-rap+rl-rd-p-rul
2
3 {
4 int z=42; (* Initial garbage value *)
5 int y=2;
6 int *x=&y; (* x is the list head; initially it points to y *)
7 }
8
9 P0(int **x, int *y, int *z)
10 {
11 *z = 1;
12 rcu_assign_pointer(*x, z); // Now x points to z.
13 }
14
15 P1(int **x, int *y, int *z)
16 {
17 int *r1;
18 int r2;
19
20 rcu_read_lock();
21 r1 = rcu_dereference(*x); // Pick up list head.
22 r2 = *r1; // Pick up value.
23 rcu_read_unlock();
24 }
25
26 locations [x; y; z]
27 exists (1:r1=z /\ 1:r2=42) (* Better not be pre-initialization value!!! *)
Quick Quiz 7: 不过,Litmus Test #10里面没有调用synchronize_rcu(),那么这里17行的rcu_read_lock()和20行的rcu_read_unlock()的目的是什么?
Answer: 理论上来说,它们都是可以省略掉的,因为事实上没有synchronize_rcu()跟它们交互。尽管如此,它们还是能起到一个解释说明的作用的。此外,有它们存在的话,今后有人要更新这个litmus test的时候更容易弄懂逻辑,可以省下很多时间。
注意,上面在设置初始化状态的部分,使用的是一个特别的注释格式,用(*和*)来添加注释。第5和第6两行在herd7看来就是创建了一个最基本的link连接数据结构,x起初就是指向了y。P0()在第11行初始化z,然后在11行把它插到link里。P1()在RCU-side critical section里面来通过x间接访问到z。
这样就避免了data race,也让P0()避免看到z在初始化之前的垃圾数据,LKMM检查结果如下:
Outcome for Litmus Test #10 (linux-kernel model)
1 Test C-MP+p-rap+rl-rd-p-rul Allowed
2 States 2
3 1:r1=y; 1:r2=2; x=z; y=2; z=1;
4 1:r1=z; 1:r2=1; x=z; y=2; z=1;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (1:r1=z /\ 1:r2=42)
9 Observation C-MP+p-rap+rl-rd-p-rul Never 0 2
10 Time C-MP+p-rap+rl-rd-p-rul 0.01
11 Hash=fbe83006932079946732b23c5af9033d
经常还有需要从linked list里面删掉一些项目并且free内存,这样才能避免内存泄露。这个过程可以用下面的test来展示:
Litmus Test #11
1 C C-MP+rap-sync-p+rl-rd-rd-rul
2
3 {
4 int z=1;
5 int y=2;
6 int *x=&y; (* x is the list head; initially it points to y *)
7 }
8
9 P0(int **x, int *y, int *z)
10 {
11 rcu_assign_pointer(*x, z); // Now x points to z.
12 synchronize_rcu();
13 *y = 0; // Emulate kfree(y).
14 }
15
16 P1(int **x, int *y, int *z)
17 {
18 int *r1;
19 int r2;
20
21 rcu_read_lock();
22 r1 = rcu_dereference(*x); // Pick up list head.
23 r2 = *r1; // Pick up value.
24 rcu_read_unlock();
25 }
26
27 locations [1:r1; x; y; z]
28 exists (1:r2=0) (* Better not be freed!!! *)
这种做法也避免了data race,reader不会碰到use-after-free bug:
Outcome for Litmus Test #11 (linux-kernel model)
1 Test C-MP+rap-sync-p+rl-rd-rd-rul Allowed
2 States 2
3 1:r1=y; 1:r2=2; x=z; y=0; z=1;
4 1:r1=z; 1:r2=1; x=z; y=0; z=1;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (1:r2=0)
9 Observation C-MP+rap-sync-p+rl-rd-rd-rul Never 0 2
10 Time C-MP+rap-sync-p+rl-rd-rd-rul 0.00
11 Hash=abfbb3196e583a4f3945a3e3846442b0
还有可以拿synchronize_rcu()当做更强烈的memory barrier,类似Litmus Test #3一样的效果:
Litmus Test #12
1 C C-MP+p-sync-o+rl-o-ctrl-p-rul
2
3 {
4 }
5
6 P0(int *x0, int *x1)
7 {
8 *x0 = 1;
9 synchronize_rcu();
10 WRITE_ONCE(*x1, 1);
11 }
12
13 P1(int *x0, int *x1)
14 {
15 int r1;
16 int r2;
17
18 rcu_read_lock();
19 r1 = READ_ONCE(*x1);
20 if (r1) {
21 r2 = *x0;
22 }
23 rcu_read_unlock();
24 }
25
26 exists (1:r1=1 /\ 1:r2=0)
LKMM根据exists语句里的条件,确认了这个方案可以避免data race以及不希望出现的异常结果。
Outcome for Litmus Test #12 (linux-kernel model)
1 Test C-MP+p-sync-o+rl-o-ctrl-p-rul Allowed
2 States 2
3 1:r1=0; 1:r2=0;
4 1:r1=1; 1:r2=1;
5 No
6 Witnesses
7 Positive: 0 Negative: 2
8 Condition exists (1:r1=1 /\ 1:r2=0)
9 Observation C-MP+p-sync-o+rl-o-ctrl-p-rul Never 0 2
10 Time C-MP+p-sync-o+rl-o-ctrl-p-rul 0.00
11 Hash=7672d2fc273055d3dcf0fc68801e113a
Debug output
如果我们按照类似Litmus Test #9里面的样子,有了一个简洁干净的引用计数方案,但是我又想加个printk()或者trace event来帮助debug一些相关问题,那么一上来想到的做法就会是下面这样:
Litmus Test #13
1 C C-SBr+p-rc-p-p+p-rc-p-p+p
2
3 {
4 atomic_t rc=2;
5 }
6
7 P0(int *x0, int *x1, atomic_t *rc)
8 {
9 int r0;
10 int r1;
11
12 *x0 = 1;
13 if (atomic_dec_and_test(rc)) {
14 r0 = *x0;
15 r1 = *x1;
16 }
17 }
18
19 P1(int *x0, int *x1, atomic_t *rc)
20 {
21 int r0;
22 int r1;
23
24 *x1 = 1;
25 if (atomic_dec_and_test(rc)) {
26 r0 = *x0;
27 r1 = *x1;
28 }
29 }
30
31 P2(int *x0, int *x1) // Emulate debug output.
32 {
33 int r0;
34 int r1;
35
36 r0 = *x0;
37 r1 = *x1;
38 }
39
40 exists ~(0:r0=0:r1 /\ 1:r0=1:r1 /\ ~(0:r0=1:r0) /\ ~(0:r0=1:r1))
不过,这段按直觉写的代码就会产生data race了:
Outcome for Litmus Test #13 (linux-kernel model)
1 Test C-SBr+p-rc-p-p+p-rc-p-p+p Allowed
2 States 2
3 0:r0=0; 0:r1=0; 1:r0=1; 1:r1=1;
4 0:r0=1; 0:r1=1; 1:r0=0; 1:r1=0;
5 No
6 Witnesses
7 Positive: 0 Negative: 8
8 Flag data-race
9 Condition exists (not (0:r0=0:r1 /\ 1:r0=1:r1 /\ not (0:r0=1:r0) /\ not (0:r0=1:r1)))
10 Observation C-SBr+p-rc-p-p+p-rc-p-p+p Never 0 8
11 Time C-SBr+p-rc-p-p+p-rc-p-p+p 0.05
12 Hash=f752f7f65493e036e734709bdb9233be
针对这个测试结果,可以对所有不产生真正冲突的普通C语言写操作加上WRITE_ONCE(),也就是第12和24行的这两处。不过,在复杂的大量代码环境里面,会引入非常多的WRITE_ONCE()增删操作,很难维护,相应的拼写错误也会立刻导致bug。
所以还有一种做法是看一下系列文章中上一篇里指出的编译器可能会产生的所有优化的列表,然后就能看出唯一的一个common-case store transformation是store fusing,如果没有其他并发问题的话,这个store fusing应该不会出问题。这就意味着使用READ_ONCE()比起使用WRITE_ONCE()更加重要,这是Linus Torvalds最近多次提出的一个观点。
可能有人会不赞成这个观点,认为是对C标准的视而不见。不过正如Torvalds指出的:“如果出现这种问题,我们应该去修复编译器本身,我们之前已经做了不少类似的事情,例如-fno-strict-aliasing,-fno-delete-null-pointer-checks,-fno-strict-overflow,因为所有这些所谓的优化本身就是根本不安全的。”
简单来说,kernel社区会负责保证新发布的编译器不会导致kernel代码出错,也会采取相应措施来处理这些会导致问题的编译器优化。https://lore.kernel.org/lkml/20190821103200.kpufwtviqhpbuv2n@willie-the-truck/ 和https://gcc.gnu.org/ml/gcc-patches/2019-08/msg01538.html 就是两个例子。并且,公平地说,kernel社区有很多成功经验了,过去很多问题都得到了解决。
不过,如果我们不太信任编译器能解决这些问题的话,该怎么办呢?Torvalds提出:“今后,WRITE_ONCE()的最常用用法应该是跟non-synchronized read时用到的READ_ONCE()进行配对调用。”
因此,假如你想用WRITE_ONCE()来标记一处有data-race风险的store操作,Torvalds可以接受,只要有一个对相同变量的READ_ONCE()对应操作即可。
Quick Quiz8: 是否所有牵涉到data race的情况,都需要至少一个READ_ONCE()?
Answer:首先,开发者如果担心store tearing, fused stores, invented stores, store-to-load这些变化的话,都认为只要有并发的对同一个变量进行store操作并且其中有一个是普通C语言store那就会是个data race,不论是否使用READ_ONCE()。
不过,假如这个变量是同时被load和store操作访问的,并且并发进行的store都已经用WRITE_ONCE()保护起来了,那么是否至少有一个load操作需要使用READ_ONCE()?
正常来说应该确实是这样的,当然没有办法100%保证。例如,理论上来说,在reader拿到read-writer lock的时候,所有对同一个变量的store操作都是可以进行的。同理,如果writer拿到锁的话,所有load操作也可能会在同时进行的。在这个例子里,只有store操作会导致data race。实际项目中,怎么会有人真的这么用呢?
Access-marking policies
有人可能会希望把所有可能会导致data race的数据访问都标记(用WRITE_ONCE()和READ_ONCE())出来,包括load和store。按照这个方向来实施的话,最好有个列表能列出所有“允许普通load/store访问某个变量、同时又需要额外用READ_ONCE() WRITE_ONCE()等标记对同一个变量的其他访问”的情况。列表如下:
有一个公共变量只会被拥有它的一个CPU或者线程来修改,但是会被其他CPU或线程读取。所有的store都应该用WRITE_ONCE()。拥有变量写权限的CPU或线程在load的时候可以直接用普通操作即可。其他CPU和线程都必须要用READ_ONCE()来做load操作。
公共变量的修改,全部都是在拿到指定的锁之后进行的,不过read操作没有锁保护。这种情况下,所有的store操作都要用WRITE_ONCE()。拿到锁的CPU或线程可以使用普通load操作。其他人都必须要用READ_ONCE()。
公共变量的修改只有拥有它的CPU或线程拿到锁之后才会进行,但是read操作可能会发生在其他CPU上,也可能会在没有拿锁情况下发生。这样所有的store操作都必须用WRITE_ONCE()。拥有这个变量的CPU或者线程可以使用普通load操作,其他CPU或线程拿到锁的情况下也可以使用普通load。其他所有情况都必须用READ_ONCE()。
公共变量仅由某个特定CPU或者线程访问,还有运行在此CPU或线程上下文的信号或者中断处理函数里面访问。信号、中断处理函数里可以使用普通的load/store,其他地方如果代码里确保信号、中断处理函数不可能被调用的(例如关中断了),也可以使用普通load/store。其他情况必须使用READ_ONCE()和WRITE_ONCE()。
公共变量仅由某个特定CPU或线程访问,还有这个CPU或线程上下文里执行的信号、中断处理函数,并且这个信号、中断处理函数在每次返回之前都会把它写过的变量内容恢复回原始值。这样的话,信号、中断处理函数可以使用普通load/store,确保信号、中断处理函数不会发生的情况下也可以使用普通load/store。其他所有情况都可以使用普通load,但是需要利用WRITE_ONCE()来防止store tearing, store fusing, invented store。
其他大多数情况下,对公共变量的load和store操作都应该用READ_ONCE()和WRITE_ONCE(),或者更强的保护。不过需要再次强调的是,READ_ONCE()和WRITE_ONCE()都没法保证顺序(ordering),完全是由compiler决定的。
Quick Quiz 9: 假如中断处理函数或者信号处理函数本身被打断了,会发生什么?
Answer: 如果是这样的话,中断处理函数就必须要遵循其他中断处理函数的规则。只有那些本身不会被打断的处理函数、或者它访问的变量不会被打断它的处理函数访问,那才可以安全地使用普通访问方式。并且也要保证这些变量不会被其他CPU或线程在同一时刻访问。
其他开发人员可能会忽略WRITE_ONCE()调用(perhaps give or take stores of constants),否则的话应该遵守上面的建议。还有一些开发者可能会采用更严格的策略。
LKMM目前能处理普通load和store,不过至于代码最终怎么写,还是取决于kernel开发者和维护者的决定。
Limitations
在2017年的LKMM LWN文章中介绍的很多LKMM的局限,现在仍然是一样的,不过还是值得重复再说一下:
不支持很多种access size
不支持部分重复覆盖(partially overlapping)的access size
不支持复杂(nontrivial)变量,包括数组和结构。不过简单的linked list还是支持的。
不支持动态分配的内存。
不支持异常和中断。
不支持I/O,包括DMA。
不支持会动态改动的代码。在kernel里其实这种代码用得很多,包括一些“alternative”机制,function tracer,eBPF JIT编译器,以及module加载代码。
这个memory model不包含各种CPU厂家的特定的体系结构的官方支持。尽管相应厂家有一些员工参与了开发。例如,有可能某个CPU厂家有一天就可能会针对这个memory model的某个版本的行为报出bug。因此本memory model无法替代我们平常进行的系统正常验证过程。并且memory model还在频繁更新中,经常会变。最后,尽管大家都很欢迎这个方案,不过暂时还是有一些CPU family没有提供memory model支持。
很有可能这个memory model跟真正的CPU或者硬件行为并不一致。例如,可能某些行为在真实CPU上是不被允许的,但是本memory model会允许,原因仅仅是因为开发难度太大。反之,有可能一些行为是CPU允许的,但是会导致bug(model本身bug或者CPU真实bug),那么memory model也会禁止。不过,大家一致在持续改进这一部分,通过各种litmus test来验证model是跟对应硬件尽量吻合的。
这个工具分析代码的耗时会随着代码量而指数级增长。litmus test还是很小的代码,所以herd tool分析的时候还不用太久时间。不过分析kernel就不是那么容易了。也就是说,这个工具对于穷举法分析这些同步问题上,还是很有效率的。
herd tool只能按照你预设好的assertion条件来工作,这个缺点也是软件形式化方法(common method)的共同缺点,这也是我们为什么觉得需要更多测试。正如Donal Knuth所说:“小心上面代码里的bug,我只是证明了它是对的,不过没试过。”
不过,从2017年到现在,还是有不少改进的:
有限算数(limited arithmetic)可以使用了。
支持了很多种read-modify-write原子操作,不过kernel社区还在不断发明新的此类API,因此LKMM短期内没办法承诺能支持kernel中所有的read-modify-write原子操作。
支持了SRCU(Sleepable RCU)。不过,RCU和SRCU的模型仍然不支持那些异步的grace-period调用,例如call_rcu()和srcu_barrier()。
支持了锁,不过只支持显式加锁。Reader-writer locking今后会支持,包括最近提出的double-lock(支持多个reader或者多个writer,但是reader和writer不会同时进行)后续也会支持。
支持了普通C语言的访问(plain C-language access),编译器优化可以通过比较保守地标记data race来建模。
LKMM的不少原有限制也仍然没有解决,不过还是有很大进展了。但是,对data race的保守策略,也就意味着LKMM不应该作为普通C语言访问使用正确与否的最终裁判者。正如在上一篇文章里所说,很多时候开发者和代码维护者还是需要对编译器优化有一些敬畏才好。
Summary
本文介绍了LKMM新增的对普通C语言访问的处理能力,给出了一些深入示例,然后介绍了锁、引用计数、RCU如何用来确保不会出现data-race。接下来介绍了由于调试输出信息引入的一些复杂问题,介绍了集中可能的标记访问的规则,最终总结了一下LKMM的限制以及进展。
LKMM扩展了data race概念,很容易用来对一些具体的情形进行建模。其中一种解决方法是把相应的访问在litmus test里面用READ_ONCE()或WRITE_ONCE()标记出来。如果编译器优化有很大自由度的话,就需要提供多个litmus test,每个能针对一组可能的编译器优化配置。
这样一来,LKMM就可以用来对多种访问标记策略之后的普通C语言访问进行建模了,会对各类开发者和维护者有帮助。
Acknowledgment
一如既往,我们需要感谢很多位编译器开发者,C和C++标准委员会成员,他们一起为我们介绍了编译器在极端情况下会做哪些优化。还要感谢SeongJae Park,帮助我们完成了access-marking polices这一节的可读性很强的初稿。还要感谢Kara Todd的支持。
全文完
LWN文章遵循CC BY-SA 4.0许可协议。
热烈欢迎转载以及基于现有协议修改再创作~
长按下面二维码关注:Linux News搬运工,希望每周的深度文章以及开源社区的各种新近言论,能够让大家满意~
以上是关于LWN: 如何验证代码是否被编译器优化后出错?的主要内容,如果未能解决你的问题,请参考以下文章