现代C++实战30讲
吴咏炜
前 Intel 资深软件架构师
立即订阅
3995 人已学习
课程目录
已更新 33 讲 / 共 33 讲
0/4登录后,你可以任选4讲全文学习。
课前必读 (2讲)
开篇词 | C++这么难,为什么我们还要用C++?
免费
课前必读 | 有关术语发音及环境要求
基础篇 (9讲)
01 | 堆、栈、RAII:C++里该如何管理资源?
02 | 自己动手,实现C++的智能指针
03 | 右值和移动究竟解决了什么问题?
04 | 容器汇编 I:比较简单的若干容器
05 | 容器汇编 II:需要函数对象的容器
06 | 异常:用还是不用,这是个问题
07 | 迭代器和好用的新for循环
08 | 易用性改进 I:自动类型推断和初始化
09 | 易用性改进 II:字面量、静态断言和成员函数说明符
提高篇 (11讲)
10 | 到底应不应该返回对象?
11 | Unicode:进入多文字支持的世界
12 | 编译期多态:泛型编程和模板入门
13 | 编译期能做些什么?一个完整的计算世界
14 | SFINAE:不是错误的替换失败是怎么回事?
15 | constexpr:一个常态的世界
16 | 函数对象和lambda:进入函数式编程
17 | 函数式编程:一种越来越流行的编程范式
18 | 应用可变模板和tuple的编译期技巧
19 | thread和future:领略异步中的未来
20 | 内存模型和atomic:理解并发的复杂性
实战篇 (7讲)
21 | 工具漫谈:编译、格式化、代码检查、排错各显身手
22 | 处理数据类型变化和错误:optional、variant、expected和Herbception
23 | 数字计算:介绍线性代数和数值计算库
24 | Boost:你需要的“瑞士军刀”
25 | 两个单元测试库:C++里如何进行单元测试?
26 | Easylogging++和spdlog:两个好用的日志库
27 | C++ REST SDK:使用现代C++开发网络应用
新年特别策划 (2讲)
新春寄语 | 35年码龄程序员:人生漫长,走点弯路在所难免
新春福利 | C++好书荐读
未来篇 (2讲)
28 | Concepts:如何对模板进行约束?
29 | Ranges:无迭代器的迭代和更方便的组合
现代C++实战30讲
登录|注册

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

吴咏炜 2020-01-10
你好,我是吴咏炜。
上一讲我们讨论了一些并发编程的基本概念,今天我们来讨论一个略有点绕的问题,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/1000字
划线
笔记
复制
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
该试读文章来自付费专栏《现代C++实战30讲》,如需阅读全部文章,
请订阅文章所属专栏。
立即订阅
登录 后留言

精选留言(13)

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

    谢谢!

    作者回复: 1. “可见”,可以理解成获得和释放操作的两个线程能观察到相同的内存修改结果。

    2. 原则上任何多线程访问的变量应该要么是原子量,要么有互斥量来保护,这样最安全。特别要考虑内存序的,当然就是有多个有逻辑相关性的共享变量了。对于单个的变量,比如检查线程是否应该退出的布尔变量,只要消除了编译器优化,不需要保证访问顺序也可以正常工作;这样原子量可以使用 relaxed 的访问方式。

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

    作者回复: 用原子量的地方,粗想一下,你用锁都可以。但如果锁导致阻塞的话,性能比起原子量那是会有好几个数量级的差异了。锁即使不导致阻塞,性能也会比原子量低——锁本身的实现就会用到原子量,是个复杂的复合操作。

    反过来不成立,用互斥量的地方不能都改用原子量。原子量本身没有阻塞机制,没有保护代码段的功能。

    2020-01-12
    2
  • 禾桃
    和大家分享一个链接


    操作系统中锁的实现原理


    https://mp.weixin.qq.com/s/6MRi_UEcMybKn4YXi6qWng

    作者回复: 这篇太简单了,基本上只是覆盖尝试加锁这一步(大致是 compare_exchange_strong)。而且,现代操作系统上谁会用关中断啊。

    最关键的是,一个线程在加锁失败时会发生什么。操作系统会挂起这个线程,并在锁释放时可能会重新唤起这个线程。文中完全没有提这个。

    2020-01-14
    3
    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
    2
    1
  • 花晨少年
    这一节讲的实在是太好了,我对前几节的编译器模版相关的不是很感冒,要是能把这期更深入的细节探讨一下,多做几节,就更好了。

    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_ 被修改,再看到单件的构造完成。

    2020-01-12
    1
    1
  • tt
    感觉这里的无锁操作就像分布式系统里面谈到的乐观锁,普通的互斥量就像悲观锁。只是CPU级的乐观锁由CPU提供指令集级别的支持。

    内存重排会引起内存数据的不一致性,尤其是在多CPU的系统里。这又让我想起分布式系统里讲的CAP理论。

    多线程就像分布式系统里的多个节点,每个CPU对自己缓存的写操作在CPU同步之前就造成了主内存中数据的值在每个CPU缓存中的不一致,相当于分布式系统中的分区。

    我大概看了参考文献一眼,因为一级缓存相对主内存速度有数量级上的优势,所以各个缓存选择的策略相当于分布式系统中的可用性,即保留了AP(分区容错性与可用性,放弃数据的一致性),然后在涉及到缓存数据一致性问题上,相当于采取了最终一致性。

    其实我觉得不论是什么系统,时间颗足够小的话,都会存在数据的不一致,只是CPU的速度太快了,所以看起来都是最终一致性。在保证可用性的时候,整个程序的某个变量或内存中的值看起来就是进行了重排。

    分布式系统中将多个节点解耦的方式是用异步、用对列。生产者把变化事件写到对列里就返回,然后由消费者取出来异步的实施这些操作,达到数据的最终一致性。

    看资料里,多CPU同步时,也有在CPU之间引入对列。当需要“释放前对内存的修改都在另一个线程的获取操作后可见”时,我的理解就是用了所谓的“内存屏障”强制让消费者消费完对列里的"CPU级的事物"。所以才会在达到严格内存序的过程中降低了程序的性能。

    也许,这个和操作系统在调度线程时,过多的上下文切换会导致系统性能降低有关系。

    作者回复: 思考得挺深入,很好。👍

    操作系统的上下文切换和内存序的关系我略有不同意见。内存屏障的开销我查下来大概是 100、200 个时钟周期,也就是约 50 纳秒左右吧。而 Linux 的上下文切换开销约在 1 微秒多,也就是两者之前的性能差异超过 20 倍。因此,内存屏障不太可能是上下文切换性能开销的主因。

    上下文切换实际需要做的事情非常多,那应该才是主要原因。

    2020-01-10
    1
  • 陈志恒
    专栏里面的评论都满地是宝,这就是比啃书本强太多的地方,大家可以讨论请教。文章需要复习,评论也同样需要复习,看看是否有了新的想法💡。

    在阅读的时候,我心里也有前面几个读者的关于锁、互斥量、原子操作的区别与联系的疑问🤔️。

    我尝试说一下我的理解:站在需求的角度
    1.对单独没有逻辑联系的变量,直接使用原子量的relaxed就够了,没必要加上内存序
    2.对于有联系的多个多线程中的变量,这时就需要考虑使用原子量的内存序
    3.对于代码段的保护,由于原子量没有阻塞,所以必须使用互斥量和锁来解决
    ps:互斥量+锁的操作 可取代 原子量。反之不可。

    另外,还产生新的疑问:
    1.互斥量的定义中,一个互斥量只允许在多线程中加一把锁,那么是否可以说互斥量只有和锁配合达到保护代码段的作用,互斥量还有其他单独的用法吗?
    2.更近一步,原子量+锁,是否可以完成对代码段的保护?而吴老师也在评论区里提到:锁是由原子量构成的。

    望老师解答,纠正。

    作者回复: 你从需求方面理解的 1、2、3 我觉得都对,很好!

    “互斥量只有和锁配合”这个提法我觉得很怪:互斥量是个对象,(加/解)锁是互斥量支持的动作——如果你指 lock_guard 之类的类,那是辅助的 RAII 对象,目的只是自动化互斥量上的对应操作而已。

    你可能是被“操作系统中锁的实现原理”这样的提法带偏了。没有作为名字的专门锁对象,只有互斥量、条件变量、原子量。我也被带偏了,我在某个评论里说“锁”的时候,指的就是互斥量加锁。

    2020-02-05
  • 花晨少年
    https://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,并且后两者是等价的。

    2020-01-13
    2
  • 禾桃

      void 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

    2020-01-12
    1
  • 花晨少年
    介绍memory_order_seq_cst时,说这是所有原子操作的默认内存序,但是在文章前面又说

    y = 2 相当于 y.store(2, memory_order_release)
    y == 2 相当于 y.load(memory_order_acquire) == 2

    有点凌乱,这里。

    作者回复: 别漏了前面那几句:

    「`memory_order_seq_cst`:顺序一致性语义,对于读操作相当于获取,对于写操作相当于释放」

    2020-01-12
    3
  • 花晨少年
    memory_order_acq_rel只能作用到读取-修改-写操作吗,貌似单纯的读或者写操作也可以用这个order.
    那这个order和seq_cst貌似并没有很大的区别,
    不明白这两个order的不止区别是什

    作者回复: 按标准的规定,store 只能用 relaxed、release 或 seq_cst,load 只能用 relaxed、acquire 或 seq_cst,等等。其他组合在标准中明确说是未定义行为,就算能过也有点凑巧,不保证换个编译器或甚至换个版本还能继续工作。

    不要这么做。

    2020-01-12
  • 李亮亮
    C++真是博大精深

    作者回复: 计算的世界真是复杂。C++是为了性能,让你能够看到这些复杂性而已。对性能没那么关注的,可以把这些复杂性隐藏掉。

    2020-01-11
  • 禾桃
    Preshing

    “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语句的主要目的是防止编译器做出任何重排,而没有对处理器提出要求。结果是会跟你说的一样。

    2020-01-10
收起评论
13
返回
顶部