百科问答小站 logo
百科问答小站 font logo



为什么编译器过度优化导致线程安全问题? 第1页

  

user avatar   bei-ji-85 网友的相关建议: 
      

__asm volatile (x ::: "memory")和mfence可以解决这个问题

-------------------------

其实这是lock实现的bug,正规的操作系统不应该出现这种情况,此书有点老了。
只看代码的话(不涉及操作系统对lock的具体实现)那么你的同事的理解其实更正确一点。

有三个前置条件:
1. 大多数CPU操作都是访问寄存器,访问变量也是访问寄存器。
2. 真实的数据都是放在内存里的。
3. CPU不一定按照编译后的汇编指令顺序执行,可能是乱序的。

所以,如果操作系统(注意,不是编译器)对lock的操作实现的不够好的话,那么lock操作可能会无法防止编译器优化,导致CPU直接使用寄存器里的值去操作,而不是到内存里取得变量的真实值。尤其是,如果这个lock不是一个操作系统提供的操作,而是你自己写的lock的话,就更容易出问题了。

volatile只是强制让这里的代码去内存里读数据,但无法阻止乱序。为了阻止乱序,需要用mfence这条指令,CPU遇到这条指令,就保证在mfence之前的东西不会跑到mfence之后去运行,阻止CPU乱序。

但是这本书,实际上指的是理论情况

正常的操作系统,如果提供锁的机制,不会脑残到让用户自己调用mfence,一个设计合理的锁机制,都在锁函数实现里调用了mfence之类的指令,保证CPU走到lock里的时候,不会乱序。

所以,第一种情况可能会有,但不多见,用volatile可以搞定,第二种CPU乱序的情况,仅存在于理论上不,正常操作系统不会有这种问题。


user avatar   Ivony 网友的相关建议: 
      

通常来说,lock和unlock会自动做一个Memory Barrier,也就是说通常来说,书上说的那种情况是不可能发生的。

因为在lock/unlock的时候会强制写回(如果是一个比较正常的实现)。


我觉得这本书是不是混淆了volatile和Memory Barrier?


user avatar   s.invalid 网友的相关建议: 
      

这本书就属于“以其昏昏,使人昭昭”的典型了。


先说锁。

锁的原理和作用其他答案已经提到了,它实质上是用一个原子操作指令来保护另外的一堆非原子指令,从而使它们也得到“原子性”。

所谓“原子性”,你可以理解为“做这些事时,内存只有我一个人能改,从而使得我的动作完全体现我的意图,要么完全成功要么完全失败,不会出现第三种状态”。


典型的原子操作指令如CPU提供的test & set类指令:先测试内存中某个位置存储的值是否符合条件(比如为0表示未上锁),若符合条件则执行set操作(把指定值写入内存);否则不执行任何操作。

这个过程中,指令执行过程就需要禁止内存访问。不然另一个test&set指令就可能读到头一个test&set指令即将写入的单元的原始值,从而造成“脏读”。


类似的,我们可以用test&set指令维护一个内存单元的内容,用它作为旗标(flag);当我们需要读写另一块内存之前,先检查并设置旗标——当每个访问这块内存的操作都先检查和设置旗标、发现旗标状态不对就主动避让时,我们就说“这块内存被锁保护起来了,现在对它的操作都是独占的”。


显而易见,你完全可以不去请求锁(检查旗标、主动避让),那么锁对你就是完全没有强制力的。


换句话说,“锁”其实是个“君子协定”。

“我”害怕这块内存脏读脏写,使“我”的程序出现bug,所以“我”才主动调用锁,发现锁条件不满足时主动等待(除了自旋锁。锁一般是OS提供的,因此请求锁失败OS马上就会知道,就会暂时中止线程执行,直到锁被释放才会重新把它调度到待执行队列)。

但如果写程序的是个野生的二蛋,他压根就不知道脏读脏写这回事……那么所谓的“锁保护”自然就不存在了(所以我喜欢把共享资源封装成个类,各种访问都必须通过接口进行)。


明白了这个,自然就该知道了:锁是(程序员自己选择的)主动退避行为,疏忽了或者学艺不精就不会知道退避,并不存在“锁什么什么位置”的说法。


或者,简单说,在这个例子里,lock/unlock之间的代码,无论是读写内存也好、访问磁盘也罢,它们一定是串行的。绝对不存在A lock了、还没unlock呢,B居然能抢在A unlock之前执行一说。

写编译器的不是傻子。锁相关的指令是很特殊的(往往会带lock前缀、或者设置内存屏障,视不同CPU不同);遇到这种指令,用脚趾头想也该知道接下来那块代码不能乱来——绝对不可能把锁相关指令之前/之后的其他指令提到它之前/之后执行。


类似的,C/C++的函数调用往往伴随着无数(发生在内存或其他地方的)副作用。没有哪个傻蛋敢把函数调用位置前后的指令随意调换位置的。这本书的说法纯属无稽之谈。


再说volatile。


volatile其实是这么一回事:在过去那个还没搞出线程的黑暗年代里,C/C++语言的使用者发现,编译器优化有时候会搞砸他们的程序。


这件事情的缘由是这么来的:读写内存的时间代价非常非常昂贵。比如在奔三CPU上,一次访存同时又cache未命中引起的开销是70个时钟周期(来自我的记忆,数字未必正确);而读取寄存器几乎没有开销。

因此,编译器必然倾向于“尽量压缩掉所有不必要的内存访问指令”。这个技术被称为“常量优化”或者“常量分析”;用人话说就是“观察一段子程序,尽量把‘内存变量的读写’去掉,替换为‘只往寄存器加载一次,然后一直用寄存器内容’;但同时要保证程序语义等价”。

       void fun(int &arg1) {    //一些操作,此时arg1可以全部优化为寄存器访问    ....    //这个调用把arg1的地址传给了另一个函数fun2    //此时不得不把寄存器内的arg1值写入,否则fun2就会‘脏读’    fun2(&arg1);    //由于fun2可能修改了arg1,因此这里必须重新读取arg1的新值    ... }      


为了配合这个优化,现代CPU内部制造了大量的寄存器(组),方便程序使用;同时,C++引入了新的const关键字——过去,你传址一个变量到另一个函数中,那么这个变量很可能就会被这个函数修改;那么当函数执行返回后,你就不得不重新读一下内存中这个变量的值,这样才能保证寄存器里面的值是最新的。

而const关键字相当于告诉编译器,这个函数并不会修改这个被声明为const的引用变量;所以无需在本函数执行返回后、强制调用者重新加载新值。

       void fun(int &arg1) {    //一些操作,注意此时arg1可以全部优化为寄存器访问    ....    //这个调用把arg1的地址传给了另一个函数fun2    //此时不得不把寄存器内的arg1值写入,否则fun2就会‘脏读’    fun2(arg1);    //由于fun2的声明为fun2(const &arg1),因此它不会修改arg1,所以无需重读内存中的arg1的值    ... }      


但有些情况下,这个优化会引起问题。

举例来说,某些CPU上,外设和内存是统一编址的。比如你把指针p指向内存位置100H,在intel CPU上这是访问内存;但在其它CPU上,这可能是读写地址编码为100H的那个外设的内容(比如网卡)。

于是,当它是内存时,只在开头读一次到寄存器、之后一直操作寄存器是正确的优化;但如果它是外设……

因此,C/C++不得不增加了一个volatile关键字:这个变量的内容随时可能因不明原因改变,因此不要对它执行常量优化。

       void read_nic(char *buf, size_t len) {     volatile int *nic_port;     nic_port=100H;     for(size_t i = 0; i < len; i++) {         //必须有volatile声明,否则buf内容就可能是从100H读进来的第一个值的len次重复         buf[i] = *nic_port;     } }      

问题圆满解决。



然后,多线程时代到来。

多线程允许一个程序内部同时存在多个执行绪;这时候,哪怕变量指向内存,它的内容也可能随时改变了——只要它被共享给另一个线程。

于是,为了图方便,人们直接“挪用”了volatile关键字——这个变量的内容随时可能改变,因此不要对它做常量优化。


事情似乎解决了。


但是,volatile这个关键字太容易误导初学者:

1、因为volatile的意思是“可变”,所以既然我都这么声明了,编译器一定能安排的妥妥贴贴——比如多线程程序里,只要把一个变量声明为volatile就能保证数据安全。

但实际上,对一个volatile变量的访问指令未必是原子的;volatile仅保证了“每次都从内存取数据”,并不能保证数据安全(无法避免脏读/脏写)。你必须自己想办法保护共享数据、确保对它们访问的原子性。

2、既然volatile保证不了数据安全,我用锁保护它总行了吧?顺便的,反正volatile也没用,统统删了算了……

错。哪怕用了锁,volatile仍然有用。它的作用是阻止编译器的常量优化。多线程访问的数据的确不能常量优化,所以你还必须写上它——换句话说,需要阻止常量优化的地方,你仍然需要自己明确声明。



然后呢,你提到的“利用volatile避免过度优化”这个说法,又是一个典型的、更浅薄的错误理解——volatile的作用很精确,就是阻止常量优化;如果编译器的常量优化没问题,用它就是典型的“负优化”;如果编译器真优化出了问题,用它也并不能阻止其他方面的优化。


正本清源之后,这个问题就很容易回答了。


1、编译器的确会“通过调整代码执行次序优化程序效率”,甚至CPU自己都会通过“乱序执行”来优化自己内部资源的利用效率;但这个优化必然是严格同义的——绝对绝对不可能出现“两个线程被锁保护部分的指令交错执行”这么奇葩的事情


换句话说,这本书的作者压根没搞明白锁究竟是什么、背后是什么机制。


2、真正惹祸的是“常量优化”

编译器一“看”,你的程序仅执行了x++,没把它传给别人;那好,x++就可以做常量优化——于是thread1就再也看不见thread2的修改了。

甚至于,它还可以“聪明”的发现,其实函数内部也用不着真从内存读x值,直接读取参数值——然后只需在函数末尾写一次内存,节约一次内存读取,岂不美哉?

如果你在调用其它函数前操作x的话,它甚至还能贴心的直接用指令中的立即数0代替x,帮你把效率优化到极致!


而volatile的作用正是“阻止常量优化”——所以它立即解决了问题。

但请注意,问题是“多余的常量优化”,并不是什么“交换指令执行顺序”。


3、书上的示例是一个无效示例

你照这样写一个程序,反复执行一千万遍也不可能出现脏读脏写问题。


这是因为,“常量优化”说白了是一种“相信X这个变量在我控制之下,因此没必要执行那么多‘多余的读写’”这样过于乐观的假定。

因此,对这段代码:

       for(循环1000) {     lock();     x++;     unlock(); }      

正常生成的、无优化的汇编伪代码应该是:

       loop: //循环1000遍 CALL lock LOAD x to EAX //每次循环都要从内存载入x值 inc EAX SAVE EAX to x //每次循环都要把x值写回内存 //判断循环次数是否足够 ... //代码略 JNZ loop //返回loop标签,重复执行如上动作     

很明显,循环1000遍,那么就要执行1000次LOAD x to EAX 和 SAVE EAX to x;期间执行权变动可能引起cache失效,每次cache失效都需要至少70个时钟周期访问DDR……

注意,x86汇编支持在指令中访问内存,并不需要像某些RISC机一样使用独立的load/save指令访存。但拆开写有助于理解这里的实际执行过程,因此这里我把它写成了三条指令。


一旦打开优化,编译器会优化成这样:

       LOAD x to EAX //在整个函数中仅载入x值一次  loop: //循环1000遍 CALL lock inc EAX //判断循环次数是否足够 ... //代码略 JNZ loop //返回loop标签,重复执行如上动作  SAVE EAX to x //不再读写x之后、函数返回之前,把x写回内存     

在循环开始之前执行LOAD x to EAX把x内容载入EAX,然后一直操纵EAX;直到循环结束、线程返回前,这才调用SAVE EAX to x,把EAX内容写回x。


显然,如此一来,当某个单线程进程执行这段循环时,它只会操纵自己那个执行现场的EAX;不仅少执行了1998条访存指令,还可以借助现代CPU海量的寄存器/寄存器窗口,获得极致的执行效率。


但是,很明显的,把这段代码丢线程里执行时,里面的lock/unlock其实是没有实际作用的。因为两个线程都仅仅读写了一次内存中的x,之后的执行完全是各做各的。这样显然是要出大事的。

换句话说,常量优化错误的删除了循环中的访存指令,这才是bug出现的原因。

注意这不是简单的“调整指令执行次序”,而是“把访存操作从循环中去掉、在使用之前载入一次,然后在函数返回之前写回一次”——用对术语有助于理解问题。


把变量x声明为volatile,就可以避免编译器错误的删除这1998次访存操作,这样里面的加解锁操作才有意义,才能保证执行结果正确。


那么,回过头来,我们看书上的示例。看出问题了吗?

没错,这个线程太简单了:

       //thread begin lock(); x++; unlock(); //thread end      

它对应的指令是:

       //thread begin CALL lock LOAD x to EAX inc EAX SAVE EAX to x CALL unlock //thread end     

仅仅内存读写各一次,这还怎么做“常量优化”?

既然没法做常量优化,那么你尽管放心,编译器又不是神经病,不会无意义的改动你的代码。作者臆想中的bug并不会出现。

因此,这本书的作者显然从未真正实验过自己写的东西,完全是基于一知半解在胡说八道。


很简单点事;但如果真跟着它的思路,肯定越学越糊涂。

耗费了N倍的精力,反而构建了一个错误的知识体系;然后一步错,步步错,深陷泥潭无法脱出……这就是垃圾书的危害。




  

相关话题

  c#多播或event监听太多后gc和时间都会爆炸,那么比起List<Action>存在的意义是什么? 
  为什么C/C++要分为头文件和源文件? 
  计算机学术界是不是喜欢发明一些没什么用的概念? 
  编译器在遇到指向基类的指针时都会进行动态联编吗? 
  为什么好多人说C++代码丑? 
  为何巴西人能做出 Lua 这种出彩的东西? 
  C++的优势有哪些? 
  CMake是不是阻碍了C++的发展? 
  假如有某种力量使得世界上所有编译器都被删除了, 人类是否需要从打孔卡开始写第一个编译器? 
  CPython有GIL是因为当年设计CPython的人偷懒吗? 

前一个讨论
为何x86中“系统调用”要使用“内中断”实现?
下一个讨论
磁盘未格式化的部分是否不被寻址?





© 2024-12-22 - tinynew.org. All Rights Reserved.
© 2024-12-22 - tinynew.org. 保留所有权利