
Linux 内核中的 Per-CPU 机制详解一、引言什么是 Per-CPUPer-CPUPer-Processor是 Linux 内核中一种为每个处理器核心维护独立数据副本的机制。它允许内核为系统中的每一个 CPU或逻辑处理器分配一份专属的数据实例各 CPU 访问自己的副本时无需加锁从而在保证数据一致性的前提下实现极高的并发性能。一句话概括Per-CPU 是内核用来“用空间换时间、用副本换锁”的核心基础设施。二、设计动机为什么需要 Per-CPU2.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_64GS baseMSR_GS_BASE保存当前 CPU 的 per-CPU 基址访问极快%gs:前缀一条指令完成arm64专用系统寄存器TPIDR_EL1有专用寄存器访问快ARMv732 位TPIDRPRW等协处理器寄存器同上老式/通用实现通过current_thread_info()-cpu查per_cpu_offset[]表通用性好但稍慢在 x86_64 上内核态 GS base 指向当前 CPU 的 per-CPU 区域系统调用入口通过swapgs指令在用户态/内核态 GS base 之间切换。易混淆点用户态程序使用的 FS base 保存的是 TLSThread-Local Storageper-thread基址与内核的 per-CPUper-processor机制是两回事只是都借助了段基址寄存器的思路。3.3 与 NUMA 的关系在 NUMA 架构下per-CPU area 会尽量分配在该 CPU 所属 NUMA 节点的本地内存中确保访问延迟最低。四、API 接口如何使用 Per-CPU 变量4.1 静态定义编译期#includelinux/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() 返回的是引用不是值拷贝 */intvalget_cpu_var(my_counter);/* 只是把值读到了局部变量 */val;/* 改的是局部副本per-CPU 变量并没有变 */put_cpu_var(my_counter);/* 这段代码等于什么都没做 */更简洁的写法自内核 2.6.322009 年起引入并被推荐this_cpu_inc(my_counter);/* 当前 CPU 副本 1无需手动禁抢占 */this_cpu_read(my_counter);/* 读取当前 CPU 的值 */this_cpu_write(my_counter,42);/* 写入当前 CPU 的值 *//* 访问指定 CPU 的副本 */intcpu2;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 变量的大小或数量在编译期无法确定时使用动态分配#includelinux/percpu.h/* 分配一个 per-CPU 的 int 变量每个 CPU 一份 */int__percpu*dyn_varalloc_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*pfalloc_percpu(structfoo);structfoo*fthis_cpu_ptr(pf);/* 之后像普通结构体指针一样操作 f */f-x1;五、实现细节从编译到运行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_64arm64基址来源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 变量可能被软中断访问需要禁用 BHBottom Halflocal_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.1CPU 热插拔过程中不会临时分配或释放。离线offline该 CPU 的 per-CPU area 保留数据不丢失只是不再被访问。再次上线online沿用原有区域数据延续上次的值例如统计计数会跨热插拔持续累计不会从模板重新初始化。如果某个变量需要在 CPU 上线时重置或初始化应注册 CPU hotplugcpuhp回调处理。迁移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_ALIGNEDrunqueue 会被其他 CPU 在 load balance 时读取正是第七节所说的需要对齐隔离的场景。9.2 网络收包NAPI / softnetDEFINE_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几乎无竞争读聚合时才需遍历各 CPUPer-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