现代 C++ 编程实战
吴咏炜
前 Intel 资深软件架构师
34196 人已学习
新⼈⾸单¥59
登录后,你可以任选4讲全文学习
课程目录
已完结/共 51 讲
加餐 (1讲)
现代 C++ 编程实战
15
15
1.0x
00:00/00:00
登录|注册

20 | 内存模型和atomic:理解并发的复杂性

并发安全的接口设计
atomic 类的成员函数
内存屏障和获得、释放语义
原子对象和内存序
用法和局限性
Scott Meyers 和 Andrei Alexandrecu 的分析
问题分析
多处理器架构的缓存不一致性问题
编译器和处理器的代码执行顺序调整
参考资料
课后思考
内容小结
并发队列的接口
C++11 的内存模型
volatile
双重检查锁定
C++98 的执行顺序问题
理解并发的复杂性

该思维导图由 AI 生成,仅供参考

你好,我是吴咏炜。
上一讲我们讨论了一些并发编程的基本概念,今天我们来讨论一个略有点绕的问题,C++ 里的内存模型和原子量。

C++98 的执行顺序问题

C++98 的年代里,开发者们已经了解了线程的概念,但 C++ 的标准里则完全没有提到线程。从实践上,估计大家觉得不提线程,C++ 也一样能实现多线程的应用程序吧。不过,很多聪明人都忽略了,下面的事实可能会产生不符合直觉预期的结果:
为了优化的必要,编译器是可以调整代码的执行顺序的。唯一的要求是,程序的“可观测”外部行为是一致的。
处理器也会对代码的执行顺序进行调整(所谓的 CPU 乱序执行)。在单处理器的情况下,这种乱序无法被程序观察到;但在多处理器的情况下,在另外一个处理器上运行的另一个线程就可能会察觉到这种不同顺序的后果了。
对于上面的后一点,大部分开发者并没有意识到。原因有好几个方面:
多处理器的系统在那时还不常见
主流的 x86 体系架构仍保持着较严格的内存访问顺序
只有在数据竞争(data race)激烈的情况下才能看到“意外”的后果
举一个例子,假设我们有两个全局变量:
int x = 0;
int y = 0;
然后我们在一个线程里执行:
x = 1;
y = 2;
在另一个线程里执行:
if (y == 2) {
x = 3;
y = 4;
}
确认放弃笔记?
放弃后所记笔记将不保留。
新功能上线,你的历史笔记已初始化为私密笔记,是否一键批量公开?
批量公开的笔记不会为你同步至部落
公开
同步至部落
取消
完成
0/2000
荧光笔
直线
曲线
笔记
复制
AI
  • 深入了解
  • 翻译
    • 英语
    • 中文简体
    • 中文繁体
    • 法语
    • 德语
    • 日语
    • 韩语
    • 俄语
    • 西班牙语
    • 阿拉伯语
  • 解释
  • 总结

C++并发编程中的内存模型和原子量是一个复杂而重要的话题。在C++98标准中,由于缺乏对线程的明确支持,编译器和处理器的优化和乱序执行可能导致意想不到的结果。双重检查锁定等技巧也存在难以填补的漏洞。然而,C++11标准引入了适合多线程的内存模型,包括原子对象和获得、释放语义,可以精确地控制内存访问的顺序性,为并发编程提供了更可靠的基础。文章还介绍了`atomic`模板的应用、内存序的定义以及`mutex`的加锁和解锁操作。此外,文章还探讨了并发队列的接口设计和实现,强调了原子量在实现无锁并发队列时的重要性。总的来说,本文深入探讨了C++对并发的底层支持,为读者提供了深入了解并发编程的基础知识和技术特点。

仅可试看部分内容,如需阅读全部内容,请付费购买文章所属专栏
《现代 C++ 编程实战》
新⼈⾸单¥59
立即购买
登录 后留言

全部留言(26)

  • 最新
  • 精选
  • tt
    感觉这里的无锁操作就像分布式系统里面谈到的乐观锁,普通的互斥量就像悲观锁。只是CPU级的乐观锁由CPU提供指令集级别的支持。 内存重排会引起内存数据的不一致性,尤其是在多CPU的系统里。这又让我想起分布式系统里讲的CAP理论。 多线程就像分布式系统里的多个节点,每个CPU对自己缓存的写操作在CPU同步之前就造成了主内存中数据的值在每个CPU缓存中的不一致,相当于分布式系统中的分区。 我大概看了参考文献一眼,因为一级缓存相对主内存速度有数量级上的优势,所以各个缓存选择的策略相当于分布式系统中的可用性,即保留了AP(分区容错性与可用性,放弃数据的一致性),然后在涉及到缓存数据一致性问题上,相当于采取了最终一致性。 其实我觉得不论是什么系统,时间颗足够小的话,都会存在数据的不一致,只是CPU的速度太快了,所以看起来都是最终一致性。在保证可用性的时候,整个程序的某个变量或内存中的值看起来就是进行了重排。 分布式系统中将多个节点解耦的方式是用异步、用对列。生产者把变化事件写到对列里就返回,然后由消费者取出来异步的实施这些操作,达到数据的最终一致性。 看资料里,多CPU同步时,也有在CPU之间引入对列。当需要“释放前对内存的修改都在另一个线程的获取操作后可见”时,我的理解就是用了所谓的“内存屏障”强制让消费者消费完对列里的"CPU级的事物"。所以才会在达到严格内存序的过程中降低了程序的性能。 也许,这个和操作系统在调度线程时,过多的上下文切换会导致系统性能降低有关系。

    作者回复: 思考得挺深入,很好。👍 操作系统的上下文切换和内存序的关系我略有不同意见。内存屏障的开销我查下来大概是 100、200 个时钟周期,也就是约 50 纳秒左右吧。而 Linux 的上下文切换开销约在 1 微秒多,也就是两者之前的性能差异超过 20 倍。因此,内存屏障不太可能是上下文切换性能开销的主因。 上下文切换实际需要做的事情非常多,那应该才是主要原因。

    2020-01-10
    21
  • 木瓜777
    您好,看了这篇后,对互斥量和原子量的使用 有些不明白,什么时候应该用互斥量,什么时候用原子量,什么时候一起使用?

    作者回复: 用原子量的地方,粗想一下,你用锁都可以。但如果锁导致阻塞的话,性能比起原子量那是会有好几个数量级的差异了。锁即使不导致阻塞,性能也会比原子量低——锁本身的实现就会用到原子量,是个复杂的复合操作。 反过来不成立,用互斥量的地方不能都改用原子量。原子量本身没有阻塞机制,没有保护代码段的功能。

    2020-01-12
    8
  • 禾桃
    和大家分享一个链接 操作系统中锁的实现原理 https://mp.weixin.qq.com/s/6MRi_UEcMybKn4YXi6qWng

    作者回复: 这篇太简单了,基本上只是覆盖尝试加锁这一步(大致是 compare_exchange_strong)。而且,现代操作系统上谁会用关中断啊。 最关键的是,一个线程在加锁失败时会发生什么。操作系统会挂起这个线程,并在锁释放时可能会重新唤起这个线程。文中完全没有提这个。

    2020-01-14
    4
    4
  • prowu
    吴老师,您好!有两个问题请帮忙解答下: 1、在解释相关memory_order_acquire, memory_order_release等时,都有提到“当前线程可见”,这个“可见”该怎么理解? 2、可以帮忙总结下,在什么场景下需要保证内存序,比如:满足了以下条件,就需要考虑是否保证内存序了: (1)多线程环境下 (2)存在多个变量是可多个线程共享的,比如:类成员变量、全局变量 (3)这多个共享变量在实现逻辑上存在相互依赖的关系 (4)... 谢谢!

    作者回复: 1. “可见”,可以理解成获得和释放操作的两个线程能观察到相同的内存修改结果。 2. 原则上任何多线程访问的变量应该要么是原子量,要么有互斥量来保护,这样最安全。特别要考虑内存序的,当然就是有多个有逻辑相关性的共享变量了。对于单个的变量,比如检查线程是否应该退出的布尔变量,只要消除了编译器优化,不需要保证访问顺序也可以正常工作;这样原子量可以使用 relaxed 的访问方式。

    2020-01-14
    4
  • czh
    专栏里面的评论都满地是宝,这就是比啃书本强太多的地方,大家可以讨论请教。文章需要复习,评论也同样需要复习,看看是否有了新的想法💡。 在阅读的时候,我心里也有前面几个读者的关于锁、互斥量、原子操作的区别与联系的疑问🤔️。 我尝试说一下我的理解:站在需求的角度 1.对单独没有逻辑联系的变量,直接使用原子量的relaxed就够了,没必要加上内存序 2.对于有联系的多个多线程中的变量,这时就需要考虑使用原子量的内存序 3.对于代码段的保护,由于原子量没有阻塞,所以必须使用互斥量和锁来解决 ps:互斥量+锁的操作 可取代 原子量。反之不可。 另外,还产生新的疑问: 1.互斥量的定义中,一个互斥量只允许在多线程中加一把锁,那么是否可以说互斥量只有和锁配合达到保护代码段的作用,互斥量还有其他单独的用法吗? 2.更近一步,原子量+锁,是否可以完成对代码段的保护?而吴老师也在评论区里提到:锁是由原子量构成的。 望老师解答,纠正。

    作者回复: 你从需求方面理解的 1、2、3 我觉得都对,很好! “互斥量只有和锁配合”这个提法我觉得很怪:互斥量是个对象,(加/解)锁是互斥量支持的动作——如果你指 lock_guard 之类的类,那是辅助的 RAII 对象,目的只是自动化互斥量上的对应操作而已。 你可能是被“操作系统中锁的实现原理”这样的提法带偏了。没有作为名字的专门锁对象,只有互斥量、条件变量、原子量。我也被带偏了,我在某个评论里说“锁”的时候,指的就是互斥量加锁。

    2020-02-05
    2
    3
  • Counting stars
    链接[2]的代码在msvc编译器release模式下用atomic int测试了一下,X Y通过 store的指定memory_order_release并没有达到期望的内存屏障效果,仍然出现了写读序列变成读写序列的问题,仔细分析了一下: memory_order_release在x86/64上看源码有一个提示, case memory_order_release: _Compiler_or_memory_barrier(); _ISO_VOLATILE_STORE32(_Storage, _As_bytes); return; 查看了一下具体定义 #elif defined(_M_IX86) || defined(_M_X64) // x86/x64 hardware only emits memory barriers inside _Interlocked intrinsics #define _Compiler_or_memory_barrier() _Compiler_barrier() 看起来msvc的做法,并没有针对memory_order_release实现标准的内存屏障支持 参考老师提供示例连接中的例子MemoryBarrier()是可以手动效果实现这一个效果 最终结论如下: msvc2019下,memory_order_release并不能保证内存屏障效果,只能通过默认的memory_order_seq_cst来保证 老师可以和您交流一下我的观点吗

    作者回复: 这个问题提得相当好。事实上,这个行为是标准的,GCC/Clang下也可以验证这个效果。 仔细看一下你会发现release可以防止前面的读写被重排到后面,而acquire可以防止后面的读写被重排到前面。但只用acquire/release机制不能防止例子中的读提前,哪怕把X、Y、r1、r2全部变成原子量也不行!——我们是想防止load被提前,但release只能防止延后,不能防止提前。 acquire/release机制一般用于基于单个原子量的同步,基于多个原子量的同步,就需要顺序一致性了。只有“顺序一致性还保证了多个原子量的修改在所有线程里观察到的修改顺序都相同”。

    2021-05-16
    2
    2
  • fengbeihong
    老师请教下单例的实现: 一种是利用static变量的初始化: Foo& getInst() { static Foo inst(...); return inst; } 一种是利用pthread_once来保证线程安全 这两种方式是否可行呢,应该可以简化单例模式的代码实现吧

    作者回复: 嗯,一是推荐做法。我漏提了。

    2023-11-06归属地:北京
    1
  • 李云龙
    老师,单例类的加锁过程如果用读取-修改-写入的方式,比如compare_exchange_strong,也是可以的吗?我觉得这种方式写起来会更简单。

    作者回复: 你试试看就知道了,不好写的。这里需要对整个单例的构造进行加锁保护。用你说的方式,至少是可能发生初始化时构造多次(再丢弃多余的实例)的情况。

    2023-10-27归属地:北京
    1
  • 王大为
    y.store(4, memory_order_relaxed); 应该是released吧?某段代码第4行

    作者回复: 文中我已经写了: 「在线程 2 我们对 y 的读取应当使用获得语义,但存储只需要松散内存序即可」 这儿没有使用释放语义的必要。

    2020-09-11
    1
  • 禾桃
    is_lock_free,判断对原子对象的操作是否无锁(是否可以用处理器的指令直接完成原子操作) #1 这里的处理器的指令指的是, “lock cmpxchg”? #2 “是否可以用处理器的指令直接完成原子操作”, 这里的直接指的是仅使用“处理器的指令吗? #3 能麻烦给个is_not_lock_free的对原子对象的操作的大概什么样子吗? 谢谢!

    作者回复: #1 不一定。比如,对于 store,生成可能就只是 mov 指令加个 mfence。 #2 是。 #3 你可以对比一下编译器生成的汇编代码: https://godbolt.org/z/UHsDRj

    2020-01-12
    3
    1
收起评论
显示
设置
留言
26
收藏
沉浸
阅读
分享
手机端
快捷键
回顶部