:percpu 变量 — 每个 CPU 一份数据,无锁访问)
〇、全景多核下有些数据每核一份才快多核系统里如果所有 CPU 共享同一个全局变量那么每次读它都要加锁、而且会引发缓存乒乓cacheline bouncing——一个 CPU 改了它其他 CPU 缓存里的副本就全失效下次读要重新从内存拉。这类频繁读写、但每个 CPU 只关心自己那份的数据最好的做法是给每个 CPU 单独一份访问自己那份时既不用锁、也不打扰别人。这就是percpu 变量。它的启动/初始化分三个阶段阶段一编译期DEFINE_PER_CPU 放进 .data..percpu 段阶段二运行期setup_per_cpu_areas 为每核分配副本阶段三访问%gs 段 this_cpu 一条指令链接器给每个变量算一个『段内偏移』算 per_cpu_offset(cpu) 每核副本的基址偏移GS base 本核基址mov %gs:偏移 零开销一句话主线percpu 变量 编译期放进.data..percpu段链接器算偏移→ 运行期setup_per_cpu_areas给每核分配一份副本算per_cpu_offset→ 访问时用%gs段 this_cpu一条指令零查表。它解决的是多核下共享变量要加锁、会缓存乒乓的问题。一、为什么需要 percpu共享变量 锁的代价先看反面教材如果内核用一个共享的全局变量统计当前 CPU 正在运行的任务数会发生什么要加锁两个 CPU 同时写会互相覆盖必须用 spinlock 保护。锁本身有开销锁竞争时 CPU 还得空转等。缓存乒乓CPU0 写这个变量硬件缓存一致性协议会失效CPU1、CPU2… 缓存里的这份数据。下次 CPU1 读只能重新去内存/别的 cache 拿。一个所有人都读写的变量会在所有 CPU 的 cache 之间来回弹跳拖慢所有人。但当前 CPU 正在运行的任务数这类数据每个 CPU 只读写自己那份就够了根本不需要和别人共享。于是内核给每个 CPU 发一份副本CPU0 写 CPU0 的、CPU1 写 CPU1 的谁也不碰谁既不用跨核的锁也没有跨核的缓存乒乓。这就是 percpu 的动机——把共享变成隔离。内核里 percpu 无处不在current当前任务、每个 CPU 的 runqueue、调度统计、网络收包队列、中断计数……下一篇会看到的调度器rq就是一个典型 percpu。1.1 缓存乒乓到底是什么一致性协议下的 cache line 弹跳上一条 bullet 里反复出现的缓存乒乓是理解 percpu 动机的钥匙这里展开讲透。它的根子是缓存一致性协议。1前提每核有独立 cache一致性协议保证同一地址处处一致每个 CPU 有自己的 L1/L2 cache但内存是共享的硬件必须保证某个地址在所有 cache 里的值一致。x86 靠一致性协议MESI 一族核心规则是——一个核要写某条 cache line必须先拿到独占权也就是把其他所有核 cache 里这条 line 的副本标记成 Invalid失效。2于是一个被多核频繁读写的共享变量陷入乒乓循环以两个核轮流读写同一个共享 counter 为例CPU0 写 counter → 把 CPU1 缓存里的 counter 副本失效Invalid CPU1 读 counter → 缓存未命中得从 CPU0 的缓存把这条 line 拉回来 CPU1 写 counter → 又把 CPU0 的副本失效 CPU0 读 counter → 未命中再拉回来 ……循环往复这条 cache line 像乒乓球一样在 CPU0 和 CPU1 的缓存之间来回弹跳——这就是缓存乒乓cacheline bouncing。3为什么它这么贵每一次弹跳都是一次缓存未命中 互联总线传输要花上百个 cycle正常 L1 命中只要几个 cycle。更糟的是这种失效/重取会持续产生一致性流量snoop 流量把核间互联带宽打满拖慢的不是这一个变量、而是所有共享这条链路的人。4一个隐蔽变体伪共享false sharing还有一种更隐蔽的情况两个逻辑上毫不相干的变量碰巧落在**同一条 cache line通常 64 字节**里被两个核分别写。它们地址不同但物理上共享同一条 cache line写其中一个也会失效另一个所在的那条 line——照样乒乓。这就是伪共享看起来没共享物理上却共享了一条 cache line#11 的 SMT 场景里兄弟逻辑核共享 L1/L2伪共享的代价更明显。5percpu 怎么从根上消灭它乒乓的根源是所有核读写同一个地址。percpu 给每个核各自一个副本、各自的地址——CPU0 写地址 X自己的副本、CPU1 写地址 Y自己的副本。X、Y 是不同地址、通常也落在不同 cache linepercpu 布局时对每个副本做了 cache line 对齐见 §2.2 的ALIGN(cacheline)所以写 X 不会失效 Y。没有共享地址 → 没有失效 → 没有乒乓——这就是 §一 说的把共享变成隔离。二、编译期DEFINE_PER_CPU把变量放进.data..percpu段2.1 宏展开一个 section 属性搞定定义 per-cpu 变量用DEFINE_PER_CPU// include/linux/percpu-defs.h (v6.6, line 114)#defineDEFINE_PER_CPU(type,name)\DEFINE_PER_CPU_SECTION(type,name,)它一路展开到__PCPU_ATTRS核心就是这个 section 属性// include/linux/percpu-defs.h (v6.6, line 49)#define__PCPU_ATTRS(sec)\__percpu__attribute__((section(PER_CPU_BASE_SECTION sec)))\__aligned(...)而PER_CPU_BASE_SECTION就是 percpu 段的名字// include/asm-generic/percpu.h (v6.6, line 55)#definePER_CPU_BASE_SECTION.data..percpu所以DEFINE_PER_CPU(int, foo)的本质就是把foo这个变量放进.data..percpu这个特殊的链接段里。这一行 section 属性就是编译期 percpu 机制的全部。2.2 链接脚本给每个变量算段内偏移链接器把所有.data..percpu段里的变量收集起来用PERCPU_INPUT宏排布成一个整体// include/asm-generic/vmlinux.lds.h (v6.6, line 1033)#definePERCPU_INPUT(cacheline)\__per_cpu_start.;\*(.data..percpu..first)\.ALIGN(PAGE_SIZE);\*(.data..percpu..page_aligned)\.ALIGN(cacheline);\*(.data..percpu..read_mostly)\.ALIGN(cacheline);\*(.data..percpu)\*(.data..percpu..shared_aligned)\__per_cpu_end.;关键结论__per_cpu_start是整个 percpu 段的起始SMP 下是零基的见 vmlinux.lds.S:235 的PERCPU_VADDR(..., 0, ...)。于是每个 per-cpu 变量在链接完成后都有一个确定的段内偏移相对__per_cpu_start——这个偏移就是后面运行时寻址的钥匙。注意段内还按用途分了子段.data..percpu..first必须排最前、.page_aligned页对齐、.read_mostly读多写少单独缓存行避免和常写变量共享等这是为了缓存友好。三、运行期setup_per_cpu_areas给每核分配一份副本编译期只生成了一份模板.data..percpu段里的初始值。运行时内核要为每个 CPU 复制一份这一步在setup_per_cpu_areas里完成。3.1 调用时机start_kernel里、sched_init之前// init/main.c (v6.6, line 897)setup_per_cpu_areas();它必须在sched_init:940之前——因为调度器的rq是 percpu 变量sched_init里for_each_possible_cpu要cpu_rq(i)访问每核的 rq此时per_cpu_offset必须已经就位。这是它和上一篇调度器启动的直接衔接。3.2 分配第一个 chunk算per_cpu_offsetsetup_per_cpu_areassetup_percpu.c:116先分配 percpu 区域再给每核算偏移// arch/x86/kernel/setup_percpu.c (v6.6, line 153)rcpcpu_embed_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,dyn_size,atom_size,pcpu_cpu_distance,pcpu_cpu_to_node);if(rc0)rcpcpu_page_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,pcpu_cpu_to_node);// …delta(unsignedlong)pcpu_base_addr-(unsignedlong)__per_cpu_start;for_each_possible_cpu(cpu){per_cpu_offset(cpu)deltapcpu_unit_offsets[cpu];per_cpu(this_cpu_off,cpu)per_cpu_offset(cpu);// …}两个关键点分配策略首选pcpu_embed_first_chunk把每核的副本嵌入一大块连续内存省空间、连续性好失败则退回pcpu_page_first_chunk每核独立页。64 位下用PMD_SIZE作为 atom_size让 percpu 区域按 PMD 对齐。per_cpu_offset的计算setup_percpu.c:168deltapcpu_base_addr-__per_cpu_start;per_cpu_offset(cpu)deltapcpu_unit_offsets[cpu];__per_cpu_start是编译期段的地址pcpu_base_addr是运行时实际分配的基址两者差delta内核可能被重定位或 percpu 区域挪到了别处。pcpu_unit_offsets[cpu]是第 cpu 份副本相对第一份的步距每核占固定大小的一块。加起来就是第 cpu 份副本相对模板的偏移。这个偏移存在一个全局数组里供访问指定 CPU的变量时查表用// include/asm-generic/percpu.h (v6.6, line 21)#defineper_cpu_offset(x)(__per_cpu_offset[x])3.3 迁移早期数据 切换基址setup_per_cpu_areas还有两件收尾活setup_percpu.c:181-205迁移早期数据启动早期内核用静态数组early_per_cpu_map暂存每核的 apicid、acpiid、node 映射现在把它们复制进正式 per-cpu 区然后把这些early_*_ptr置 NULL 表示静态数组作废。BSP 切换基址if (!cpu) switch_gdt_and_percpu_base(cpu)——在此之前 BSP 一直在用.init.data里的临时 percpu现在切到正式分配的 percpu 区更新 GS base。四、访问机制%gs段 this_cpu一条指令per-cpu 变量最终要被频繁访问所以怎么快速拿到本 CPU 的那份是这套机制的灵魂。x86_64 的答案是用段寄存器%gs存本 CPU 的 percpu 基址一条带段前缀的指令就搞定。4.1 两条访问路径内核提供两条路用途不同this_cpu_read(x)per_cpu(x, cpu)访问谁的本 CPU的x指定 CPU的x怎么算地址%gs段前缀 编译期偏移x __per_cpu_offset[cpu]查表开销一条mov %gs:偏移硬件完成读数组 算地址软件完成场景热路径current、rq 统计…偶尔访问别的 CPU迁移、调试…核心区别this_cpu走硬件段机制零查表per_cpu(cpu)走软件查数组。4.2%gs段编译期偏移 运行时基址x86_64 上__percpu_seg就是gs// arch/x86/include/asm/percpu.h (v6.6, line 6)#ifdefCONFIG_X86_64#define__percpu_seggs// …#definePER_CPU_VAR(var)%__percpu_seg:var于是this_cpu_read(x)展开到底就是一条mov %gs:偏移(x), regarch/x86/include/asm/percpu.h:368的percpu_from_op生成。这里的偏移是编译期就算好的段内偏移基址是%gs段寄存器里存的值。关键在于%gs的基址要指向本 CPU 的 percpu 区。这个基址在启动时写入MSR_GS_BASE// arch/x86/kernel/cpu/common.c (v6.6, line 765)wrmsrl(MSR_GS_BASE,cpu_kernelmode_gs_base(cpu));而cpu_kernelmode_gs_base(cpu)返回的是这个 CPU 的fixed_percpu_data地址也就是 percpu 区基址见下。CPU 做mov %gs:偏移时硬件自动用MSR_GS_BASE 偏移作为最终地址——整个过程没有一次查表、没有一次额外的地址计算。4.3fixed_percpu_data为什么必须排在 percpu 区最前有个结构必须第一个出现在 percpu 区就是fixed_percpu_data// arch/x86/include/asm/processor.h (v6.6, line 381)structfixed_percpu_data{/* * GCC hardcodes the stack canary as %gs:40. Since the * irq_stack is the object at %gs:0, we reserve the bottom * 48 bytes of the irq stack for the canary. */chargs_base[40];unsignedlongstack_canary;};DECLARE_PER_CPU_FIRST(structfixed_percpu_data,fixed_percpu_data)__visible;它的存在是被一个硬约束逼出来的GCC 把栈保护 canary 硬编码在%gs:40。这要求%gs基址即 percpu 区起始往后第 40 字节恰好是 stack canary。所以fixed_percpu_data用DECLARE_PER_CPU_FIRST声明——放进.data..percpu..first子段而链接脚本里__per_cpu_start之后第一个排的就是这个子段见 §2.2 的PERCPU_INPUT因此它天然落在 percpu 区偏移 0 处链接脚本再用ASSERT(fixed_percpu_data 0)vmlinux.lds.S:519校验这一点。此外INIT_PER_CPU(fixed_percpu_data):515还为它生成一个init_per_cpu__版本值 ABSOLUTE(x) __per_cpu_load供 BSP 在正式 percpu 建立之前就能定位到它。而fixed_percpu_data.gs_base的地址恰好就是 percpu 区基址于是顺理成章地成了 GS base 寄存器的值// arch/x86/include/asm/processor.h (v6.6, line 397)staticinlineunsignedlongcpu_kernelmode_gs_base(intcpu){return(unsignedlong)per_cpu(fixed_percpu_data.gs_base,cpu);}4.4 为什么%gs能这么省对比一下两条路的工作量per_cpu(x, cpu)读__per_cpu_offset[cpu]一次内存访问→ 加到x上 → 再访问变量第二次内存访问。至少两次访存。this_cpu_read(x)mov %gs:偏移硬件用段基址寄存器直接算出地址一次访存还省掉一次加法。在调度器这种每秒跑无数次的路径上一次访存 vs 两次访存就是实打实的性能差。这就是为什么 percpu 要动用段寄存器这种古董硬件机制——为最高频的操作争取最短的指令序列。五、为什么这样设计Why 层5.1 为什么 percpu 而非共享 锁最直接的答案是缓存乒乓。锁只能保证同一时刻一个人写解决不了一个人写、所有人都得失效缓存的物理开销。而 percpu 从根上消灭了这个开销——数据根本不共享就没有乒乓可言。所以对每核只关心自己那份的数据percpu 是比共享 锁更优的结构。当然它不是万能的真正需要全局一致的数据比如全局配置还是得共享 锁。取舍的关键在于这份数据要不要跨核一致。5.2 为什么分编译期偏移 运行期基址两段percpu 变量的地址 段内偏移编译期定 每核基址运行期定。为什么要拆开因为每份副本的内容布局完全一样不同的只是放在哪。既然布局一样就让链接器在编译期把某个变量在副本里的相对位置算死偏移运行期只需要决定每份副本的起点在哪基址。这样访问本核变量 一个固定的偏移 一个会变的基址%gs指令短给新 CPU 分配副本只是多复制一份、多记一个基址不用重新算任何偏移。“不变的部分编译期固化可变的部分运行期注入”——这是 percpu 高效的本质。5.3 为什么 x86 要用%gs这种奇怪的段机制x86 的段机制是 32 位时代的遗留64 位下大部分段基址都被忽略了唯独%gs和%fs被保留下来专门干这个——提供一个每核可切换的基址。切换 CPU 时内核只需改一次MSR_GS_BASE写一个 MSR之后所有%gs:访问就自动落到新 CPU 的数据上不用改任何指令、不用传任何参数。如果不用段寄存器就得像per_cpu(x, cpu)那样每次访问都显式查__per_cpu_offset数组。所以%gs的选择本质是用硬件的一个隐式基址寄存器把访问本核数据这件高频事压成一条指令。这是把软件开销下沉到硬件的典型例子。5.4 但无锁不是绝对的本核内的抢占与中断上面说 percpu不用锁要精确成不用跨核的锁——因为 percpu 消灭的只是跨 CPU 的数据竞争和缓存乒乓本核内仍有一类竞态要防同一个逻辑 CPU 上任务会被抢占、会被中断打断先后访问同一份 percpu 副本。抢占 迁移this_cpu_read(x)读到的是当前 CPU 的副本若读完还没用完就被抢占、迁移到别的核再写就写错副本了。所以有get_cpu_var()/put_cpu_var()内部关抢占要求读、用、写都在关抢占区间内完成。中断打断本核的中断处理函数也可能碰同一个 percpu 变量。this_cpu_inc单条指令对中断原子但this_cpu_add(x, n)是读改写会被中断打断需关中断或用*_irqsave变体。至于超线程兄弟逻辑核之间有没有竞争——没有跨核数据竞争兄弟各自的%gs基址独立访问的是各自副本但它们在微架构层共享 cache/执行单元有另一层面的资源竞争与侧信道风险详见第 #11 篇。六、与已有博客的交叉引用已有博客本篇覆盖的关系CPU #7 调度器启动调度器的rq是 percpu 变量sched_init里cpu_rq(i)访问它——所以setup_per_cpu_areas必须在sched_init之前本篇 §3.1 正是这个衔接CPU #2 AP 拉起每拉起一个 AP都要为它准备一份 percpu 副本 设好它的%gs基址AP 才能访问this_cpuCPU #3 hotplug 状态机运行时热插拔 CPU 时动态 percpualloc_percpu的分配/释放也走 hotplug 回调CPU #11 SMT/超线程兄弟逻辑核各自独立的%gs基址是 percpu 在 SMT 下依然无跨核竞争的根因本篇 §5.4 的无锁边界SMT 侧详见 #11附本篇关键源码索引符号位置DEFINE_PER_CPU/DEFINE_PER_CPU_SECTIONinclude/linux/percpu-defs.h:114/:90__PCPU_ATTRSsection 属性include/linux/percpu-defs.h:49PER_CPU_BASE_SECTION“.data…percpu”include/asm-generic/percpu.h:55PERCPU_INPUT段内排布 /PERCPU_VADDRinclude/asm-generic/vmlinux.lds.h:1033/:1070percpu 段链接脚本PERCPU_VADDR(..., 0, ...)arch/x86/kernel/vmlinux.lds.S:235setup_per_cpu_areas分配 算 offsetarch/x86/kernel/setup_percpu.c:116setup_per_cpu_areas调用点init/main.c:897per_cpu_offset(x)查__per_cpu_offset数组include/asm-generic/percpu.h:21__percpu_seggs/PER_CPU_VARarch/x86/include/asm/percpu.h:6/:14this_cpu_read_8mov %gs:偏移arch/x86/include/asm/percpu.h:368fixed_percpu_data/cpu_kernelmode_gs_basearch/x86/include/asm/processor.h:381/:397wrmsrl(MSR_GS_BASE, …)写 GS 基址arch/x86/kernel/cpu/common.c:765