C++ 非对称内存栅栏的细节
摘要
深入探讨 C++ 并发中的非对称线程栅栏,涵盖 C++ 提案 P1202R0 及其通过 Linux membarrier() 系统调用的实现,并附有来自 Folly 同步原语的示例。
暂无内容
查看缓存全文
缓存时间: 2026/07/07 14:12
# 学习笔记:Linux membarrier() 系统调用 - 非对称栅栏详解
来源:https://nekrozqliphort.github.io/posts/membarrier/
## 非对称栅栏?
在浏览Folly的同步原语(https://github.com/facebook/folly/tree/main/folly/synchronization)以学习并发概念时,代码库中一个熟悉的名字引起了我的注意:Asymmetric Thread Fence。我已经知道`std::atomic_thread_fence` 意在实现什么,但这个结构体是做什么的?它所谓的“非对称”又体现在哪里?
这个问题引导我进入了一个有趣的探索:理解非对称线程栅栏的细节,以及它们在底层(至少在 Linux 上)实际做了什么。我们将从 C++ 提案开始,然后深入实现细节。
## C++ P1202R0 简介
我们稍后会详细讲解具体机制,但现阶段只需理解这些栅栏的目标。参考提案(https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p1202r0.pdf),让我们看看概述:
> 某些类型的并发算法可以拆分为**常见路径**和**非典型路径**,两者都需要栅栏(或具有非宽松内存序的其他操作)才能保证正确性。在许多平台上,可以通过在非典型路径上添加更强的栅栏类型(比 `memory_order_seq_cst` 更强)来**加速常见路径**。这些设施正被越来越多地用于并发库中。我们提议将这些非对称栅栏标准化,并将其纳入内存模型。
本质上,一个并发算法最初可能需要在两侧都使用栅栏。但是,如果一条路径比另一条更常见,我们可以通过在该路径上使用更轻量的栅栏来进行优化,同时将更重的同步开销转移到非典型路径上。如果确保常见路径的性能提升远超非典型路径上较重栅栏的开销,那么整体性能就能得到改善。
这种模式比最初看起来更常见。浏览 Folly 的代码库时,可以找到它的几个用途:
- `folly/synchronization/HazptrDomain.h`:Hazard Pointers(危险指针)
- `folly/synchronization/detail/ThreadCachedReaders.h`:RCU(读-复制-更新)
- `folly/executors/ThreadPoolExecutor.cpp`:线程池执行器
让我们从论文中的一个修改示例开始,即 Dekker 示例:
`1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 atomic_int x{0}, y{0}; int r1, r2; // 路径 A: x.store(1, memory_order_relaxed); atomic_thread_fence(memory_order_seq_cst); r1 = y.load(memory_order_relaxed); // 路径 B: y.store(1, memory_order_relaxed); atomic_thread_fence(memory_order_seq_cst); r2 = x.load(memory_order_relaxed); // 在两条路径完成后执行。 assert(!(r1 == 0 && r2 == 0)); // 永不失败。`
我们知道这个断言不会失败。让我们在 C++ 内存模型下分析原因:Dekker's Example (https://nekrozqliphort.github.io/assets/img/posts_img/2026-07-06-membarrier/dekkers-example-light.png) Dekker's Example (https://nekrozqliphort.github.io/assets/img/posts_img/2026-07-06-membarrier/dekkers-example-dark.png)
- 约束 $(1) \rightarrow (2)$ 和 $(1) \rightarrow (5)$ 来源于线程构造与线程函数开始之间的同步关系[\[thread.thread.constr\]/6](https://eel.is/c++draft/thread.thread.constr#6)。这也通过[\[intro.races\]/15](https://timsong-cpp.github.io/cppwp/n4950/intro.races#15)中描述的写-写一致性保证,建立了 `X` 和 `Y` 的修改顺序。
- 约束 $(4) \rightarrow (5)$ 和 $(7) \rightarrow (2)$ 来源于[\[atomics.order\]/3.3](https://timsong-cpp.github.io/cppwp/n4950/atomics.order#3.3)中的一致性排序要求。例如,$(4)$ 和 $(5)$ 不是同一个原子读-修改-写操作,$(4)$ 读取了 $(1)$ 存储的值,并且 $(1)$ 在 `Y` 的修改顺序中先于 $(5)$。
- 根据[\[atomics.order\]/4.4](https://timsong-cpp.github.io/cppwp/n4950/atomics.order#4.4),`memory_order::seq_cst` 栅栏 $A$ 在 $(4)$ 之前发生(happens-before),而 $(5)$ 在 `memory_order::seq_cst` 栅栏 $B$ 之前发生,因此 $(3)$ 必须在所有 `memory_order::seq_cst` 操作的单一全局顺序 $S$ 中先于 $(6)$。由对称推理,$(6)$ 也必须在同一全局顺序 $S$ 中先于 $(3)$。
这是不可能的,因此产生矛盾。因此执行结果 `r1 == 0 && r2 == 0` 是被禁止的。
现在,回到非对称线程栅栏。其关键思想是,在某些并发算法中,我们关心的是**以非典型路径为代价优化常见路径**。如果我们愿意让慢速路径吸收更强的同步开销,就可以使用非对称栅栏重构代码。论文通过以下转换说明了这一点:
`1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 全局变量: atomic_int x{0}, y{0}; int r1, r2; // 快速路径: x.store(1, memory_order_relaxed); asymmetric_thread_fence_light(); // 类似于 atomic_signal_fence;// 仅阻止编译器重排序 r1 = y.load(memory_order_relaxed); // 慢速路径: y.store(1, memory_order_relaxed); asymmetric_thread_fence_heavy(); // 强于 atomic_thread_fence(memory_order_seq_cst); r2 = x.load(memory_order_relaxed); // 在快速路径和慢速路径都完成后执行。 assert(!(r1 == 0 && r2 == 0)); // 永不失败。`
目前为止,这就是我们需要知道的全部。现在让我们深入了解这些非对称栅栏是如何实现的。
## 非对称轻量栅栏 - 编译器屏障
我们先从更简单的原语开始:`asymmetric_thread_fence_light`。在 Folly 的实现中,在 GCC/Clang 上这通过 `asm volatile("" : : : "memory")` 实现,在 MSVC 上通过 `_ReadWriteBarrier()` 实现,并以 `std::atomic_thread_fence(order)` 作为回退。顺便提一下,根据[\[dcl.asm\]/1](https://timsong-cpp.github.io/cppwp/n4950/dcl.asm#1),`asm` 声明在 C++ 中仅条件支持,因此我们将参考 GCC 的文档,特别是扩展汇编部分(https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html)以了解确切的语义。
`1 2 3 4 asm asm-qualifiers ( AssemblerTemplate : OutputOperands [ : InputOperands [ : Clobbers ] ])`
这里,我们只关心 clobber 列表,特别是特殊的 `"memory"` clobber 参数:
> `"memory"` clobber 告诉编译器,汇编代码执行的内存读或写超出了输入和输出操作数中列出的项目(例如,访问由某个输入参数指向的内存)。为了确保内存包含正确的值,GCC 可能需要在执行 asm 之前将特定的寄存器值刷新到内存。此外,编译器不会假定在 asm 之前从内存读取的任何值在 asm 之后保持不变;它会根据需要重新加载它们。使用 `"memory"` clobber 有效地形成了编译器的**读/写内存屏障**。请注意,此 clobber **不会阻止处理器在 asm 语句之后进行推测性读取**。要防止这种情况,您需要**处理器特定的栅栏指令**。
这意味着编译器**必须保留相对于 `asm volatile("" : : : "memory")` 的内存读写的顺序**,从而使其成为**编译器屏障**。但是,这只限制编译器执行的指令重排,而不是处理器本身。CPU 仍然可能在运行时乱序执行内存操作。
作为补充说明,我们还可以看看 `volatile` 限定符在这里的作用:
> GCC 的优化器有时会丢弃 `asm` 语句,如果它们确定输出变量不需要的话。此外,如果优化器认为代码总是返回相同的结果(即其输入值在调用之间不变),它们可能会将代码移出循环。使用 `volatile` 限定符**禁用这些优化**。**没有输出操作数**的 `asm` 语句和 `asm goto` 语句是**隐式 volatile** 的。
在这里,`volatile` 只是阻止了对 `asm` 语句本身的不希望的编译器优化。严格来说,在这种情况下它并不是必需的,因为一个没有输出操作数的 `asm` 语句已经是隐式 volatile 的了。
## 非对称重量栅栏 - membarrier()
非对称栅栏背后的关键同步机制在于 `asymmetric_thread_fence_heavy`。有几种实现策略,但我们主要关注通常的 Linux 路径:依赖于 `membarrier()` 系统调用(另一种替代方案是强制进行 TLB 失效,但我对该方法不够熟悉,无法详细讨论)。和之前一样,`std::atomic_thread_fence(order)` 被用作回退。
让我们看看文档:
> `membarrier()` 系统调用有助于减少多核系统上排序内存访问所需的内存屏障指令的开销。但是,该系统调用比内存屏障更重,因此有效使用它并不像简单地将内存屏障替换为该系统调用那么简单,而是需要理解下面的细节。使用内存屏障时需要考虑,内存屏障总是需要与其它内存屏障对应使用,或者架构的内存模型不需要对应的屏障。有些情况下,**对应屏障的一侧(我们称之为“快速侧”)执行的频率远高于另一侧(我们称之为“慢速侧”)**。这是使用 `membarrier()` 的主要目标。关键思路是,对于这些对应的屏障,**将快速侧的内存屏障替换为简单的编译器屏障**,例如:`asm volatile ("" : : : "memory")`,并**将慢速侧的内存屏障替换为对 `membarrier()` 的调用**。这将增加慢速侧的额外开销,同时消除快速侧的开销,只要慢速侧足够不频繁,以至于 `membarrier()` 调用的开销不会超过快速侧的性能增益,就能带来整体性能提升。
这正是我们之前讨论过的非对称权衡:将快速路径的硬件栅栏替换为编译器屏障,同时将更重的同步开销转移到不频繁的慢速路径上。`membarrier()` 的系统调用签名如下:
`1 int syscall(SYS_membarrier, int cmd, unsigned int flags, int cpu_id);`
在本文中,我们将主要关注 Folly 使用的两个特定的 `cmd` 参数:`MEMBARRIER_CMD_PRIVATE_EXPEDITED` 和 `MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED`。让我们快速查看相关文档:
> `MEMBARRIER_CMD_REGISTER_PRIVATE_EXPEDITED`(自 Linux 4.14 起)注册进程使用 `MEMBARRIER_CMD_PRIVATE_EXPEDITED` 的意图。
这个命令只是注册进程稍后使用 `MEMBARRIER_CMD_PRIVATE_EXPEDITED` 调用 `membarrier()` 的意图。
> `MEMBARRIER_CMD_PRIVATE_EXPEDITED`(自 Linux 4.14 起)在**与调用线程属于同一进程的每个运行中线程**上执行内存屏障。从系统调用返回时,调用线程保证**其所有运行中的线程兄弟都已经过了一个状态,在该状态中,对用户空间地址的所有内存访问与程序顺序一致,位于进入和返回系统调用之间**(非运行线程实际上已经处于这种状态)。此保证仅针对与调用线程在同一进程中的线程提供。“expedited”命令比非 expedited 命令完成得更快;它们从不阻塞,但缺点是带来额外开销。进程必须在使用私有的 expedited 命令之前注册其意图。我们选择 `MEMBARRIER_CMD_PRIVATE_EXPEDITED` 而不是 `MEMBARRIER_CMD_GLOBAL` 或 `MEMBARRIER_CMD_GLOBAL_EXPEDITED`,因为我们只需要屏障影响单个进程内的线程。
文档还给出了对每个目标线程的保证:在 `membarrier()` 被调用和返回之间的某个时间点 $T$,该线程对用户空间地址的内存访问**与发出的汇编中的程序顺序一致**。也就是说,对于每个目标线程,在该区间内存在某个时间点 $T$,在该点不存在内存访问 $A$ 和 $B$,使得 $A$ 在程序顺序上先于 $B$,但 $B$ 的效果在 $A$ 之前可观测。
此外,为了确保按预期工作,`membarrier()` 对目标线程强制了一个排序保证,如下所述:
> 保证从每个目标线程按程序顺序执行的所有内存访问相对于 `membarrier()` 是有序的。
简而言之,线程的程序顺序内存访问相对于 `membarrier()` 是有序的,从而将在屏障之前发生的操作与之后发生的操作分开。**在线程内部**,被认为在 `membarrier()` 之前发生的写操作(和读操作)必须是在被认为在 `membarrier()` 之后发生的写操作(和读操作)之前**全局可见(并满足)**。如果您记得我的上一篇博客(https://nekrozqliphort.github.io/posts/happens-b4),这基本上就是 `sync`/`hwsync` 在 PowerPC 上的作用,我们稍后会看到。
Linux 手册页用下表总结了这些原语之间的配对排序关系(O:有序,X:无序):
barrier() smp_mb() membarrier()
barrier() X X O
smp_mb() X O O
membarrier() O O O
## Linux Membarrier 是如何工作的?
我不是 Linux 内核专家,实际上这是我第一次探索 Linux 源代码。因此,我们不会进行详尽的深入分析,但我们将研究它在底层是如何实现的以及为什么有效。为了保持可管理性,我们将重点关注 Facebook 的 Folly 库使用的 expedited private `membarrier()` 调用,它本质上归结为 `call_membarrier(MEMBARRIER_CMD_PRIVATE_EXPEDITED)`:
`1 2 3 4 5 6 7 FOLLY_ERASE int call_membarrier(int cmd, unsigned int flags = 0) { if (linux_syscall_nr_membarrier < 0) { errno = ENOSYS; return -1; } return linux_syscall(linux_syscall_nr_membarrier, cmd, flags); }`
如果我们沿着这个调用链进入 Linux 内核源代码,并去除注释以及与我们特定参数无关的条件分支,我们将得到以下骨架:
`1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 static void ipi_mb(void *info) { smp_mb(); /* IPIs 应该已经是串行化的,但多疑一下。*/ } static int membarrier_private_expedited(int flags, int cpu_id) { cpumask_var_t tmpmask; struct mm_struct *mm = current->mm; smp_call_func_t ipi_func = ipi_mb; { // 删除了其他 if 分支 WARN_ON_ONCE(flags); if (!(atomic_read(&mm->membarrier_state) & MEMBARRIER_STATE_PRIVATE_EXPEDITED_READY)) return -EPERM; } if (flags != MEMBARRIER_FLAG_SYNC_CORE && (atomic_read(&mm->mm_users) == 1 || num_online_cpus() == 1)) return 0; smp_mb(); // (1) if (cpu_id < 0 && !zalloc_cpumask_var(&tmpmask, GFP_KERNEL)) return -ENOMEM; SERIALIZE_IPI(); cpus_read_lock(); { int cpu; rcu_read_lock(); for_each_online_cpu(cpu) { struct task_struct *p; p = rcu_dereference(cpu_rq(cpu)->curr); if (p && p->mm == mm) __cpumask_set_cpu(cpu,
相似文章
理解C++20中的std::counting_semaphore和std::binary_semaphore
本文讲解C++20的std::counting_semaphore和std::binary_semaphore,涵盖其API、用于限制并发和线程间信号传递的用法,以及重要细节。
C语言中的Go风格并发
一篇详细的技术文章,探讨如何在C语言中复制Go的并发模型,使用POSIX线程、互斥锁、条件变量和工作池,作为Solod转译器项目的一部分。
Zig 的 Io.Threaded 很巧妙
文章讨论了 Zig 的 std.Io.Threaded,这是 Zig Io 接口的一种实现,使用阻塞系统调用并通过信号支持取消,同时对比了并发与并行。
从头构建现代C++中的快速无锁队列
一本关于在现代C++中实现快速无锁队列的指南,涵盖了无需锁的并发数据结构技术。
glibc malloc 中跨线程双重释放检测的可能实现方式
这篇博文解释了 glibc malloc 中每线程 tcache 的当前双重释放检测机制,指出了允许跨线程双重释放的漏洞,并提出了使用每线程随机密钥的潜在修复方案。