20 | 内存模型和atomic:理解并发的复杂性
20 | 内存模型和atomic:理解并发的复杂性
讲述:吴咏炜
时长18:15大小16.67M
C++98 的执行顺序问题
双重检查锁定
volatile
C++11 的内存模型
内存屏障和获得、释放语义
atomic
is_lock_free 的可能问题
mutex
并发队列的接口
内容小结
课后思考
参考资料
赞 1
提建议
精选留言(21)
- tt2020-01-10感觉这里的无锁操作就像分布式系统里面谈到的乐观锁,普通的互斥量就像悲观锁。只是CPU级的乐观锁由CPU提供指令集级别的支持。 内存重排会引起内存数据的不一致性,尤其是在多CPU的系统里。这又让我想起分布式系统里讲的CAP理论。 多线程就像分布式系统里的多个节点,每个CPU对自己缓存的写操作在CPU同步之前就造成了主内存中数据的值在每个CPU缓存中的不一致,相当于分布式系统中的分区。 我大概看了参考文献一眼,因为一级缓存相对主内存速度有数量级上的优势,所以各个缓存选择的策略相当于分布式系统中的可用性,即保留了AP(分区容错性与可用性,放弃数据的一致性),然后在涉及到缓存数据一致性问题上,相当于采取了最终一致性。 其实我觉得不论是什么系统,时间颗足够小的话,都会存在数据的不一致,只是CPU的速度太快了,所以看起来都是最终一致性。在保证可用性的时候,整个程序的某个变量或内存中的值看起来就是进行了重排。 分布式系统中将多个节点解耦的方式是用异步、用对列。生产者把变化事件写到对列里就返回,然后由消费者取出来异步的实施这些操作,达到数据的最终一致性。 看资料里,多CPU同步时,也有在CPU之间引入对列。当需要“释放前对内存的修改都在另一个线程的获取操作后可见”时,我的理解就是用了所谓的“内存屏障”强制让消费者消费完对列里的"CPU级的事物"。所以才会在达到严格内存序的过程中降低了程序的性能。 也许,这个和操作系统在调度线程时,过多的上下文切换会导致系统性能降低有关系。展开
作者回复: 思考得挺深入,很好。👍 操作系统的上下文切换和内存序的关系我略有不同意见。内存屏障的开销我查下来大概是 100、200 个时钟周期,也就是约 50 纳秒左右吧。而 Linux 的上下文切换开销约在 1 微秒多,也就是两者之前的性能差异超过 20 倍。因此,内存屏障不太可能是上下文切换性能开销的主因。 上下文切换实际需要做的事情非常多,那应该才是主要原因。
17 - 木瓜7772020-01-12您好,看了这篇后,对互斥量和原子量的使用 有些不明白,什么时候应该用互斥量,什么时候用原子量,什么时候一起使用?
作者回复: 用原子量的地方,粗想一下,你用锁都可以。但如果锁导致阻塞的话,性能比起原子量那是会有好几个数量级的差异了。锁即使不导致阻塞,性能也会比原子量低——锁本身的实现就会用到原子量,是个复杂的复合操作。 反过来不成立,用互斥量的地方不能都改用原子量。原子量本身没有阻塞机制,没有保护代码段的功能。
6 - prowu2020-01-14吴老师,您好!有两个问题请帮忙解答下: 1、在解释相关memory_order_acquire, memory_order_release等时,都有提到“当前线程可见”,这个“可见”该怎么理解? 2、可以帮忙总结下,在什么场景下需要保证内存序,比如:满足了以下条件,就需要考虑是否保证内存序了: (1)多线程环境下 (2)存在多个变量是可多个线程共享的,比如:类成员变量、全局变量 (3)这多个共享变量在实现逻辑上存在相互依赖的关系 (4)... 谢谢!展开
作者回复: 1. “可见”,可以理解成获得和释放操作的两个线程能观察到相同的内存修改结果。 2. 原则上任何多线程访问的变量应该要么是原子量,要么有互斥量来保护,这样最安全。特别要考虑内存序的,当然就是有多个有逻辑相关性的共享变量了。对于单个的变量,比如检查线程是否应该退出的布尔变量,只要消除了编译器优化,不需要保证访问顺序也可以正常工作;这样原子量可以使用 relaxed 的访问方式。
4 - 禾桃2020-01-14和大家分享一个链接 操作系统中锁的实现原理 https://mp.weixin.qq.com/s/6MRi_UEcMybKn4YXi6qWng展开
作者回复: 这篇太简单了,基本上只是覆盖尝试加锁这一步(大致是 compare_exchange_strong)。而且,现代操作系统上谁会用关中断啊。 最关键的是,一个线程在加锁失败时会发生什么。操作系统会挂起这个线程,并在锁释放时可能会重新唤起这个线程。文中完全没有提这个。
共 4 条评论3 - Counting stars2021-05-16链接[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机制一般用于基于单个原子量的同步,基于多个原子量的同步,就需要顺序一致性了。只有“顺序一致性还保证了多个原子量的修改在所有线程里观察到的修改顺序都相同”。
共 2 条评论2 - czh2020-02-05专栏里面的评论都满地是宝,这就是比啃书本强太多的地方,大家可以讨论请教。文章需要复习,评论也同样需要复习,看看是否有了新的想法💡。 在阅读的时候,我心里也有前面几个读者的关于锁、互斥量、原子操作的区别与联系的疑问🤔️。 我尝试说一下我的理解:站在需求的角度 1.对单独没有逻辑联系的变量,直接使用原子量的relaxed就够了,没必要加上内存序 2.对于有联系的多个多线程中的变量,这时就需要考虑使用原子量的内存序 3.对于代码段的保护,由于原子量没有阻塞,所以必须使用互斥量和锁来解决 ps:互斥量+锁的操作 可取代 原子量。反之不可。 另外,还产生新的疑问: 1.互斥量的定义中,一个互斥量只允许在多线程中加一把锁,那么是否可以说互斥量只有和锁配合达到保护代码段的作用,互斥量还有其他单独的用法吗? 2.更近一步,原子量+锁,是否可以完成对代码段的保护?而吴老师也在评论区里提到:锁是由原子量构成的。 望老师解答,纠正。展开
作者回复: 你从需求方面理解的 1、2、3 我觉得都对,很好! “互斥量只有和锁配合”这个提法我觉得很怪:互斥量是个对象,(加/解)锁是互斥量支持的动作——如果你指 lock_guard 之类的类,那是辅助的 RAII 对象,目的只是自动化互斥量上的对应操作而已。 你可能是被“操作系统中锁的实现原理”这样的提法带偏了。没有作为名字的专门锁对象,只有互斥量、条件变量、原子量。我也被带偏了,我在某个评论里说“锁”的时候,指的就是互斥量加锁。
共 2 条评论3 - 禾桃2020-01-12is_lock_free,判断对原子对象的操作是否无锁(是否可以用处理器的指令直接完成原子操作) #1 这里的处理器的指令指的是, “lock cmpxchg”? #2 “是否可以用处理器的指令直接完成原子操作”, 这里的直接指的是仅使用“处理器的指令吗? #3 能麻烦给个is_not_lock_free的对原子对象的操作的大概什么样子吗? 谢谢!展开
作者回复: #1 不一定。比如,对于 store,生成可能就只是 mov 指令加个 mfence。 #2 是。 #3 你可以对比一下编译器生成的汇编代码: https://godbolt.org/z/UHsDRj
共 3 条评论1 - 花晨少年2020-01-12这一节讲的实在是太好了,我对前几节的编译器模版相关的不是很感冒,要是能把这期更深入的细节探讨一下,多做几节,就更好了。 singleton* singleton::instance() { @a if (inst_ptr_ == nullptr) {//@1 @b lock_guard lock; // 加锁 if (inst_ptr_ == nullptr) { @c inst_ptr_ = new singleton();//@2 @d } } return inst_ptr_; } 有个问题,就是对double check那个例子的疑惑,会出现什么问题? inst_ptr_应该就两种状态,null和非null。 如果线程1在@b处,等待锁,这个时候线程2不管在@c或者@d处,线程a获得锁的时候,都不会进入@c,因为inst_ptr已经非空。 如果线程1在@a处,线程2在@2处,执行new操作,难道@2这个语句有什么问题吗,难道@2不是一个原子操作,会导致线程1已经得到线程2分配的对象地址,而内存还没有准备好吗?如果是这种情况的话, 那么下面加入了原子操作后,也没有解决new问题啊, singleton* singleton::instance() { singleton* ptr = inst_ptr_.load( memory_order_acquire); if (ptr == nullptr) { lock_guard<mutex> guard{lock_}; ptr = inst_ptr_.load( memory_order_relaxed); if (ptr == nullptr) { ptr = new singleton(); inst_ptr_.store( ptr, memory_order_release); } } return inst_ptr_; }展开
作者回复: 看参考资料4吧。如果嫌太长,就只看代码,编译器和处理器眼里允许重排成的样子。 简单说,就是赋值顺序的问题。至少在某些处理器上,其他线程可能先看到 inst_ptr_ 被修改,再看到单件的构造完成。
共 3 条评论1 - 禾桃2020-01-10Preshing “In particular, each processor is allowed to delay the effect of a store past any load from a different location. “ 这里的”delay”指的是1已经被写到X_cpu_cache, 但是还没有没到推送到X_memeory? #1 X = 1; asm volatile("" ::: "memory"); // Prevent memory reordering r1 = Y; 上面的代码,能确保cpu会先执行store,(至少先写到X_cpu_cache,无法保证1被推送到X_memory),然后再read? #2 X = 1; asm volatile("mfence" ::: "memory"); r1 = Y; 上面的代码,能确保cpu会先执行store(包括把1写到X_cpu_cache,再推送至X_memoery), 然后再read? 上面的代码,cpu 执行到mfence时,会确保1从X_cpu_cache推送到X_memory, 然后再去读Y? 谢谢!展开
作者回复: delay部分和第二个问题的回答是“是”。 第一个问题你这么说似乎也对,但这个asm语句的主要目的是防止编译器做出任何重排,而没有对处理器提出要求。结果是会跟你说的一样。
1 - raisecomer2022-04-28关于单例的双检查锁定的例子,个人认为inst_ptr_.store还是应该使用release语义,虽然它确实在互斥量的保护范围内,互斥量只能保证它以及它前面的构造函数调用时对内存的写操作,都在互斥量释放锁之前完成。但是在第19行读取inst_ptr_时并没有互斥量的保护,既然不受保护,别的线程在此处是可以(不经过互斥量)直接读取它的,也就没有互斥量加锁时的acquire和在29行互斥量解锁时的release形成的release-acquire语义,而26、27行虽然都在互斥量的保护范围内,如果在27行使用relaxed语义,可能会造成26、27行乱序(不过,26、27不管谁先谁后,它们整体都在互斥量释放锁之前完成),造成原子量inst_ptr_虽然已经store了,但是构造函数没有完成(当然,不是表面上看到的c++语句,而是指汇编指令级上的写内存,可能优化后乱序),这样另一个线程在19行可能得到的是inst_ptr_已经不为nullptr了,但单例还未完全初始化的对象实例。 恰恰相反,可以在19行把内存序改为memory_order_relaxed,因为inst_ptr_原子量在此处并没有要保证其它内存读取数据的顺序要求,只要它不是nullptr,就能保证单例对象构造时的写内存操作已经完成,这是由28行的release内存序保证的。展开
作者回复: 分析得不错。release 这里不可以改 relaxed。不过,acquire 和 release 还是配对使用更清晰,也不容易出错。
- Slience-0°C2021-10-28最开始的问题,对x,y加锁,不就能实现想要的结果了么?当然加锁肯定有损耗,内存模型,一直没有理解为了解决什么问题
作者回复: 加锁当然可以解决大部分问题,但性能开销就大了。 事实上,加锁在底层也是会使用原子操作的,并且在产生冲突的时候阻塞程序执行。不加锁指令比较少,开销更低,并在简单的类似加一、减一的操作时不会阻塞。当然,如果用原子操作实现自旋锁之类的操作,就需要认真考虑是不是用原子操作一定能带来性能上的提高了:原子操作编程更为复杂,而且现代的 mutex 实现性能已经非常高了(取决于操作系统,较新的一般都实现得比较好)。
- Geek_64affe2021-07-02老师,有个疑问 1. if (y.load(memory_order_acquire) == 2) { 2. x = 3; 3. y.store(4, memory_order_relaxed); 4. } 如果其他线程在 第2行 或者 第3行期间 修改了 y 的值,逻辑不就出错了吗展开
作者回复: 逻辑是需要代码本身来保证的。我这儿的代码不会产生这样的情况。这儿主要说明的是可能会让即使考虑了多线程情况的程序员都会惊讶的问题——乱序。
- Dooms2021-01-13#include <thread> #include <iostream> #include <atomic> int x = 0; std::atomic<int> y; void a() { x = 1; y.store(2, std::memory_order_release); // 保证x = 1的写操作, 不会因为指令重排, 导致出现在y赋值的后执行. x = 2; y.store(3, std::memory_order_release); } void b() { // 保证y读操作, 后续的读写操作都不会重排到这个指令之前. // 并且线程a中y的释放(写入), 对内存的修改, 在b线程的获取(读取)操作是必定可见的, // 就是说a只要在执行y写入后, b线程执行到y读取的时候, 一定会读取到a线程写入的值. 当读取到 y = 2 的时候, x 的是 1 还是 2 呢? if (y.load(std::memory_order_acquire) == 2) { printf("%d %d\n", x, y.load(std::memory_order_acquire)); // 这里读取到的是 x = 2, y = 3 有没可能x = 2, y = 2 或者 x = 1, y = 2 ?? 还是说这个具体的值是不确定的 ?? x = 3; y.store(4, std::memory_order_relaxed); } } int main() { y = 0; std::thread t1(a); std::thread t2(b); t1.detach(); t2.detach(); std::this_thread::sleep_for(std::chrono::microseconds(1500)); printf("%d %d\n", x, y.load(std::memory_order_acquire)); return 0; } std::atomic 的实现原理是什么? 内存屏障指令吗?展开
作者回复: 你可以在godbolt.org上检查生成的汇编。 根据具体的操作、内存序要求和架构,有些情况下就是简单的移动指令,有些会使用lock前缀,有些会插入内存屏障指令,等等。原子量的意义就是要屏蔽平台差异。
- 鲁·本2020-09-28#include <thread> #include <atomic> #include <cassert> #include <string> #include <iostream> #include <unistd.h> struct ServerT { int id; int port; }; typedef struct ServerT serverT; std::atomic<ServerT*> ptr; void set() { ServerT* pS = new serverT; ptr.store(pS, std::memory_order_release); std::cout << std::this_thread::get_id() << " " << __FUNCTION__ << " 1 port:" << pS->port << std::endl; pS->port = 2345; std::cout << std::this_thread::get_id() << " " << __FUNCTION__ << " 2 port:" << pS->port << std::endl; } void get() { ServerT* pS = nullptr; while (!(pS = ptr.load(std::memory_order_acquire))); std::cout << std::this_thread::get_id() << " " << __FUNCTION__ << " 1 port:" << pS->port << std::endl; pS->port = 4567; std::cout << std::this_thread::get_id() << " " << __FUNCTION__ << " 2 port:" << pS->port << std::endl; } int main() { std::thread t1(set); sleep(1); std::thread t2(get); sleep(2); std::thread t3(get); t1.join(); t2.join(); t3.join(); } 运行结果: 140388121650944 set 1 port:0 140388121650944 set 2 port:2345 140388111161088 get 1 port:2345 140388111161088 get 2 port:4567 140388100671232 get 1 port:4567 140388100671232 get 2 port:4567 老师,您看这个例子,load 取出的指针,直接修改指针指向的对象的内容,不调用store,其他线程也能看到修改的, 那么这种情况, 要怎么对原子对象进行原子修改呢? 难道要在修改的地方加上互斥锁?展开
作者回复: “load 取出的指针,直接修改指针指向的对象的内容,不调用store,其他线程也能看到修改的” 原子对象是为了保证无论在什么架构下和开启了什么程度的优化,代码都可以正常工作。而不是说,你不这么写,就一定不能正常工作。原子、加锁不正确时,有可能在开发机上,或者大部分情况下代码是正常的,但在高并发情况下,或者在不同的硬件环境下,或者不同的编译器选项下,代码就会出错。 “要怎么对原子对象进行原子修改呢?” 如果你指的是代码中的 Server,它不是一个原子对象——你只定义了 Server* 作为原子对象。修改一个 int 值,在绝大多数平台上也是不需要加锁的。我不太看得出你的代码的用意,但一般的并发代码里是不建议去直接改你的 pS 指向的内容。更常规的做法是生成新的,然后使用交换的操作去尝试修改。你需要一个更真实的例子才能让你的代码更有意义。
- 鲁·本2020-09-27老师load返回的指针是原始内存地址,还是副本地址?
作者回复: load返回的不是地址,是实际的数值。
- 王大为2020-09-11y.store(4, memory_order_relaxed); 应该是released吧?某段代码第4行
作者回复: 文中我已经写了: 「在线程 2 我们对 y 的读取应当使用获得语义,但存储只需要松散内存序即可」 这儿没有使用释放语义的必要。
1 - 花晨少年2020-01-13https://en.cppreference.com/w/cpp/atomic/memory_order最后一段讲解 memory_order_seq_cst提到,如果要保证最后的断言"assert(z.load() != 0);"不会发生,必须使用 memory_order_seq_cst,这里很不理解。 下面是代码 #include <thread> #include <atomic> #include <cassert> std::atomic<bool> x = {false}; std::atomic<bool> y = {false}; std::atomic<int> z = {0}; void write_x() { x.store(true, std::memory_order_seq_cst); } void write_y() { y.store(true, std::memory_order_seq_cst); } void read_x_then_y() { while (!x.load(std::memory_order_seq_cst))//@1 ; if (y.load(std::memory_order_seq_cst)) {//@2 ++z; } } void read_y_then_x() { while (!y.load(std::memory_order_seq_cst)) ;//@3 if (x.load(std::memory_order_seq_cst)) {//@4 ++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); // will never happen } 把代码全部改成memory_order_acq_rel操作为什么不可以? 按照memory_order_acq_rel的描述,在其他线程中,@2的所有操作应该都不会被重排到@1之前, @4的操作也不会被重排到@3之前, 那如果是这样的话,也能确保断言永远不会发生。展开
作者回复: memory_order_seq_cst 不是拿来和 memory_order_acq_rel 对比的,而是和 memory_order_relaxed 对比的。正如我在另外一个回答里说的,这里使用 memory_order_acq_rel 可能是非法的。比如 load,只能使用 relaxed、acquire 和 seq_cst,并且后两者是等价的。
共 3 条评论 - 禾桃2020-01-12void add_count() noexcept { count_.fetch_add( 1, std::memory_order_relaxed); } void add_count() noexcept { count_.fetch_add( 1, std::memory_order_seq_cst); } std::memory_order_seq_cst 比std::memory_order_relaxed, 性能方面的浪费,具体指的是什么? 谢谢!展开
作者回复: 好问题。这个问题我之前没细究,但现在仔细一看,常见架构上内存序参数对 fetch_add 是没影响的……似乎读-修改-写操作里,一般都是实现成顺序一致的。 也有例外,如 Power、Raspbian Buster、RISC-V: https://godbolt.org/z/Du85RX
共 2 条评论 - 花晨少年2020-01-12介绍memory_order_seq_cst时,说这是所有原子操作的默认内存序,但是在文章前面又说 y = 2 相当于 y.store(2, memory_order_release) y == 2 相当于 y.load(memory_order_acquire) == 2 ? 有点凌乱,这里。展开
作者回复: 别漏了前面那几句: 「`memory_order_seq_cst`:顺序一致性语义,对于读操作相当于获取,对于写操作相当于释放」
共 4 条评论 - 花晨少年2020-01-12memory_order_acq_rel只能作用到读取-修改-写操作吗,貌似单纯的读或者写操作也可以用这个order. 那这个order和seq_cst貌似并没有很大的区别, 不明白这两个order的不止区别是什
作者回复: 按标准的规定,store 只能用 relaxed、release 或 seq_cst,load 只能用 relaxed、acquire 或 seq_cst,等等。其他组合在标准中明确说是未定义行为,就算能过也有点凑巧,不保证换个编译器或甚至换个版本还能继续工作。 不要这么做。