原子操作的语义解读

来源:互联网 发布:php content length 编辑:程序博客网 时间:2024/06/06 09:03

任何 C++ 操作符的求值顺序不同于运算结合性和优先级,他们都是 unspecified(后面提到的除外),这包括在函数调用表达式中函数参数的求值顺序以及任何表达式的子表达式的求值顺序。编译器可以按照任意顺序将它们求值,对于相同的表达式,编译器也可以选择不同的顺序将它们求值。
在 C++ 中,没有什么从左到右或者从右到左的求值顺序,只有操作符从左到右和从右到左的结合性。就比如说表达式f1() + f2() + f3() 会通过加法操作符从左到右的结合性被解析为(f1() + f2()) + f3(),但是对 f3 的调用可能会最先执行,然后是 f1,最后是 f2,因为它们的求值顺序是 unspecified。
C++ 是一个注重效率的语言,标准不指定一些表达式的求值顺序就是为了让编译器能做尽可能多的优化,即便要牺牲掉例如 i=i++ 这样表达式的正确性。
在 C++98/03 的标准中定义了 sequence point 来描述求值顺序,即便是完全符合标准的编译器也可能各个脑袋里面只装着一个线程,于是在对代码作优化的时候总是一不小心就可能做出危害多线程正确性的优化来。我们来看一个例子:

x = y = 0;  Thread1 Thread2  x = 1;     y = 1;  r1 = y;     r2 = x; 

理论上来说,r1==r2==0这种输出是不可能的,但现实往往是残酷的,编译器有可能在优化时把Thread1中的x=1和r1=y操作互换。


再来个更极端的例子:

pthread_mutex_lock(…);  x = … x …  pthread_mutex_unlock(…);  
对x的访问已经被pthread_mutex_lock/pthread_mutex_unlock包围了,这下总算安全了吧?不,编译器可以运用“Register Promotion”的技术进行优化,对此,POSIX线程库也无能为力。

事实上这也不完全是编译器的错,实际上这里的“罪魁祸首”是一种称为“Memory Reordering”(内存乱序)的存在,而“Compiler Reordering”仅是其中的一个来源,另一个就是更为底层的“Processor Reordering”。简单来说,我们编写的程序使用的内存交互会被按照一定规则乱序。内存乱序同时由编译器(编译期)和处理器(运行时)造成,都为了使代码运行的更快。


很多人试图通过大量使用volatile来解决这个问题,但是这样做并不可行。一方面,它无法保证atomicity(机器字长内的变量由于内存对齐无法保证),另一方面,它无法保证memory order(它仅仅禁止编译器优化,而对于Processor Reordering无能为力)。

有时候这种方法会奏效,这和具体硬件平台相关,x86平台属于strongly-ordered模型,绝大多数场景下,Release-Acquire ordering是可以自动获得的。至于atomicity的问题,一定要留意机器字长以及内存对齐。

PS:这里的讨论仅限于C/C++,不同语言对于volatile赋予的语义并不相同,比如java中的volatile是保证顺序一致性的。


好了,下面让我们切入正题,看看C++11给我们带来的Atomic operations library。

“Atomic operations library”顾名思义,其实就是原子操作库。而在以往,我们往往需要借助汇编语言或者第三方线程库方能实现。atomic对于多线程编程,尤其是lock-free算法,其重要性不言而喻,有了std::atomic库,我们终于可以摆脱那些繁琐的汇编代码了!
PS:乐衷于lock-free编程的读者需要注意的一点,并非所有的atomic内置类型操作均是lock-free的,与具体平台相关


C++11引入这些概念本质上是为了解决 “visible side-effects”的问题,用通俗的话来讲:
线程1执行写操作A之后,如何可靠并高效地保证线程2执行读操作B时,操作A的结果是完整可见的?
为了解决上述问题,C++11引入了“happens-before”关系,其比较完整的定义如下:
Let A and B represent operations performed by a multithreaded process. If A happens-before B, then the memory effects of A effectively become visible to the thread performing B before B is performed.



sequenced-before(线程内)
        在同一个线程内,操作A先于操作B
dependency-ordered before(线程间)
        case 1:线程1的操作A对变量M执行“release”写,线程2的操作B对变量M执行“consume”读,并且操作B读取到的值源于操作A之后的“release”写序列中的任何一个(包括操作A本身)
        case 2:线程1的操作A 与线程2的操作X之间存在dependency-ordered before关系,同时线程2的操作B“depends on”操作X(所谓B“depends on”A,这里就不给出精确的定义,举个直观的例子:B=M[A])
synchronizes-with(线程间)
        线程1的操作A对变量M执行“release”写,线程2的操作B对变量M执行“acquire”读,并且操作B读取到的值源于操作A之后的“release”写序列中的任何一个(包括操作A本身)


知道这些概念之后,我们来具体分析一些代码

Relaxed ordering

x = y = 0;  // Thread 1:  r1 = y.load(memory_order_relaxed); // A  x.store(r1, memory_order_relaxed); // B  // Thread 2:  r2 = x.load(memory_order_relaxed); // C  y.store(42, memory_order_relaxed); // D  
简单来说,标记为memory_order_relaxed的atomic操作对于memory order几乎不作保证,它们唯一的承诺就是“atomicity”,当然,不能破坏“modification order”的一致性要求。
 对于上述代码片段而言,输出r1 == r2 == 42是合法的。这里,我们可以推导出的关系只有A sequenced-before B、C sequenced-before D,仅此而已。


Release-Acquire ordering

std::atomic<std::string*> ptr;int data;     void producer()  {      std::string* p = new std::string("Hello"); // A      data = 42; // B      ptr.store(p, std::memory_order_release); // C  }     void consumer()  {      std::string* p2;      while (!(p2 = ptr.load(std::memory_order_acquire))); // D      assert(*p2 == "Hello"); // E      assert(data == 42); // F  }     int main()  {      std::thread t1(producer);      std::thread t2(consumer);      t1.join(); t2.join();  }  
首先,我们可以直观地得出如下关系:A sequenced-before B sequenced-before C、C synchronizes-with D、D sequenced-before E sequenced-before F。
利用前述happens-before推导图,不难得出A happens-before E、B happens-before F,因此,这里的E、F两处的assert永远不会fail。

Release-Consume ordering

void producer()  {      std::string* p = new std::string("Hello"); // A      data = 42; // B      ptr.store(p, std::memory_order_release); // C  }     void consumer()  {      std::string* p2;      while (!(p2 = ptr.load(std::memory_order_consume))); // D      assert(*p2 == "Hello"); // E      assert(data == 42); // F  }  
这次我们把D处修改为memory_order_consume,情况又会有何不同呢?首先,基本的关系对毋庸置疑:A sequenced-before B sequenced-before C、C dependency-ordered before D、D sequenced-before E sequenced-before F。
那么我们还能那么轻易地推导出A happens-before E、B happens-before F吗?答案是:A、E关系成立,而B、F关系破裂。根据我们之前的定义,E depends-on D,从而可以推导出,接着就是水到渠成了。反观D、F之间并不存在这种依赖关系。因此,这里的E永远不会fail,而F有可能fail。

Sequentially-consistent ordering

std::atomic x = ATOMIC_VAR_INIT(false);  std::atomic y = ATOMIC_VAR_INIT(false);  std::atomic z = ATOMIC_VAR_INIT(0);     void write_x()  {      x.store(true, std::memory_order_seq_cst); // A  }     void write_y()  {      y.store(true, std::memory_order_seq_cst); // B  }     void read_x_then_y()  {      while (!x.load(std::memory_order_seq_cst));      if (y.load(std::memory_order_seq_cst)) {          ++z;      }  }     void read_y_then_x()  {      while (!y.load(std::memory_order_seq_cst));      if (x.load(std::memory_order_seq_cst)) {          ++z;      }  }     int main()  {      std::thread a(write_x);      std::thread b(write_y);      std::thread c(read_x_then_y);      std::thread d(read_y_then_x);      a.join(); b.join(); c.join(); d.join();      assert(z.load() != 0); // C  }  

所谓的Sequentially-consistent ordering,其实就是“顺序一致性”,它是最严格的memory order,除了满足前面所说的Release-Acquire/Consume约束之外,所有的线程对于该顺序必须达成一致。
这里,C处的assert是永远不会faile的。反证法:在线程c的世界里,如果++z未执行,需要操作A先于操作B完成;在线程d的世界里,如果++z未执行,需要操作B先于操作A完成。由于这些操作都是memory_order_seq_cst类型,因此,所有的线程需要达成一致,出现矛盾。
0 0
原创粉丝点击