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 CPU(cpu_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()->cpu查per_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 静态定义(编译期)
#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(structfoo,my_shared_foo);/* 定义一个 per-CPU 数组 */DEFINE_PER_CPU(char[64],my_buffer);/* 定义一个 per-CPU 结构体 */structfoo{intx;longy;};DEFINE_PER_CPU(structfoo,my_foo);4.2 访问(读写)
/* 访问当前 CPU 的副本 —— 经典写法(禁抢占 + 返回左值引用) */get_cpu_var(my_counter)++;/* 直接对副本自增 */put_cpu_var(my_counter);/* 恢复抢占,必须与 get 配对 *//* 错误示范:get_cpu_var() 返回的是引用,不是值拷贝 */intval=get_cpu_var(my_counter);/* 只是把值读到了局部变量 */val++;/* 改的是局部副本,per-CPU 变量并没有变 */put_cpu_var(my_counter);/* 这段代码等于什么都没做 */更简洁的写法(自内核 2.6.32(2009 年)起引入并被推荐):
this_cpu_inc(my_counter);/* 当前 CPU 副本 +1,无需手动禁抢占 */this_cpu_read(my_counter);/* 读取当前 CPU 的值 */this_cpu_write(my_counter,42);/* 写入当前 CPU 的值 *//* 访问指定 CPU 的副本 */intcpu=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 变量的大小或数量在编译期无法确定时,使用动态分配:
#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 标志):
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但不检查抢占,用于已知安全上下文 |
structfoo__percpu*pf=alloc_percpu(structfoo);structfoo*f=this_cpu_ptr(pf);/* 之后像普通结构体指针一样操作 f */f->x=1;五、实现细节:从编译到运行
5.1 编译期:特殊段
DEFINE_PER_CPU宏将变量放入名为.data..percpu的 ELF 段(而非普通.data段):
#defineDEFINE_PER_CPU(type,name)\__attribute__((section(".data..percpu")))\typeof(type)name5.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),执行以下步骤:
- 为每个possible CPU分配一块内存(大小 =
__per_cpu_end - __per_cpu_start,按分配器的 unit 边界对齐,NUMA 系统上优先落在各 CPU 的本地节点)。 - 将模板内容逐字节复制到每个 CPU 的区域。
- 将每个 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)最终生成的汇编类似:
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):
local_bh_disable();this_cpu_inc(my_counter);/* 多语句序列在此期间不会被软中断打断 */local_bh_enable();或者使用spin_lock_bh()保护。注意local_bh_disable()挡不住硬中断。如果硬中断处理程序也会访问同一变量,需要关中断:
unsignedlongflags;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)让这类变量独占缓存行,与相邻变量隔离:
DEFINE_PER_CPU_SHARED_ALIGNED(structheavy_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 调度器运行队列
DEFINE_PER_CPU_SHARED_ALIGNED(structrq,runqueues);每个 CPU 有独立的struct rq,调度器操作本 CPU 的 runqueue 时无需全局锁。注意这里用的是SHARED_ALIGNED:runqueue 会被其他 CPU 在 load balance 时读取,正是第七节所说的需要对齐隔离的场景。
9.2 网络收包(NAPI / softnet)
DEFINE_PER_CPU(structsoftnet_data,softnet_data);每个 CPU 独立的收包队列,避免多核竞争。
9.3 内核统计计数器
DEFINE_PER_CPU(structkernel_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.h、mm/percpu.c、arch/x86/kernel/setup_percpu.c