跳到主要内容
预计阅读 35 分钟

10|性能优化与内核原理

当你能够解释 CPU 缓存为什么会让程序快 100 倍、内存屏障为什么不能省略、上下文切换到底”切”了什么,你就真正迈入了”精通”的门槛。本章将带你从硬件底层出发,理解性能优化的根因,并初探 Linux 内核模块开发。

📋 开篇自测:你已经知道多少?

  1. L1、L2、L3 缓存的典型大小和访问延迟分别是多少?
  2. 什么是 false sharing?它为什么会严重影响多线程程序的性能?
  3. 内存屏障(Memory Barrier)解决了什么问题?

一、CPU 缓存:速度的秘密

1.1 存储层次金字塔

现代计算机的存储体系是一个金字塔结构,越靠近 CPU 速度越快但容量越小:

                 +---------+
                 |  寄存器  |  ~0.3ns    几十个~几百个
                 +---------+
                /           \
           +---+-------------+---+
           |    L1 缓存 (32-64KB) |  ~1ns     每核独享
           +---------------------+
          /                       \
     +---+-------------------------+---+
     |      L2 缓存 (256KB-1MB)        |  ~4ns     每核独享
     +---------------------------------+
    /                                   \
+--+-------------------------------------+--+
|         L3 缓存 (8-64MB)                   |  ~12ns    所有核共享
+--------------------------------------------+
|              主内存 (GB级)                   |  ~100ns   所有核共享
+--------------------------------------------+
|              SSD (NVMe ~5-50μs, SATA ~100μs) |  ~5-100μs
+--------------------------------------------+
|              HDD                            |  ~10ms    (~10000000ns)
+--------------------------------------------+

直观比较 (如果 L1 访问是 1 秒):
  L1:   1 秒
  L2:   4 秒
  L3:   12 秒
  主内存: 100 秒 (~2 分钟)
  SSD:  5,000-100,000 秒 (NVMe ~1.5 小时, SATA ~28 小时)
  HDD:  10,000,000 秒 (~115 天)

1.2 缓存行(Cache Line)

CPU 缓存的最小读写单位不是一个字节,而是一个缓存行(Cache Line),通常为 64 字节。当 CPU 读取某个地址的数据时,会把该地址所在的整个 64 字节缓存行一次性加载到缓存中。

内存:
+-------+-------+-------+-------+-------+-------+
| 地址0 | 地址8 | 地址16| 地址24| 地址32| 地址40| ...
+-------+-------+-------+-------+-------+-------+
|<----- 缓存行1 (64B) ----->|<----- 缓存行2 ----->|

CPU 读取地址 16 的数据:
  -> 整个缓存行1 (地址0~63) 被加载到 L1 缓存
  -> 后续访问地址 0~63 的任何数据都能命中缓存

这就是空间局部性原理的硬件实现——如果你访问了某个地址,你很可能接下来会访问附近的地址。

1.3 缓存对程序性能的影响

// 示例: 二维数组遍历的两种方式
int matrix[4096][4096];

// 方式A: 按行遍历 (缓存友好)
for (int i = 0; i < 4096; i++)
    for (int j = 0; j < 4096; j++)
        sum += matrix[i][j];

// 方式B: 按列遍历 (缓存不友好)
for (int j = 0; j < 4096; j++)
    for (int i = 0; i < 4096; i++)
        sum += matrix[i][j];

// 性能差距: 方式A 比方式B 快 5-10 倍!
原因分析:

C语言中二维数组按行存储:
  内存: [0,0][0,1][0,2]...[0,4095][1,0][1,1]...

按行遍历 (方式A):
  访问 [0,0] -> 加载缓存行 -> [0,1]~[0,7] 全部命中!
  [0,0] [0,1] [0,2] [0,3] [0,4] [0,5] [0,6] [0,7]
   MISS  HIT   HIT   HIT   HIT   HIT   HIT   HIT
  命中率: 7/8 = 87.5%

按列遍历 (方式B):
  访问 [0,0] -> 加载缓存行 -> 下次访问 [1,0] 在不同的缓存行!
  [0,0]  [1,0]  [2,0]  [3,0]  ...
   MISS   MISS   MISS   MISS
  每次都是 MISS! (4096*4字节 >> 缓存行64字节)

1.4 False Sharing:隐蔽的性能杀手

当两个线程分别修改的数据恰好在同一个缓存行中时,就会发生 False Sharing(伪共享)

线程A 修改 counter_a      线程B 修改 counter_b

struct {
    long counter_a;    // 8字节, 偏移0
    long counter_b;    // 8字节, 偏移8
};  // 两个变量在同一个64字节的缓存行中!

  CPU核0                缓存行             CPU核1
  +--------+    +------------------+    +--------+
  |counter_a|    | counter_a | counter_b |    |counter_b|
  +--------+    +------------------+    +--------+
       |                |                    |
  修改counter_a         |              修改counter_b
       |                v                    |
  使核1的缓存行无效!   乒乓效应        使核0的缓存行无效!

  核0改a -> 核1的缓存行失效 -> 核1重新加载 -> 核1改b
  -> 核0的缓存行失效 -> 核0重新加载 -> 核0改a -> ...

  解决方案: 缓存行填充 (Padding)
  struct {
      long counter_a;
      char padding[56];  // 填充到64字节
      long counter_b;    // 在不同的缓存行中
  };
# 查看系统的缓存行大小
getconf LEVEL1_DCACHE_LINESIZE
# 输出: 64

# 查看缓存层次信息
lscpu | grep cache
# L1d cache:  32K
# L1i cache:  32K
# L2 cache:   256K
# L3 cache:   16384K

🤔 想一想 Java 中的 @Contended 注解和 C++ 中的 alignas(64) 分别是如何解决 false sharing 的?


二、内存屏障:多核时代的秩序守护者

2.1 为什么需要内存屏障

现代 CPU 为了提高性能,会对指令和内存操作进行乱序执行延迟写入。在单线程中这不是问题(CPU 保证结果和按序执行一致),但在多线程中可能导致灾难。

程序员写的代码:             CPU实际执行顺序:
  x = 42;    // 语句1        y = 1;     // 语句2 (被提前!)
  y = 1;     // 语句2        x = 42;    // 语句1

  另一个核上:
  if (y == 1) {
      print(x);  // 期望输出 42
                  // 实际可能输出 0! (x还没被写入)
  }

2.2 Store Buffer 和 Invalidate Queue

CPU 的缓存一致性协议(如 MESI)需要时间来同步,CPU 引入了两个硬件缓冲来减少等待:

CPU核0                              CPU核1
+----------+                        +----------+
| CPU      |                        | CPU      |
+----+-----+                        +----+-----+
     |                                    |
+----+-----+                        +----+-----+
|Store     |                        |Invalidate|
|Buffer    |  缓存一致性总线          |Queue     |
|(延迟写入) | <===================> |(延迟失效) |
+----+-----+                        +----+-----+
     |                                    |
+----+-----+                        +----+-----+
| L1 Cache |                        | L1 Cache |
+----------+                        +----------+

Store Buffer: CPU 写入数据先放这里,不等缓存一致性确认
Invalidate Queue: 收到失效请求先放这里,不立刻处理

问题: 核0写入的数据可能还在 Store Buffer 中
     核1 的缓存可能还没处理 Invalidate 消息
     --> 两个核看到的内存状态不一致!

2.3 四种内存屏障

写屏障 (Store Barrier / sfence):
  确保屏障前的所有写操作在屏障后的写操作之前完成
  作用: 刷新 Store Buffer

读屏障 (Load Barrier / lfence):
  确保屏障前的所有读操作在屏障后的读操作之前完成
  作用: 处理 Invalidate Queue

全屏障 (Full Barrier / mfence):
  同时包含读屏障和写屏障的效果

数据依赖屏障 (仅某些架构需要):
  确保依赖于前一个读操作结果的后续操作正确

使用位置:
  x = 42;
  wmb();    // 写屏障: 确保 x=42 对其他核可见
  y = 1;    // 此时其他核看到 y=1 时,一定能看到 x=42

2.4 实际应用

// Linux 内核中的内存屏障:
smp_wmb()   // SMP 写屏障
smp_rmb()   // SMP 读屏障
smp_mb()    // SMP 全屏障

// C11 原子操作带有内存序语义:
atomic_store_explicit(&flag, 1, memory_order_release);
// release = 屏障前的所有操作对其他线程可见

atomic_load_explicit(&flag, memory_order_acquire);
// acquire = 屏障后的操作一定能看到 release 前的写入

// Java 的 volatile 关键字隐含了内存屏障:
volatile boolean ready = false;
// 写 ready 时插入 Store Barrier
// 读 ready 时插入 Load Barrier

三、上下文切换:看不见的开销

3.1 上下文切换的成本

当操作系统从一个进程/线程切换到另一个时,需要执行一系列操作:

上下文切换的完整过程:

1. 保存当前进程的 CPU 上下文
   - 通用寄存器 (rax, rbx, rcx, rdx, rsi, rdi, ...)
   - 程序计数器 (rip)
   - 栈指针 (rsp)
   - 标志寄存器 (rflags)
   - 浮点寄存器 / SIMD 寄存器
   保存到当前进程的 task_struct

2. 切换内核栈
   - 切换到新进程的内核栈

3. 切换页表 (仅进程切换, 线程切换不需要)
   - 加载新进程的 CR3 寄存器 (页目录基地址)
   - 这会导致 TLB 刷新!

4. 恢复新进程的 CPU 上下文
   - 从新进程的 task_struct 恢复所有寄存器

5. 跳转到新进程的程序计数器位置继续执行

直接开销:
  - 保存/恢复寄存器: ~几百纳秒
  - 切换页表 (进程切换): ~微秒级

间接开销 (更大!):
  - TLB 刷新: 新进程的内存访问全部 TLB Miss
  - L1/L2 缓存失效: 新进程的工作集不在缓存中
  - 分支预测器失效: 新进程的代码分支模式不同
  这些间接开销可能需要 数十微秒 才能恢复

3.2 测量上下文切换

# 查看系统整体的上下文切换速率
vmstat 1
# 看 cs 列 (context switches per second)

# 查看特定进程的上下文切换
cat /proc/<pid>/status | grep ctxt
# voluntary_ctxt_switches:    1234   (主动切换, 如等待I/O)
# nonvoluntary_ctxt_switches: 567    (被动切换, 时间片用完)

# 使用 perf 测量上下文切换
perf stat -e context-switches,cpu-migrations ./my_program

3.3 减少上下文切换的策略

策略                           适用场景
--------------------------------------------------------------
1. CPU 亲和性 (CPU Affinity)   绑定进程到特定核,减少迁移
   taskset -c 0,1 ./program

2. 线程池                      避免频繁创建/销毁线程

3. 无锁数据结构                减少锁竞争导致的阻塞切换

4. 协程 (Coroutine)           用户态切换,不经过内核

5. 减少系统调用                批量处理,减少用户态/内核态切换

6. NUMA 感知                   让进程访问本地内存节点
   numactl --membind=0 ./program

🤔 想一想 Redis 为什么选择单线程模型?这种选择和上下文切换有什么关系?


四、NUMA 架构

4.1 UMA vs NUMA

UMA (统一内存访问):
  所有 CPU 核访问内存的延迟相同

  +------+  +------+  +------+  +------+
  |Core 0|  |Core 1|  |Core 2|  |Core 3|
  +------+  +------+  +------+  +------+
      |         |         |         |
  +---+---+---+---+---+---+---+---+---+
  |           共享总线                 |
  +---+---+---+---+---+---+---+---+---+
                    |
              +----------+
              |   内存    |
              +----------+
  问题: 核越多,总线竞争越激烈

NUMA (非统一内存访问):
  每个 CPU 有自己的"本地"内存,访问远端内存更慢

  Node 0                    Node 1
  +------+  +------+       +------+  +------+
  |Core 0|  |Core 1|       |Core 2|  |Core 3|
  +------+  +------+       +------+  +------+
      |         |               |         |
  +---+---------+---+       +---+---------+---+
  |   内存控制器    |<====>|   内存控制器     |
  +---------+-------+ QPI  +-------+---------+
            |                      |
      +----------+           +----------+
      | 本地内存  |           | 本地内存  |
      | 32GB     |           | 32GB     |
      +----------+           +----------+

  Core 0 访问本地内存 (Node 0): ~100ns
  Core 0 访问远端内存 (Node 1): ~150-200ns
# 查看 NUMA 拓扑
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 3
# node 0 size: 32768 MB
# node 1 cpus: 4 5 6 7
# node 1 size: 32768 MB
# node distances:
#   node  0  1
#     0: 10 21
#     1: 21 10

# 绑定进程到特定 NUMA 节点
numactl --cpunodebind=0 --membind=0 ./my_program

五、Linux 内核模块

5.1 内核模块是什么

Linux 内核模块(Loadable Kernel Module, LKM)允许在不重新编译内核的情况下动态地向内核添加功能。设备驱动、文件系统、网络协议等都可以以模块形式加载。

5.2 编写一个简单的内核模块

// hello_module.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple hello world kernel module");

static int __init hello_init(void) {
    printk(KERN_INFO "Hello, Kernel World!\n");
    return 0;  // 0 表示成功
}

static void __exit hello_exit(void) {
    printk(KERN_INFO "Goodbye, Kernel World!\n");
}

module_init(hello_init);  // 模块加载时调用
module_exit(hello_exit);  // 模块卸载时调用
# Makefile
obj-m += hello_module.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
# 编译
make

# 加载模块
sudo insmod hello_module.ko

# 查看内核日志
dmesg | tail
# [12345.678] Hello, Kernel World!

# 查看已加载模块
lsmod | grep hello

# 卸载模块
sudo rmmod hello_module

# 查看内核日志
dmesg | tail
# [12350.123] Goodbye, Kernel World!

5.3 内核模块的注意事项

内核模块运行在内核空间 (Ring 0), 需要格外小心:

1. 没有标准C库
   - 不能用 printf, malloc, free
   - 使用 printk, kmalloc, kfree

2. 没有内存保护
   - 野指针会直接导致内核崩溃 (kernel panic)
   - 没有段错误, 而是 Oops 或 panic

3. 并发问题更严重
   - 内核代码可能被中断打断
   - 必须使用内核提供的锁 (spinlock, mutex, rcu)

4. 不能浮点运算 (默认)
   - 内核中不保存浮点寄存器状态
   - 需要特殊处理 (kernel_fpu_begin/end)

5. 模块需声明 MODULE_LICENSE("GPL") 才能访问 EXPORT_SYMBOL_GPL 导出的内核符号
   - 非 GPL 模块仍可加载,但无法调用 EXPORT_SYMBOL_GPL 导出的符号,且内核会被标记为"tainted"

5.4 常见的内核模块类型

设备驱动模块:
  字符设备驱动 (char device)  -- 串口、键盘、GPU
  块设备驱动 (block device)   -- 硬盘、SSD
  网络设备驱动 (network)      -- 网卡

文件系统模块:
  ext4, xfs, btrfs, nfs, fuse

网络协议模块:
  TCP拥塞控制算法 (cubic, bbr)
  Netfilter (iptables/nftables)

安全模块:
  SELinux, AppArmor

六、性能优化清单

+-------------------------------------------------------------+
|               性能优化实践清单                                |
+-------------------------------------------------------------+
|                                                             |
| CPU 优化:                                                    |
|   [ ] 数据结构对齐到缓存行 (避免 false sharing)              |
|   [ ] 按行遍历多维数组 (缓存友好)                            |
|   [ ] CPU 亲和性绑定 (减少核间迁移)                          |
|   [ ] 使用线程池 (减少上下文切换)                             |
|   [ ] 热点函数内联 (减少函数调用开销)                         |
|                                                             |
| 内存优化:                                                    |
|   [ ] 使用大页 (Huge Pages) 减少 TLB Miss                   |
|   [ ] NUMA 感知分配 (访问本地内存)                            |
|   [ ] 内存池 (减少 malloc/free 开销)                         |
|   [ ] 避免频繁 mmap/munmap                                  |
|                                                             |
| I/O 优化:                                                    |
|   [ ] 使用 epoll 而非 select/poll                           |
|   [ ] 使用 sendfile/splice 零拷贝                           |
|   [ ] 使用 io_uring 异步 I/O                                |
|   [ ] 调整 readahead 预读参数                                |
|                                                             |
| 并发优化:                                                    |
|   [ ] 读多写少用读写锁                                       |
|   [ ] 短临界区用自旋锁                                       |
|   [ ] 使用 RCU (Read-Copy-Update) 无锁读                    |
|   [ ] 使用无锁队列 (lock-free queue)                         |
|   [ ] 正确使用内存屏障                                       |
|                                                             |
+-------------------------------------------------------------+

七、本章总结

+----------------------------------------------------+
|            性能优化与内核原理核心知识                  |
+----------------------------------------------------+
|                                                    |
|  CPU 缓存:                                          |
|    L1(1ns) < L2(4ns) < L3(12ns) < 主内存(100ns)    |
|    缓存行 = 64B | 空间局部性 | 时间局部性             |
|    False Sharing: 同一缓存行被多核争用                |
|                                                    |
|  内存屏障:                                          |
|    CPU 乱序执行 + Store Buffer + Invalidate Queue   |
|    wmb/rmb/mb 保证多核间的内存可见性                  |
|                                                    |
|  上下文切换:                                         |
|    直接开销: 寄存器保存/恢复 (~us)                    |
|    间接开销: TLB/缓存/分支预测器失效 (~几十us)        |
|                                                    |
|  NUMA: 本地内存快, 远端内存慢                        |
|                                                    |
|  内核模块: 动态扩展内核功能                           |
|    insmod/rmmod | printk/kmalloc | 无浮点           |
|                                                    |
+----------------------------------------------------+

📝 结尾自测:检验你的收获

  1. 画出 CPU 缓存层次金字塔,标注每层的典型大小和访问延迟。
  2. 什么是 False Sharing?画图说明它的产生原因和解决方案。
  3. 为什么多核系统需要内存屏障?Store Buffer 和 Invalidate Queue 如何导致内存不一致?
  4. 上下文切换的直接开销和间接开销分别是什么?哪个更大?
  5. 编写一个最简单的 Linux 内核模块需要哪些要素?它和用户态程序有哪些关键区别?

购买课程解锁全部内容

系统底层入门:10 章掌握操作系统核心

¥29.90