Linux 内核中的 Per-CPU 机制详解

Linux 内核中的 Per-CPU 机制详解

一、引言:什么是 Per-CPU?

  Per-CPU(Per-Processor)是 Linux 内核中一种为每个处理器核心维护独立数据副本的机制。它允许内核为系统中的每一个 CPU(或逻辑处理器)分配一份专属的数据实例,各 CPU 访问自己的副本时无需加锁,从而在保证数据一致性的前提下实现极高的并发性能。

  一句话概括:Per-CPU 是内核用来"用空间换时间、用副本换锁"的核心基础设施。

二、设计动机:为什么需要 Per-CPU?

2.1 缓存局部性(Cache Locality)

  在多核系统中,如果所有 CPU 共享同一个全局变量,每次写入都会触发缓存一致性协议(如 MESI),导致其他核心的缓存行被标记为 Invalid,引发频繁的 cache line bouncing(缓存行弹跳)。

Cache Line Bouncing(缓存行弹跳),也称为 False Sharing(伪共享) 或 Cache Thrashing(缓存抖动),是多核处理器中一种严重的性能退化现象。

简单来说:多个 CPU 核心频繁读写位于同一条缓存行(Cache Line)的不同变量,导致该缓存行在核心之间反复传输、失效、重新加载,像乒乓球一样来回"弹跳",严重拖慢程序执行速度。

  Per-CPU 变量让每个 CPU 只读写自己独占的内存区域,彻底消除了跨核缓存同步开销。

2.2 避免锁竞争

  统计计数器、调度器运行队列、内存分配器状态等数据结构被所有 CPU 高频访问。如果使用全局变量 + 自旋锁,锁竞争会成为严重的性能瓶颈。Per-CPU 变量天然隔离了各 CPU 的写入,读写自己的副本不需要任何锁。

2.3 典型场景

场景 说明
中断计数器 每个 CPU 独立统计自己的中断次数
调度器 runqueue 每个 CPU 维护自己的就绪队列
slab/slub 分配器 每个 CPU 有本地对象缓存
网络协议栈 每个 CPU 独立的收发包队列
时间戳 / TSC 校准 每个 CPU 的时钟源数据

三、内存布局:Per-CPU 变量存储在哪里?

3.1 静态 Per-CPU 区(编译期确定)

  内核启动时(setup_per_cpu_areas()),会为系统中所有 possible CPUcpu_possible_mask,上限为编译期配置的 NR_CPUS,而不仅仅是当前在线的 CPU)预留一块独立的内存区域,称为 per-CPU area。所有静态声明的 per-CPU 变量按编译顺序排列在这块区域内。

复制代码
CPU 0 的 per-CPU area:
┌──────────────────────────────────────────────┐
│ var_a │ var_b │ var_c │ ... │ (padding)      │
└──────────────────────────────────────────────┘

CPU 1 的 per-CPU area:
┌──────────────────────────────────────────────┐
│ var_a │ var_b │ var_c │ ... │ (padding)      │
└──────────────────────────────────────────────┘

CPU N 的 per-CPU area:
┌──────────────────────────────────────────────┐
│ var_a │ var_b │ var_c │ ... │ (padding)      │
└──────────────────────────────────────────────┘

  每个 CPU 的副本布局完全相同,只是基地址不同。

注意:所有 possible CPU 的区域在启动时就一次性划分完毕,这样即使后续 CPU 热插拔上线,也无需临时分配(详见第八节)。

3.2 基址的保存方式

架构类型 实现方式 说明
x86_64 GS base(MSR_GS_BASE)保存当前 CPU 的 per-CPU 基址 访问极快,%gs: 前缀一条指令完成
arm64 专用系统寄存器 TPIDR_EL1 有专用寄存器,访问快
ARMv7(32 位) TPIDRPRW 等协处理器寄存器 同上
老式/通用实现 通过 current_thread_info()->cpuper_cpu_offset[] 通用性好但稍慢

  在 x86_64 上,内核态 GS base 指向当前 CPU 的 per-CPU 区域,系统调用入口通过 swapgs 指令在用户态/内核态 GS base 之间切换。

易混淆点:用户态程序使用的 FS base 保存的是 TLS(Thread-Local Storage,per-thread )基址,与内核的 per-CPU(per-processor)机制是两回事,只是都借助了段基址寄存器的思路。

3.3 与 NUMA 的关系

  在 NUMA 架构下,per-CPU area 会尽量分配在该 CPU 所属 NUMA 节点的本地内存中,确保访问延迟最低。

四、API 接口:如何使用 Per-CPU 变量

4.1 静态定义(编译期)

c 复制代码
#include <linux/percpu.h>

/* 定义一个 per-CPU 整型变量,初始值为 0 */
DEFINE_PER_CPU(int, my_counter);

/* 定义并按缓存行对齐(用于体积较大、对齐敏感的变量) */
DEFINE_PER_CPU_ALIGNED(int, my_aligned_var);

/* 定义可能被其他 CPU 访问的变量:缓存行对齐 + 填充,
 * 避免跨核访问时殃及同一缓存行内的其他变量(见第七节) */
DEFINE_PER_CPU_SHARED_ALIGNED(struct foo, my_shared_foo);

/* 定义一个 per-CPU 数组 */
DEFINE_PER_CPU(char[64], my_buffer);

/* 定义一个 per-CPU 结构体 */
struct foo {
	int x;
	long y;
};
DEFINE_PER_CPU(struct foo, my_foo);

4.2 访问(读写)

c 复制代码
/* 访问当前 CPU 的副本 ------ 经典写法(禁抢占 + 返回左值引用) */
get_cpu_var(my_counter)++;          /* 直接对副本自增 */
put_cpu_var(my_counter);            /* 恢复抢占,必须与 get 配对 */

/* 错误示范:get_cpu_var() 返回的是引用,不是值拷贝 */
int val = get_cpu_var(my_counter);  /* 只是把值读到了局部变量 */
val++;                              /* 改的是局部副本,per-CPU 变量并没有变 */
put_cpu_var(my_counter);            /* 这段代码等于什么都没做 */

更简洁的写法(自内核 2.6.32(2009 年)起引入并被推荐):

c 复制代码
this_cpu_inc(my_counter);           /* 当前 CPU 副本 +1,无需手动禁抢占 */
this_cpu_read(my_counter);          /* 读取当前 CPU 的值 */
this_cpu_write(my_counter, 42);     /* 写入当前 CPU 的值 */

/* 访问指定 CPU 的副本 */
int cpu = 2;
per_cpu(my_counter, cpu) = 100;     /* 直接写 CPU 2 的副本,需自行保证安全 */

关于"原子性"的准确理解:this_cpu_* 操作提供的是同 CPU 内的原子性 ------在 x86 上它被编译为单条指令(如 incl %gs:0x1a0),单条指令不会被本核中断打断,且只访问本 CPU 独占的副本、不存在跨核共享,因此既不需要锁也不需要显式禁抢占。它不是跨 CPU 原子操作(也不需要是)。

4.3 核心访问宏/函数一览

API 作用 是否禁抢占 备注
get_cpu_var(var) 获取当前 CPU 变量引用 需配对 put_cpu_var,临界区内不可睡眠
put_cpu_var(var) 释放引用,恢复抢占 --- 与 get 配对
this_cpu_read(var) 读当前 CPU 值 隐含 单指令,同 CPU 原子
this_cpu_write(var, v) 写当前 CPU 值 隐含 单指令,同 CPU 原子
this_cpu_inc(var) 当前 CPU 值 +1 隐含 单指令,同 CPU 原子
this_cpu_add(var, n) 当前 CPU 值 +n 隐含 单指令,同 CPU 原子
this_cpu_cmpxchg(var, old, new) 当前 CPU 上的 CAS 隐含 用于多语句读-改-写
per_cpu(var, cpu) 访问指定 CPU 的变量 调用者自行保证安全(加锁或禁抢占)
__this_cpu_read(var) 读当前 CPU(不检查抢占) 仅用于已知不会被迁移/打断的上下文(如硬中断处理)

注:x86 上 this_cpu_* 并非真的执行 preempt_disable(),而是靠"单条指令执行期间不可能发生任务迁移"这一硬件保证,因此比 get_cpu_var() 更轻量。

4.4 动态分配(运行期)

  当 per-CPU 变量的大小或数量在编译期无法确定时,使用动态分配:

c 复制代码
#include <linux/percpu.h>

/* 分配一个 per-CPU 的 int 变量,每个 CPU 一份 */
int __percpu *dyn_var = alloc_percpu(int);
if (!dyn_var)
	return -ENOMEM;

/* 访问当前 CPU 的副本 */
this_cpu_write(*dyn_var, 123);

/* 访问指定 CPU 的副本 */
*per_cpu_ptr(dyn_var, 2) = 456;

/* 释放 */
free_percpu(dyn_var);

  更复杂的分配(指定大小、对齐、GFP 标志):

c 复制代码
void __percpu *p = __alloc_percpu(size, align);
void __percpu *q = __alloc_percpu_gfp(size, align, GFP_KERNEL | __GFP_NOWARN);

4.5 指针类访问接口(动态分配必备)

API 作用
this_cpu_ptr(p) 返回指向当前 CPU 副本的指针(per_cpu_ptr(p, smp_processor_id()) 的快速版)
per_cpu_ptr(p, cpu) 返回指向指定 CPU 副本的指针
raw_cpu_ptr(p) this_cpu_ptr 但不检查抢占,用于已知安全上下文
c 复制代码
struct foo __percpu *pf = alloc_percpu(struct foo);
struct foo *f = this_cpu_ptr(pf);   /* 之后像普通结构体指针一样操作 f */
f->x = 1;

五、实现细节:从编译到运行

5.1 编译期:特殊段

  DEFINE_PER_CPU 宏将变量放入名为 .data..percpu 的 ELF 段(而非普通 .data 段):

c 复制代码
#define DEFINE_PER_CPU(type, name)              \
	__attribute__((section(".data..percpu")))   \
	typeof(type) name

5.2 链接期:生成模板

  链接器脚本(vmlinux.lds.S)将 .data..percpu 段收集到一起,形成一份模板(template)。此时变量只有相对于段首的"偏移量",没有真实地址:

复制代码
vmlinux.lds.S:
    .data..percpu : {
        __per_cpu_start = .;
        *(.data..percpu)
        __per_cpu_end = .;
    }

5.3 运行期:分配与复制

  内核初始化时(setup_per_cpu_areas(),实现在 arch/arm64/mm/numa.c,通用逻辑在 mm/percpu.c),执行以下步骤:

  1. 为每个 possible CPU 分配一块内存(大小 = __per_cpu_end - __per_cpu_start,按分配器的 unit 边界对齐,NUMA 系统上优先落在各 CPU 的本地节点)。

  2. 将模板内容逐字节复制到每个 CPU 的区域。

  3. 将每个 CPU 的区域基址记入 per_cpu_offset[] 数组,并设置基址寄存器(如 x86_64 的 GS base、arm64 的 TPIDR_EL1)。

    模板(.data..percpu 段)

    ├──复制──→ CPU 0 area (base_0) → per_cpu_offset[0]
    ├──复制──→ CPU 1 area (base_1) → per_cpu_offset[1]
    ├──复制──→ CPU 2 area (base_2) → per_cpu_offset[2]
    └──复制──→ CPU N area (base_N) → per_cpu_offset[N]

注意:复制过程不做 逐副本的指针重定位。per-CPU 符号的"地址"在链接期就确定为相对 __per_cpu_start 的偏移,运行期一律通过 基址 + 偏移 计算真实地址;变量中静态初始化的指针指向的是所有 CPU 共享的全局对象,各副本完全相同,无需也不应该重定位。

5.4 访问时的地址计算

  以 arm64 为例,访问 this_cpu_read(my_counter) 最终生成的汇编类似:

asm 复制代码
mrs x1, tpidr_el1       ; 读取系统寄存器 TPIDR_EL1 → x1 = 当前 CPU 的 per-CPU 基址
ldr w0, [x1, #0x1a0]    ; 基址 + 偏移0x1a0 = my_counter 在当前 CPU 的地址,读入 w0

只需两条指令:一条 mrs 取基址,一条访存指令完成读写,无需函数调用、无需查表。与 x86_64 的差异:

x86_64 arm64
基址来源 GS base(%gs: 段前缀隐式携带) TPIDR_EL1 系统寄存器(需显式 mrs 读出)
单条指令访问 mov %gs:0x1a0, %eax 一条完成 ❌ 需先 mrs 取基址,再加一条访存指令

补充:像 this_cpu_inc() 这类读-改-写操作,arm64 依赖 LSE 原子指令(如 ldadd w0, w1, [x1, #0x1a0])保证单指令原子性;在不支持 LSE 的老芯片上则展开为 ldxr/stxr 独占访问循环。这一点与 x86 用 lock 前缀实现的单指令原子异曲同工,都是为了让中断无法撕开操作,从而免去显式禁抢占。## 六、Per-CPU 与锁的关系

6.1 Per-CPU 本身不提供互斥

Per-CPU 变量只是让每个 CPU 有自己的副本,并不阻止其他 CPU 读取或写入你的副本。以下情况仍需加锁:

  • 一个 CPU 需要读取/修改另一个 CPU 的 per-CPU 变量。
  • 多个 CPU 需要聚合所有副本的值(如 /proc/stat 中的总中断数,聚合读取期间其他 CPU 仍在写入时需要约定一致性规则)。
  • 进程上下文与中断上下文访问同一 CPU 的同一个 per-CPU 变量(见 6.3)。

6.2 何时不需要锁

  • 每个 CPU 只读写自己的副本,且对副本的操作是单条 this_cpu_* 指令(同 CPU 原子,不会被本核中断撕开)。
  • 或已确保不会被本 CPU 的中断/软中断抢占(如已关中断、关软中断)。

6.3 与软中断 / 硬中断的配合

  如果 per-CPU 变量可能被软中断访问,需要禁用 BH(Bottom Half):

c 复制代码
local_bh_disable();
this_cpu_inc(my_counter);   /* 多语句序列在此期间不会被软中断打断 */
local_bh_enable();

  或者使用 spin_lock_bh() 保护。注意 local_bh_disable() 挡不住硬中断。如果硬中断处理程序也会访问同一变量,需要关中断:

c 复制代码
unsigned long flags;
local_irq_save(flags);
/* 多语句读-改-写序列 */
local_irq_restore(flags);

  若每次访问本身只是单条 this_cpu_inc() 之类的操作,则无需上述保护------单条指令天然不会被中断撕开。

七、False Sharing 与对齐

  首先要澄清一个常见误解:纯本 CPU 访问的 per-CPU 变量之间不存在 false sharing。因为各 CPU 的副本位于不同的物理地址,本 CPU 副本所在的缓存行长期处于本核的 Modified/Exclusive 状态,几乎没有 MESI 流量------这正是 per-CPU 机制的优势所在。

  真正需要缓存行对齐处理的是会被其他 CPU 访问的 per-CPU 变量

  • 典型例子是调度器的 runqueue:本 CPU 高频操作自己的队列,但 load balance 时别的 CPU 也要读取它。
  • 一旦别的 CPU 访问某个 per-CPU 实例,该实例所在的整个缓存行就会在核间弹跳。如果这一行里还挨着本 CPU 的其他高频私有变量,就会互相拖累、无谓弹跳。

  解决方法:用 DEFINE_PER_CPU_SHARED_ALIGNED(或 __cacheline_aligned)让这类变量独占缓存行,与相邻变量隔离:

c 复制代码
DEFINE_PER_CPU_SHARED_ALIGNED(struct heavy_struct, hot_data);

  对于体积较大、自身跨缓存行的变量,可用 DEFINE_PER_CPU_ALIGNED 做一般性缓存行对齐。

八、Per-CPU 与 CPU 热插拔

  关键事实:per-CPU 区域在启动时就为所有 possible CPU 一次性划分完毕(见 3.1),CPU 热插拔过程中不会临时分配或释放。

  • 离线(offline):该 CPU 的 per-CPU area 保留,数据不丢失,只是不再被访问。
  • 再次上线(online) :沿用原有区域,数据延续上次的值(例如统计计数会跨热插拔持续累计),不会从模板重新初始化。如果某个变量需要在 CPU 上线时重置或初始化,应注册 CPU hotplug(cpuhp)回调处理。
  • 迁移 :per-CPU 变量不随任务迁移。如果一个任务从 CPU 0 被调度到 CPU 1,它通过 this_cpu_* 访问的就是 CPU 1 的副本。因此,per-CPU 变量适合存储"与 CPU 绑定"的数据(队列、缓存、统计),而非"与任务绑定"的数据。

九、实际案例

9.1 调度器运行队列

c 复制代码
DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);

  每个 CPU 有独立的 struct rq,调度器操作本 CPU 的 runqueue 时无需全局锁。注意这里用的是 SHARED_ALIGNED:runqueue 会被其他 CPU 在 load balance 时读取,正是第七节所说的需要对齐隔离的场景。

9.2 网络收包(NAPI / softnet)

c 复制代码
DEFINE_PER_CPU(struct softnet_data, softnet_data);

  每个 CPU 独立的收包队列,避免多核竞争。

9.3 内核统计计数器

c 复制代码
DEFINE_PER_CPU(struct kernel_stat, kstat);

  中断计数、上下文切换次数等按 CPU 独立统计,读取 /proc/stat 时再聚合。

延伸:对于"高频写、偶尔聚合读"的长生命期计数器,内核还提供了 percpu_counter(近似计数器,按阈值回刷全局值),介于纯 per-CPU 与全局 atomic 之间。

十、注意事项与常见陷阱

陷阱 说明
在 per-CPU 临界区内睡眠 get_cpu_var() 会禁止抢占,期间绝不能睡眠
get_cpu_var() 当取值用 它返回左值引用,int v = get_cpu_var(x); v++; 改的是局部拷贝,原变量不变
跨 CPU 访问不加保护 per_cpu(var, other_cpu) / per_cpu_ptr(p, cpu) 读写他人数据需自行加锁或确保对方 CPU 已停止
中断打断读-改-写序列 进程上下文多条语句的"读-改-写"若被硬中断中的同名操作打断,会丢失更新;单条 this_cpu_* 指令则安全
CPU 迁移 任务被调度到另一个 CPU 后,this_cpu_* 访问的是新 CPU 的副本
动态分配失败 alloc_percpu() 可能因内存不足/碎片失败,必须检查返回值
模块卸载 模块中静态定义的 per-CPU 变量随模块卸载自动回收;动态分配的必须手动 free_percpu()

十一、性能对比

  以一个简单的全局计数器 vs per-CPU 计数器为例(8 核并发递增 1 亿次,示意值,实际随架构、核数、频率浮动):

方案 耗时(近似) 瓶颈
全局 atomic_t + atomic_inc() ~1200 ms 缓存行弹跳(lock 前缀 + 跨核一致性流量)
全局变量 + spin_lock ~3500 ms 锁竞争
Per-CPU 变量 + this_cpu_inc() ~80 ms 几乎无竞争,读聚合时才需遍历各 CPU

  Per-CPU 方案快了 1~2 个数量级。代价是读取全局总量时需要累加所有 CPU 的副本(如 for_each_possible_cpu 遍历求和)。

十二、总结

  Per-CPU 机制是 Linux 内核高性能并发的基石之一。它的核心思想是:将共享数据转化为私有副本,用空间换时间,用副本换锁。

  关键要点回顾:

  • 每个 CPU 拥有独立的数据副本,布局相同、基址不同;启动时为所有 possible CPU 一次性分配。
  • 访问自己的副本无需加锁;this_cpu_* 是单指令级的"同 CPU 原子"操作。
  • 静态变量放 .data..percpu 段,动态变量用 alloc_percpu() / per_cpu_ptr()
  • x86_64 通过 GS base 实现一条指令访问,arm64 用 TPIDR_EL1
  • 跨 CPU 访问、本 CPU 的中断并发、CPU 热插拔等场景仍需额外保护。
  • 纯本 CPU 访问的变量无 false sharing;会被其他 CPU 访问的变量(如 runqueue)用 SHARED_ALIGNED 隔离缓存行。
  • 不要在禁抢占区间睡眠;不要把 get_cpu_var() 当取值用。

    理解并正确使用 Per-CPU,是编写高性能内核代码的必备技能。参考源码路径:include/linux/percpu.hmm/percpu.carch/x86/kernel/setup_percpu.c

相关推荐
云飞云共享云桌面1 小时前
自动化设备厂 10 人研发团队,如何共用一台服务器做 SolidWorks 建模?
运维·服务器·网络·3d·自动化·制造
Jay丶ke2 小时前
arm ni-700
arm开发
QH139292318802 小时前
NVIDIA H200 H300服务器
运维·服务器·开发语言·网络·信息与通信
Hello-FPGA2 小时前
嵌入式LINUX 设备 ip配置后怎么保存才能掉电不消失?
linux·运维·服务器
工业一体机老司机2 小时前
Linux工业一体机自启动服务配置:systemd服务单元编写与开机优化实战
java·linux·服务器
zhixingheyi_tian2 小时前
Linux 之 ssh
linux·服务器·ssh
艾伦_耶格宇3 小时前
【DOCKER容器实战】-2MySQL
运维·docker·容器
Brilliantwxx3 小时前
42525
运维
小鱼仙官3 小时前
Ubuntu C++ NTP校时程序
linux·c++·ubuntu