Linux 6.6内核 CPU 深度解析(八):percpu 变量 — 每个 CPU 一份数据,无锁访问

〇、全景:多核下,有些数据"每核一份"才快

多核系统里,如果所有 CPU 共享同一个全局变量,那么每次读它都要加锁、而且会引发缓存乒乓(cacheline bouncing) ------一个 CPU 改了它,其他 CPU 缓存里的副本就全失效,下次读要重新从内存拉。这类"频繁读写、但每个 CPU 只关心自己那份"的数据,最好的做法是给每个 CPU 单独一份:访问自己那份时既不用锁、也不打扰别人。

这就是 percpu 变量。它的启动/初始化,分三个阶段:
#mermaid-svg-kKm4QekvNM9YHRpS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-kKm4QekvNM9YHRpS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-kKm4QekvNM9YHRpS .error-icon{fill:#552222;}#mermaid-svg-kKm4QekvNM9YHRpS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-kKm4QekvNM9YHRpS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-kKm4QekvNM9YHRpS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-kKm4QekvNM9YHRpS .marker.cross{stroke:#333333;}#mermaid-svg-kKm4QekvNM9YHRpS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-kKm4QekvNM9YHRpS p{margin:0;}#mermaid-svg-kKm4QekvNM9YHRpS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-kKm4QekvNM9YHRpS .cluster-label text{fill:#333;}#mermaid-svg-kKm4QekvNM9YHRpS .cluster-label span{color:#333;}#mermaid-svg-kKm4QekvNM9YHRpS .cluster-label span p{background-color:transparent;}#mermaid-svg-kKm4QekvNM9YHRpS .label text,#mermaid-svg-kKm4QekvNM9YHRpS span{fill:#333;color:#333;}#mermaid-svg-kKm4QekvNM9YHRpS .node rect,#mermaid-svg-kKm4QekvNM9YHRpS .node circle,#mermaid-svg-kKm4QekvNM9YHRpS .node ellipse,#mermaid-svg-kKm4QekvNM9YHRpS .node polygon,#mermaid-svg-kKm4QekvNM9YHRpS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-kKm4QekvNM9YHRpS .rough-node .label text,#mermaid-svg-kKm4QekvNM9YHRpS .node .label text,#mermaid-svg-kKm4QekvNM9YHRpS .image-shape .label,#mermaid-svg-kKm4QekvNM9YHRpS .icon-shape .label{text-anchor:middle;}#mermaid-svg-kKm4QekvNM9YHRpS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-kKm4QekvNM9YHRpS .rough-node .label,#mermaid-svg-kKm4QekvNM9YHRpS .node .label,#mermaid-svg-kKm4QekvNM9YHRpS .image-shape .label,#mermaid-svg-kKm4QekvNM9YHRpS .icon-shape .label{text-align:center;}#mermaid-svg-kKm4QekvNM9YHRpS .node.clickable{cursor:pointer;}#mermaid-svg-kKm4QekvNM9YHRpS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-kKm4QekvNM9YHRpS .arrowheadPath{fill:#333333;}#mermaid-svg-kKm4QekvNM9YHRpS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-kKm4QekvNM9YHRpS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-kKm4QekvNM9YHRpS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kKm4QekvNM9YHRpS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-kKm4QekvNM9YHRpS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kKm4QekvNM9YHRpS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-kKm4QekvNM9YHRpS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-kKm4QekvNM9YHRpS .cluster text{fill:#333;}#mermaid-svg-kKm4QekvNM9YHRpS .cluster span{color:#333;}#mermaid-svg-kKm4QekvNM9YHRpS div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-kKm4QekvNM9YHRpS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-kKm4QekvNM9YHRpS rect.text{fill:none;stroke-width:0;}#mermaid-svg-kKm4QekvNM9YHRpS .icon-shape,#mermaid-svg-kKm4QekvNM9YHRpS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-kKm4QekvNM9YHRpS .icon-shape p,#mermaid-svg-kKm4QekvNM9YHRpS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-kKm4QekvNM9YHRpS .icon-shape .label rect,#mermaid-svg-kKm4QekvNM9YHRpS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-kKm4QekvNM9YHRpS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-kKm4QekvNM9YHRpS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-kKm4QekvNM9YHRpS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阶段一:编译期

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,伪共享的代价更明显)。

(5)percpu 怎么从根上消灭它

乒乓的根源是"所有核读写同一个地址 "。percpu 给每个核各自一个副本、各自的地址 ------CPU0 写地址 X(自己的副本)、CPU1 写地址 Y(自己的副本)。X、Y 是不同地址、通常也落在不同 cache line(percpu 布局时对每个副本做了 cache line 对齐,见 §2.2 的 ALIGN(cacheline)),所以写 X 不会失效 Y。没有共享地址 → 没有失效 → 没有乒乓------这就是 §一 说的"把共享变成隔离"。


二、编译期:DEFINE_PER_CPU 把变量放进 .data..percpu 段

2.1 宏展开:一个 section 属性搞定

定义 per-cpu 变量用 DEFINE_PER_CPU:

c 复制代码
// include/linux/percpu-defs.h (v6.6, line 114)
#define DEFINE_PER_CPU(type, name)			\
	DEFINE_PER_CPU_SECTION(type, name, "")

它一路展开到 __PCPU_ATTRS,核心就是这个 section 属性:

c 复制代码
// 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 段的名字:

c 复制代码
// include/asm-generic/percpu.h (v6.6, line 55)
#define PER_CPU_BASE_SECTION ".data..percpu"

所以 DEFINE_PER_CPU(int, foo) 的本质,就是把 foo 这个变量放进 .data..percpu 这个特殊的链接段里。这一行 section 属性,就是编译期 percpu 机制的全部。

2.2 链接脚本:给每个变量算"段内偏移"

链接器把所有 .data..percpu 段里的变量收集起来,用 PERCPU_INPUT 宏排布成一个整体:

c 复制代码
// include/asm-generic/vmlinux.lds.h (v6.6, line 1033)
#define PERCPU_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 之前

c 复制代码
// 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_offset

setup_per_cpu_areas(setup_percpu.c:116)先分配 percpu 区域,再给每核算偏移:

c 复制代码
// arch/x86/kernel/setup_percpu.c (v6.6, line 153)
rc = pcpu_embed_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
			    dyn_size, atom_size,
			    pcpu_cpu_distance, pcpu_cpu_to_node);
if (rc < 0)
	rc = pcpu_page_first_chunk(PERCPU_FIRST_CHUNK_RESERVE,
				   pcpu_cpu_to_node);
// ...
delta = (unsigned long)pcpu_base_addr - (unsigned long)__per_cpu_start;
for_each_possible_cpu(cpu) {
	per_cpu_offset(cpu) = delta + pcpu_unit_offsets[cpu];
	per_cpu(this_cpu_off, cpu) = per_cpu_offset(cpu);
	// ...
}

两个关键点:

  1. 分配策略 :首选 pcpu_embed_first_chunk(把每核的副本"嵌入"一大块连续内存,省空间、连续性好),失败则退回 pcpu_page_first_chunk(每核独立页)。64 位下用 PMD_SIZE 作为 atom_size,让 percpu 区域按 PMD 对齐。

  2. per_cpu_offset 的计算 (setup_percpu.c:168):

    c 复制代码
    delta = pcpu_base_addr - __per_cpu_start;
    per_cpu_offset(cpu) = delta + pcpu_unit_offsets[cpu];
    • __per_cpu_start 是编译期段的地址,pcpu_base_addr 是运行时实际分配的基址,两者差 delta(内核可能被重定位,或 percpu 区域挪到了别处)。
    • pcpu_unit_offsets[cpu] 是第 cpu 份副本相对第一份的步距(每核占固定大小的一块)。
    • 加起来,就是"第 cpu 份副本相对模板的偏移"。

这个偏移存在一个全局数组里,供"访问指定 CPU 的变量"时查表用:

c 复制代码
// include/asm-generic/percpu.h (v6.6, line 21)
#define per_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:

c 复制代码
// arch/x86/include/asm/percpu.h (v6.6, line 6)
#ifdef CONFIG_X86_64
#define __percpu_seg		gs
// ...
#define PER_CPU_VAR(var)	%__percpu_seg:var

于是 this_cpu_read(x) 展开到底,就是一条 mov %gs:偏移(x), reg(arch/x86/include/asm/percpu.h:368 的 percpu_from_op 生成)。这里的"偏移"是编译期就算好的段内偏移,"基址"是 %gs 段寄存器里存的值。

关键在于:%gs 的基址要指向"本 CPU 的 percpu 区" 。这个基址在启动时写入 MSR_GS_BASE:

c 复制代码
// 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.3 fixed_percpu_data:为什么必须排在 percpu 区最前

有个结构必须第一个出现在 percpu 区,就是 fixed_percpu_data:

c 复制代码
// arch/x86/include/asm/processor.h (v6.6, line 381)
struct fixed_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.
	 */
	char		gs_base[40];
	unsigned long	stack_canary;
};

DECLARE_PER_CPU_FIRST(struct fixed_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 寄存器的值:

c 复制代码
// arch/x86/include/asm/processor.h (v6.6, line 397)
static inline unsigned long cpu_kernelmode_gs_base(int cpu)
{
	return (unsigned long)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 副本。

  1. 抢占 + 迁移 :this_cpu_read(x) 读到的是当前 CPU 的副本;若读完还没用完就被抢占、迁移到别的核,再写就写错副本了。所以有 get_cpu_var() / put_cpu_var()(内部关抢占),要求"读、用、写"都在关抢占区间内完成。
  2. 中断打断 :本核的中断处理函数也可能碰同一个 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_cpu
CPU #3 hotplug 状态机 运行时热插拔 CPU 时,动态 percpu(alloc_percpu)的分配/释放也走 hotplug 回调
CPU #11 SMT/超线程 兄弟逻辑核各自独立的 %gs 基址,是 percpu 在 SMT 下依然无跨核竞争的根因(本篇 §5.4 的"无锁"边界,SMT 侧详见 #11)

附:本篇关键源码索引

符号 位置
DEFINE_PER_CPU / DEFINE_PER_CPU_SECTION include/linux/percpu-defs.h:114 / :90
__PCPU_ATTRS(section 属性) include/linux/percpu-defs.h:49
PER_CPU_BASE_SECTION(".data...percpu") include/asm-generic/percpu.h:55
PERCPU_INPUT(段内排布) / PERCPU_VADDR include/asm-generic/vmlinux.lds.h:1033 / :1070
percpu 段链接脚本(PERCPU_VADDR(..., 0, ...)) arch/x86/kernel/vmlinux.lds.S:235
setup_per_cpu_areas(分配 + 算 offset) arch/x86/kernel/setup_percpu.c:116
setup_per_cpu_areas 调用点 init/main.c:897
per_cpu_offset(x)(查 __per_cpu_offset 数组) include/asm-generic/percpu.h:21
__percpu_seg = gs / PER_CPU_VAR arch/x86/include/asm/percpu.h:6 / :14
this_cpu_read_8(mov %gs:偏移) arch/x86/include/asm/percpu.h:368
fixed_percpu_data / cpu_kernelmode_gs_base arch/x86/include/asm/processor.h:381 / :397
wrmsrl(MSR_GS_BASE, ...)(写 GS 基址) arch/x86/kernel/cpu/common.c:765
相关推荐
91刘仁德33 分钟前
传输层协议深度拆解:端口寻址、UDP 报文与 TCP 可靠性机制全解析
linux·网络·c++·网络协议·tcp/ip·udp·c
AIgorithmGEEK41 分钟前
[Linux]HTTP 应用层协议全解(中篇)
linux·网络·网络协议·http
玖石书1 小时前
linux 安装uv(python虚拟环境管理)
linux·python·uv
皓月盈江1 小时前
Hexo博客Butterfly5.7.0主题网站信息:本站访客数和本站总浏览量的数据一直加载不显示的解决方法
linux·hexo·butterfly·busuanzi·vercount·本站访客数·本站总浏览量
程序猿老A1 小时前
云服务器怎么配置视频存储?
运维·服务器·音视频
H.莓飛1 小时前
【数据结构】堆_OJ题
linux·数据结构·算法
程序员老陆1 小时前
Docker 命令全景指南:从镜像构建到生产运维
运维·docker·容器
硅基手札1 小时前
【linux内核专栏 09】内核模块与 kbuild
linux·架构
流浪0012 小时前
Linux系统篇43——线程(八) 线程的数据不一致问题,从 ticket-- 的非原子性说起
linux·操作系统·线程·线程安全·锁