ARTICLE DETAIL

资讯详情

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

Linux内核Per-CPU变量详解:接口、实现与性能优化实战

Linux内核Per-CPU变量详解:接口、实现与性能优化实战 做内核模块优化的时候我遇到过一件印象很深的事一个全局计数器在高并发下性能惨不忍睹perf 一打热点全在锁和 cache line 乒乓上。后来我把计数器改成 Linux 内核的 Per-CPU 变量代码量几乎没增加吞吐量直接上了一个台阶。从那时起我就意识到理解和用好内核里静态/动态两套 Per-CPU 变量接口是写出高性能内核代码的基本功。这篇文章把我当时整理的笔记完整写出来先讲 Per-CPU 变量解决了什么问题再分别拆静态接口和动态接口的实现与用法然后给出一份接口选型对照最后分享几个我在实际项目中踩过的坑和调试思路。适合正在写驱动、做网络或存储模块优化、接触内核热路径的读者准备内核面试的人也可以拿来做复习提纲。1. 为什么 Per-CPU 变量能成为多核性能的救星1.1 一次计数器竞态引发的连锁反应先还原我当时遇到的场景。模块里维护了一个全局统计值每次收包都执行atomic_inc(global_counter)。在低流量下毫无问题但流量上来后原子操作带来的 cache line 争抢非常严重。多个 CPU 同时修改同一个内存地址硬件要不断在核间同步缓存表现就是CPU 使用率不高但吞吐上不去perf里全是atomic_inc的等待和锁总线的开销。这时候最容易想到的方案是加锁保护或者拆分统计维度。但不管是 spinlock 还是 seqlock只要多个 CPU 还在写同一个地址性能瓶颈就依然存在。真正治本的办法是让每个 CPU 都有一份自己的变量副本各自改各自的互不干扰。这就是 Per-CPU 变量出现的根本原因。Per-CPU 变量从设计上保证每个 CPU 看到的是自己的副本写入只在本地缓存行上发生不存在跨核共享也就不需要任何锁。以计数器为例this_cpu_inc(counter)在 x86 上就是一条带%gs段前缀的指令既没有锁也没有函数调用开销。1.2 Per-CPU 变量的核心设计空间换锁Per-CPU 变量的本质是“以空间换并发”。同一份逻辑数据在内存里存在 N 份物理副本N 等于系统可能的 CPU 数量。每个处理器访问自己的那份把“多核并发写同一地址”的问题直接变成了“单核写本地地址”的问题。这个思路的关键在于“隔离”数据被放在特殊的 per-CPU 段里链接成一份模板。系统启动时为每个 CPU 复制出独立的运行时副本。访问接口通过 CPU 编号定位到具体副本。这样做也确实有代价内存占用会随 CPU 数量线性增加。假设一个 per-CPU 数组每个 CPU 占 4KB128 核机器上就要吃掉 512KB。所以 Per-CPU 变量适合存放高频访问的小对象不适合放巨大的缓冲区。大块数据一般用 per-CPU 页分配或其他机制这点后面讲动态分配时会再提到。1.3 接口的两个基本维度静态/动态与按 CPU/按本 CPUPer-CPU 变量接口可以按两个维度划分。第一个维度是“变量的来源”静态定义还是运行时分配。静态接口用DEFINE_PER_CPU在编译期声明变量符号永久存在访问时可以生成最高效的指令。动态接口用alloc_percpu在运行时分配适合数量不确定的场景比如每个设备队列一份统计数据。第二个维度是“访问目标”是按指定 CPU 编号访问副本还是只访问“当前 CPU”的副本。前者是per_cpu(var, cpu)后者是this_cpu_read/write/inc以及get_cpu_var这一族接口。这两个维度互相组合基本上就构成了整个 Per-CPU 变量 API 的骨架静态定义 按 CPU 访问常用于遍历汇总。静态定义 本 CPU 访问最常见的性能路径写法。动态分配 按 CPU 访问遍历动态 per-CPU 数组。动态分配 本 CPU 访问驱动热路径最常用的组合。理解了这两个维度再看各种宏就不会觉得乱。下面逐个拆解。2. 静态 Per-CPU 变量从 DEFINE_PER_CPU 到 GS 段寻址的完整链路2.1 定义宏与 .data..percpu 段静态 Per-CPU 变量靠一组宏定义。最基础的两个DEFINE_PER_CPU(unsigned long, my_counter); DEFINE_PER_CPU_ALIGNED(struct packet_stat, pkt_stats);DEFINE_PER_CPU(type, name)展开后实际上是把变量放进了一个专门的段.data..percpu。这个段和普通.data段是分开的链接器后续会对它做特殊处理。类似地还有DECLARE_PER_CPU用于在头文件里声明外部变量。除了基础宏内核还提供了几个变体各有明确用途DEFINE_PER_CPU_ALIGNED(type, name)按自然对齐要求对齐适合需要避免 false sharing 的结构体。DEFINE_PER_CPU_PAGE_ALIGNED(type, name)按页对齐适合 TLB 性能敏感的场合。DEFINE_PER_CPU_READ_MOSTLY(type, name)声明为“读多写少”编译器可以优化访问路径适合统计类的只读查询数据。这里有个新手容易忽略的点普通全局变量你直接取地址就能用但 Per-CPU 变量不行。my_counter拿到的地址只是链接期模板里的地址不是当前 CPU 的运行时副本地址。所以访问 Per-CPU 变量必须走接口不能当普通变量使。2.2 链接脚本与 __per_cpu_start 符号的真相静态 Per-CPU 段的布局由内核链接脚本控制。在include/asm-generic/vmlinux.lds.h里有PERCPU_INPUT和PERCPU_OUTPUT这样的宏它们会把所有.data..percpu输入段收集起来排列成一个连续区间并定义几个关键符号__per_cpu_startper-CPU 段起始地址。__per_cpu_endper-CPU 段结束地址。__per_cpu_loadper-CPU 段在 vmlinux 中的装载地址。这三个符号看起来很像含义却完全不同。__per_cpu_load指向的是静态数据的“原始拷贝”位置系统启动时要把这份数据作为种子复制到每个 CPU 的运行时区域。__per_cpu_start和__per_cpu_end表示链接期 per-CPU 区间的边界但它不是任何 CPU 的实际运行时基址。这个区分很重要。你查/proc/kallsyms看到__per_cpu_start的地址那是链接地址直接解引用它访问到的只是模板数据不是本 CPU 的副本。内核里所有访问 Per-CPU 变量的宏本质上都是“链接期地址 运行时偏移”的组合运算。2.3 运行时偏移__per_cpu_offset 是怎么来的系统启动时setup_per_cpu_areas()会为每个 CPU 分配独立的 per-CPU 运行时区域并计算每个区域和链接期__per_cpu_start之间的偏移保存在全局数组__per_cpu_offset[cpu]里。访问指定 CPU 的副本概念上就是一次地址换算CPU n 的变量地址 __per_cpu_offset[n] 链接期变量地址per_cpu(var, cpu)这个宏做的事就是这个。它读per_cpu_offset(cpu)加到变量链接地址上得到目标副本的地址然后解引用。在实际机器码层面x86 做得更彻底。x86 架构把当前 CPU 的 per-CPU 基址放进了 GS 段寄存器于是访问本 CPU 的 Per-CPU 变量可以直接生成一条带%gs前缀的访存指令不需要先查__per_cpu_offset数组。例如unsigned long v this_cpu_read(my_counter);在 x86_64 上反汇编会看到类似这样的指令mov %gs:my_counter_offset, %raxmy_counter_offset在编译链接时就已经确定是变量在__per_cpu_start段内的相对位置。GS 段基址指向当前 CPU 的 per-CPU 区域基址两者一配合一条指令就完成了本 CPU 变量的读取。这就是 Per-CPU 变量高性能的底层来源。2.4 静态接口的完整访问矩阵静态 Per-CPU 变量的访问接口按“是否需要关心抢占”和“访问本 CPU 还是指定 CPU”可以分成几类。我用一张表概括接口访问目标抢占保护典型用途per_cpu(var, cpu)指定 CPU无汇总、跨 CPU 读写get_cpu_var(var)/put_cpu_var(var)本 CPU自动关 / 恢复短临界区需要保证 CPU 稳定this_cpu_read/write/inc/add本 CPU无单指令安全热路径计数、无调度点访问__this_cpu_read/write/inc本 CPU无不做额外检查中断或关抢占上下文raw_cpu_read/write/inc本 CPU无无任何检查启动早期、NMI 等极端场景这里重点解释一下get_cpu_var和this_cpu_inc的区别。get_cpu_var内部会做preempt_disable()保证你在访问期间不会被调度到其他 CPU用完再put_cpu_var恢复。它安全但有额外开销。而this_cpu_inc这类操作单条指令在执行的瞬间 CPU 不会变所以即使不关抢占这条指令也一定会作用于当前 CPU 的副本。问题出在多条指令的组合上如果先this_cpu_read读出来中间又调用了可能睡眠的函数再this_cpu_write写回去进程就可能被调度到另一个 CPU导致读和写发生在不同副本上。这点后面踩坑部分会细讲。2.5 模块中的静态 Per-CPU 变量在可加载模块里定义静态 Per-CPU 变量要注意导出和访问的问题。模块被加载时模块自己的.data..percpu段会被拷贝到动态分配的 per-CPU 区域里模块内的符号引用也会被重定位到拷贝后的位置。如果模块 A 想访问模块 B 导出的 Per-CPU 变量B 必须显式导出EXPORT_PER_CPU_SYMBOL(my_counter); EXPORT_PER_CPU_SYMBOL_GPL(my_counter);另一个模块声明DECLARE_PER_CPU(unsigned long, my_counter);后就可以用per_cpu(my_counter, cpu)或this_cpu_read(my_counter)访问。注意导出的是符号但使用方式依然是 per-CPU 接口不能直接拿my_counter当普通指针用。3. 动态 Per-CPU 变量alloc_percpu 背后的内存管理内幕3.1 动态接口的语义与使用姿势静态变量适合“一个内核里就一份”的全局场景但很多驱动需要“每个实例一份”的 per-CPU 数据。比如每个网络设备都有自己的统计数组设备数量运行时才知道这时候就要用动态分配。基本接口是int __percpu *stats; stats alloc_percpu(int); if (!stats) return -ENOMEM; // 访问 CPU 2 上的副本 int *p per_cpu_ptr(stats, 2); *p *p 1; // 访问当前 CPU 的副本 int *local this_cpu_ptr(stats); (*local); free_percpu(stats);注意alloc_percpu(int)的返回类型是int __percpu *。这里的__percpu是给 sparse 静态检查工具看的标注表示这是一个 per-CPU 指针不能用普通指针的规则去解引用。写代码时如果直接写*stats 1sparse 会报警告语义上也是错误的。动态 Per-CPU 变量的一个关键特性它为所有 possible CPU 都分配了副本不是只为当前 online CPU 分配。所以可以安全地用for_each_possible_cpu(cpu)去遍历。但要注意访问一个 offline CPU 的副本虽然地址换算合法里面的数据却是旧数据不能想当然地当“当前在线值”用。3.2 底层分配器chunk、block 与 CPU 热插拔动态 Per-CPU 变量由mm/percpu.c里的分配器统一管理入口是pcpu_alloc()。分配器把 per-CPU 虚拟地址空间组织成一个一个的 chunk每个 chunk 内部再划分成多个 block按需分配。大致流程是根据请求 size 在已有 chunk 里查找可用 block。找到就分配返回一个 per-CPU 逻辑指针。找不到就新建 chunk扩容映射再做分配。释放时把 block 回收到空闲链表chunk 全空则可能销毁。之所以要维护这么一套复杂结构是因为 per-CPU 内存的“每个 CPU 一份”特性分配器不能像普通 buddy 系统那样只记一个地址它要保证每个 CPU 都有一块对应的区域而且逻辑指针要能被换算到任意 CPU 的副本。这就是per_cpu_ptr(ptr, cpu)的价值——ptr只是逻辑地址加上__per_cpu_offset[cpu]才是真实的内核地址。CPU 热插拔场景下分配器也要配合处理。CPU 上线时需要建立该 CPU 的 per-CPU 映射CPU 下线时相关区域不会立刻释放但数据语义上已经“冻结”。写热插拔回调时遍历动态 per-CPU 数组要区分 online 和 possible否则容易把下线 CPU 的历史数据混进实时统计里。3.3 free_percpu 与生命周期管理动态内存最忌讳的就是泄漏和悬垂。free_percpu(ptr)会释放所有 CPU 对应的副本。释放之后任何对ptr的访问都是非法访问轻则读到野数据重则直接内核崩溃。我见过一个比较隐蔽的问题模块里把this_cpu_ptr(stats)的返回值保存到全局变量后续热路径直接使用这个缓存的真实地址模块退出时又调用了free_percpu(stats)。看起来没问题但如果中间发生了 CPU 热插拔或者模块卸载后其他代码还持有那个真实地址悬垂指针就会造成随机崩溃。规范的做法是动态 per-CPU 指针作为资源统一管理所有访问都通过ptr临时换算不要缓存某个 CPU 的真实地址跨生命周期使用。释放前必须确保没有任何代码路径还持有该指针的引用。另外free_percpu不要放在原子上下文或中断上下文。虽然内部实现已经尽量优化但它可能需要释放 chunk 并修改映射属于可能睡眠的操作。进程上下文调用是最安全的。4. 接口选型get_cpu_var、this_cpu_ops、per_cpu_ptr 怎么选不翻车4.1 三类接口的语义与性能差异写 Per-CPU 代码时最纠结的往往是到底用哪个接口。我总结了三个主要家族第一个是per_cpu(var, cpu)。它访问指定 CPU 的副本不依赖当前 CPU所以不涉及抢占问题。代价是要先读__per_cpu_offset[cpu]再访存比本 CPU 访问多几步。它适合遍历汇总、跨 CPU 设置等非热路径。第二个是get_cpu_var/put_cpu_var。它自动关闭抢占然后返回当前 CPU 副本的指针用完之后再恢复抢占。因为访问期间 CPU 不会变所以是安全的。但preempt_disable/preempt_enable有开销不适合循环里高频调用。第三个是this_cpu_ops也就是this_cpu_read/write/inc/add/dec这一族。它直接对当前 CPU 副本做操作在 x86 上通常编译成单条指令性能最好。如果只是做一个计数递增this_cpu_inc就是最优解不需要关抢占。三者的取舍其实很清晰要跨 CPU 读用per_cpu。需要保证本 CPU 且临界区多步操作用get_cpu_var。只需要单指令操作本 CPU用this_cpu_ops。在已关闭抢占或中断上下文用__this_cpu_ops。this_cpu_ptr则是一个特殊的形态它拿到当前 CPU 副本的真实指针相当于per_cpu_ptr(ptr, smp_processor_id())。它不自动关抢占所以在可抢占路径上多步操作要自己保证 CPU 不迁移。4.2 上下文约束抢占、中断与 NMI接口选型不能只看性能还要看代码执行的上下文。不同类型的上下文对接口的约束完全不一样。进程上下文可以被抢占。单指令操作可以用this_cpu_ops多步操作必须用get_cpu_var或显式preempt_disable否则进程可能中途被调度到别的 CPU数据就串了。中断上下文当前 CPU 上的进程调度被阻断但同 CPU 上中断和进程代码可能交替执行。这时单靠“CPU 不变”不够还要防止中断打断临界区。如果是单条原子指令比如this_cpu_inc问题不大如果是多步读写可能需要local_irq_save或者local_bh_disable。软中断上下文同理中断返回后、进程调度前softirq 可能在同一 CPU 上运行。per-CPU 数据如果是统计累加通常在 softirq 和进程上下文中都会访问那么就要考虑用local_bh_disable保护。NMI 和启动早期是最极端的场景。NMI 中断里不能用可能触发调试检查的接口一般只能用raw_cpu_ops。启动早期per-CPU 区域可能还没完全设置好这时候访问也要格外小心。我建议普通驱动代码默认用this_cpu_ops或get_cpu_var碰到 NMI 场景再用raw_cpu_ops不要一上来就追求“最底层”。4.3 一张选型表与典型场景把上面的讨论浓缩成一张表实战中直接对着选场景推荐接口原因本 CPU 单指令计数this_cpu_inc性能最好无锁无抢占开销本 CPU 多步操作get_cpu_var/put_cpu_var自动保证 CPU 稳定遍历所有 CPU 汇总per_cpu(var, cpu)指定 CPU 副本适合 for_each 循环驱动动态 per-CPU 数组alloc_percputhis_cpu_ptr按实例分配访问灵活关中断 / 原子上下文__this_cpu_ops已具备原子性前提避免多余检查NMI 等极端上下文raw_cpu_ops跳过所有检查和偏移修正读多写少的统计快照DEFINE_PER_CPU_READ_MOSTLYthis_cpu_read编译期优化访问快具体到网络收包路径最典型的组合是收包软中断里用this_cpu_inc累加计数读取统计时用for_each_possible_cpu(cpu)配合per_cpu(counter, cpu)汇总。这样写既高效逻辑又清楚。5. 我在 Per-CPU 接口上踩过的坑与调试定位思路5.1 把 per_cpu_ptr 缓存在全局变量中的后果这是我在一个网络统计模块里犯过的错。初始化时我为了方便把this_cpu_ptr(stats)的返回值存到了全局变量global_stats_ptr之后所有 CPU 的收包路径都直接写(*global_stats_ptr)。表面上看这确实访问到了“某个 CPU”的副本但问题有两个第一这个全局指针是在初始化时由初始化所在 CPU 换算出来的它指向的是那个 CPU 的副本。其他 CPU 用它等于跨 CPU 写同一份数据Per-CPU 的隔离性完全失效cache line 乒乓又回来了。第二如果这个指针跨越了 CPU 热插拔生命周期指向的区域可能已经失效。更不要说模块卸载后其他路径还持有这个指针那就是典型的 UAF。正确的做法是每次需要访问时用this_cpu_ptr(stats)或per_cpu_ptr(stats, cpu)现场换算。虽然多几条指令但保证了正确性。5.2 动态指针直接解引用一个典型的崩溃现场另一个常见错误是把alloc_percpu返回的指针当成普通数组用。比如int __percpu *arr alloc_percpu(int); // 错误写法 arr[0] 1;arr是 per-CPU 逻辑指针不是 C 语言意义上的普通数组指针。直接解引用在部分架构和内核版本上可能访问到某个 CPU 的副本取决于具体实现在另一些情况下直接崩溃。最要命的是这种问题具有随机性你在一台机器上测没问题换到别的架构或开启不同配置后就开始出诡异 bug。正确写法是int *p per_cpu_ptr(arr, cpu); *p 1;或者访问当前 CPUint *p this_cpu_ptr(arr); *p 1;如果代码里有大量__percpu指针建议开启 sparse 检查。编译时加make C1 Mdrivers/xxx/sparse 会帮你找出泛型指针和__percpu指针混用的问题能省下很多排查时间。5.3 可抢占路径上的多步操作第三个坑来自对this_cpu_read的过度自信。单指令操作没问题但多步组合在可抢占路径上很容易出错// 危险写法 unsigned long a this_cpu_read(seq_count); do_something_may_sleep(); // 这里可能发生调度 this_cpu_write(seq_count, a 1);如果do_something_may_sleep()真的触发了调度进程可能从 CPU0 迁移到 CPU1。于是a是从 CPU0 副本读出来的写回时却写到了 CPU1 副本CPU0 的计数值丢失CPU1 的计数值凭空多了一笔。这种 bug 在低负载下很难复现因为调度不一定发生。但线上流量一起来统计数值就开始各种跳变。排查这类问题建议先在可疑函数前后加上sched_preempt_enable_no_resched之类的调试输出观察调度点或者直接用get_cpu_var把临界区包起来。5.4 调试 Per-CPU 问题的实用工具与思路Per-CPU 变量出问题时现象往往是“计数少了”“数据写串了”不像空指针那样直接崩溃所以定位要靠工具和计算。我常用的排查路径是这么几步第一步先确认问题是不是“跨 CPU 副本”引起的。在热点路径打印raw_smp_processor_id()和所访问指针的实际地址看同一指针在不同 CPU 上是否落在不同内存区域。如果所有 CPU 打印出的地址都一样说明你的指针很可能被当普通指针缓存了。第二步检查链接期地址和运行时地址。/proc/kallsyms里看到的__per_cpu_start是链接地址要换算成实际地址需要加上对应的__per_cpu_offset[cpu]。用 crash 或 gdb 查看__per_cpu_offset数组可以快速判断访问的地址属于哪个 CPU 的副本。第三步打开内核调试选项。CONFIG_DEBUG_PREEMPT和CONFIG_PROVE_LOCKING能帮你抓上下文违规CONFIG_DEBUG_ATOMIC_SLEEP能抓到在原子上下文里调用可能睡眠的函数。很多 per-CPU 误用都是先被这些选项抓出来的。第四步如果你在用 BPF 做线上排查bpf_per_cpu_ptr()可以直接在 BPF 程序里访问内核的 per-CPU 变量打印不同 CPU 上的副本值对比之间就能看出数据有没有“串”。这个手段在排查线上统计异常时特别高效。5.5 最后想提醒的两点Per-CPU 变量解决的是“CPU 间并发写同一变量”的问题它不解决“同一 CPU 上不同上下文交错”的问题。比如 softirq 和进程上下文都在访问同一个 per-CPU 计数虽然都在 CPU0 上但中断可能随时打断进程数据一样会乱。这种场景要结合local_bh_disable、local_irq_save等机制使用或者保证操作本身是单指令原子操作。另外一点不要把 Per-CPU 变量当成万能药。如果你的数据结构天然需要跨 CPU 共享和复杂同步硬拆成 per-CPU 反而会让代码变得绕还得额外处理汇总逻辑。门槛在于识别“真正被高频写入且可以容忍每 CPU 独立积累”的数据。统计计数、队列长度、分配器缓存这类场景是最典型的适用对象而需要全局一致性的状态机就不适合硬套。
返回列表