浅谈C++线程安全——同步、作用和偏序

问题

如果有一个需求,用两个线程做一个数据结构的访问和更新

为了避免竞争,多设立一个boolean flag,初始为0,

一个线程在更新时设flag=1,完成后flag=0,

而另一个线程只有当flag=0时才能访问数据结构

那么此时这种做法是否并发安全?

从逻辑上看似乎没有问题(至少它们看着是原子互斥的),但是CPU和内存的角度来看,不一定对

  • 因为在CPU眼里,跨硬件线程的执行顺序不一定是你看到的那样,

解释:在你的眼里,更新线程是更新完成后才设flag=0,但是CPU会乱序执行,可能在未执行完成时flag已经为0(你可能听别人说过,编译器也会这么干,这个背后的原因是一致的,略)

  • 在内存的眼里,他可能永远不知道flag=1的实际时机

解释:因为你的store操作,可能作用于缓存而非内存,而缓存可能是CPU共享的,也可能是独立的(这方面的一致性可能已经由硬件维护好)。不管怎样,它的行为都是令人困惑的(当然,内存只是一个没有主见的设备,是否用到它还是依靠CPU的判断,但*默认*情况下CPU正是彼此之间不知道store操作有没有放到内存里去)

我们需要与CPU和内存沟通的工具,于是(语言标准)有了synchronizes-withhappens-before等相关概念,它们所努力的一切就是为了ordering

注意

由于这是语言层面/内存模型的抽象,因为不考虑指令、store buffer、CC协议等概念,只需要在语言给出的概念范围内处理

也就是,当我们在讨论内存模型时,就是在讨论what,而不讨论背后的why

但我仍觉得理解why比理解what要好,前者是一些很具体的存在,后者更加抽象

(所以啊,这篇文章就是来梳理这些抽象的东西,告诉你一些可能想要知道的但是被cppreference劝退的事情)

我前面对例子解释why的原因是这个理由更加真实

如果以这一种只讨论what的立场来解释“这段代码为什么不对”的话

那就是——你不符合语言的规则(synchronizes-with和happens-before等)

Synchronizes-with

synchronizes-with关系只作用于原子类型之间,它的作用主体是操作(既:作用于某个操作)

(在这里,操作表示表达式或者副作用)

规则:

一个带有某种标记的对于变量x的写操作w,synchronizes-with,一个带有某种标记的对于变量x的读操作。

这个读操作能读到的值是其中下列情况之一:

  • w操作存入的值
  • w操作对应的同一线程且是w顺序执行往下的连续写入的值
  • 满足条件的使用一系列RMW操作的其它线程。条件为在这个RMW操作序列中,第一个Read的对应线程读到的初值是w存入的值

这个synchronizes-with的关系一旦确认,那么x读到的值的范围也就圈定(你看,至少不会有读到w操作之前(不含w)store的值了对吧?)

在默认的并发情况下,标记已经是存在且适合的,而这些标记的细节,就烦请查阅memory order

Happens-before

同样地,happens-before也是用于修饰操作。它的作用是指定什么操作能看到其它操作带来的影响

  • 更通俗点的说法是:我才不管你的同步顺序,我就要知道你在我执行这段代码前干了什么事情

对于单线程来说,语法执行的顺序的前后(sequenced before)就是happens-before,且是strong happens-before

  • 简单地说就是第一行的代码在第二行的代码之前(各自为单个语句)

单线程下如果不符合happens-before会是什么效果,就是对于操作看不清其它操作的影响

比如foo(bar(), bar2()),如果语言标准没有说明必须该如何执行(bar和bar2谁先谁后),那它就是UB!

至少你现在明白了不符合happens-before会造成什么后果。而你在单线程下很容易满足这个条件(只要顺着写就行了),因此不易出错。那如果到了多线程环境下,你不知道happens-before的条件,自然无法写出行为正确的代码(这里有一个教训就是,当不知道条件的时候,不要靠直觉来臆测,而前面给出的错误例子,就是用直觉认为它是正确的)

到了线程间的交互,如果一个线程的操作A inter-thread happens-before 另一个线程的操作B,那么就是A happens-before B

那么这又怎么联系上inter-thread happens-before呢?

再加一个规则:如果一个线程的操作A synchronizes-with 另一个线程的操作B,那么操作A inter-thread happens-before 操作B

有了单线程内部的sequenced-before,以及跨线程的(仅限于原子类型操作的)synchronizes-with,以及关系之间的可传递性,已经可以完整描述happens-before

为了方便起见,可以简单地认为strongly happens-before等同于happens-before

不同点在于consume语义,但是这个语义相对少人用,那就不说了

Memory order

前面我们提了同步、影响,现在又有了偏序

对于开发者而言,需要关注的接口正是后者memory order

(悲报:前面提到的都是衬托人家,比如与什么操作synchronizes-with、能观察到什么样的happens-before)

大体上,可以认为有多种组合的模式去使用:

  • seq_cst
  • relaxed
  • acquire-release
  • consume-release

而不同memory order能施加的operation是:

  • store operation
  • load operation
  • RMW operation

更细一点可以分为:

  • relaxed operation
  • acquire operation
  • release operation
  • consume operation

携带relaxed的operation称为relaex operation

  • 它不具有任何synchronizes-with关系,那不需要考虑是store还是load

携带acquire的load operation则称为acquire operation

  • 当前线程的读和写都不允许被重排序到此load前

对于所有release的当前(对应load)原子变量的线程,其所有写能在能被当前线程观察到

携带release的store operation则称为release operation

  • 当前线程的读和写都不允许被重排序到此store后
  • 当前线程的所有写能被所有acquire对应原子变量的线程观察到
  • 当前线程的所有与此原子变量有关联的写能被所有consume对应原子变量的线程观察到

携带consume的load operation则称为consume operation

  • 当前线程的关联此原子变量的读和写都不允许被重排序到此load前

对于所有release的当前(对应load)原子变量的线程,其所有关联写能在能被当前线程观察到

携带seq_cst的RMW操作的特殊在于:

  • 当它被用于load operation时,则体现为acquire operation
  • 当它被用于store operation时,则体现为release operation
  • 但是额外添加了一个total order,表示所有线程能在同一顺序观察到对应修改

END

总结是不可能总结的,文章都这么短了还要总结,那不是白写和白看了(没看是吧!)

我知道了解这些概念后,如果以后有(不对性能有极致发烧的)开发需求,大概还是会怕翻车背锅,也还是依旧使用atomic默认order

但至少也要能看懂别人的代码,明白它那里为什么这么写,以及稍微分析一下它是否有问题

——来自一个曾经摸鱼时翻google/skia源码,看到一堆memory order不知所措的人

References

C++ Concurrency in Action, 2nd

cppreference

“浅谈C++线程安全——同步、作用和偏序”的一个回复

发表评论

邮箱地址不会被公开。 必填项已用*标注