Who's afraid of a big bad optimizing compiler?

July 15, 2019

(Many contributors)

本文贡献者包括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.

原文地址:https://lwn.net/Articles/793253/

译者:LinuxEverything

在编译Linux内核代码时,一个普通的C语言load/store操作,例如“a=b”这样的写法,按照C语言标准,编译器可以认为这些牵涉到的变量在被load或store的同一时刻,没有其他线程在访问或者修改它们。因此可以随意进行各种优化变形,此前在ACCESS_ONCE()相关的LWN讨论文章里面有过介绍,还有一些在Dmitry Vyukov的KTSAN wiki page上也有描述(https://github.com/google/ktsan/wiki/READ_ONCE-and-WRITE_ONCE  )。不过目前越来越强大的现代编译器所做的代码优化已经超出我们的预料了。经过某些优化之后,开发者不能再期望C语言的load/store跟汇编语言的load/store能一一对应了。本文虽然是写给Linux kernel开发者的,其实很多场景也适用于其他的并发场景软件(concurrent code base,指可能有多方同时读写某个公共变量的软件方案)。务必注意这里所说的并发场景代码,也包括使用了中断和signal单线程的代码。随着编译器越来越强大,就让我们不禁开始担心:“这些优化具体有多危险?”。下面的内容可以帮助回答这个问题。

  1. Load tearing
  2. Store tearing
  3. Load fusing
  4. Store fusing
  5. Code reordering
  6. Invented loads
  7. Invented stores
  8. Store-to-load transformations
  9. Dead-code elimination
  10. How real is all this?

下面的每个快速问题也都附有答案。

快速问题1:但是我们不用担心on-stack或者per-CPU的变量,对吗?

Answer: 尽管on-stack和per-CPU变量通常都被保证不会被其他CPU和task访问,但是kernel在很多情况下其实允许对他们做concurrent(并发、同时)访问的。当然肯定要使用一些特殊方法来构造这种情况,例如专门传递这样变量的地址给其他线程。

举例来说,_wait_rcu_gp() 宏会使用一个on-stack的__rs_array[]数组,数组元素是rcu_synchronize结构,其中包含rcu_head和completion这两个结构。如果把rcu_head结构的地址传递给call_rcu(),就会发生对这个结构的并发访问,最终导致completion结构也同时被两者访问了。

针对per-CPU的变量也能找到类似的例子。

1. Load tearing(读取拆分)

Load tearing会在编译器用多个load指令来实现一次数据访问的时候发生。例如,编译器理论上来说可以把下面第一行从global_ptr的load操作编译生成一系列的逐个byte的load操作。

  1 ptr = global_ptr; /* BUGGY if load tearing possible. */2 if (ptr != NULL && ptr < high_address) /* BUGGY!!! */3         do_low(ptr);

假如与此同时其他线程也在修改global_ptr为NULL,那么结果可能会导致ptr指针的内容除了一个byte之外,其他bit都变成0,导致出现错误指针。后续利用这个出错指针进行store操作的时候可能会损坏内存里面某个地方的数据,最终导致crash并且很难debug问题出在哪。更坏情况下,如果是在一个8位机系统上使用16bit的指针,编译器可能会不得不用一对8bit访问指令来访问这个指针。甚至在现代的32bit或者64bit系统上,非对齐访问或者太大size的访问也会发生拆分现象。因为C语言标准支持各种系统,那么就没法从根源上避免load tearing的发生。

不过,本文后面的讨论中都假设这些访问是对齐(aligned)的,并且是按照机器支持的size的访问(machine-sized),这样只要使用READ_ONCE()就可以避免load tearing了。(在Linux kernel里面,C语言的load在某些情况下哪怕是确保对齐以及是machine-sized访问了,也出现过load tearing问题,见http://lkml.kernel.org/r/CAHk-=wj2t+GK+DGQ7Xy6U7zMf72e7Jkxn4_-kGyfH3WFEoH+YQ@mail.gmail.com  )

快速问题2:Linux kernel里面有很多对公共变量(shared variable)的load操作,这些不可能出错吧?

Answer: 这个跟load操作的上下文环境有关,以及开发者有多警觉。

先讲一下上下文环境,如果某个变量只会在某个exclusive lock或mutex的保护之下才会被访问,那么使用普通的load或者store是完全安全的。

有一些条件不那么苛刻的上下文环境也是安全的。例如对某个变量的写入操作同时能确保没有对这个变量的其他访问,那么这里的load/store也是安全的。例如,对一个变量的所有load操作都是在一个reader-writer lock或者mutex保护之下,并且对这个变量的写操作也都受对应的reader-write lock或者mutex保护之下,那么这里的load/store也是安全的。相应的,如果所有对该变量的写操作都是由一个kernel thread完成的,这个变量也只有在此之后创建的子线程里才会有load操作,那么load/store也是完全安全的。同样,如果对某个结构里进行store操作,然后才会用rcu_assign_pointer()来让它对所有reader可见,并且这些reader都通过rcu_dereference()来获取了访问权限之后才会访问这个结构,那么load/store也仍然是安全的。还有很多其他的类似情况都是安全的。

当然也有很多场景下两者并发进行是不安全的。例如,某个变量只会从0 变到1,那么不管编译器怎么拆分这个load操作,结果都只可能是0或者1。如果发生了invented loads(请看本文中对应的小节),那么就有可能会同时拿到0和1两个结果,开发者肯定是不希望看到这种匪夷所思的结果的。

换句话说,当对公用变量进行load操作的时候,开发者和维护者要负责处理好,要么避免对同一个变量同时进行store操作,要么就确保编译器不会把代码优化成不符合开发者本意的模样。

那么总结一下,Linux kernel对共享变量的load操作是否有bug?如果相应的开发者很仔细的控制好了调用上下文,也对编译器可能进行的优化非常警觉,那么kernel里的相应代码就是安全的。

2. Store tearing

类似的,Store tearing是在编译器使用多个store指令来完成一次操作的时候会发生。例如,一个线程希望对一个4字节的变量写0x12345678,另一个线程可能正在写0xabcdef00。假如编译器决定用16-bit的store操作来完成,那么结果就可能会变成0x1234ef00,这样后续读取这个变量读出这个数值的时候肯定没法正确处理。这也不是一个仅存在理论中的问题,有些CPU会直接把小立即数写入内存,这些CPU的编译器就会把32-bit store分成两个16-bit store,这样就不用花时间去构建一个32-bit的常量了。x86-64就是这样的行为。这种store tearing问题哪怕是确保了对齐以及符合machine-word-size的情况下也会触发。代码可能经常会在32位系统上使用64位的变量,那么编译器确实没有办法,只好拆分成多个store操作来完成。不过访问的时候是对齐并且符合machine-size的话,可以使用WRITE_ONCE()来避免store tearing。

3. Load fusing

Load fusing是指编译器直接使用上一次对这个变量的load结果,而不是真正再去load一次。这种操作通常来说无论是单线程环境下还是多线程环境下都是非常安全的,不过,既然说了“通常”,就意味着还是有些很讨厌的例外情况,就像在之前提到的ACCESS_ONCE()的那篇文章里介绍的情况。

假设有个实时系统,需要重复调用一个名为do_something_quickly()的函数,直到need_to_store被置1的时候才停,compiler可以确认do_something_quickly()函数内部并不会修改need_to_stop变量,所以很常见的、写成下面这样的代码,其实会出错:

  1 while (!need_to_stop) /* BUGGY!!! */2     do_something_quickly();

编译器可能会先对这个循环做16次loop unrolling(拆分循环操作),目的是能减少每次执行到循环体末尾往回跳转的开销。不过更麻烦的是,因为编译器知道do_something_quickly()不会写need_to_stop,所以它就决定只读一次这个变量即可,这样最后的代码看起来会变成类似这样:

  1 /* Optimized code */2 if (!need_to_stop)3     for (;;) {4         do_something_quickly();5       do_something_quickly();6        do_something_quickly();7        do_something_quickly();8        do_something_quickly();9        do_something_quickly();10       do_something_quickly();11       do_something_quickly();12       do_something_quickly();13       do_something_quickly();14       do_something_quickly();15       do_something_quickly();16       do_something_quickly();17       do_something_quickly();18       do_something_quickly();19       do_something_quickly();20     }

这样进到循环体之后,就会永远在第3到20行循环,不管其他线程对need_to_stop写多少次值都没用。这样优化过之后的代码肯定不能满足需求了,甚至最坏情况下有可能损坏硬件。

编译器的fuse load操作可能分布在代码里相距很远的地方,例如:

  1 int *gp;23 void t0(void)4 {5     WRITE_ONCE(gp, &myvar);6 }78 void t1(void)9 {10     p1 = gp; /* BUGGY!!! */11     do_something(p1);12     p2 = READ_ONCE(gp);13     if (p2) {14         do_something_else();15         p3 = *gp; /* BUGGY!!! */16     }17 }

这段代码里t0()和t1()会同时运行,do_something()和do_something_else()都是inline函数,第一行声明了指针gp,C语言会缺省初始化为0。程序的第5行的t0()操作里面会对gp写一个非零值。与此同时t1()在第10,12,15行分别对gp有3次load操作。假设第13行的时候发现gp是非零值,那么按理来说第15行使用到gp的时候肯定不应该出错(fault)。

可惜,编译器有权对第10和15行做fuse load,也就是说第10行如果读出来是NULL,那么第12行读出&myvar的值,然后第15行(依据第10行的fuse load结果)被解析成NULL,最终导致fault。这里插入的READ_ONCE()并不能保证前后的两次load操作不会被load fuse影响,也就是说哪怕3次都是对同一个变量做load也没用。按理来说应该不会有编译器这么做,不过Will Deacon报告过一例问题,曾经发生在Linux kernel里面。

要想避免load fusing,可以在每次读取gp的时候都使用READ_ONCE(),或者在Linux kernel里面这3处访问之间都加上barrier()。

快速问题 3:为什么do_something()和do_something_else()要是inline函数才会出现问题?

Answer:  因为gp不是一个静态变量,如果do_something()或者do_something_else()是在别处编译好的,那么编译器就需要假设认为这两个函数有可能修改gp的值。这样的假设,使得编译器需要在第15行重新读取gp,从而避免了空指针引用的错误。

这是在没有link-time optimization (LTO)的情况下。因为目前对编译器的优化越来越激进,开发者和内核维护者也需要更加激进的关闭那些有破坏性的代码优化选项,要么是通过修改调用编译器时的命令行参数,要么就是在代码里使用特定的API来给编译器发指示,例如barrier(), READ_ONCE(),WRITE_ONCE()。

4. Store fusing

Store fusing会在编译器发现代码对同一个变量进行连续多次store操作中间却没有对该变量的load操作的时候发生。这种情况下,编译器认为可以忽略第一次的store操作。这在单线程代码里不是问题,其实在写的比较好的并发执行代码里通常也不是问题。其实,如果两次store操作很快进行,那么其他线程也确实没有多少机会可以读到第一个store进去的数值。

不过,这里仍有例外情况,例如:

  1 void shut_it_down(void)2 {3     status = SHUTTING_DOWN; /* BUGGY!!! */4     start_shutdown();5     while (!other_task_ready) /* BUGGY!!! */6         continue;7     finish_shutdown();8     status = SHUT_DOWN; /* BUGGY!!! */9     do_something_else();10 }1112 void work_until_shut_down(void)13 {14     while (status != SHUTTING_DOWN) /* BUGGY!!! */15         do_more_work();16     other_task_ready = 1; /* BUGGY!!! */17 }

这里的函数shut_it_down()会对公共变量status进行一个写操作,发生在第3、8两行。假如start_shutdown()和finish_shutdown()都没有访问status,那么编译器就认为完全可以去掉第3行对status的store操作。不幸的是,这就意味着work_until_shut_down()就永远都会在14、15两行里面循环无法退出,这样也就导致other_task_ready也没有机会置1了,从而shut_it_down()也永远在第5、6两行循环无法退出(哪怕编译器没有对第5行other_task_ready做fuse load优化也不影响这个结果)。虽然WRITE_ONCE()能避免store fusing,不过我们更应该用smp_store_release()或更强的API来保证,这样能确保其他线程先看到store操作之前的所有改动了,然后再看到store。

这段代码里面还有其他问题,包括下面要讲的code reordering。

5. Code reordering

Code reordering是一个常见的编译技巧,用来把一些类似的计算归在一起,节省占用的寄存器,改善现代超标量微处理器里面各个运算单元的利用效率。这也是上述代码另一个有问题的地方。举例来说,第15行的do_more_work() 并不会访问other_task_ready,那么编译器就很有可能会把第16行的对other_task_ready的赋值给挪到第14行之前。这样一来人们本来预期第15行调用do_more_work()应该在第7行的finish_shutdown()之前完成,这就落空了。

哪怕禁止编译器做reorder也没用,因为处理器硬件自己也可能会做reorder。

在一个单CPU的系统上,如果硬件对连续的两个access做了reorder,而碰巧又有一个中断在两者之间发生了?那么中断处理函数里面会看到哪个值?

还好,其实这个并不是一个真问题,现代的处理器都有一个“exact exception”和“exact interrupts”,也就是说任何中断或者异常发生的时候,都一定是在指令流的那个特定位置,也就意味着中断处理函数会看到之前指令的结果,而不会看到后续指令的效果。READ_ONCE(),WRITE_ONCE(),barrier()就可以用来控制被中断的代码和中断处理函数之间的交流,而不用担心底层硬件进行reorder的影响。这就是为什么,当你写user-space代码的时候,各种标准组织都希望你在这种场景用atomics操作、或者sig_atomic_t类型的变量,而不用READ_ONCE()和WRITE_ONCE()。

不过在跟其他CPU交互的时候,还是需要用更强一些的操作,例如smp_load_acquire()和smp_store_release()。

6. Invented loads

Invented loads可以用下面这个代码来说明,这里编译器会把一个临时变量优化掉:

  1 /* Optimized code */2 if (global_ptr != NULL &&3     global_ptr < high_address)4         do_low(global_ptr);

编译器优化会导致global_ptr被load三次,这样会导致do_low()被调用的时候可能global_ptr是空指针,从而出错。

Invented loads也会导致性能损失,例如当某个变量位于某个被频繁访问的cacheline里的时候,对它的load操作又被提升到 if 语句之前。这种优化行为也是很正常的,却可能会导致更多cache miss,从而系统性能明显下降。

要避免invented load,使用READ_ONCE()就可以了。

快速问题 4: 不过第2行特地做了NULL检查,那么do_low()怎么可能会调用时使用NULL pointer呢?

Answer:  思考一下下述事件按顺序发生的情况:

  1. 第2行先从global_ptr load出一个非零值的指针。
  2. 其他CPU对global_ptr写入NULL
  3. 第3行从global_ptr读取出刚刚写入的NULL值,然后判断出小于high_address
  4. 出事了!这样接下来对do_low()的调用参数就是NULL指针了。

7. Invented stores

Invented stores可以在多种情况下发生,例如在上述store-fusing的例子里面,编译器生成work_until_shut_down()的汇编指令的时候,可能会看到other_task_ready是在第16行有store操作,但是没有被do_more_work()访问过,如果do_more_work()是一个复杂的inline函数的话,就有可能导致register溢出,这种时候编译器很可能会想到利用other_task_ready存放的寄存器来做临时数据存放位置,反正没有人在访问它,利用一下又没有问题。

结果,对这个变量写入一个非0值的话,如果时机不巧,会导致第5行的while循环提前结束,然后finish_shutdown()就跟do_more_work()同时运行了。既然while循环的目的就是避免这种情况,那么两者同时运行肯定会出错的啊。

这里临时占用了一个编译器误以为只有store行为的变量,这种操作看起来很古怪,也不会经常发生,我们其实没见过哪个编译器会真的做invented stores操作。不过invented store确实是C语言标准允许的行为。

不仅如此,读者如果想要看到一个不那么古怪的例子的话,可以参考这个:

  1 if (condition)2     a = 1; /* BUGGY!!! */3 else4     do_a_bunch_of_stuff();

编译器在针对上述代码生成指令的时候,可能知道 a 的初始值是0,因此编译器可能会想通过改为下面的代码来优化掉一次跳转:

  1 /* Optimized code */2 a = 1;3 if (!condition) {4     a = 0;5     do_a_bunch_of_stuff();6 }

这里,优化后代码的第2行不管condition是否满足,都先做了一个a=1操作,然后在第4行里根据condition情况来决定是否要把a写回0。这样转化之后,代码从if-then-else变成了if-then,能省掉一个跳转分支。

快速问题 5:什么?难道时说编译器不能再随心所欲的对某个普通变量增加一个store操作了?

Answer: 还好,并不需要担心这个。因为编译器绝对不被允许引入data race。这里提到的在某个普通的store操作之前又凭空加出来一个store的场景其实非常特殊:其他主体(可以是CPU,线程,signal handler,中断处理函数)都不可能看到这个invented store,除非代码本身在没有invented store的情况下已经有data race了。如果代码本身有data race,那么本来就会出现各种意想不到的问题,编译器生成什么样的代码也都不改变大局了。

不过如果本来的store操作是volatile的,例如使用了WRITE_ONCE(),那么编译器都可能会假设这里的store操作还会有个其他作用——就是会通知其他线程来在不产生data-race的情况下访问这个变量。如果这里inventing the store,编译器就得引入一个本不存在的data race,这个是编译器会绝对避免的行为。这也就是为什么memory-barriers.txt里要求使用WRITE_ONCE()来做store从而确保控制流按顺序执行的原因。当然这只是原因之一,另一个原因在本文的Store-to-Load Transformation小节有描述。凡是使用了volatile或者atomic变量的情况下,编译器都绝对禁止inventing writes。

在C11之前的编译器可能会对不相干的变量增加store操作,只要它们碰巧跟一个write-to变量紧挨着的话(可以参考Hans Boehm's的经典文章Threads cannot be implemented as a library第4.2节)。这种变种的invented stores在C11里面禁止了,为了防止data race。

快速问题6:什么是“data race”?

Answer: data race,当有多个同时进行的read/write access在对同一个变量进行时会出现,并且其中至少一个是普通C语言的读写访问,另有一个是store操作。

不过,这个规则还是有个例外:如果后面紧接着又有一个store操作,之间没有加任何保证顺序的API,那么invented store引入的data race也就意味着后续的store也会引入一个data race。这种情况下,编译器认为它没有导致data race,只不过是把已经存在的一个data race扩展了一下。编译器认为这是正常的,所以它会这么做,尽管代码本身意图不是这样的。举例来说:

  1 struct foo {2     short a;3     char b;4     char c;5 };67 void do_something(struct foo *fp)8 {9     fp->a = 0x1234;10     fp->b = 0x56;11     do_something_else();12     fp->c = 0x42;13 }

在编译器看到do_something_else()的实现之后,假如发现这个函数里没有barrier()之类的确保顺序的API,那么开发者对fp->c的写入操作,编译器就认为没有任何并发的read/write在同时进行(其实开发者自己并没有保证这一点)。编译器后续就会按它自己的理解来做下面的优化(假设是在一个big-endian系统上):

  1 struct foo {2     short a;3     char b;4     char c;5 };67 void do_something(struct foo *fp)8 {9     *(long *)fp = 0x123456ff;10     do_something_else();11     fp->c = 0x42;12 }

假如系统其他线程在同时做fp->c的load,看到这里强行加上的0xff就会非常诧异。需要说明的是,这种变化并不是一个仅存在域想象中的情况:后面跟着的对变量的store会被认为可以对这个变量做覆盖写入。

并且,早期的编译器会对不相干的变量做invent store,哪怕没有后续的普通store来触发也一样。针对上述这么多种类型的invented stores,可以使用barrier()或者WRITE_ONCE()来避免。

8. Store-to-load transformations

Store-to-load transformation,是指编译器认为某个store操作可能不会真正更改内存中的值的时候所做的优化。例如:

  1 int r1, x, y;23 void cpu1(void)4 {5     WRITE_ONCE(y, 1);6     smp_mb();7     WRITE_ONCE(x, 1);8 }910 void cpu2(void)11 {12     r1 = READ_ONCE(x);13     if (r1 == 1)14         y = 0; // BUGGY!!!15 }

这里CPU 1执行cpu1()的时候,用到了WRITE_ONCE()来对y和x写入1,中间还加了完整的memory barrier操作。CPU 2执行cpu2(),其中用到了READ_ONCE()来load x,如果x的值是1的情况下,第14行才会对y写0。看了这段代码的人,肯定认为r1最终会变成1,然后用的最终值也一定是0。

不过,编译器可以把第14行改成如下的load-compare-store操作,如下14、15行所示:

  1 int r1, x, y;23 void cpu1(void)4 {5     WRITE_ONCE(y, 1);6     smp_mb();7     WRITE_ONCE(x, 1);8 }910 void cpu2(void)11 {12     r1 = READ_ONCE(x);13     if (r1 == 1)14         if (y != 0)15             y = 0;16 }

根据上述代码,CPU 2可能把第14行对y的load操作给reorder到READ_ONCE之前,这样变化之后,执行中因为先看到y初始的0值,然后跳过第15行的对y的store操作。这样y最终很可能结果是1了。

编译器为什么要这么做呢?其实按我们目前的理解,这种代码改变其实也只是一个理论上可能发生的情况,不过今后在feedback-driven optimization的情况这个真有可能会变成现实。因此,如果你希望你的store无论在当前编译器还是未来编译器都确实要生效的话,请用WRITE_ONCE()或者更强的API。

9. Dead-code elimination

Dead-code elimination,通常发生在编译器看到某个load出来的值从来不被用到的时候,或者某个变量被store过,但是从来没有被load过的情况下。这样的优化事实上会消除某个对共享变量的访问,从而导致memory-ordering API primitive(原语)无效,因此让同时执行的代码跑出出人意料的结果。已经有过很多真实事例都是这种情况下跑出的错误。尤其是如果把那些store-only的变量(但是有外界代码通过符号表来访问这个变量的情况)给优化掉,那就更加危险。编译器根本不知道这种外部代码的访问,可能会真的把这个变量在优化中给拿掉了。

可靠地并发代码,必须要想办法让编译器能保证那些对共享变量内存的核心访问代码的次数、顺序、访问类型都不能被改变。因此Linux kernel才会提供READ_ONCE(),WRITE_ONCE(),barrier()以及多种memory barrier和read-modify-write等原子操作原语。

10. How real is all this?

上述列举的这些优化类型中,有一部分发生概率比别的高很多。

  Occurs in the Wild(真实发生过)?
优化类型 (假设已经确保了访问是Aligned, Machine-Word Sized)
Load Tearing Yes
Store Tearing Yes, for constants
Load Fusing Yes
Store Fusing Yes
Code Reordering Yes
Invented Loads Yes
Invented Stores In some cases
Store-to-Load Transformations Unknown
Dead-Code Elimination Yes

那么Linux kernel开发者该怎么办?各位开发者的处理方式各不相同,他们使用READ_ONCE()和WRITE_ONCE()的方式也各不相同,有如下4种类型:

  • 从来不用
  • 对这些情况使用:访问某些共享变量并且能看出肯定会有data race的时候,或者能明显看出编译器优化可能会导致bug的时候。
  • 对这些情况使用:访问所有可能导致data race的共享变量的时候(哪怕只有其中一次access可能出错)
  • 所有共享变量的访问都使用。

Linux kernel里的代码也都能找到上述4种情况的例子。不过开发者/维护者如果选择了前两种方式,那么就要负责确保今后新发布的编译器编译自己这部分代码的时候代码逻辑不受影响。开发者应该对今后的编译器可能采取的优化措施保持足够的敬畏,并且今后一直能基于对编译器的敬畏来贡献代码。

快速问题 7:本文里描述了编译器所有可能进行的优化,对吗?

Answer: 不是的.

还有很多很多其他的优化方式,这也不奇怪,毕竟C语言标准里面很多情况下的行为没有确定的定义,这样每个地方都可能会引出一些各有特色的编译器优化。有人在做工作希望把编译器的优化行为控制在一定范围内(参见http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1797r0.html ,http://www.open-std.org/jtc1/sc22/wg14/www/docs/n2369.pdf  ,http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1756r0.pdf  ),不过编译器开发者以及标准委员会成员并不是必须要支持这些工作的,尽管开发者碰到并发代码的时候非常希望编译器能有限制。

快速问题 8:既然有风险,为什么不要求所有对共享变量的访问都使用READ_ONCE()和WRITE_ONCE()?

Answer: 当然大家确实可以要求更广泛的使用这些API,不过其实并不需要采用这么极端的做法。例如,上述某个快速问题里面已经提到过,kernel里面的很多机制,例如大家最常用的锁机制,都提供了进程互斥的方法,这样就并不需要READ_ONCE()和WRITE_ONCE()了。

此外,尽管READ_ONCE()和WRITE_ONCE()的cost不高,那也还是有cost的,毕竟它们会限制编译器的优化。例如,编译器在看到连续多个READ_ONCE()操作的时候生成的代码必须要让这些操作按顺序执行,但是其实也许这些操作并不需要严格按顺序执行?没准还能更加快速的完成。因此某些需要快速执行完成的代码就可以直接使用普通C语言的read/write操作即可,不过最好开发者能加一些注释说明,方便后续其他人阅读代码时能更好的理解。

Linux kernel还有一些使用方式的限制,这样就不需要额外使用编译器指示(compiler directives)。一个例子是地址和数据有依赖关系的情况,相关的使用限制在rcu_dereference.txt里面有描述;还有控制流程的依赖关系(control dependencies),在memory-barriers.txt的CONTROL DEPENDENCIES小节有详细描述。

不过,我们还是需要理解今后的编译器优化可能越来越激进。这样就会导致,对这些直接使用load/store访问共享变量并且有潜在data race的代码,会耗费更多的开发维护精力。这可能能解释为什么READ_ONCE()和WRITE_ONCE()在Linux kernel里面的使用一直在缓慢增长。

另一方面,开发者如果采用后面两种策略,那就不用过于担心今后出现的编译器优化。不过他们可以利用一些工具来确认是不是需要加READ_ONCE() WRITE_ONCE()或者更强的保护,能帮助他们确认无论在如今的还是未来的编译器里都能是安全的。LWN后续会有文章介绍Linux kernel memory model最近的一个改动,将会有助于开发者做上述检测。

Acknowledgments

非常感谢很多编译器开发者,很多C/C++标准委员会的成员,他们给我们介绍了极端情况下编译器可能会做的各种优化改动。感谢Junchang Wang, SeongJae Park,Slavomir Kaslev,他们帮助撰写了第一版文章。也非常感谢Mark Figley和Kara Todd,感谢他们对此工作的支持。

全文完

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

极度欢迎将文章分享到朋友圈 
热烈欢迎转载以及基于现有协议上的修改再创作~

长按下面二维码关注微信公众号:Linux News搬运工,希望每周的深度文章以及开源社区的各种新近言论,能够让大家满意~

LWN:怕不怕编译器优化让你的代码彻底乱套?相关推荐

  1. 编译器优化陷阱之典型代码

    static BOOL timer2_flag;       ISR(TIMER2_COMPA_vect)       {              timer2_flag = true;       ...

  2. String类型的认识以及编译器优化

    Java中String不是基本类型,但是有些时候和基本类型差不多,如String b = "tao" ; 可以对变量直接赋值,而不用 new 一个对象(当然也可以用 new).所以 ...

  3. 干货:嵌入式C语言源代码优化方案(非编译器优化)

    点击上方"大鱼机器人",选择"置顶/星标公众号" 福利干货,第一时间送达! 1.选择合适的算法和数据结构 选择一种合适的数据结构很重要,如果在一堆随机存放的数中 ...

  4. volatile关键字的作用:防止变量被编译器优化

    本文大部分来自于:http://witmax.cn/volatile.html 我怕链接会失效,故转载此篇文章... volatile关键字是一种类型修饰符,用它声明的类型变量,编译器对访问该变量的代 ...

  5. 最强通用编译器优化工具!MIT三篇顶会论文打造,准确率是传统方法5倍

    乾明 十三 发自 凹非寺 量子位 报道 | 公众号 QbitAI 新代码在自家芯片上运行状况如何?英特尔自己都没有别人家的新工具清楚. 这就是MIT耗时一年提出的研究成果,名为Ithemal,核心功能 ...

  6. 【Linux 内核 内存管理】优化内存屏障 ① ( barrier 优化屏障 | 编译器优化 | CPU 执行优化 | 优化屏障源码 barrier 宏 )

    文章目录 一.优化屏障 ( 编译器优化 | CPU 执行优化 ) 二.优化屏障源码 一.优化屏障 ( 编译器优化 | CPU 执行优化 ) " 代码 " 编译成 " 可执 ...

  7. 【转】C 编译器优化过程中的 Bug

    C 编译器优化过程中的 Bug 一个朋友向我指出一个最近他们发现的 GCC 编译器优化过程(加上 -O3 选项)里的 bug,导致他们的产品出现非常诡异的行为.这使我想起以前见过的一个 GCC bug ...

  8. 深入理解JVM虚拟机(八):编译器优化

    本博客从编译期源码实现的层次上让我们了解了Java源代码编译为字节码的过程,分析了Java语言中泛型.主动装箱/拆箱.条件编译等多种语法糖的前因后果. 1. 概述 java语言的"编译期&q ...

  9. C#编译器优化那点事

    使用C#编写程序,给最终用户的程序,是需要使用release配置的,而release配置和debug配置,有一个关键区别,就是release的编译器优化默认是启用的. 优化代码开关即optimize开 ...

最新文章

  1. 大四Java复习笔记之Java基础
  2. linux脚本:给定目录下所有文件中查找某字符串
  3. deebot扫地机器人怎么清洁_智能清洁小助手开始工作 360扫地机器人S7评测
  4. python读取大文件的某行_Python按行读取文件的实现方法【小文件和大文件读取】...
  5. office 高效办公智慧树_华为发布首款商用台式机,打造未来高效智慧办公体验_企业...
  6. javax.management.InstanceNotFoundException: org.springframework.boot:type=Admin,name=SpringApplicati
  7. UI界面排版搞不定 ?看看这些优秀的实例模板,可临摹学习!
  8. 现金贷风控生命周期——贷前风控
  9. PC端编辑 但能在PC端模拟移动端预览的富文本编辑器
  10. asp.net抓取网页html源代码失败 只因UserAgent作怪
  11. 微信小程序——律师事务所微官网
  12. FastJson(阿里巴巴)基础
  13. Unirech:阿里云国际版免备案虚拟主机的优点与缺点
  14. 带你实战Android深色模式,深入原理剖析
  15. it行业se是_计算机行业SSE、SE、BSE、PE、PL各自是什么职位意思?
  16. 计算机填充表格,表格自动填充 这几种你也会?
  17. qt 模拟鼠标滑轮_【游戏流体力学基础及Unity代码(四)】用欧拉方程模拟无粘性染料之公式推导...
  18. 北京航空航天大学计算机学院 孙,北京航空航天大学计算机学院导师教师师资介绍简介-孙磊磊...
  19. 壹度婚礼邀请函请帖小程序免费制作
  20. Matlab小波变换双端行波测距凯伦布尔变换放射状配电网单相故障测距Simulink模型及对应程序

热门文章

  1. linux 注释批处理,Linux_批处理 正则表达式(findstr) 整理,语法 findstr [/b] [/e] [/l] [/r] [/s] - phpStudy...
  2. 面试心得与总结---BAT、网易、蘑菇街等
  3. Flask后端实践 连载十三 Flask输出Excel报表
  4. 在校大学生计算机等级考试可以在其他省考吗
  5. mDNS原理的简单理解
  6. 微软输入法半角全角切换
  7. java插入图片_如何在java窗体程序中添加图片
  8. php 表示每月一号,关于适合每月一号发的说说
  9. 《真倚天屠龙记》详解攻略一
  10. 【HDFS】HDFS文件块大小(重点)