ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核Per-CPU变量深度剖析:从静态到动态,解锁无锁性能优化

Linux内核Per-CPU变量深度剖析:从静态到动态,解锁无锁性能优化 在SMP时代写内核代码Per-CPU变量是躲不开的一道坎。我刚接触这块时看着DEFINE_PER_CPU、get_cpu_var、alloc_percpu、per_cpu_ptr这一堆接口相当郁闷——明明都叫Per-CPU静态和动态两套用法却完全是两个思路搞错了轻则读到脏数据重则直接死锁。后来把源码和汇编级实现捋了一遍才明白这套机制的本质空间换时间每个CPU一份独立副本从根本上绕开锁和CacheLine竞争。这篇文章我打算把手头的理解完整梳理一遍包括静态Per-CPU变量的定义与访问接口、动态Per-CPU变量的分配机制、两者背后的地址重定位原理以及实操中常见的翻车现场和排查手段。适用对象是写内核模块的、读内核源码的、做性能调优的人如果你刚入门也没关系我会尽量从最基础的宏展开开始讲保证能跟上。1. Per-CPU变量的设计动机与核心价值1.1 为什么每个CPU都需要一份“独享空间”先想一个最简单的场景统计网络收包总量。多核环境下最粗暴的做法是个全局变量加自旋锁或者直接用原子变量。全局锁的问题大家都懂竞争激烈时CPU都在自旋等锁性能直接崩掉。原子操作看起来优雅一点但atomic64_inc背后牵扯到总线锁或者CacheLine锁定高频率更新时同样会让多个CPU互相拖累。还有一个更隐蔽的问题伪共享。就算你把数据拆成a_cpu0、a_cpu1、a_cpu2三份每份只由对应CPU读写只要这三份数据落在同一个CacheLine的花费实际性能会惨不忍睹。硬件CacheLine一般64字节同一个CacheLine同时被多个CPU写就会产生反复的失效和同步俗称CacheLine bouncing。Per-CPU变量的核心思路就是给每个CPU分配独立的存储副本配合关抢占或者this_cpu一类机制让当前CPU只在属于自己的那份副本上操作。这样一来没有锁竞争每个CPU写自己的副本互不干扰没有伪共享前提是访问接口和____cacheline_aligned对齐用对了无原子操作开销普通读写指令即可完成。当然有得必有失别的CPU想读当前CPU的副本就得走特殊接口拿到的也只是“别人的快照”不再是严格意义上的全局一致视图。1.2 典型场景与方案对比Per-CPU在内核里几乎是遍地开花。页分配器为每个CPU维护一份热页列表调度器用Per-CPU的rq结构装运行队列网络子系统统计每CPU收发包计数软中断向量表、时间轮、时钟事件设备全是Per-CPU结构。写驱动时最常见的用途也是计数和统计每CPU计数器、每CPU环形缓冲、每CPU内存池。下面把三种常见实现方式放一起对比方案并发开销最坏情况典型使用场景全局变量自旋锁竞争时自旋总线压力大高竞争下吞吐归零低频配置变更、短暂临界区原子变量总线/CacheLine锁定多核更新昂贵CPU互相等待延迟抬升低频修改高频读取的计数器Per-CPU变量无锁、无原子、无伪共享数据可能不是全局一致高频更新、只关心本CPU数据的统计一句话总结如果你需要“每个CPU各自维护一份、各自高频更新”的数据Per-CPU就是最合适的答案。2. 静态Per-CPU变量的完整接口解析2.1 定义与声明接口DEFINE_PER_CPU与DECLARE_PER_CPU静态Per-CPU变量定义在编译期就完成了。标准写法是DEFINE_PER_CPU(unsigned long, my_counter);这个宏展开后变量my_counter会被放进内核镜像专门划出的.percpu段而不是普通的.bss或.data。链接时每个CPU都有自己的副本区域访问的时候通过__per_cpu_offset[cpu]跳转到对应的物理副本。特别提醒跨文件使用静态Per-CPU变量时不能用普通extern声明必须写DECLARE_PER_CPU(unsigned long, my_counter);为什么因为DEFINE_PER_CPU生成的符号带有特殊的per-cpu重定位属性直接extern会让链接器和后续的relocation处理一头雾水访问时拿到的地址大概率是CPU0的副本而不是当前CPU的副本。这一点我见过不少模块就是这么翻车的。如果需要在定义时给初始值可以配合PER_CPU_INITDEFINE_PER_CPU(unsigned long, my_counter, PER_CPU_INIT(1));静态Per-CPU变量还有个特性系统启动阶段这段区域会为每个CPU拷贝一份CPU热插拔时对应的副本空间也会由percpu底层机制负责初始化模块代码通常不需要关心。2.2 访问接口per_cpu、get_cpu_var、this_cpu的取舍访问接口是新手最容易混乱的地方。我按访问目标和并发语义拆开讲。访问指定CPU的实例用per_cpu宏unsigned long v per_cpu(my_counter, cpu)这里cpu可以是任意CPU编号。宏展开后本质是__per_cpu_offset[cpu] 变量在percpu区间的偏移。这也意味着你访问的是指定CPU副本的地址不需要关抢占但也没有任何同步保护。如果另一个CPU正在写这份副本你可能读到中间状态也就是撕裂数据。访问当前CPU的实例并保证CPU不被迁移用get_cpu_var和put_cpu_varget_cpu_var(my_counter); put_cpu_var(my_counter);get_cpu_var背后做了两件事关闭内核抢占然后返回当前CPU对应副本的地址。关闭抢占意味着当前进程不会被调度到别的CPU上所以后续操作始终作用在同一份副本。用完必须用put_cpu_var恢复抢占两者必须配对出现。这里有个关键点关抢占不等于关中断也不等于禁止睡眠检测。里面虽然不能调用kmalloc(GFP_KERNEL)这类可能睡眠的函数否则很容易触发“scheduling while atomic”这是Per-CPU临界区最常见的死法之一。快速访问当前CPU副本用this_cpu_read、this_cpu_add等this_cpu_inc(my_counter);这类接口在x86上最终编译成带%gs段前缀的单条指令实现真正的无锁无重定位开销访问。它内部没有显式关抢占但它天然只针对当前CPU如果进程在这条指令执行后被迁移到其他CPU下一次访问就会作用于另一个副本这在很多场景下是可接受的。相比get_cpu_var它的代价更小适合单条或少量指令完成的原子更新。那么问题来了get_cpu_var和per_cpu(var, smp_processor_id())有什么区别简单说前者关抢占保证CPU不变后者只是读了当前CPU id但随后代码可能被调度到另外一个CPU上——先前的读操作和后续操作就不再是原子的了。凡是“先取当前CPU再访问Per-CPU变量”的写法中间没有关抢占或migration_disable保护都是一颗定时炸弹。2.3 一个可运行的每CPU计数器示例写个简单模块展示静态Per-CPU的定义、读取和清零#include linux/module.h #include linux/percpu.h #include linux/smp.h static DEFINE_PER_CPU(unsigned long, per_cpu_hits); static void show_per_cpu_stats(void) { int cpu; for_each_possible_cpu(cpu) pr_info(CPU%d hits %lu\n, cpu, per_cpu(per_cpu_hits, cpu)); } static int __init demo_init(void) { int cpu; for_each_online_cpu(cpu) per_cpu(per_cpu_hits, cpu); /* 当前CPU再自增一次 */ get_cpu_var(per_cpu_hits); put_cpu_var(per_cpu_hits); show_per_cpu_stats(); /* 清零 */ for_each_possible_cpu(cpu) per_cpu(per_cpu_hits, cpu) 0; return 0; } module_init(demo_init);注意清零循环里如果系统启动后又发生了CPU热插拔possible_cpu和online_cpu的集合可能不一致这里只演示静态接口的基本用法实际产品代码还需要考虑热插拔时的Per-CPU副本访问安全。3. 动态Per-CPU变量的接口分析3.1 alloc_percpu与__alloc_percpu的分配机制如果编译期没法确定变量个数或者变量大小在模块加载后才能决定就得走动态分配。接口是void *alloc_percpu(type); void *__alloc_percpu(size_t size, size_t align); void free_percpu(void *__pdata);以alloc_percpu(unsigned long)为例它实际调用了__alloc_percpu(sizeof(unsigned long), __alignof__(unsigned long))。返回的指针类型是void __percpu *注意这个指针不是一个可以直接解引用的线性地址它更像一个“间接句柄”真正落地时必须经过per_cpu_ptr转换。底层实现在mm/percpu.c。分配器把整个percpu运行时区划分成多个chunk每个chunk又按CPU分成固定大小的unit。分配的时候从合适的slot里取出一个pcpu_chunk在某个unit中划出大小匹配的块返回的是“块在unit内的相对偏移”之后系统通过chunk base per_cpu_offset[cpu] 内部偏移才能换算到每个CPU的真实地址。这个设计和静态区方案完全不同的点是静态Per-CPU变量在编译期已嵌入镜像段每个CPU的副本固定在预留区内动态Per-CPU变量则是在运行时从percpu动态区分配CPU个数和布局不同会导致不同的分配结果但对外暴露的接口封装了这些差异写代码时不需要关心。常见错误是把alloc_percpu返回的指针直接解引用unsigned long *ptr alloc_percpu(unsigned long); *ptr 1; // 错这不会落到当前CPU的副本正确做法永远是先转成当前CPU对应的实际地址。3.2 动态变量的访问接口per_cpu_ptr、get_cpu_ptr、free_percpu访问动态Per-CPU变量最基础的是unsigned long *real_addr per_cpu_ptr(ptr, cpu);这个宏做的事情等价于(unsigned long *)((unsigned long)ptr __per_cpu_offset[cpu])。所以在模块里初始化时往往写for_each_possible_cpu(cpu) { unsigned long *p per_cpu_ptr(ptr, cpu); *p 0; }当前CPU上下文里可以用get_cpu_ptr拿到本CPU的地址并关闭抢占unsigned long *p get_cpu_ptr(ptr); *p 1; put_cpu_ptr(ptr);同理还有个轻量版本this_cpu_ptr(ptr)返回当前CPU对应地址但不关闭抢占。这里和静态接口的this_cpu_xxx是同一语义层级轻、快、不管调度。来看一个稍完整的动态分配示例我经常在驱动里用这种方式统计每个队列的处理耗时struct my_stats { u64 packets; u64 bytes; u64 max_latency_ns; }; static struct my_stats __percpu *stats; static int stats_init(void) { int cpu; stats alloc_percpu(struct my_stats); if (!stats) return -ENOMEM; for_each_possible_cpu(cpu) { struct my_stats *s per_cpu_ptr(stats, cpu); memset(s, 0, sizeof(*s)); } return 0; } static void stats_exit(void) { free_percpu(stats); stats NULL; }释放时只需要传回alloc_percpu返回的原始指针内核会负责把动态区里这个变量在每CPU上的副本都归还分配器。如果你把per_cpu_ptr之后的地址传进free_percpu那就等着内核报错吧。3.3 动态与静态在内存布局上的差异落实到具体内存布局内核镜像的percpu区大致分为静态区、保留区和动态区。静态Per-CPU变量占用静态区动态Per-CPU变量分配在动态区。每个CPU的基址由per_cpu_offset[cpu]描述访问变量时统一是“基址 变量偏移”的模式。一个容易忽略的点per_cpu_ptr(ptr, cpu)的ptr其实包含了分配器返回的“偏移”所以它天然就是percpu区内的一个偏移量而不是普通虚拟地址。这也解释了为什么把它强转成真实地址去访问之前一定要加per_cpu_offset。很多新人在写代码时会把alloc_percpu返回的值塞到结构体里保存这没问题但结构体里存的是“句柄”不是“地址”访问前一定记得转换。4. 静态与动态的选型对比与最佳实践4.1 一张表看清关键差异维度静态Per-CPU动态Per-CPU分配时机编译期运行期模块加载/函数调用定义接口DEFINE_PER_CPU / DECLARE_PER_CPUalloc_percpu / __alloc_percpu每CPU副本编译时预留自动为所有CPU准备运行时在percpu动态区分配当前CPU访问get_cpu_var / this_cpu_readget_cpu_ptr / this_cpu_ptr指定CPU访问per_cpu(var, cpu)per_cpu_ptr(ptr, cpu)释放静态定义不用释放free_percpu(ptr)CPU热插拔副本随percpu区域初始化一般随percpu区域自动处理模块钩子可选典型场景内核固有子系统、固定统计项驱动per-queue/per-device数据、变量个数和大小动态决定4.2 到底什么时候用静态什么时候用动态我个人的判断标准就三条。第一个数是否编译期确定。比如系统全局只有“每个CPU一个计数器”用静态但如果你要为每张网卡分配若干Per-CPU统计块网卡数量在运行期才知道动态更合适。第二是否跨模块大量使用。内核核心代码偏爱静态因为编译期就能定位链接重定位处理简单性能也最好。驱动模块里用到几个Per-CPU变量静态定义一样支持模块加载时静态Per-CPU区会为模块扇区分配副本。但模块卸载时需要注意找个专门的percpu_free动作实际上不需要模块的percpu资源会在卸载时统一回收。第三访问频率和临界区长度。高频热路径建议用静态加this_cpu_xxx原语低频但需要复杂临界区的用动态加get_cpu_ptr也完全没问题。性能差距在低频场景基本无所谓。4.3 性能优化CacheLine对齐与原语选择静态定义时如果变量可能和相邻Per-CPU变量存在伪共享加上____cacheline_alignedDEFINE_PER_CPU(struct stats_block, cpu_stats) ____cacheline_aligned;这会强制每个副本按CacheLine对齐使不同CPU的副本落进不同的CacheLine。注意它扩大了对齐要求内存开销会增加但统计类热点变量这点妥协完全值得。动态分配时如果要特定对齐用__alloc_percpu直接指定ptr __alloc_percpu(sizeof(struct stats_block), SMP_CACHE_BYTES);SMP_CACHE_BYTES是架构相关的CacheLine大小用这个就可以让每个CPU的副本在不同CacheLine上。读写原语的选择上我的习惯是只自增/自减一个量用this_cpu_inc/dec需要复合更新比如同时加包数和字节数用get_cpu_var包一段普通C代码读当前CPU值并对精确性要求高用this_cpu_read读别的CPU的值用per_cpu但接受同步风险最好配合数据一致性协议确保对方没有正在写。5. 常见问题与排查技巧实录5.1 我在代码里见过/踩过的典型坑坑一Per-CPU临界区里睡眠。get_cpu_var关了抢占此时调用kmalloc(GFP_KERNEL)、mutex_lock等可能睡眠的函数等待醒来时抢占可能已经变了内核直接报告 “BUG: scheduling while atomic”。调试方法很简单打开CONFIG_DEBUG_PREEMPT这类非法行为会打印调用栈和rip地址。坑二get_cpu_var与put_cpu_var不配对。常见于中间加了return或者异常分支。关抢占长时间不恢复系统调度延迟会被放得很大表现为随机性的“卡顿”。这不是马上崩而是慢慢拖垮系统。写代码时尽量把临界区收敛到一条return之前完成所有Per-CPU操作或者用preempt_disable与preempt_enable配对检查。坑三直接解引用alloc_percpu返回的指针。这个我在前面反复强调了。有些人图省事会写ptr alloc_percpu(unsigned long); *ptr 123;看起来在单核上能跑通多核上就开始访问错误地址或写进其他CPU的副本行为非常诡异。正确姿势永远是per_cpu_ptr(ptr, smp_processor_id())。坑四读其他CPU副本不做同步撕裂。per_cpu(var, cpu)本质是无锁读取。如果那个CPU正在更新一个64位变量你读到的可能是指令流中间状态。架构上64位对齐一般保证原子性但联合更新多个字段时不保证一致性。这时候就得靠调用方协议比如要求写入方用this_cpu_xchg或先置标志位。坑五静态与动态混用接口。静态变量用per_cpu_ptr(var, cpu)是不对的静态变量虽然也是重定位地址但标准接口是per_cpu(var, cpu)或per_cpu(var, cpu)。动态变量反过来用per_cpu(*ptr, cpu)也不对。一开始我把这两套宏搞混最后编译过了但地址算错数据时对时错。5.2 排查与验证工具链排查Per-CPU问题最有效的工具清单如下打开内核调试开关CONFIG_DEBUG_PREEMPT、CONFIG_PROVE_LOCKING、CONFIG_DEBUG_ATOMIC_SLEEP。这么干能帮你快速定位“scheduling while atomic”和抢占不平衡。用debugfs看percpu分配详情挂载debugfs后查看/sys/kernel/debug/percpu能看到分配器的chunk、slot、free分布对诊断动态Per-CPU泄漏非常有帮助。用kallsyms查静态Per-CPU变量地址/proc/kallsyms里能看到形如__per_cpu_start、__per_cpu_offset的符号。单用per_cpu(var, cpu)时实际地址可以通过per_cpu_offset[cpu] (unsigned long)per_cpu(var, 0)手动算出来再用crash或者gdb比对内存内容。反汇编验证原语this_cpu_inc应该编译成类似addq $1, %gs:offset的指令看到%gs前缀基本就对了如果看到的是普通全局地址说明压根没用对Per-CPU接口。我自己的排查习惯是先在模块里加一段自检遍历所有CPU把Per-CPU数据打印出来同时对比/proc/kallsyms里的地址确认每个CPU副本地址是否落在预期区间。这一步能排除九成以上的地址重定位错误。写在最后的一个实操体会拿网络驱动的包统计举例。刚开始我用一个全局spinlock加结构体保护10G流量下锁竞争直接让某个CPU飙到90%占用还伴随中断处理抖动。改成Per-CPU计数器后每个CPU只管自己那部分最后汇总时遍历一次for_each_possible_cpu加总CPU占用直接掉了两个数量级。但第一次改成brstatic DEFINE_PER_CPU(struct pkt_stats, per_cpu_stats);时我用per_cpu(per_cpu_stats, smp_processor_id())去更新某个压测场景下统计值偶尔丢失排查半天才意识到中间发生了迁移。后来老老实实换成this_cpu_ptr(per_cpu_stats)原地自增统计一下就准了。Per-CPU这套接口看着绕本质就是“空间换时间”四个字。静态和动态两套接口的差异根源在于地址的重定位方式不同理解了“每个CPU副本地址 CPU基址 变量偏移”这一条再回头看per_cpu、get_cpu_var、per_cpu_ptr整个脉络就通了。建议你写个小模块把每个接口过一遍然后在/proc/kallsyms里对比验证地址亲手跑一圈比看十遍文档都管用。
返回列表