WEBKT

深入Linux内核:__read_mostly 标记如何从硬件层面干掉 Cache Line 伪共享?

3 0 0 0

在多核处理器时代,编写高性能系统级代码不仅需要考虑算法复杂度,更要考虑控制处理器缓存(L1/L2/L3 Cache)的物理行为

在 Linux 内核源码中,我们经常会看到一些全局变量被赋予了 __read_mostly 属性。例如:

struct ion_device *icedev __read_mostly;
int sysctl_sched_rt_runtime __read_mostly = 950000;

这个看似简单的修饰符,背后隐藏着内核开发者与现代 CPU 缓存一致性协议(Cache Coherency Protocol)之间长达数十年的暗战。本文将从硬件和微架构视角,深度剖析 __read_mostly 规避 Cache Line 伪共享(False Sharing)的实际物理效应。


一、 硬件痛点:Cache Line 与伪共享的隐形杀手

现代 CPU 并不是按字节(Byte)从内存中读取数据的,而是以 Cache Line(缓存行) 为基本单位进行缓存,主流的 x86_64 和 ARM64 架构中,Cache Line 的大小通常是 64 字节

1. 什么是伪共享?

假设在内核中有两个全局变量 AB,它们在物理内存中的地址非常靠近,恰好落在了同一个 64 字节的 Cache Line 中:

  • 变量 A:几乎全是读取操作(例如:系统调度周期 sysctl_sched_latency)。
  • 变量 B:频繁被某个内核线程写入(例如:某个网卡驱动的收包计数器)。
+-------------------------------------------------------------+
|                     64-Byte Cache Line                      |
|  +---------------------------+  +------------------------+  |
|  |   Variable A (Read-Heavy)  |  | Variable B (Write-Hot) |  |
|  +---------------------------+  +------------------------+  |
+-------------------------------------------------------------+

当 Core 0 频繁读取 A,而 Core 1 频繁写入 B 时,虽然在软件逻辑上 AB 毫无关联,但由于它们在物理上共享同一个 Cache Line,硬件层面会发生灾难性的 Cache Bouncing(缓存抖动)

2. MESI 协议下的硬件风暴

现代多核 CPU 使用 MESI(Modified, Exclusive, Shared, Invalid)或其变体(如 MOESI)协议来维持缓存一致性:

  1. Core 0 读取 A,将该 Cache Line 加载到自己的 L1 Cache,状态设为 Shared (S)
  2. Core 1 写入 B。为了写入,Core 1 必须向总线发送 RFO (Request For Ownership) 信号,强制将其他所有 CPU 核心中对应的 Cache Line 标记为 Invalid (I)
  3. Core 0 再次尝试读取 A。由于其 L1 Cache 中的 Line 已被标记为 Invalid,发生 Cache Miss。Core 0 必须挂起流水线,等待从 L3 甚至系统内存(DRAM)中重新加载该 Cache Line。
  4. 加载完成后,状态恢复为 Shared (S)。但紧接着 Core 1 又写入了 B,循环往复……

这种现象被称为伪共享(False Sharing)。在 macro-benchmark 中,这种频繁的 Cache 状态转换和总线锁会导致 CPU 核心将大量时钟周期浪费在等待内存响应(Memory Stall Counters)上,严重拖慢系统吞吐量。


二、 __read_mostly 的编译与链接机制

Linux 内核引入 __read_mostly 就是为了打破上述硬件魔咒。

1. 宏定义解析

在内核源码 include/linux/cache.h 中,__read_mostly 的定义如下:

#ifndef __read_mostly
#define __read_mostly __attribute__((__section__(".data..read_mostly")))
#endif

这里使用了 GCC 的 __attribute__((__section__(...))) 属性。它的作用是显式告知编译器:不要把这个变量放在常规的 .data 段,而是将其打包放入一个名为 .data..read_mostly 的特殊 ELF 段中

2. 链接器的空间聚合

在内核编译的最后阶段,链接脚本(通常在 arch/x86/kernel/vmlinux.lds.Sinclude/asm-generic/vmlinux.lds.h 中定义)会将所有目标文件中的 .data..read_mostly 段融合成一个连续的物理内存区域:

#define READ_MOSTLY_DATA(align)						\
    . = ALIGN(align);						\
    *(.data..read_mostly)						\
    . = ALIGN(align);

关键在于这个 ALIGN(align)(在现代 x86 上通常是 CONFIG_X86_L1_CACHE_BYTES,即 64 字节对齐)。

通过这种机制,内核将所有**“读多写少”**的变量强行在物理内存上“抱团取暖”,集中存储在这一片专属的连续区域中。


三、 规避伪共享的实际硬件效应

这种内存布局的改变,在 CPU 硬件流水线上会引发连锁的积极效应:

1. 维持长期稳定的 Shared (S) 状态

由于整个 .data..read_mostly 段内几乎全是被高频读取、极少写入的变量,这意味着填充到 CPU 各个核心 L1/L2 Cache 中的这些 Cache Line,几乎永远不会收到来自其他核心的 RFO 写入无效信号

在硬件运行期间,这些 Cache Line 在多核之间的状态会长期稳定在 Shared (S) 状态。

  • 没有状态切换的开销
  • 没有总线无效化风暴(Invalidation Storms)
  • CPU 核心读取这些变量时,几乎 100% 命中 L1 Cache(耗时仅约 4 个时钟周期,而发生 Cache Miss 穿透到 DRAM 则需要 200+ 个时钟周期)。
[ 物理内存布局对比 ]

没有使用 __read_mostly(冷热杂糅,极易伪共享):
+-------------------+-------------------+-------------------+
| Read-Mostly A     | Write-Heavy B     | Read-Mostly C     | ---> 共享同一 Cache Line 导致频繁 Invalid
+-------------------+-------------------+-------------------+

使用 __read_mostly(物理隔离,冷热分流):
+-------------------+-------------------+
|  .data..read_mostly 段 (稳定在 Shared 状态)               |
|  +-----------------+  +-----------------+                 |
|  | Read-Mostly A   |  | Read-Mostly C   |                 | ---> 极少写入,无 Invalid 信号干扰
|  +-----------------+  +-----------------+                 |
+-------------------+-------------------+
|  .data 段 (频繁写入)                                       |
|  +-----------------+                                      |
|  | Write-Heavy B   |                                      | ---> 即使高频抖动,也绝不波及 A 和 C
|  +-----------------+                                      |
+-------------------+-------------------+

2. 提升 TLB(Translation Lookaside Buffer)的命中率

将读多写少的变量集中存放,还顺带提升了虚拟地址到物理地址转换的效率。由于这些变量集中在少数几个物理页(Page)中,CPU 的 Data TLB 只需要维持极少的条目(Entries)就能覆盖绝大部分的高频读操作,减少了页表行走(Page Table Walk)的概率。


四、 硬件实测:有无 __read_mostly 的性能分野

为了直观理解这种硬件效应,我们可以通过修改内核模块并使用 perf 工具进行微架构级别的采样。

实验场景

假设有两个内核线程运行在不同的 CPU 核心上,不断循环读取一个全局变量。

  • 对照组 A:该变量与一个高频写入的计数器相邻,且没有声明 __read_mostly
  • 实验组 B:该变量被声明为 __read_mostly

利用 Linux perf 抓取硬件 PMU(Performance Monitoring Unit)事件:

perf stat -e cache-misses,L1-dcache-load-misses,l1d_pend_miss.pending_outstanding -- <your_benchmark>

在对照组 A 中,你会观察到:

  1. L1-dcache-load-misses 暴增
  2. cache-misses 保持高位
  3. 通过 perf c2c(Cache-to-Cache)可以清晰地探测到大量的 Remote HITM (Hit in Modified Cache)。这代表一个核心想读的数据,正好在另一个核心的 L1/L2 中且处于 Modified 状态,必须要通过跨核互联总线(如 Intel UPI 或 AMD Infinity Fabric)进行昂贵的数据拷贝。

而在实验组 B 中:

  1. Remote HITM 降接近于零
  2. 运行该逻辑的 CPU 核心的 IPC (Instructions Per Cycle) 显著提升

五、 使用 __read_mostly 的致命禁忌

既然这个属性这么好,是否可以把所有变量都加上 __read_mostly

绝对不行。

如果误将一个高频写入的变量标记为了 __read_mostly,其后果将是灾难性的:

  • 这个高频写入的变量会被链接器放入 .data..read_mostly 区域。
  • 当它被写入时,它会向该区域的其他变量所在的 Cache Line 发送 RFO 信号。
  • 由于该段内挤满了其他高频读取的内核核心变量,相当于你亲手将伪共享引入了原本安全的“净土”,导致大面积的 Cache Line 被无辜误伤退化

因此,内核编码规范极其严格地限制了 __read_mostly 的滥用:只有当该变量的读写比达到数个数量级以上,且处于系统关键路径上时,才允许使用。

总结

Linux 内核的 __read_mostly 绝非玄学,它是一次完美的软件微调驱动硬件行为的工程实践。通过在编译期操纵 ELF 段,实现运行期物理内存的冷热分流,从而让 CPU 核心的 Cache Line 稳固在 Shared 状态。理解这一点,对于我们在用户态下通过对齐(如 alignas(64))和数据结构重排来优化多线程 C/C++ 程序,同样具有极高的指导意义。

核隐极客 Linux内核缓存一致性性能调优

评论点评