当前位置: 首页 > news >正文

【C++并发系列】第十二章:CPU cache line 和 false sharing

博主介绍:程序喵大人

  • 35 - 资深C/C++/Rust/Android/iOS客户端开发
  • 10年大厂工作经验
  • 嵌入式/人工智能/自动驾驶/音视频/游戏开发入门级选手
  • 《C++20高级编程》《C++23高级编程》等多本书籍著译者
  • 更多原创精品文章,首发gzh,见文末
  • 👇👇记得订阅专栏,以防走丢👇👇
    😉C++基础系列专栏
    😃C语言基础系列专栏
    🤣C++大佬养成攻略专栏
    🤓C++训练营
    👉🏻个人网站

让我们从一段看似最为普通的多线程计数代码开始探讨。假设我们需要让两个线程各自去累加自己专属的 atomic 计数器,在业务逻辑上它们完全独立,既没有共享变量也没有互斥锁的羁绊。从直觉上判断,这似乎是无锁并发最为理想的运转形态——两个计算核心各司其职互不干扰,整体性能理应十分接近单线程执行的两倍。

#include<atomic>structCounters{std::atomic<longlong>a{0};std::atomic<longlong>b{0};};Counters counters;

在这个简单的设定中,线程 1 专门在一个庞大的循环里累加counters.a,而线程 2 则在自己的循环里累加counters.b,两个线程各自独立执行一亿次操作。

如果我们把这段并发代码和“使用单线程依次累加 a 再累加 b 共计两亿次”的基准版本做个客观的性能对比,往往会得出一个令人意外的结论:多线程版本不仅没有变快,反而经常会出现明显的降速。在某些架构的机器上进行测试,它甚至可能会慢上两到三倍之多。

第一次面对这样的测试结果时,很多开发者都会觉得非常反直觉。毕竟这两个 atomic 变量是完全分开的独立对象,在 C++ 内存模型的范畴内它们之间不存在任何同步关系,并发检测工具 TSan 也不会报出任何 data race,而且 atomic 操作本身的理论开销也比 mutex 轻量得多。为什么两个物理核心明明只是在各自操作自己的独立变量,却在暗地里互相严重拖了后腿?

解开这个性能谜题的答案,其实深深地隐藏在现代 CPU 底层的物理结构之中。这两个变量虽然在 C++ 的抽象语义上是完全独立的对象,但它们在实际的内存布局里往往挨得非常近,以至于同时落在了同一条物理层面的缓存行(cache line)上。当两个核心分别试图修改这同一条缓存行上的不同变量时,这种行为被底层硬件的缓存一致性协议视为“针对同一条缓存行的竞争性写入”。高昂的性能代价就这样在无形之中产生了。

在本章接下来的内容里,我们将把这套底层机制详细梳理清楚。我们将探讨 CPU 为什么需要引入缓存层级、缓存行究竟是一个什么样的物理概念、为什么写入彼此独立的不同变量也会导致对同一条缓存行的争抢、这就是所谓的 false sharing 是如何引发性能灾难的、以及在现代 C++ 中我们应该用什么样的方法去修复它。在读完这一章之后你就会明白,原子操作在真实世界里的开销远远不仅限于我们在软件层面设置的“内存屏障”——一种更普遍、也更隐蔽的性能成本,恰恰来自于物理缓存行在各个核心之间的来回搬运。

CPU 访问底层内存有着巨大的远近差异

在深入理解缓存行(cache line)的工作原理之前,我们需要先在脑海中建立起一个关于硬件层面的基础直觉:CPU 对于分布在不同存储层级的数据,其访问延迟的差距是非常巨大的。

现代 CPU 内核的运行频率通常高达几个 GHz,这就意味着它的每个时钟周期往往不到一纳秒。然而,如果它需要直接跨越主板去访问主内存(DRAM),一次数据往返往往需要耗费 100 纳秒左右的时间——这折算下来就是几百个宝贵的时钟周期。如果在执行指令时每次读写都要老老实实地走主内存,那么 CPU 绝大部分的时间都会被荒废在漫长的等待数据之中,再高的主频也无法发挥出应有的计算威力。

为了填平处理速度与存储延迟之间这道巨大的鸿沟,硬件工程师们在 CPU 内部引入了多级缓存(Cache Hierarchy)机制:

寄存器 < 1 ns (集成在核心运算单元内部,速度最快) L1 cache ~ 1 ns (每个核心独占使用,容量通常在几十 KB) L2 cache ~ 3-10 ns (每个核心独占或小簇共享,容量通常在几百 KB) L3 cache ~ 10-30 ns (整块芯片多核心共享,容量从几 MB 到几十 MB 不等) 主内存 ~ 100 ns (系统所有核心与设备共享,容量达 GB 级别)

在这个清晰的层级结构中,每一级缓存都比下一级速度更快,但受限于制造成本和物理空间,它的容量也更小,物理距离也更贴近运算核心。当 CPU 需要访问某些数据时,它会首先去查最快的 L1 缓存,如果没找到命中数据(cache miss)就接着去查 L2,再没命中就继续退化去查 L3,最后实在找不到才会去漫长的主内存里提取。自然而然地,数据命中的层级越靠前,访问速度就越快。

一个软件程序到底跑得快不快,在很大程度上取决于“它经常需要操作的数据是不是常驻在靠近核心的热缓存里”。这也是为什么在日常编程中,循环顺序遍历一个连续的数组往往比随机跳跃访问节点要快得多——前者的数据排布具有极佳的空间局部性,底层的预取器(prefetcher)能够提前把相邻的数据顺道搬进缓存;而后者由于地址的不确定性,每访问一次都面临着 cache miss 的风险,每次都要为此支付一笔沉重的内存访问延迟代价。

当讨论范围扩展到多核心并发体系时,事情还会变得更加复杂一些。由于每个核心都拥有自己私有的 L1 和 L2 缓存,这就意味着同一份数据的副本,可能在同一时间散落在多个不同核心的本地缓存里。一旦其中某一个核心修改了自己手里的那份数据,其他核心手里持有的副本就立刻变成过期状态了。为了维持逻辑上的一致性,CPU 必须在底层实现一套严格的机制,确保这些散落的副本要么同步进行更新、要么被标记为作废失效——这就是著名的缓存一致性协议(cache coherence protocol),其中最为经典的实现模型就是 MESI 协议。关于这套协议的工作方式,我们在后续的小节里会作展开分析。

在这里还需要补充一个在性能分析时经常被忽视的物理细节:缓存这种硬件资源除了有着速度的“远近”之分,更是有着严格的容量限制。正如前面提到的,L1 缓存通常每核只有 32KB 到 64KB,L2 每核几十上百 KB,L3 全核共享若干 MB。一旦你的程序工作集(Working Set)超出了某一级特定缓存的承载能力,那一级的缓存就会不可避免地开始大量发生 cache miss,访问的延迟代价就会直接从悬崖上掉落到下一个层级。这意味着在现实世界里,内存访问的性能损耗曲线并不是平缓下降的——而是一个非常陡峭的台阶接一个台阶。一个占用 32KB 内存的紧凑循环可能跑得飞快,但一旦数据规模膨胀到了 35KB,性能可能会遭遇突然的腰斩,仅仅是因为最关键的 L1 缓存已经装不下这些数据了。这种基于物理容量瓶颈的行为,在做极致性能调优时经常会带来一些让人捉摸不透的诡异拐点。

Cache line 是硬件搬运数据的物理颗粒度

在明确了缓存层级的基本运作方式之后,我们需要掌握的下一个关键事实是:CPU 在主内存和多级缓存之间进行数据搬运时,绝对不是按单个字节或者单个整型变量为单位零敲碎打来搬运的,而是以整块固定的“缓存行”为基础单位来进行批量传输。

在当前主流的 x86 和 ARM 处理器架构上,一条标准缓存行的大小通常被固定为 64 字节。不要小看这 64 字节,它足以容纳 16 个标准的int变量、或者 8 个long long长整型变量、又或者是 8 个 64 位的内存指针,甚至可以装下一个经过精心设计的小型结构体。

当你在代码里执行int x = arr[0]这一行读取操作时,底层的 CPU 不会仅仅抠搜地只从内存里搬运arr[0]所占的这 4 个字节出来——它会非常大方地将arr[0]所在的整条 64 字节缓存行一口气全部搬进 L1 高速缓存中。伴随着这次顺水推舟的动作,从arr[0]一直到arr[15]的数据,全部一次性地住进了最快的热缓存里。当你接下来的代码继续访问arr[1]或者是arr[2]的时候,CPU 就不再需要跟主内存打交道了,所有的请求都会在 L1 缓存里被瞬间命中,这个后续的访问代价几乎约等于零。

假设在内存地址 0x1000 起始的一条 cache line(长度为 64 字节): ┌──────┬──────┬──────┬──────┬─────────────────┐ │ a[0] │ a[1] │ a[2] │ a[3] │ ... 共装载 16 个 int │ └──────┴──────┴──────┴──────┴─────────────────┘ ↑ CPU 在读取 a[0] 时,会顺带将整条 cache line 全部搬入 L1 缓存

这种“按缓存行整块搬运”的硬件级设计,正是构成程序空间局部性红利的物理基础。程序中在内存地址上相邻排布的数据会被底层硬件批量地装载进缓存体系,针对这块连续区域的后续密集访问几乎不需要再去忍受走主内存的延迟折磨。然而,凡事都有代价,这种设计的代价就是缓存搬运和一致性维护的最小物理颗粒度被死死地固定在了 64 字节上——而这正是我们在后面马上要剖析的 false sharing 性能问题的物理根源。

顺带一提,cache line 的大小并不是由 C++ 软件标准来凭空规定的,它是完全由底层硬件工程师在设计芯片时敲定的物理规格。虽然目前主流平台上的 64 字节是一个相对通用的事实标准,但也存在不少引人注目的例外。例如 Apple 引以为傲的 M1/M2 系列 ARM 架构芯片,其底层的 cache line 宽度就被扩充到了 128 字节;在某些古早的 ARM 核心上,这个值又可能是偏小的 32 字节;甚至早期的 PowerPC 处理器也曾使用过 128 字节的规格。如果你在编写高性能代码时,直接硬编码写下char padding[64]这种做法,在如今跨平台编译的背景下往往就是一个定时炸弹——因为在 M1 这种平台上,64 字节的空洞根本不足以填满一条完整的缓存行,你自以为精妙的 padding 设计在物理层面上压根就没有起到应有的隔离作用。

多个核心尝试写入同一缓存行会互相引发硬件级打断

在单核时代或者单线程程序里,cache line 无疑是提升性能的神兵利器,但一旦将其置入多核并发的语境中,它却往往会成为一系列麻烦的起源。

我们来假设系统目前有两个活跃的核心 Core 0 和 Core 1,它们各自拥有相互独立的 L1 高速缓存。在内存里有某条 cache line 被我们记作L,它由于被两边的业务代码分别读取过,此刻同时存在于两个核心的缓存体系中——Core 0 的 L1 里静静躺着L的一个副本,而 Core 1 的 L1 里也存放着L的另一个副本。在这个阶段,两个核心都只是在执行读取操作,整个体系处于和谐的“只读共享”状态,彼此之间没有任何冲突。

但是,只要其中有任何一个核心试图去修改L里的任何一点数据,事情的性质就立刻发生改变了。按照经典的 MESI 缓存一致性协议的状态流转规则,这个核心的写操作必须强制将该条缓存行在本地升级到 Modified(已修改)状态——这象征着“这条缓存行的当前唯一正确版本只存在于我这里的本地缓存中,主内存里那个已经是废弃版本了”。为了在物理层面上做到这一点,在这个核心动手写入数据之前,它必须先要在总线上广播一条强势的消息给所有其他的核心:请立刻把你们本地持有的那个旧副本作废掉。这种导致他人副本失效的通信动作,在体系结构中被称为 cache line invalidation。

为了更好地理解这个过程,我们先大致了解一下 MESI 这四个核心状态的具体含义:

  • Modified(已修改):意味着当前核心绝对独占了这条 cache line,并且已经在本地对它进行了修改。主内存里的那个版本目前是过期的陈旧数据,在未来的某个时刻,这条修改过的缓存行在被驱逐时必须被写回主存。
  • Exclusive(独占):意味着当前核心独占了这条 cache line,但目前尚未对其进行任何修改,缓存行里的数据和主内存保持着完全一致。
  • Shared(共享):意味着有多个核心当前都同时持有着这条 cache line 的只读副本,它们的内容完全一致,且均未被修改。
  • Invalid(无效):意味着当前核心手里握着的这条 cache line 副本已经宣布过期作废,它里面的数据已经不能再被使用了,如果下次还需要访问,必须重新去总线上索要。

这些状态之间的迁移完全是由在底层总线上穿梭的消息信号来驱动的:当某个核心想要读取一段不在自己本地缓存里的数据时,它会在总线上发出 Read 请求;当它想要独占写入一条处于 Shared 状态的缓存行时,它会发出 Invalidate 请求;而当其他核心接收到 Invalidate 信号时,它们别无选择,只能立刻把自己手里的本地副本强行标记为 Invalid 状态。现代 CPU 中采用的 MESI 扩展版本(如 MOESI、MESIF 等变体),其运作原理依然类似,只是在某些特定的状态转换效率上做了一些针对性的细节优化。

我们可以顺着时间线完整地走一遍这个并发写入的物理流程:

初始状态: Core 0 L1: [L, Shared 状态] Core 1 L1: [L, Shared 状态] 此时 Core 0 想要修改 L 里的某个独立字段: Core 0 在底层的缓存一致性总线上向全网广播 "Invalidate L" 消息 Core 1 在收到广播信号后,只能无奈把自己 L1 里的 L 标记为 Invalid 状态 此时状态变为:Core 0 L1: [L, Modified 状态] Core 1 L1: [L, Invalid 状态] Core 0 在拿稳独占权后,终于完成了本地的快速写入 紧接着 Core 1 又想要读或者写 L 里的另一个完全独立的字段: Core 1 在访问本地缓存时发现自己 L1 里的 L 已经是 Invalid 过期状态了,它必须重新拿 Core 1 在总线上向全网(特别是目前持有最新数据的 Core 0)请求获取最新的 L Core 0 被迫停下手中的工作,把 L 的最新状态传回给 Core 1,同时自己降级到 Shared 或者是干脆放弃所有权 此时状态更新为:Core 1 L1: [L, Modified 或 Shared 状态] Core 0 的状态相应变为:Core 0 L1: [L, Shared 或 Invalid 状态] Core 1 在拿到最新缓存行后,终于完成了自己被延迟的访问

走完这样一个完整的失效与重新获取流程,在底层硬件上的代价通常是几十纳秒的时间开销——这比起直接命中 L1 缓存的 1 纳秒来说,整整慢出了一个甚至好几个数量级。如果我们的代码逻辑使得两个独立核心在反复地轮流写入同一条缓存行,那么每一次物理上的写入都要触发这样一轮漫长的 invalidation 加上繁琐的重新获取动作。从宏观上看,原本宝贵的 CPU 计算时间被大量消耗在了等待缓存行通过总线来回搬运的路上。这个由于频繁争抢导致的高代价现象,在业界有一个很形象的俗称:缓存乒乓(cache ping-pong)——因为这条缓存行就像一颗乒乓球一样,在两个物理核心之间被频繁地来回弹射。

这里有一个关键但常被软件工程师忽略的重点:cache line invalidation 的发生,跟代码在逻辑上究竟修改了 cache line 里的哪一个特定变量或字段是完全无关的。MESI 协议管理一致性的最小物理颗粒度就是一整条 64 字节的 cache line,而不是某一个 4 字节的独立变量。当 Core 0 修改了L里的字段 X 时,按照底层的物理规则,它会让 Core 1 手里的整条完整的 L 统统失效——哪怕在软件语义层面,Core 1 仅仅只是想要读取L里与之完全毫不相干的另一个字段 Y。

这正是我们接下来要讨论的 false sharing 现象之所以会爆发的物理根源所在。

False sharing 的典型发生现场

如果把上述的微观硬件机制对应回到我们的 C++ 高层业务代码中,许多性能问题就能得到合理的解释。

structCounters{std::atomic<longlong>a{0};// 物理偏移量 0std::atomic<longlong>b{0};// 物理偏移量 8};

在这个结构体中,ab是两个完全独立声明的atomic<long long>变量,它们各自占据 8 个字节的内存空间,并且在物理上非常紧凑地紧挨着排布在同一个结构体内部。这个Counters对象的整体大小仅仅只有 16 字节,这个尺寸远远小于一条标准 64 字节缓存行的容量。在 x86 等主流平台上,当这个结构体被分配到堆或者栈上时,ab这两个变量几乎是不可避免地会共同落在同一条物理 cache line 里。

此时,如果我们安排线程 1 反复地去执行counters.a.fetch_add(1),同时安排线程 2 反复地去执行counters.b.fetch_add(1)。站在 C++ 语言的语义视角来看,这两个原子操作分别访问的是完全不同的独立变量,它们之间并不存在任何的竞争修改,也完全符合多线程代码的正确性规范。但是,当我们切换到底层硬件的物理视角来看时,这两个分属不同核心的线程,实际上都在极其密集地试图写入同一条被共享的 cache line:

  • 线程 1(通常跑在 Core 0 上)尝试写入a,为了完成写操作,它必须向硬件要求将这整条 cache line 置入 Modified 状态——这必然导致 Core 1 缓存里的整个副本宣告失效。
  • 线程 2(通常跑在 Core 1 上)紧接着尝试写入b,由于本地副本已失效,它必须跨越总线重新去抢夺这条 cache line 的 Modified 独占所有权——这又必然导致 Core 0 手里好不容易拿到手的副本再次失效。
  • 线程 1 随后又要开启下一轮对a的累加,于是它又不得不把 cache line 从 Core 1 那里生拉硬拽地抢回来。
  • 这个争抢过程伴随着巨大的总线流量,陷入了无限的循环之中。

这就是我们在并发调优中常常提及的 false sharing(伪共享) 现象——在业务逻辑的代码层面上,我们并没有让两个线程共享去修改同一个对象(因为ab明明是各自分开的),但是在底层的物理布局层面上,它们却极其不幸地共享了同一条缓存行。“false”这个词用在这里是非常精准的:在高级语言的抽象语义中,这绝对不是真正意义上的共享;但是底层那套死板的缓存一致性硬件协议,却将这种物理重叠当成了真正的严重共享来严阵以待地处理。于是,原本用来保证正确性的沉重性能代价,就这样被无辜的软件代码给全盘照收了。

必须再次强调的是,false sharing 不是代码层面的数据竞争(data race)。存在 false sharing 的代码在逻辑上完全合法,TSan 这类并发分析工具不会报出任何问题——因为从 C++ 抽象语义来看,两个线程确实在访问不同的变量。性能倒退完全发生在硬件层面,是缓存一致性协议在物理总线上付出的实际代价。调试这类问题,不能依赖语言层面的工具,必须切换到硬件的视角。

深入思考并克制地进行热数据(hot data)和冷数据(cold data)的物理分离设计。 在绝大多数追求极限速度的高并发系统架构中,系统经常要高频次、低延迟访问的核心字段集合(也就是绝对热点数据,hot data),理应被精心地集中放置在一起;而与此对应的,那些偶尔才被查询的非核心业务字段(也就是绝对冷门数据,cold data),则应该在物理排布上放在远离热点数据的区域。如果在代码结构体里大意地把这两类访问频率相差甚远的数据随手混在一起,直接导致的硬件后果就是:每当底层硬件因为 cache miss 从缓慢的主内存里拉回来一整条宝贵的 64 字节 cache line 时,里面往往有一大半装载的是当前运算环节压根碰都不会碰的冷门数据,这就等于白白浪费了原本就捉襟见肘的高速缓存空间。

不要凭借主观臆想,一定要运用底层的sizeofalignof关键字来反复验证数据的最终排布是否符合预判。 在你调整字段顺序完成对齐优化之后,请务必在日志或单元测试里,把sizeof(WorkerStats)alignof(WorkerStats)甚至是运行时对象的物理地址打印出来确认一遍。只有亲自确认编译器输出的真实对齐状况和物理体积完美契合了你的推演,才能放心地把代码并入主干。需要警惕的一点是,像std::vector这种标准库容器,由于内部实现细节的差异,在为内部元素分配内存时,有时可能不会严格遵循我们在上层为元素类标记好的alignas对齐规范。为了防止踩中这种隐蔽的坑,最严谨的做法还是专门写几段运行时验证代码来进行事后确认。

支撑这一切优化动作的最后一条底线铁律是:永远要依靠真实的 benchmark 实测数据说话。 以上所有看似精妙的底层空间排布理论指南和主观预判,在残酷的生产实战环境里,唯一的检验标准就是在贴近真实线上高并发流量的极限冲击下,真刀真枪跑出来的 benchmark 成绩。在纸面上看起来理所当然、“加个 padding 肯定会起飞”的主观错觉,和最终实测出来的冷冰冰的结果之间,往往横亘着一道深渊——也许你费尽心机的优化不但没起正面作用,反而导致了大规模的性能倒退。这压根就不是出在假想的 false sharing 或复杂的内存序上;很可能仅仅是因为你过度滥用 padding,直接导致系统内存占用急剧膨胀,随之引入的宏观缓存灾难(大量 cache miss 和被迫换页)反而彻底抵消了你原本指望省下来的那点乒乓开销。又或者在某种离奇的巧合下,仅仅是因为硬件预取器(prefetcher)在应对被人为打散的碎片化空间时出现难以理喻的抽风行为,就把最终的性能扭曲成了完全反直觉的灾难结果。总而言之,一切理论推演最终都必须向 profiler 的运行报告和 benchmark 计时器让路。

因此,当你怀疑一段代码是否存在 false sharing 时,关键的着眼点绝对不应该是去盯着 C++ 层面看“这几个变量是不是被共享声明了”,而是要像一个硬件工程师一样去审视在物理内存里这些字段究竟是怎么挤在一起排布的。这时候,通过打印出运行时的对象物理地址、使用offsetof宏去精确查看字段偏移量、或者用简单的(addr >> 6) << 6位运算去倒推出 cache line 的对齐起点——这些贴近底层的硬核手段,往往会比单纯用肉眼去死盯抽象源码来得更加直接且有效。

自己动手写一个 benchmark 看真实性能现象

纸上得来终觉浅,为了让你对 false sharing 的破坏力有更加直观的感受,我们可以直接把它的代价用实际代码测出来。下面这段精简过的 benchmark 代码,在绝大多数主流的 x86 开发机上都能够非常稳定地复现出极具冲击力的性能差异。

#include<atomic>#include<chrono>#include<iostream>#include<thread>structCounters{std::atomic<longlong>a{0};std::atomic<longlong>b{0};};constexprintkIterations=100'000'000;voidAddA(Counters*c){for(inti=0;i<kIterations;++i){c->a.fetch_add(1,std::memory_order_relaxed);}}voidAddB(Counters*c){for(inti=0;i<kIterations;++i){c->b.fetch_add(1,std::memory_order_relaxed);}}intmain(){Counters counters;autostart=std::chrono::steady_clock::now();std::threadt1(AddA,&counters);std::threadt2(AddB,&counters);t1.join();t2.join();autoend=std::chrono::steady_clock::now();std::cout<<std::chrono::duration_cast<std::chrono::milliseconds>(end-start).count()<<" ms\n";return0;}

在亲自去编译和运行这段代码之前,有几个关于性能测量领域的基础注意事项必须先交代清楚,否则你跑出来的数据有可能会产生非常大的误导性。

必须固定一个合适的编译器优化级别。 推荐统一使用-O2级别来进行基准编译。如果在毫无优化的-O0下跑,编译器不仅没有做常规的寄存器分配,各种低效的指令乱序也会掩盖掉真实的缓存负载代价,这种情况下看到的数据不能反映真实的业务压力;而如果你激进地开到了-O3级别,编译器有时会对这种过于简单的累加循环进行激进的展开合并优化,这反而会彻底掩盖掉我们想测的那个细微的底层瓶颈。

务必要重复运行多次并取其中的最优值或稳定中位数。 任何一次单机上的单次性能测量,都很容易受到当时操作系统的内核调度策略、CPU 动态变频温度控制、以及后台运行的其他进程的显著干扰。一个负责任的测试,至少应该循环跑上 5 次左右,然后取一个稳定的中位数或是理论最优值作为基准参考。

想尽办法避免测试用的循环被聪明的编译器给无端优化掉。 在上面的代码中,我们之所以坚持使用fetch_add配合relaxed内存序,就是为了告诉编译器这是一个非常真实的底层 atomic 操作,绝对不容许进行任何粗暴的消除或折叠。如果为了图省事把它换成了普通整数变量的自增操作,那些聪明的现代编译器有时候一眼就能看穿你的意图,然后直接把那个漫长的循环在编译期给折叠成了c->a += kIterations这样一步到位的汇编指令。如果发生了这种事,你这个辛辛苦苦写的 benchmark 也就彻底失去了所有的测量意义。

有条件的话尽量进行绑核测试。 如果你在使用 Linux 系统,可以通过使用taskset这类工具,强行将负责计算的这两个独立线程分别绑定到两个在物理层面上完全分隔的核心上去。因为如果操作系统恰好把这两个线程调度到了同一个物理核心内部包含的两个超线程(Hyper-Thread)上执行时,false sharing 带来的破坏力反而显得不那么严重了——因为这两个处于同一屋檐下的超线程本来在物理上就是天然共享着同一份 L1 缓存的,自然也就不会产生跨核心的缓存一致性同步流量。只有当你把它们硬生生地绑到两个真正分离的物理核心上,这趟浑水里的 false sharing 带来的完整跨线惩罚才能得以毫无保留地完全暴露出来。

学会使用专业的 perf 工具去洞察底层的缓存事件。 在 Linux 系统生态里,强大的perf stat命令行工具能够绕过上层的抽象,直接向你汇报底层的硬件事件监控报告,包含诸如 L1 cache miss、L2 cache miss、以及跨核心 cache line 来回传递的确切次数等硬核指标。对于一段确实存在着严重 false sharing 的可疑代码,它跑出来的cache-missesLLC-load-misses指标数据将会远远超出你的理论预期,甚至会比同样工作量的单线程基准版本高出好几个数量级。这是一种直接从底层硬件计数器里挖掘确凿证据、彻底确认问题根源的专业手段,远比单纯只看表面程序执行花费了多少 wall time 要来得更加精确和让人信服。

在我自己用来测试的一台常规 x86 机器上,上面这段有缺陷的并发代码跑出来大约需要 1500 毫秒的时间(具体数值因不同代系的机器硬件而有所浮动)。那如果我们把这些同样枯燥的工作量全都集中退回到单线程,让它在一个核心上自己按部就班地顺序执行完呢——

voidAddBoth(Counters*c){// 单线程包揽全部工作for(inti=0;i<kIterations;++i){c->a.fetch_add(1,std::memory_order_relaxed);c->b.fetch_add(1,std::memory_order_relaxed);}}

这个看似效率低下的单线程版本最终跑完的成绩是大约 800 毫秒。两个线程并行去算,成绩反而比单线程慢了一大截。明明投入了两倍的物理核心,执行着理论上相同的两套 atomic 操作,总体耗时却多出了 87%。这就是 false sharing 在现实工程中的真实代价——在这 1500 毫秒里,两个物理核心在绝大部分时间里没有进行实质性的加法运算,而是在缓存一致性总线上排队等待那条被对方持有的 cache line 传递过来。

学会使用 padding 和 alignas 从物理上隔开 cache line

在工程上修复 false sharing 的核心解决思路其实非常朴素且直接:既然底层硬件是以 cache line 为单位进行管理,那我们就只需要通过某种手段,把那两个很容易被并发高频写入的关键变量,在物理内存上强行隔离并分配到完全不同的 cache line 上去就可以了。为了规范化这种底层的硬件隔离需求,C++17 标准官方为我们提供了一个很好用的标准化常量工具:std::hardware_destructive_interference_size

#include<atomic>#include<new>// 使用标准推荐的干扰隔离常数进行对齐structalignas(std::hardware_destructive_interference_size)PaddedCounter{std::atomic<longlong>value{0};};structCounters{PaddedCounter a;PaddedCounter b;};

这段代码中用到的std::hardware_destructive_interference_size定义在标准的<new>头文件里,从字面意思上翻译,它代表的就是“为了在多核架构下彻底避免发生破坏性的缓存性能干扰,处于并发访问状态下的两个独立对象之间,最起码应该被物理隔开的最小安全字节数”。在目前的主流 x86 平台上它通常会被编译器展开为 64,而在 Apple M 系列这类 ARM 芯片上则通常会被展开为 128。通过使用这个标准常量配合alignas关键字进行修饰,现代编译器就会在内存分配时尽心尽力地保证每一个被实例化的PaddedCounter对象都至少会严格按照这个巨大的字节数起点来进行物理对齐——这也就意味着,在这段重构后的代码里,ab之间被隔开了至少一整条 cache line 的安全距离,它们不会再落在同一条缓存行上。

如果你把这个 padded 版本代入前面的 benchmark 重新跑一遍,会发现完成同样工作的耗时从 1500 毫秒降到了大约 400 毫秒。在这个隔离距离下,两个线程终于可以各干各的、互不干扰。没有了缓存乒乓效应,整体速度比单线程版本快了一倍——这才是多核并发理论上应该兑现的性能红利。

当然,如果你在去翻阅一些有着深厚历史包袱的 C/C++ 旧代码库时,你极有可能会见到那些前辈们纯靠手写硬编码搭建起来的 padding 防御工事:

structPaddedCounter{std::atomic<longlong>value{0};// 粗暴地用无意义的字符数组填满剩余的空间charpadding[64-sizeof(std::atomic<longlong>)];};

这套做法的逻辑是对的——在value字段后面塞入填充字节,把结构体撑大到 64 字节,相邻对象自然就被物理空间分开了。但这种写法的前提是:目标平台上的 cache line 宽度永远是 64 字节。随着硬件演进,在 Apple M 系列这类采用 128 字节缓存行的平台上,这样一个被 padded 过的对象只有 64 字节,两个对象依然可能挤在同一条 128 字节的 cache line 里,硬编码的 padding 隔离就此失效。

因此从长远来看,如果你的基础库环境已经能够平滑支持 C++17 标准,请毫不犹豫地优先使用官方推荐的hardware_destructive_interference_size。如果你的工程仍然必须兼容更早的老旧标准,也请务必在底层架构库中把 cache line 大小规范地定义成一个有意义的常量宏(最好是能够根据编译时识别出的目标平台参数进行动态宏定义区分),绝对不要把64这种充满隐患的魔法数字直接散落在业务代码的各个角落里。

在每次完成对这种底层结构的对齐修复之后,强烈建议你在紧随其后的代码里补上一次简单的静态自检防线:

static_assert(sizeof(PaddedCounter)>=64);static_assert(alignof(PaddedCounter)>=64);Counters c;std::cout<<"offsetof a: "<<offsetof(Counters,a)<<"\n";std::cout<<"offsetof b: "<<offsetof(Counters,b)<<"\n";std::cout<<"addr diff: "<<(reinterpret_cast<char*>(&c.b)-reinterpret_cast<char*>(&c.a))<<"\n";

这些offsetof检测出来的偏移量,以及通过指针相减算出来的物理地址差值,至少在数值上要大于等于目标 cache line 的标准大小,否则说明编译器背后的填充没有生效。这种自检防线在需要长期维护的大型代码库里能发挥兜底作用——因为在漫长的协作迭代中,核心结构体的字段排列顺序有可能被后来的开发者无意重排,如果精心设计的 padding 距离被打散,整个模块的并发性能就会悄然退化。而如果能提前把关键的内存对齐约束和空间不变量,用static_assert钉在代码里,那么以后不管谁动了这个结构体的布局,编译阶段就会立刻收到编译错误。

最后还需要特别提一句,关于hardware_destructive_interference_size这个特性本身,在业界各大编译器巨头之间其实一直存在着不小的争议:因为这只是一个标准库对外提供的环境常量,它在不同厂商的不同编译器底层实现里的取值标准可能差异很大,C++标准委员会官方也仅仅只是给出了一个“为了尽可能避免发生破坏性干扰的建议推荐值”这样模糊的定性描述。比如 GCC 阵营在很长一段时间里在面对 ARM 架构时都固执地取值为 64,但 Apple 阵营自家的 Clang 编译器在面对同属 ARM 的 M 系列芯片时却会毫不犹豫地取值为 128。在最权威的 LLVM 官方开发者邮件讨论列表里,甚至曾经爆发出过一场关于要不要干脆把这个特性直接彻底废弃掉的激烈争论——其中核心的反方理由就是,它的所谓“最准确值”往往与目标 CPU 的运行时微架构批次强相关,如此底层多变的参数从工程哲学上来讲就不应该被做成一个在编译期就被僵硬锁死的静态常量。虽然这些争议并不至于实质影响日常使用,但这也在从侧面说明:试图用简单的常量 padding 在跨平台场景下一劳永逸地解决底层性能冲突,在 C++ 标准体系层面上至今不算一个完美的终极方案。


优秀的数据布局往往比随手强加 padding 更值得花心思

依靠 padding 确实能够在短时间内有效压制住 false sharing 的性能问题,但这不是免费的午餐。

它带来的最直接副作用是显著放大内存占用。 原本一个只占 8 字节的atomic<long long>,套上 padding 之后体积膨胀到 64 字节,是原来的 8 倍。如果你声明了一个std::array<PaddedCounter, 1024>这样的统计数组,原本只需要 8KB 的缓存空间,现在变成了 64KB——这个体积已经超出了大多数 CPU 的 L1 cache 容量。在这种放大效应下,线程每次扫过这个数组,硬件都不得不跨越更多 cache line 进行加载。你为了规避缓存乒乓付出的代价,到头来可能反而引入了大面积的 L1 cache miss。

它同时还会破坏程序的空间局部性。 padding 的本质是把本该紧凑排列的变量强行撑开。原本一条 64 字节 cache line 可以轻松容纳 8 个统计变量,加上保护壳之后,每条 cache line 里只装着 1 个有效变量。在这种松散的数据排列下,顺序内存访问的吞吐量会出现明显下降,硬件预取器(prefetcher)的工作效率也会跟着降低。

因此,更根本的优化思路,是从系统架构设计阶段就去规划数据的排布布局,让那些容易被并发密集写入的字段在物理上天然分离,而不是等到上线后发现性能问题再靠 padding 来补救。

尽量在设计上让每个独立线程只更新私有专属变量,最后再在主线程统一汇总,这永远优于一开始就暴露一个所有人争抢的全局共享计数器。 如果业务诉求只是让每个独立线程更新专属的监控计数器,那么最干净的无锁方案,就是把这些计数器隔离在线程私有的存储区域内(比如使用thread_local关键字,或将它们挂载在工作线程独立持有的对象内部),然后另排一个低频的后台动作完成数据全局汇总。由于这类线程私有变量在物理内存分配上往往非常遥远,天然归属于不同的物理内存页和区域,自然就从根源上彻底避免了 false sharing 的交叉干扰。

#include<atomic>#include<new>#include<vector>// 这是一个典型的专门针对每个独立 worker 线程进行统计的监控结构体structalignas(std::hardware_destructive_interference_size)WorkerStats{std::atomic<longlong>completed_tasks{0};std::atomic<longlong>failed_tasks{0};// 这里还可以继续堆叠其他的每线程独立统计字段};// 系统预先为每个 worker 准备了一个专属的槽位std::vector<WorkerStats>stats;

由于我们在设计上保证了每个WorkerStats对象在内存里都至少占据一整条独立的 cache line,所以各个核心上的 worker 在更新各自业务进度时不会互相干扰。当需要进行全局汇总时,简单写个循环遍历所有 worker 的统计字段并加和即可——这个汇总动作通常只在一个单独的线程里低频执行,整个过程不需要在底层承受缓存乒乓的代价。

学会在业务结构的定义阶段,就把只读的静态字段和频繁改写的高压字段在物理上分离开来。 在一些结构体定义中,经常能看到大量在并发环境下被重复只读的静态配置(如服务地址、超时上限等),旁边又挤着几个被高频反复擦写的原子计数器。如果不幸把这两类字段塞进了同一条 cache line 里,就会导致性能外溢——那些只想快速读取静态配置的线程,每次都会被旁边高频写状态的活跃线程连累,导致自己手里的缓存副本失效。迫使那些原本可以在 L1 缓存里快速命中的只读访问,不得不跨越主板去主内存里重新读取。面对这种情况,最合理的优化思路就是在一开始把这两类字段拆分成不同的结构体,或者通过 padding 在大对象内部用物理空间切开。

具体落实到能够被团队落地的重构代码层面:

// 典型的糟糕布局范例:频繁的读写操作被硬生生地揉在了一起structServiceState{std::string endpoint;// 偏向静态属性的只读配置,服务上线跑起来后几乎就再也不变了intmax_connections;// 稳定的只读阈值限制inttimeout_ms;// 稳定的只读配置std::atomic<longlong>hits{0};// 会在后台被大量业务线程高频反复并发写入std::atomic<longlong>misses{0};// 同样会遭遇高频并发改写};// 经过架构师精心考量过底层物理特性的优雅布局:实行严格的冷热读写物理分离政策structServiceConfig{std::string endpoint;intmax_connections;inttimeout_ms;};// 单独把这几个容易引发缓存地震的高压统计字段隔离出来,并加上严苛的安全距离防护罩structalignas(std::hardware_destructive_interference_size)ServiceStats{std::atomic<longlong>hits{0};std::atomic<longlong>misses{0};};// 最终用来向外暴露的业务封装体structService{ServiceConfig config;ServiceStats stats;};

在这两种布局里,第一种设计会让所有试图读取endpoint的线程,无端被旁边高频更新hits的线程频繁打断——仅仅因为它们在物理上挤在了同一条 cache line 里。而第二种分离式布局把只读配置和可变计数器拆成了两个物理隔绝的独立实体:配置项可以稳定地驻留在只读 cache line 中(多个核心长期维持 Shared 状态),计数器则被安置在独立的隔离行中,内部再怎么高频 invalidate 也不会外溢干扰到只读业务流。

总结一下:与其在发现性能问题后靠 padding 硬补,不如在设计阶段就把高频写入字段和低频只读字段拆开到不同的结构体里。padding 是补救手段,布局分离才是更根本的方案。

正确性和性能要分两层来看

至此,关于并发编程两面性的探讨就已经相对完整了。

在前面的 11 章篇幅里,我们始终紧密围绕着多线程编程的正确性这一核心展开。原子操作用来保证底层动作不可分割,内存序规则用来保证跨越缓存的可见性与代码执行顺序,通过 release/acquire 来完成安全的数据发布,依靠 CAS 来应对复杂的竞争修改,以及偶尔用 fence 拆分出独立的物理屏障。这一系列语言层面的机制设计,旨在让多线程代码能够在抽象的语义层面上“把事情绝对做对”。

而这一章的重点,则是将目光聚焦到了性能层面上。在确认一段代码的抽象语义已经正确的前提下,隐藏在底层的缓存调度行为细节,往往决定了这段代码究竟能跑多快。两段在抽象语义上完全等效的并发代码,可能会因为开发者在变量存放位置和物理布局上的一点差异,在实际性能表现上相差好几倍。这个巨大的性能偏差鸿沟不是来自于 C++ 并发内存抽象模型本身,也不仅仅来自于内存序的开销,它真切地来源于 CPU 缓存一致性维护协议所付出的物理总线代价。

在实际的工程实践中,这两层差异化的领域往往容易被混为一谈。“在这个场景中是用 atomic 跑得比较快,还是直接用常规的 mutex 互斥锁会更快?”这是一个在严谨的工程科学里无法提供标准答案的问题。它取决于具体的物理落地场景。如果那两个竞争的 atomic 变量幸运地落在不同的物理 cache line 上,且整体并发激烈度较低,那么使用 atomic 无疑会在性能上碾压 mutex。但如果这两个关键的 atomic 变量不幸地挤在同一条 cache line 上,且被多个核心高频反复争抢修改,那么看似轻量的 atomic 跑出来的成绩可能会比沉重的 mutex 还要慢得多。因为传统互斥锁在定义时往往自身体积极大,自带了自然的隔离和填充效果,这就使得这把大锁在运行期间反而不易踩中 false sharing 的缓存陷阱。

本系列的下一章也是最后一章,将作为整个系列的压轴收尾。我们将把之前分散剖析过的互斥锁、不同强度的 atomic 内存序模型、无锁开发中的 CAS 重试机制、fence 内存屏障,以及本章探讨的底层 cache line 缓存隔离等工具全部放在一起,结合真实的工程项目实战来进行综合探讨。这其中囊括了大家关注的核心问题:到底在何种具体场景里我们理应果断使用普通的互斥锁?又该在何种特定范围内去精细选用无锁的 atomic 接口?什么时候才值得我们不惜代价去全面引入极纯的无锁结构体系?各种压测 benchmark 数据指标到底该怎么理性看待?高质量的 code review 到底应该重点深挖哪些容易出错的隐秘死角?以及当 Thread Sanitizer 之类的检测工具也束手无策时,我们该以何种经验和流程手段来排查深层隐患。

完整读完这篇压轴大章之后,我无法保证你能在一夜之间脱胎换骨成为无锁编程的顶尖专家,但我至少能够确信一点:当你在未来不得不在工作中去审查或维护一段复杂难懂的多线程并发代码时,你必然会比以前的自己更为清楚,该向那些代码问出什么样切中要害的工程问题、该去谨慎地看待什么样的压测数字,以及最重要的是,明白在什么样的危险边界前应该理智且果断地选择停下自己试图强行手写优化的脚步。

码字不易,欢迎大家点赞,关注,评论,谢谢!

http://www.jsqmd.com/news/1106205/

相关文章:

  • 打包带在高温环境下会变形吗?
  • Python代码重构最佳实践
  • Zephyr RTOS入门:设备树(DTS)与Kconfig配置体系——设备树、配置系统
  • 实测 Claude Sonnet 5 vs Claude Sonnet 4.6:别只看发布公告,API 跑起来才知道差距
  • Python集合使用技巧解析
  • 《代码随想录》刷题打卡day28:动态规划part01
  • 纯HTML离线项目零部署优化方案|单文件离线运行、无环境依赖 前言
  • 从0开始为Vue3+TS+Vite项目配置ESLint+Prettier
  • LTC6904与PIC18F86J11实现高精度时钟同步方案
  • 客服工单表怎么关联订单和玩家记录
  • 即时通信服务器架构的一些思考
  • 我把《易经》做成了AI,发现了沟通的底层规律
  • Go网络开发教程
  • Kubernetes日志管理技巧
  • console.log不可用解决
  • DAC161S997与STM32F429NI构建高精度4-20mA电流环方案
  • 简述交换机
  • 从百万行代码库中拯救编译速度:IDEA 2023.3+ Clean Import Pipeline实战(含Gradle/Maven双模自动化校验模板)
  • 【最全】 Codex保姆级使用教程:安装、配置、汉化、Skills 一天上手
  • 2026 新版多盘对比命理工具榜:玄易为何更适合高频看盘与合盘场景
  • 【JAVA毕设源码分享】基于Web的社交媒体平台的设计与实现(程序+文档+代码讲解+一条龙定制)
  • AI编曲工具实战:从入门到专业音乐制作
  • AI赋能当代大学生创新创业|零壹岛走进广东交通职业技术学院开展信息技术专题讲座
  • 小程序没那么难-物业工单系统
  • AI协作模式匹配与风险规避实践指南
  • Codex 额度总是不够用?先判断是任务范围问题,还是使用强度问题
  • 些年搞不懂的高深术语——依赖倒置•控制反转•依赖注入•面向接口编程
  • 星盘接口开发文档:骰子占卜接口指南
  • 广告效果监测技术:EEG模拟与微表情分析的实战应用
  • 突破音乐枷锁:NcmpGui如何让网易云音乐文件重获自由