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

你好,我是吴咏炜。

上一讲我们讨论了一些并发编程的基本概念,今天我们来讨论一个略有点绕的问题,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;
}

想一下,你认为上面的代码运行完之后,xy 的数值有几种可能?

你如果认为有两种可能,1、2 和 3、4 的话,那说明你是按典型程序员的思维模式看问题的——没有像编译器和处理器一样处理问题。事实上,1、4 也是一种结果的可能。有两个基本的原因可以造成这一后果:

  • 编译器没有义务一定按代码里给出的顺序产生代码。事实上,跟据上下文调整代码的执行顺序,使其最有利于处理器的架构,是优化中很重要的一步。就单个线程而言,先执行 x = 1 还是先执行 y = 2 完全是件无关紧要的事:它们没有外部“可观察”的区别。
  • 在多处理器架构中,各个处理器可能存在缓存不一致性问题。取决于具体的处理器类型、缓存策略和变量地址,对变量 y 的写入有可能先反映到主内存中去。之所以这个问题似乎并不常见,是因为常见的 x86 和 x86-64 处理器是在顺序执行方面做得最保守的——大部分其他处理器,如 ARM、DEC Alpha、PA-RISC、IBM Power、IBM z架构和 Intel Itanium 在内存序问题上都比较“松散”。x86 使用的内存模型基本提供了顺序一致性(sequential consistency);相对的,ARM 使用的内存模型就只是松散一致性(relaxed consistency)。较为严格的描述,请查看参考资料 [1] 和里面提供的进一步资料。

虽说 Intel 架构处理器的顺序一致性比较好,但在多处理器(包括多核)的情况下仍然能够出现写读序列变成读写序列的情况,产生意料之外的后果。参考资料 [2] 中提供了完整的例子,包括示例代码。对于缓存不一致性问题的一般中文介绍,可以查看参考资料 [3]

双重检查锁定

在多线程可能对同一个单件进行初始化的情况下,有一个双重检查锁定的技巧,可基本示意如下:

// 头文件
class singleton {
public:
  static singleton* instance();
  …
private:
  static singleton* inst_ptr_;
};

// 实现文件
singleton* singleton::inst_ptr_ =
  nullptr;

singleton* singleton::instance()
{
  if (inst_ptr_ == nullptr) {
    lock_guard lock;  // 加锁
    if (inst_ptr_ == nullptr) {
      inst_ptr_ = new singleton();
    }
  }
  return inst_ptr_;
}

这个代码的目的是消除大部分执行路径上的加锁开销。原本的意图是:如果 inst_ptr_ 没有被初始化,执行才会进入加锁的路径,防止单件被构造多次;如果 inst_ptr_ 已经被初始化,那它就会被直接返回,不会产生额外的开销。虽然看上去很美,但它一样有着上面提到的问题。Scott Meyers 和 Andrei Alexandrecu 详尽地分析了这个用法 [4],然后得出结论:即使花上再大的力气,这个用法仍然有着非常多的难以填补的漏洞。本质上还是上面说的,优化编译器会努力击败你试图想防止优化的努力,而多处理器会以令人意外的方式让代码走到错误的执行路径上去。他们分析得非常详细,建议你可以花时间学习一下。

volatile

在某些编译器里,使用 volatile 关键字可以达到内存同步的效果。但我们必须记住,这不是 volatile 的设计意图,也不能通用地达到内存同步的效果。volatile 的语义只是防止编译器“优化”掉对内存的读写而已。它的合适用法,目前主要是用来读写映射到内存地址上的 I/O 操作。

由于 volatile 不能在多处理器的环境下确保多个线程能看到同样顺序的数据变化,在今天的通用应用程序中,不应该再看到 volatile 的出现。

C++11 的内存模型

为了从根本上消除这些漏洞,C++11 里引入了适合多线程的内存模型。我们可以在参考资料 [5] 里了解更多的细节。跟我们开发密切相关的是:现在我们有了原子对象(atomic)和使用原子对象的获得(acquire)、释放(release)语义,可以真正精确地控制内存访问的顺序性,保证我们需要的内存序。

内存屏障和获得、释放语义

拿刚才的那个例子来说,如果我们希望结果只能是 1、2 或 3、4,即满足程序员心中的完全存储序(total store ordering),我们需要在 x = 1y = 2 两句语句之间加入内存屏障,禁止这两句语句交换顺序。我们在此种情况下最常用的两个概念是“获得”和“释放”:

  • 获得是一个对内存的操作,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去。
  • 释放是一个对内存的操作,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去。

具体到我们上面的第一个例子,我们需要把 y 声明成 atomic<int>。然后,我们在线程 1 需要使用释放语义:

x = 1;
y.store(2, memory_order_release);

在线程 2 我们对 y 的读取应当使用获得语义,但存储只需要松散内存序即可:

if (y.load(memory_order_acquire) ==
    2) {
  x = 3;
  y.store(4, memory_order_relaxed);
}

我们可以用下图示意一下,每一边的代码都不允许重排越过黄色区域,且如果 y 上的释放早于 y 上的获取的话,释放前对内存的修改都在另一个线程的获取操作后可见:

事实上,在我们把 y 改成 atomic<int> 之后,两个线程的代码一行不改,执行结果都会是符合我们的期望的。因为 atomic 变量的写操作缺省就是释放语义,读操作缺省就是获得语义(不严格的说法,精确表述见下面的内存序部分)。即

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

但是,缺省行为可能是对性能不利的:我们并不需要在任何情况下都保证操作的顺序性。

另外,我们应当注意一下,acquire 和 release 通常都是配对出现的,目的是保证如果对同一个原子对象的 release 发生在 acquire 之前的话,release 之前发生的内存修改能够被 acquire 之后的内存读取全部看到。

atomic

刚才是对 atomic 用法的一个非正式介绍。下面我们对 atomic 做一个稍完整些的说明(更完整的见 [6])。

C++11 在 <atomic> 头文件中引入了 atomic 模板,对原子对象进行了封装。我们可以将其应用到任何类型上去。当然对于不同的类型效果还是有所不同的:对于整型量和指针等简单类型,通常结果是无锁的原子对象;而对于另外一些类型,比如 64 位机器上大小不是 1、2、4、8(有些平台/编译器也支持对更大的数据进行无锁原子操作)的类型,编译器会自动为这些原子对象的操作加上锁。编译器提供了一个原子对象的成员函数 is_lock_free,可以检查这个原子对象上的操作是否是无锁的。

原子操作有三类:

  • 读:在读取的过程中,读取位置的内容不会发生任何变动。
  • 写:在写入的过程中,其他执行线程不会看到部分写入的结果。
  • 读‐修改‐写:读取内存、修改数值、然后写回内存,整个操作的过程中间不会有其他写入操作插入,其他执行线程不会看到部分写入的结果。

<atomic> 头文件中还定义了内存序,分别是:

  • memory_order_relaxed:松散内存序,只用来保证对原子对象的操作是原子的
  • memory_order_consume:目前不鼓励使用,我就不说明了
  • memory_order_acquire:获得操作,在读取某原子对象时,当前线程的任何后面的读写操作都不允许重排到这个操作的前面去,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见
  • memory_order_release:释放操作,在写入某原子对象时,当前线程的任何前面的读写操作都不允许重排到这个操作的后面去,并且当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见
  • memory_order_acq_rel:获得释放操作,一个读‐修改‐写操作同时具有获得语义和释放语义,即它前后的任何读写操作都不允许重排,并且其他线程在对同一个原子对象释放之前的所有内存写入都在当前线程可见,当前线程的所有内存写入都在对同一个原子对象进行获取的其他线程可见
  • memory_order_seq_cst:顺序一致性语义,对于读操作相当于获取,对于写操作相当于释放,对于读‐修改‐写操作相当于获得释放,是所有原子操作的默认内存序(除此之外,顺序一致性还保证了多个原子量的修改在所有线程里观察到的修改顺序都相同;我们目前的讨论暂不涉及多个原子量的修改)

atomic 有下面这些常用的成员函数:

  • 默认构造函数(只支持零初始化)
  • 拷贝构造函数被删除
  • 使用内置对象类型的构造函数(不是原子操作)
  • 可以从内置对象类型赋值到原子对象(相当于 store
  • 可以从原子对象隐式转换成内置对象(相当于 load
  • store,写入对象到原子对象里,第二个可选参数是内存序类型
  • load,从原子对象读取内置对象,有个可选参数是内存序类型
  • is_lock_free,判断对原子对象的操作是否无锁(是否可以用处理器的指令直接完成原子操作)
  • exchange,交换操作,第二个可选参数是内存序类型(这是读‐修改‐写操作)
  • compare_exchange_weakcompare_exchange_strong,两个比较加交换(CAS)的版本,你可以分别指定成功和失败时的内存序,也可以只指定一个,或使用默认的最安全内存序(这是读‐修改‐写操作)
  • fetch_addfetch_sub,仅对整数和指针内置对象有效,对目标原子对象执行加或减操作,返回其原始值,第二个可选参数是内存序类型(这是读‐修改‐写操作)
  • ++--(前置和后置),仅对整数和指针内置对象有效,对目标原子对象执行增一或减一,操作使用顺序一致性语义,并注意返回的不是原子对象的引用(这是读‐修改‐写操作)
  • +=-=,仅对整数和指针内置对象有效,对目标原子对象执行加或减操作,返回操作之后的数值,操作使用顺序一致性语义,并注意返回的不是原子对象的引用(这是读‐修改‐写操作)

有了原子对象之后,我们可以轻而易举地把[第 2 讲] 中的 shared_count 变成线程安全。我们只需要包含 <atomic> 头文件,并把下面这行

  long count_;

修改成

  std::atomic_long count_;

即可(atomic_longatomic<long> 的类型别名)。不过,由于我们并不需要 ++ 之后计数值影响其他行为,在 add_count 中执行简单的 ++、使用顺序一致性语义略有浪费。更好的做法是将其实现成:

  void add_count() noexcept
  {
    count_.fetch_add(
      1, std::memory_order_relaxed);
  }

is_lock_free 的可能问题

注意,macOS 上在使用 Clang 时似乎不支持对需要加锁的对象使用 is_lock_free 成员函数,此时链接会出错。而 GCC 在这种情况下,需要确保系统上装了 libatomic。以 CentOS 7 下的 GCC 7 为例,我们可以使用下面的语句来安装:

sudo yum install devtoolset-7-libatomic-devel

然后,用下面的语句编译可以通过:

g++ -pthread test.cpp -latomic

Windows 下使用 MSVC 则没有问题。

mutex

上一讲我们已经讨论了互斥量。今天,我们只需要补充两点:

  • 互斥量的加锁操作(lock)具有获得语义
  • 互斥量的解锁操作(unlock)具有释放语义

有了目前讲过的这些知识,我们终于可以实现一个真正安全的双重检查锁定了:

// 头文件
class singleton {
public:
  static singleton* instance();
  …
private:
  static mutex lock_;
  static atomic<singleton*>
    inst_ptr_;
};

// 实现文件
mutex singleton::lock_;
atomic<singleton*>
  singleton::inst_ptr_;

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_;
}

并发队列的接口

在结束这一讲之前,我们来检查一下并发对编程接口的冲击。回想我们之前讲到标准库里 queue 有下面这样的接口:

template <typename T>
class queue {
public:
  …
  T& front();
  const T& front() const;
  void pop();
  …
}

我们之前还问过为什么 pop 不直接返回第一个元素。可到了并发的年代,我们不禁要问,这样的接口设计到底明智吗?

会不会在我们正在访问 front() 的时候,这个元素就被 pop 掉了?

事实上,上面这样的接口是不可能做到并发安全的。并发安全的接口大概长下面这个样子:

template <typename T>
class queue {
public:
  …
  void wait_and_pop(T& dest)
  bool try_pop(T& dest);
  …
}

换句话说,要准备好位置去接收;然后如果接收成功了,才安安静静地在自己的线程里处理已经被弹出队列的对象。接收方式还得分两种,阻塞式的和非阻塞式的……

那我为什么要在内存模型和原子量这一讲里讨论这个问题呢?因为并发队列的实现,经常是用原子量来达到无锁和高性能的。单生产者、单消费者的并发队列,用原子量和获得、释放语义就能简单实现。对于多生产者或多消费者的情况,那实现就比较复杂了,一般会使用 compare_exchange_strongcompare_exchange_weak。讨论这个话题的复杂性,就大大超出了本专栏的范围了。你如果感兴趣的话,可以查看下面几项内容:

  • nvwa::fc_queue [7] 给出了一个单生产者、单消费者的无锁并发定长环形队列,代码长度是几百行的量级。
  • moodycamel::ConcurrentQueue [8] 给出了一个多生产者、多消费者的无锁通用并发队列,代码长度是几千行的量级。
  • 陈皓给出了一篇很棒的对无锁队列的中文描述 [9],推荐阅读。

内容小结

在这一讲里,我们讨论了 C++ 对并发的底层支持,特别是内存模型和原子量。这些底层概念,是在 C++ 里写出高性能并发代码的基础。

课后思考

在传统 PC 上开发的程序员,应当比较少接触具有松散或弱内存一致性的系统,但原子量和普通变量的区别还是很容易在代码中表现出来的。请你尝试一下多个线程对一个原子量和一个普通全局变量做多次增一操作,观察最后的结果。

在 Intel 处理器架构上,唯一可见的重排是多处理器下的写读操作。大力推荐你尝试一下参考资料 [2] 中的例子(Windows 和 Linux 下可直接运行;macOS 下需要使用我的修改版本或备用下载链接来覆盖下载代码中的 gcc/ordering.cpp),并修改预定义宏。另外一种改法就是把代码中的 XY 的类型改成 atomic_int,重排也就消失了。

如果遇到任何特别问题,欢迎留言与我交流。

参考资料

[1] Wikipedia, “Memory ordering”. https://en.wikipedia.org/wiki/Memory_ordering

[1a] 维基百科, “内存排序”. https://zh.wikipedia.org/zh-cn/内存排序

[2] Jeff Preshing, “Memory reordering caught in the act”. https://preshing.com/20120515/memory-reordering-caught-in-the-act/

[3] 王欢明, 《多处理器编程:从缓存一致性到内存模型》. https://zhuanlan.zhihu.com/p/35386457

[4] Scott Meyers and Andrei Alexandrescu, “C++ and the perils of double-checked locking”. https://www.aristeia.com/Papers/DDJ_Jul_Aug_2004_revised.pdf

[5] cppreference.com, “Memory model”. https://en.cppreference.com/w/cpp/language/memory_model

[5a] cppreference.com, “内存模型”. https://zh.cppreference.com/w/cpp/language/memory_model

[6] cppreference.com, “std::atomic”. https://en.cppreference.com/w/cpp/atomic/atomic

[6a] cppreference.com, “std::atomic”. https://zh.cppreference.com/w/cpp/atomic/atomic

[7] 吴咏炜, nvwa. https://github.com/adah1972/nvwa

[8] Cameron Desrochers, moodycamel::ConcurrentQueue. https://github.com/cameron314/concurrentqueue

[9] 陈皓, 《无锁队列的实现》. https://coolshell.cn/articles/8239.html

精选留言

  • tt

    2020-01-10 23:07:05

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

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

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

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

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

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

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

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

    思考得挺深入,很好。👍

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

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

    2020-01-11 17:23:16

  • 木瓜777

    2020-01-12 10:15:31

    您好,看了这篇后,对互斥量和原子量的使用 有些不明白,什么时候应该用互斥量,什么时候用原子量,什么时候一起使用?
    作者回复

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

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

    2020-01-13 14:37:11

  • czh

    2020-02-05 12:41:54

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

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

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

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

    望老师解答,纠正。
    作者回复

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

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

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

    2020-02-05 23:37:37

  • 禾桃

    2020-01-14 22:26:30

    和大家分享一个链接


    操作系统中锁的实现原理


    https://mp.weixin.qq.com/s/6MRi_UEcMybKn4YXi6qWng
    作者回复

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

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

    2020-01-15 22:26:18

  • prowu

    2020-01-14 08:56:55

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

    谢谢!
    作者回复

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

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

    2020-01-14 09:44:02

  • Counting stars

    2021-05-16 01:09:06

    链接[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 21:02:28

  • fengbeihong

    2023-11-06 10:48:40

    老师请教下单例的实现:
    一种是利用static变量的初始化:
    Foo& getInst()
    {
    static Foo inst(...);
    return inst;
    }

    一种是利用pthread_once来保证线程安全

    这两种方式是否可行呢,应该可以简化单例模式的代码实现吧
    作者回复

    嗯,一是推荐做法。我漏提了。

    2023-11-14 16:44:11

  • 李先生

    2023-10-27 14:11:02

    老师,单例类的加锁过程如果用读取-修改-写入的方式,比如compare_exchange_strong,也是可以的吗?我觉得这种方式写起来会更简单。
    作者回复

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

    2023-10-28 09:01:50

  • 王大为

    2020-09-11 22:14:07

    y.store(4, memory_order_relaxed);
    应该是released吧?某段代码第4行
    作者回复

    文中我已经写了:

    「在线程 2 我们对 y 的读取应当使用获得语义,但存储只需要松散内存序即可」

    这儿没有使用释放语义的必要。

    2020-09-11 22:53:09

  • 禾桃

    2020-01-12 21:03:04

    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-13 13:57:37

  • 花晨少年

    2020-01-12 20:17:52

    这一节讲的实在是太好了,我对前几节的编译器模版相关的不是很感冒,要是能把这期更深入的细节探讨一下,多做几节,就更好了。

    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-13 14:26:53

  • 禾桃

    2020-01-10 23:01:47

    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-11 13:48:55

  • Edison

    2025-07-18 21:49:30

    老师,您再GitHub上实现fcQueue有bug哦!多线程读、写有问题
    作者回复

    一个线程读、一个线程写应该可以。多读多写本来就不是这个实现的目标。

    如果你发现一读一写都不行,请直接在 GitHub 上提交 issue。

    2025-07-19 13:57:03

  • xiaoloudongf

    2025-07-15 22:25:56

    moodycamel::ConcurrentQueue项目的samples.md中,Producer/consumer model (simultaneous)一节的示例代码,while (itemsLeft || doneConsumers.fetch_add(1, std::memory_order_acq_rel) + 1 == ConsumerCount);这行实在想不通,有release和acquire做屏障,为什么还要最后一个消费者再做一次dequeue
    作者回复

    我看了几遍,也没看出原因。可能问作者 Cameron Desrochers 本人最合适。

    根据注释,似乎也不是 producer 方面的问题,而是说要看到其他 consumer 的内存效果。也许是 try_dequeue 里面的特殊逻辑,使得一个线程 try_dequeue 的效果在另一个线程里不一定能立即看到?

    单从这个额外的检查角度来说,它用 doneConsumers 建立了一个所有 consumer 在退出时的屏障,确保了所有其他 consumer 的修改对最后一个 consumer 可见,然后这最后一个 consumer 又单独再检查一次。但我还是想不出这样做的必要性。

    2025-07-19 17:24:02

  • 少年

    2024-12-16 18:51:05

    由于 volatile 不能在多处理器的环境下确保多个线程能看到同样顺序的数据变化,在今天的通用应用程序中,不应该再看到 volatile 的出现。
    这句话并不是太理解,想问下:如果只有一个线程修改某一变量,其他线程对此变量只读,那么是否应该用volatile?或者不用volatile也可?
    作者回复

    不管你用不用 volatile,严格来讲你说的代码存在数据竞争,有未定义行为。

    如果没有锁保护的话,这样的变量应变为 atomic 变量才行。还应该根据这个变量跟其他变量有没有相关性,选用合适的内存序。

    2024-12-20 12:25:12

  • nxt

    2023-11-02 17:53:47

    老师您好,有两个线程T1和T2,分别执行如下代码的话,有可能会print出“bug”么。
    主要是想问“T1读到x的旧值,memory_order_seq_cst语义” 是否happen before于“T2修改x为新值,memory_order_seq_cst语义”
    int x=0;
    int y=0;
    int z=0;

    T1:
    y=1;
    if( load(x, memory_order_seq_cst) == 0 ) z=1;
    else y=0;

    T2:
    if( CAS(x, 0, 1, memory_order_seq_cst, memory_order_seq_cst) )
    {
    if( z==1&&y==0 )
    {
    printf("bug");
    }
    }
    作者回复

    我的理解是仍可能有“bug”——seq_cst 只对所有使用 seq_cst 的原子操作保证一个全局写入顺序,对非原子操作不提供保证。

    2023-11-15 08:15:35

  • Geek_26c53e

    2023-09-05 15:41:36

    请教一下,如果在获取锁之前,别的线程对inst_ptr_进行了store,那加锁之后走到load时,由于是松散的load,会不会读取到旧的inst_ptr_(null)啊?

    // 头文件
    class singleton {
    public:
    static singleton* instance();

    private:
    static singleton* inst_ptr_;
    };

    // 实现文件
    singleton* singleton::inst_ptr_ =
    nullptr;

    singleton* singleton::instance()
    {
    if (inst_ptr_ == nullptr) {
    lock_guard lock; // 加锁
    if (inst_ptr_ == nullptr) {
    inst_ptr_ = new singleton();
    }
    }
    return inst_ptr_;
    }
    作者回复

    不是说下面的代码吧,那一定有问题的。

    文中的示例(使用 atomic)不会。锁本身具有获得释放语义,有同步的效果。

    2023-09-08 08:46:48

  • tony

    2023-03-04 22:39:06

    老师,被mutex保护的全局变量是否有使用原子变量的必要?
    举例如下:
    int global_var1_;
    mutex.lock();
    global_var1_ = 2;
    mutex.unlock();
    假设有两个核cpu1, cpu2, 变量global_var1_在cpu2 cache中,线程A 在cpu1上访问该代码块赋值变量为2,该代码赋值时只是写入到cpu1的store buffer中,还没有更新到cpu2的缓存就返回;此时线程B在cpu2上被调度,执行同样代码块,读取到的变量值是否可能不等于2?
    个人认为应该等于2,理由如下:
    a. 线程B在cpu2上被调度执行需要等待的时间要远大于从cpu1 cache中同步到cpu2 cache的时间;
    b. unlock中可能有内存屏障指令,确保数据同步以后才返回(还需再确认一下);
    请斧正,谢谢。
    作者回复

    首先,lock 具有 acquire 语义,unlock 具有 release 语义,所以另外一个线程如果执行了 mutex.lock()、 并且本线程已经进入锁,那另外一个线程就一定会看到 global_var1 的修改结果。

    反过来,如果另外一个线程没有用 mutex 加锁,那仍然没有任何保证。甚至直接读取 global_var1 的结果的原子性都没有保证——虽然从实践的角度,只要 global_var1 在内存里是对齐存储的,那结果在主流系统里一定要么是老的数值,要么是新的数值。但 C++ 标准并没有给出这样的保证。相反,如果没锁的话,另外一个线程读取普通全局变量 global_var1 的结果是未定义行为,因为是数据竞争存在。

    回到最初的问题,如果你在另外一个线程不加锁读取这个全局变量,那这个全局变量在 C++ 里最好声明成原子量。其他行为你还是得考虑原子量读写的内存序参数。

    2023-03-09 23:43:52

  • raisecomer

    2022-04-28 15:23:25

    关于单例的双检查锁定的例子,个人认为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 还是配对使用更清晰,也不容易出错。

    2022-05-01 21:29:15

  • Slience-0°C

    2021-10-28 21:43:17

    最开始的问题,对x,y加锁,不就能实现想要的结果了么?当然加锁肯定有损耗,内存模型,一直没有理解为了解决什么问题
    作者回复

    加锁当然可以解决大部分问题,但性能开销就大了。

    事实上,加锁在底层也是会使用原子操作的,并且在产生冲突的时候阻塞程序执行。不加锁指令比较少,开销更低,并在简单的类似加一、减一的操作时不会阻塞。当然,如果用原子操作实现自旋锁之类的操作,就需要认真考虑是不是用原子操作一定能带来性能上的提高了:原子操作编程更为复杂,而且现代的 mutex 实现性能已经非常高了(取决于操作系统,较新的一般都实现得比较好)。

    2021-10-31 09:08:22