ARTICLE DETAIL

资讯详情

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

Linux Per-CPU变量详解:从多核缓存一致性到静态动态分配与访问

Linux Per-CPU变量详解:从多核缓存一致性到静态动态分配与访问 多核处理器普及到今天内核里最贵的资源不是算力而是“等待”。想象一个全局计数器在80核的机器上被疯狂自增每一次写入都会让所有核心的缓存行互相作废Cache一致性协议在一堆缓存行之间来回“踢皮球”性能肉眼可见地往下掉。Linux内核为了根治这种跨核缓存震荡把一份数据按CPU拆分成多份副本每核只动自己那份这个机制就是Per-CPU变量。这套机制从定义接口、分配路径到访问手段都有一套完整的静态/动态双通道设计。这篇文章我带你把两类Per-CPU变量的定义接口、背后内存布局、访问API和常见坑串一遍对写驱动、做性能优化、读内核源码的朋友都有用。1. 为什么要用Per-CPU变量多核竞争的痛点1.1 一个计数器引发的“缓存血案”先说个最简单的场景一个全局变量atomic_long_t total_packets每个网卡中断进来都atomic_long_inc()一下。单核时代毫无压力一到24核、48核甚至更高规格的服务器上麻烦就来了。问题不在加法指令本身而在atomic_long_inc()背后的缓存一致性流量。CPU要修改这个变量必须先拿到包含这个变量的缓存行cache line的独占所有权。而多个核同时操作同一个地址时硬件一致性协议会让这块缓存行在所有核心的L2/L3之间反复“搬家”。每个核改一次其它所有核的副本立刻失效下次别的核再改又重新抢一遍。性能开销随核数线性恶化极端情况下比持锁还慢因为锁至少还有排队缓存震荡是全网乱打。Per-CPU变量正是为这个问题设计的把同一个逻辑变量在每个CPU上各自放一份物理副本。CPU 0 改自己的副本CPU 1 改自己的副本互相不共享缓存行一致性流量直接归零。真正需要全局结果时再遍历所有CPU把副本加起来。这是空间换时间的典型trade-off——用N倍内存换掉了锁竞争和缓存行震荡。1.2 Per-CPU变量的核心思想与适用场景用一个生活类比帮助理解一个办公室只有一台饮水机高峰期所有人都挤过去排队接水。Per-CPU变量的思路是给每个工位配一个水杯大家先接水到自己杯子里最后秘书统一统计每个人喝了多少。多出来的成本是杯子数量省掉的是排队时间。这套思路在内核里应用极广调度器的运行队列runqueues、页分配器每CPU页表per_cpu_pages、网络协议栈的统计计数、各种per-CPU缓存池全是Per-CPU变量的忠实用户。适合用Per-CPU变量的场景有这几个特征一是数据更新极其频繁二是更新操作可以容忍“最终一致”三是数据规模可控不会因为副本数导致内存爆炸。典型如计数统计、热路径上的临时缓存、按CPU分片的数据结构。不适合的场景也很明显只有一个全局事实的数据比如文件系统超级块需要严格串行语义的跨CPU数据或者非常大的数据结构每CPU一份会直接吃爆内存。这种时候老老实实用锁或者RCU别硬上Per-CPU。2. 静态Per-CPU变量编译期固定下来的私有领地2.1 定义/声明宏与链接期布局先看静态定义接口这是内核代码里出现频率最高的// 定义一个Per-CPU变量放在专属section里 DEFINE_PER_CPU(unsigned long, rx_packets); // 在其它编译单元引用宏自带extern不需要手动加 DECLARE_PER_CPU(unsigned long, rx_packets);宏展开后rx_packets会带__percpu这个sparse属性并且被放进一个叫.data..percpu的链接段。这个段很特殊它存的不是一份数据而是一个“模板”。链接器会把所有DEFINE_PER_CPU出来的变量按顺序堆在一起生成__per_cpu_start和__per_cpu_end两个边界符号两者之间的总大小就是单个CPU需要的副本总大小。静态接口的几个变体也要认识一下宏作用典型使用场景DEFINE_PER_CPU(type, name)定义普通Per-CPU变量全局计数、状态标志DEFINE_PER_CPU_ALIGNED(type, name)定义并按L1缓存行对齐高频读写的热数据防止false sharingDEFINE_PER_CPU_PAGE_ALIGNED(type, name)按页对齐定义DMA或需要页对齐的per-CPU缓冲DECLARE_PER_CPU(type, name)外部引用声明跨编译单元访问其它文件的per-CPU变量EXPORT_PER_CPU_SYMBOL(name)导出给内核模块使用模块中访问核心子系统暴露的per-CPU变量模块里用DEFINE_PER_CPU同样会被放到模块自己的percpu section模块加载时内核会为每个CPU各拷贝一份。这点要心里有数模块里静态per-CPU变量定义得越多每个CPU都要多占一份内存数量失控会直接推高内存占用。2.2 每个CPU副本从哪来静态Per-CPU变量编译期只是定好了“模板”真正每个CPU的私有副本是内核启动阶段创建的。在setup_per_cpu_areas()里内核会做几件事先算出__per_cpu_end - __per_cpu_start这个模板大小然后为每个CPU分配一块同样大小的物理内存区域把模板内容逐CPU拷贝进去最后记录每个CPU区域与模板地址之间的偏移关系存到__per_cpu_offset[cpu]数组里。这就是Per-CPU变量访问的核心黑话符号地址 CPU偏移 该CPU私有副本的真实地址。__per_cpu_offset[0]、__per_cpu_offset[1]每个对应一个CPU的基地址。从使用者角度看DEFINE_PER_CPU得到的rx_packets这个符号并不直接指向可读写的内存它只是一个锚点必须经过偏移换算才能访问。x86_64上还有一个实现细节访问当前CPU的Per-CPU变量时不走普通的内存寻址而是通过GS段寄存器加上一个当前CPU偏移配合this_cpu_xxx接口一条指令就能完成读写。这也是为什么当前CPU访问能做得这么快的原因之一。2.3 静态变量怎么访问per_cpu/this_cpu系列访问静态Per-CPU变量的核心接口有三个层次// 方式1指定CPU读取/写入副本不保护抢占 unsigned long n per_cpu(rx_packets, cpu); per_cpu(rx_packets, cpu) 100; // 方式2取指定CPU副本的指针 unsigned long *p per_cpu_ptr(rx_packets, cpu); *p 100; // 方式3访问当前CPU副本单向操作 unsigned long n this_cpu_read(rx_packets); this_cpu_write(rx_packets, n 1); // 方式4当前CPU副本的原子读改写最推荐 this_cpu_inc(rx_packets); this_cpu_add(rx_packets, 64); this_cpu_xchg(rx_packets, 0);这里有个新手最容易踩的点per_cpu(rx_packets, cpu)看似是变量名展开后其实是*per_cpu_ptr(rx_packets, cpu)先做偏移换算再解引用。如果你把这个接口用在循环里频繁读写CPU偏移计算会重复执行性能不是最优。this_cpu_read/this_cpu_inc这类接口专攻当前CPU副本x86上会编译成%gs:前缀访问不经过普通地址计算。特别是this_cpu_inc这种读改写操作在支持它的架构上可以生成原子指令比per_cpu(v, cpu)这种“先算偏移再读取再写回”的方式既安全又高效。注意一个大坑this_cpu_xxx系列没有自动禁止抢占。如果代码在中途被调度到另一个CPU后续访问的可能是新CPU的副本数据就“串台”了。严谨的写法应该配合get_cpu_var()/put_cpu_var()或者自行保证所在上下文不会发生CPU迁移。3. 动态Per-CPU变量运行时按需分配3.1 alloc_percpu/free_percpu接口使用静态Per-CPU变量大小编译期就敲死了数量不可能动态伸缩。实际项目中经常遇到这种需求驱动要管理的设备数量模块加载时才知道每个设备都想配一份Per-CPU统计结构或者某个临时模块只是想在自己生命周期内用一下Per-CPU缓冲根本不想污染全局section。这时候就该动态分配接口上场了。#include linux/percpu.h struct counter { unsigned long hits; unsigned long bytes; }; static struct counter __percpu *stats; // 分配按类型大小和对齐要求为每个CPU各准备一份 stats alloc_percpu(struct counter); if (!stats) return -ENOMEM; // 访问当前CPU副本 struct counter *s this_cpu_ptr(stats); s-hits; // 遍历所有CPU副本做汇总 unsigned long total 0; int cpu; for_each_possible_cpu(cpu) { struct counter *c per_cpu_ptr(stats, cpu); total c-hits; } // 释放必须在可以睡眠的上下文调用 free_percpu(stats);底层接口是__alloc_percpu(size, align)alloc_percpu(type)只是自动帮你填了大小和对齐参数。较新的内核还提供了alloc_percpu_gfp(type, gfp)允许手动指定内存分配的GFP标志需要睡眠分配或紧急分配时更灵活。动态接口返回的stats是一个__percpu修饰的指针但它并不是一个可以直接解引用的普通指针。它更像一把“钥匙”或者锚点必须在this_cpu_ptr/per_cpu_ptr的配合下换算成某个CPU的真实地址后才能读写。直接stats-hits是典型的错误用法轻则访问到随机地址重则直接page fault。3.2 动态分配背后的chunk机制动态Per-CPU分配的实质是一个内核内部的内存池核心数据结构叫pcpu_chunk。系统启动时内核会预留一大块Per-CPU地址空间这段空间被切分成若干个chunk。每个chunk内部维护一套分配位图记录哪些空闲、哪些占用。alloc_percpu进来后内核按size对齐要求找到一个合适的chunk在其中一个CPU的slot里划出一块区域然后让其它所有CPU在相同偏移位置分配各自的私有页面——要求每个CPU副本都落在相同相对偏移这样只要知道“偏移量”就能快速换算出任意CPU的真实地址。这个设计有一个额外好处遍历所有CPU副本时只要拿一个偏移量不断加__per_cpu_offset[cpu]就能找到所有副本计算非常简单。代价是内存浪费举个例子某个CPU的chunk里分配了一个16字节的变量其它所有CPU的对应slot也必须预留不能拿去别用这会形成内碎片。所以别在Per-CPU区域里搞一堆大小不一的小对象内核会把它们凑到相近的size bucket里但碎片仍然不可避免。free_percpu()做的事正好反向把区域标记为空闲回收进chunk的空闲位图。注意它和普通kfree不同内部可能要处理批量chunk的回收调用时不能在硬中断上下文里做。3.3 静态与动态的选型对照我平时做选型时习惯用这套判断逻辑维度静态动态定义时机编译期运行期数量限制受section总大小限制受动态region剩余空间限制内存占用编译期就固化每CPU副本实际分配多少才占多少访问速度最快符号偏移即可略有一层间接查找但热路径影响很小适合场景全局核心数据、高频热路径模块私有数据、设备级结构、大小不定数据释放管理不需要跟随内核生命周期必须手工free否则泄漏内核核心子系统里大量使用静态Per-CPU变量因为它们的生命周期和内核一样长、访问频率极高能省掉每次查找的开销。而驱动和可加载模块里我强烈建议优先动态分配一是不会污染全局percpu section二是生命周期清晰模块卸载时统一free_percpu不容易泄漏三是对大小的需求通常模块加载后才知道。补充一点静态Per-CPU变量放进模块时需要小心模块里的.data..percpusection 在模块加载时也会给每个CPU各拷贝一份。如果模块定义了一个很大的Per-CPU数组代价就是每CPU一份大块内存。动态分配反而能更精细控制这是很多人忽略的细节。4. 访问API全景别在接口上栽跟头4.1 三组核心接口对比Per-CPU变量的访问接口看似花样很多核心其实就三组理清楚之后就不容易用错。第一组是锚点换算接口per_cpu_ptr(ptr, cpu) // ptr per_cpu_offset[cpu]得到CPU cpu的副本指针 this_cpu_ptr(ptr) // ptr 当前CPU偏移第二组是快捷读写接口主要针对当前CPUper_cpu(var, cpu) // *per_cpu_ptr(var, cpu)指定CPU的读写 this_cpu_read(var) // 读当前CPU副本 this_cpu_write(var, val) this_cpu_inc(var) / this_cpu_dec(var) / this_cpu_add(var, n) this_cpu_xchg(var, n) / this_cpu_cmpxchg(var, old, new)第三组是保护性访问接口get_cpu_var(var) // 禁止抢占 取当前CPU副本左值 put_cpu_var(var) // 恢复抢占 get_cpu_ptr(ptr) put_cpu_ptr(ptr)4.2 this_cpu_xxx为什么快this_cpu_xxx系列在x86上的实现大量利用了“当前CPU偏移量会被维护在一个专门寄存器/变量里”的特性最终生成类似mov %gs:offset(%rip), %rax这样一条带段前缀的指令。普通变量访问要先取符号地址再查__per_cpu_offset数组然后再加地址三步变一步热路径上的收益非常可观。更关键的差异在原子性上。this_cpu_inc(var)在x86上可以生成单条incq %gs:offset天然原子而per_cpu(var, cpu)在编译后会变成“读、改、写”三条普通指令完全不原子。如果你在统计时写per_cpu(rx_packets, cpu)中断随时可能插进来丢更新结果就是计数器偏低。所以凡是“读-改-写”操作一律用this_cpu_xxx系列。raw_cpu_xxx系列也值得认识。它是this_cpu_xxx的“裸奔版”不做任何调试检查和上下文保护纯粹为了压榨最后一点性能。正常代码里我建议先用this_cpu_xxx只有在你用perf测过、确认raw版本带来的收益确实可观并且对上下文有百分百把握时才考虑换raw版本。安全第一性能第二。4.3 get_cpu_var与抢占保护很多人用this_cpu_ptr拿完地址后就放心大胆地访问实际上中间是可能被抢占的。假如CPU 0上的进程A拿到CPU 0的副本地址刚执行一半调度器把进程B切到CPU 0上此时进程A恢复执行时它可能已经跑到CPU 1上用的还是CPU 0的旧地址——数据还是能读能写但语义已经不对了。正确姿势是短临界区保护// 写法1保护性访问接口 get_cpu_var(stats)-hits; put_cpu_var(stats); // 写法2手动保护 int cpu get_cpu(); struct counter *c per_cpu_ptr(stats, cpu); c-hits; put_cpu();get_cpu_var展开后其实就是preempt_disable()加上this_cpu_ptr()put_cpu_var负责preempt_enable()。注意临界区里不要睡眠不要在临界区里调用alloc_percpu、kmalloc、mutex_lock这些可能睡眠的函数否则会触发调度器警告。在硬中断、软中断上下文里其实已经天然禁止抢占多数情况下可以不用再套get_cpu_var但中断可能嵌套所以如果你在硬中断里用this_cpu接口问题不大如果在普通进程上下文里访问务必加上保护。4.4 __percpu注解与编译期检查所有Per-CPU变量和指针都带一个__percpu的Sparse属性。这不是运行时强制的而是编译期静态检查用的。写代码时坚持给Per-CPU指针加上__percpu注解配合make C1跑Sparse检查能提前抓到“把percpu指针当普通指针解引用”这类错误。// 错误示范s是percpu指针直接解引用Sparse会报警 struct counter *s alloc_percpu(struct counter); s-hits; // 正确示范先取当前CPU指针再解引用 struct counter *s this_cpu_ptr(stats); s-hits;这点是我特别想强调的Per-CPU接口的错误大多不是运行时立刻崩而是“看起来能用实际数据分散在错误的CPU副本上”这种bug极难复现。让编译器帮你把关是成本最低的防线。5. 实战避坑指南常见问题与调试验证5.1 五个容易翻车的错误示范先列一个错误快查表都是我见过甚至亲手踩过的坑现象根因正确做法stats-xxx直接解引用dump没有经过this_cpu_ptr/per_cpu_ptr换算先换地址再访问中断里alloc_percpu触发睡眠告警动态分配可能睡眠中断上下文禁止分配改为预分配计数一直偏低或偶发丢失用per_cpu(var, cpu)做RMW换成this_cpu_inc数据莫名“漂移”到别的CPU拿完this_cpu_ptr后被抢占迁移加get_cpu_var/put_cpu_var保护模块加载后系统内存消耗暴涨静态Per-CPU数组定义过大改用动态分配或缩小结构这里单独讲一下跨CPU访问的问题。Per-CPU变量思路上就是“每个CPU玩自己的”你非要在CPU 2上修改CPU 5的副本虽然接口允许但两个CPU对同一地址的并发读写没有任何原子保证结果不可预期。如果业务上确实需要跨CPU修改要么用原子操作、要么配合锁实在不行考虑换成普通共享变量加原子操作。另一个隐蔽坑是“把Per-CPU指针保存到全局链表”。模块A在CPU 0上拿了一个this_cpu_ptr存到全局链表里过一会儿模块B在CPU 3上读这个指针——地址确实是CPU 0那个副本但你不能保证CPU 0没被热插拔更不能保证这个副本还有效。Per-CPU指针的生命周期和所在CPU上下文强相关跨节点保存这个指针就是埋雷。5.2 调试Per-CPU变量的实用手法内核对Per-CPU调试给了一些工具先确认内核开了对应配置。CONFIG_DEBUG_PER_CPU_USE会开启额外检查应用层sparse配合__percpu注解也能抓问题。启动后可以用以下手段验证第一看动态分配状态。内核暴露的debugfs节点在/sys/kernel/debug/percpu部分内核需要挂载debugfs直接cat能看到chunk的分配使用情况确认有没有泄漏或异常膨胀。第二用bpftrace或kprobe跟踪分配释放。pcpu_alloc和pcpu_free是关键函数可以挂探针记录size、对齐参数和调用栈快速定位到底是哪个驱动在反复分配Per-CPU内存bpftrace -e kprobe:pcpu_alloc { [kstack] count(); } bpftrace -e kprobe:pcpu_alloc { printf(pcpu_alloc size%d align%d\\n, arg0, arg1); }第三gdb调试内核时注意Per-CPU符号不能直接打印。普通p runqueues打出来只是符号地址要访问CPU 0的真实副本得手动加上__per_cpu_offset[0]。内核配置了CONFIG_GDB_SCRIPTS的话可以用辅助命令lx-per-cpu runqueues 0之类的方式查看记不住命令时多敲apropos lx找找。最后是大招写个临时模块遍历所有CPU打印副本值和地址确认数据分布。别嫌土这个方法在验证“是否所有CPU副本都正确初始化”时最直观尤其是你怀疑初始化遗漏了某个CPU时。5.3 我沉淀下来的一组使用规范踩过足够多坑之后我给自己定了一套Per-CPU变量使用规范分享出来定义上全局核心数据用静态模块私有数据用动态结构体尽量控制在1到2个缓存行以内避免每CPU副本体积失控复杂度超过一定规模就拆成指针里面再按需分配。访问上当前CPU的热路径一律this_cpu_xxx系列需要保护抢占时用get_cpu_var/put_cpu_var跨CPU只读遍历用per_cpu_ptr加for_each_possible_cpu跨CPU写一定要有明确的同步措施并写注释说明原因。生命周期上动态分配的Per-CPU变量初始化函数里分配并清零一般分配后内存是零页但别依赖该清零就清零卸载路径里释放中断上下文绝对不能动态分配把free_percpu放到模块退出函数里保证错误路径也能走到。调试上引入__percpu注解跑C1sparse检查在代码里实现对副本数量合法性检查不匹配立刻告警上线前用bpftrace跑一轮分配热点看有没有异常频繁的alloc/free。Per-CPU变量这套机制理解起来不难真正体现功力的是接口选择和边界情况的处理。很多人觉得它只是“多核计数器”的优化工具实际上它是内核并行设计哲学的缩影——用拷贝换隔离用空间换扩展性。你在阅读调度器、页分配器、网络栈源码时会反复看到这套思路的影子下次遇到类似的并发瓶颈不妨先想想这份数据是否天生该按CPU分片如果是Per-CPU变量可能是比锁和原子操作更优雅的答案。
返回列表