看《链接装载与库》关于多线程中“过度优化”一节的困惑,烦请指点

2019-12-23 20:31:05 +08:00
 wheeler

书中举了一个很简单的例子:

int global_x = 0; // 两个线程共享的全局变量.

Thread1: // 线程 1 的定义                              Thread2: // 线程 2 的定义

lock();                                                lock();

global_x++;                                          global_x++;

unlock();                                             unlock();

看上去,因为线程 1 和线程 2 在访问 global_x 时都使用了 lock()unlock() 保护,因此 global_x++ 的行为不会被并发破坏,所以在线程 1 和线程 2 结束之后,global_x 的值似乎一定是 2。但其实,这么理所当然的猜测有可能是错误的。解释如下:

  1. [Thread1] 读 global_x 值到寄存器 R[1]
  2. [Thread1] R[1]++ (R[1]=1)
  3. [Thread2] 读 global_x 值到寄存器 R[2]
  4. [Thread2] R[2]++ (R[2]=1)
  5. [Thread2] 将寄存器 R[2]的值写回 global_x (global_x=1)
  6. [Thread1] 将寄存器 R[1]的值写回 global_x (global_x=1)

出现这样的问题,是因为编译器为了提高global_x的访问速度,将global_x的值放到了某个寄存器里,这就导致了所谓过度优化的问题。

书中给出的为了阻止过度优化的方法是使用 volatile 关键字。(注:这里的 volatile 仅指 C/C++的关键字,不要和 java 中的搞混)


我的理解是volatile从来不是多线程中需要的,靠操作系统提供的同步原语应该就足够了。

相关链接:

https://wiki.sei.cmu.edu/confluence/display/c/CON02-C.+Do+not+use+volatile+as+a+synchronization+primitive

https://stackoverflow.com/questions/78172/using-c-pthreads-do-shared-variables-need-to-be-volatile#answer-784840

https://stackoverflow.com/questions/3208060/does-guarding-a-variable-with-a-pthread-mutex-guarantee-its-also-not-cached

pthread locks implement memory barriers that will ensure that cache effects are made visible to other threads. You don't need volatile to properly deal with the shared variable i if the accesses to the shared variable are protected by pthread mutexes.

1458 次点击
所在节点    问与答
8 条回复
wheeler
2019-12-23 21:01:36 +08:00
移动端代码排版乱了。

![例子.jpeg]( https://i.loli.net/2019/12/23/ISfyg9HcNrOThRj.jpg)
qieqie
2019-12-23 23:45:19 +08:00
原则上编译器应当不需要 volatile 的提示也要保证程序的正确性。但实际上编译器优化基本上是个黑盒的过程,所以有这么个关键字作为一个提示手段。
另外我手边也有这本书,书的写作时间是十年前了,当年主流编译器的行为和现在的也可能存在一定的出入。
wevsty
2019-12-24 00:59:22 +08:00
就楼主举的这个例子来说,我认为没有 volatile 编译器也会正确的进行优化,不会导致计数结果出现错误。

原因是在这个代码的函数中没有做除了++以外的操作,++操作也要求一定要同步修改到内存,并没有什么必要需要把操作数保存到寄存器里来预先载入。
从汇编的角度来看,编译器转化为 ASM 的伪代码应该类似于:
```
call lock()
mov <reg>,<mem>
inc <reg>
mov <mem>,<reg>
call unlock()
```
甚至更简单一些直接化简为:
```
call lock()
inc <mem>
call unlock()
```
为了保持语义正确,这已经是最简化的代码了,没有什么优化的空间。
对于单纯写入内存的操作,操作系统提供的同步语义已经能提供足够的保护了。

但 volatile 也并非在多线程开发中没有意义,举个例子:
```
int global_x = 0;

void thread_01()
{
while(global_x < 100)
{
sleep(1);
}
}

void thread_02()
{
lock();
global_x++;
unlock();
}
```
当 thread_01 执行的时候,thread_01 内部并没有对 thread_01 做任何修改,这时候编译器无法预测到 global_x 可能被改变,所以优化的时候很有可能会把 global_x 放到寄存器来加速循环的执行。
这种时候即使 thread_02 里对 global_x 操作的时候加了锁,最终 thread_01 还是可能会陷入死循环。

volatile 关键字代表强制要求编译器每一次使用这个变量的时候都必须从内存读取,所以在这个例子中,使用 volatile 后就可以避免死循环的出现。

所以通常的,对于可能被读取线程以外的什么条件或者代码改动的变量应该使用 volatile 关键字才不容易出现问题。
secondwtq
2019-12-24 02:12:00 +08:00
volatile 是给编译器看的,过了编译器就不需要了。也就是如果楼主直接写汇编是根本不需要 volatile 之类的概念的。
这是条件之一。也是楼主提到的

另一个条件是 SO 回复中的"function call (a sequence point in C)"( C++ 里面是 sequenced-before 关系)
,这使得编译器必须保证生成 #3 的代码,而不是先放到寄存器里面等 unlock 之后再存回内存

如果没有这个保证,书里面的问题是真实存在的,楼主不能只看 OS 不看编译器
secondwtq
2019-12-24 02:16:17 +08:00
@wevsty 楼主这个明显是个 minimal example,在实际代码里面很多不是”函数中没有做除了++以外的操作“的情况(比如在算法中更新全局的 statistics (嘛,虽然这种情况一般应该用一个 local 变量存下))
如果这个变量的 live range 比较长(在函数中被用了多次),并且没有 function call (或没有 function call 作为 sequence point 的保证),那编译器是可能选择把它在寄存器里面扣一会的
anytk
2019-12-24 10:03:40 +08:00
书中的这个例子恐怕需要商榷,锁语义会插入 memory barrier,进锁会读同步,出锁会写同步。
wheeler
2019-12-24 11:30:44 +08:00
@secondwtq #5 感谢。请教一下,3 楼给出例子会被优化成死循环吗?这里有 sleep 的存在我觉得似乎不会呀。
secondwtq
2019-12-24 22:24:46 +08:00
@wheeler

在主流编译器 + 主流硬件的实现中不会,编译器会写死识别一些常用函数,比如你写个 sin 可能就没了,但是一般不会认 sleep 函数。
但是注意这个 thread_01 并没有做任何同步,按标准来的话是 UB。
volatile 这里没用,可以加锁,不过一般是用 atomic

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/631644

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX