Linux SMP 实现机制深度剖析
1. 概览摘要
先把话说在前面:早期的 Linux 0.11 是典型的单 CPU 内核, 不支持 SMP(Symmetric Multi-Processing, 对称多处理). 真正在 PC 平台上较稳定、系统化地支持 SMP, 大概是 2.0 之后的事. 下面的分析会以现代 Linux 内核(2.6 以后为主)为参照, 但讲的都是 Linux 核心机制, 而不是拍脑袋的理论
你可以把单核系统想象成一个只有一个厨师的小厨房:所有菜都得排队做;SMP 则是有多名厨师、多个灶台, 但共享同一套菜谱(内核代码)和冰箱(物理内存). 难点不在于"多找几个厨师", 而在于:
- 菜怎么分工(调度)
- 避免抢错食材(并发冲突、锁)
- 保证大家看到的库存是一致的(缓存一致性、内存屏障)
- 某个厨师出问题时, 怎么协调其他人(IPI、CPU hotplug)
本文涉及CPU 启动、CPU 间通信、调度与负载均衡、锁与并发控制、内存一致性与屏障、per-CPU 数据结构等关键点
2. 核心概念详解
2.1 关键术语
| 术语 | 含义 | 在 SMP 中的角色 |
|---|---|---|
| SMP (Symmetric MP) | 对称多处理, 每个 CPU 权限对等 | 所有 CPU 共享同一内核与内存 |
| AP (Application Processor) | 除 BSP 外的其他 CPU | 需要由 BSP 唤醒和初始化 |
| BSP (Bootstrap Processor) | 系统上电后首先启动的 CPU | 负责内核引导、其他 CPU 启动 |
| IPI (Inter-Processor Interrupt) | 处理器间中断 | 用于跨 CPU 通知、TLB 刷新等 |
| Local APIC / IO APIC | 本地/IO 中断控制器 | 管理中断路由和分发 |
| Per-CPU 变量 | 每个 CPU 独立的一份变量副本 | 避免多核竞争、提高缓存命中 |
| Spinlock | 自旋锁 | 短时加锁, 不睡眠, 常用于中断/底半部 |
| RCU | Read-Copy-Update | 读多写少的高并发读优化机制 |
| Memory Barrier | 内存屏障 | 控制 CPU/编译器重排序, 保障时序关系 |
2.2 举个栗子
-
单核 vs SMP
- 单核:一个收银员 + 一条队伍, 所有顾客排队结账
- SMP:多个收银员共用一个收银系统和一个库存数据库, 顾客可以去任意收银台. 问题是:
- 库存扣减要一致(缓存一致性)
- 有活动时要所有收银台一起参与(IPI 广播)
-
Spinlock
- 类似办公室里的一把"签字笔", 谁拿到笔, 谁就能在文件上签字;
- 如果笔正在别人手里, 你只能在旁边干等(自旋), 不能离开(不能睡眠)
-
Per-CPU 数据
- 每个收银台桌上都放一只小钱盒(每 CPU 单独一份统计),
- 最终结算时再把所有小钱盒里的钱加总,
- 平时没人抢一个盒子用, 冲突少、效率高
2.3 与其它模式对比
| 模式 | 特点 | 优点 | 缺点 |
|---|---|---|---|
| UP(单处理器) | 只有一个 CPU | 实现简单, 无锁或少锁 | 无法利用多核 |
| SMP | 所有 CPU 对等, 统一内存 | 编程模型简单(统一视图) | 锁竞争、缓存抖动 |
| NUMA | 非一致内存访问, 多节点 | 可扩展到很多 CPU | 内存访问成本差异大, 调度复杂 |
Linux 主流内核在抽象层以 SMP 为基准模型, 底层则会针对 NUMA、超线程、CPU 拓扑做复杂的优化
3. 实现机制深度剖析
3.1 数据结构概览
以 x86 为例, SMP 相关的核心结构大致分布在:
arch/x86/kernel/smp.c:CPU 启动、IPI、smp_call_function 系列kernel/sched/core.c/kernel/sched/fair.c:调度器与多队列负载均衡kernel/locking/*.c:自旋锁、读写锁、RCU 等kernel/cpu.c:CPU hotplug 相关arch/x86/mm/tlb.c:TLB shootdown IPI 相关
下面用一些简化版结构来说明设计思路(非内核原样代码):
c
// 典型的 CPU 描述结构 (简化)
struct cpu_info {
int id; // 逻辑 CPU ID
int online; // 是否在线
int apic_id; // 对应的 APIC ID
int numa_node; // 所在 NUMA 节点
struct rq *rq; // 调度就绪队列
void *percpu_data; // per-CPU 数据基址
};
c
// 每 CPU 运行队列 (调度器核心数据之一, 简化自 rq)
struct rq {
raw_spinlock_t lock; // 保护该队列的自旋锁
struct task_struct *curr; // 当前在此 CPU 运行的进程
struct cfs_rq cfs; // CFS 调度队列 (红黑树等)
unsigned long nr_running; // 就绪进程数量
int cpu; // 所属 CPU
};
c
// IPI 处理相关 (高度简化)
typedef void (*smp_fn)(void *info);
struct smp_call_item {
smp_fn func;
void *info;
struct list_head list;
};
struct smp_call_queue {
raw_spinlock_t lock;
struct list_head list; // 待执行的函数队列
};
3.2 CPU 启动流程
3.2.1 BSP 启动 AP 的整体流程
系统上电, BSP 启动
内核初始化完成
扫描可用 CPU/APIC 信息
为每个 AP 准备启动代码和栈
通过 APIC 发送 INIT IPI
发送 STARTUP IPI(SIPI)
AP 从实模式/保护模式启动代码入口开始执行
AP 初始化 GDT/IDT/分页等
AP 调用 cpu_init, 加入调度器
所有 CPU 在线, SMP 启动完成
大致上:
- BSP 完成常规的内核启动(类似 0.11 的单核起步流程)
- 探测硬件, 构建 CPU 拓扑结构(ACPI/MADT、MP Table 等)
- 为每个即将启动的 AP 准备栈、页表、启动跳板代码
- 通过 Local APIC 对应地址写寄存器, 发送 IPI 给目标 AP:
INIT IPI:复位 AP 到已知状态SIPI(Startup IPI):告诉 AP 从某段物理地址开始执行
- AP 跳到一段汇编启动代码, 逐步切到保护模式/长模式、加载 GDT/IDT、页表
- AP 调用
start_secondary()一类函数, 执行本地初始化(本地定时器、中断、per-CPU 结构等), 最后进入调度循环
3.2.2 启动入口的简化代码示意
文件大致位置:arch/x86/kernel/head_64.S / smpboot.c 等
c
// 伪代码: AP 启动主流程
void start_secondary(void)
{
cpu_init(); // 初始化 CPU 本地状态, per-CPU 结构
smp_callin(); // 与 BSP 同步, 通知自己已经在线
setup_local_APIC(); // 本地 APIC 初始化
calibrate_delay(); // BogoMIPS 校准, delay loop 相关
notify_cpu_starting(); // 通知其他子系统 (如 RCU) 本 CPU 启动
scheduler_start(); // 进入调度循环, 开始执行进程
}
注:这段是高度抽象的示意代码, 只为了说明流程关联
3.3 CPU 间通信:IPI 与 smp_call_function
3.3.1 IPI 类型
常见 IPI 类型(以 x86 为例)包括:
- TLB shootdown IPI:某 CPU 修改页表后, 需要通知其他 CPU 刷新对应 TLB 项
- reschedule IPI:让另外一个 CPU 尽快重新调度, 例如负载均衡、优先级更新
- call_function IPI:让其他 CPU 执行某个回调函数(如刷新某些 per-CPU 状态)
- stop IPI:停机、panic 等场景中, 让所有 CPU 停在安全位置
3.3.2 smp_call_function 伪代码
文件大致位置:kernel/smp.c / arch/x86/kernel/smp.c
c
// 向其他 CPU 广播调用 func(info)
int smp_call_function(smp_fn func, void *info, bool wait)
{
struct smp_call_item item;
item.func = func;
item.info = info;
for_each_online_cpu(cpu) {
if (cpu == smp_processor_id())
continue;
enqueue_call(cpu, &item); // 加入目标 CPU 的队列
send_IPI(cpu, IPI_CALL_FUNC); // 触发 IPI
}
if (wait)
wait_for_all_cpu_done();
return 0;
}
c
// 目标 CPU 的 IPI 处理函数 (高度简化)
void handle_call_function_ipi(void)
{
struct smp_call_item *item;
while ((item = dequeue_call(this_cpu))) {
item->func(item->info); // 在当前 CPU 上执行回调
}
notify_caller_if_needed();
}
这套机制把"在某个 CPU 上执行某个函数"抽象成通用服务, 其他子系统(如 TLB、RCU、时钟同步)都基于它构建高层逻辑
CPU1 (目标) 本地APIC CPU0 (调用方) CPU1 (目标) 本地APIC CPU0 (调用方) 构造 smp_call_item enqueue_call(队列) 发送 IPI_CALL_FUNC 触发 IPI IPI handler 取出队列项 执行 func(info) (可选) 完成通知
3.4 调度与负载均衡:多运行队列模型
3.4.1 多队列调度器
从 2.6 时代开始, Linux 调度器演进到每 CPU 一个就绪队列 的模型(struct rq), 再通过周期性的负载均衡实现跨 CPU 迁移:
- 每个 CPU 有独立的
rq, 减少锁竞争 - 普通情况下, 任务尽量留在本 CPU(cache-friendly)
- 周期性检查各 CPU 负载, 必要时从繁忙 CPU 把任务"偷"到空闲 CPU(work stealing)
CPU2
CPU1
CPU0
负载均衡
负载均衡
就绪队列 rq0
就绪队列 rq1
就绪队列 rq2
3.4.2 核心调度循环伪代码
c
// 伪代码, 概念类似 schedule()
void schedule(void)
{
struct rq *rq = this_rq();
struct task_struct *prev = rq->curr;
struct task_struct *next;
raw_spin_lock(&rq->lock);
put_prev_task(rq, prev); // 把当前进程放回就绪队列
next = pick_next_task(rq); // 从就绪队列选择下一个
rq->curr = next;
context_switch(prev, next); // 切换寄存器、栈、地址空间等
raw_spin_unlock(&rq->lock);
}
c
// 周期性负载均衡 (高度简化)
void rebalance_domains(int cpu)
{
struct rq *rq = cpu_rq(cpu);
raw_spin_lock(&rq->lock);
if (rq->nr_running < threshold) {
int src = find_busiest_cpu();
if (src >= 0)
steal_tasks(cpu, src); // 从繁忙 CPU 偷几个任务过来
}
raw_spin_unlock(&rq->lock);
}
这里的难点在于:
- 锁粒度:避免全局大锁, 尽量用每 CPU 自旋锁
- 拓扑感知:在 NUMA/多级缓存拓扑下, 优先在"距离近"的 CPU 之间平衡
- 实时进程/亲和性:调度器要尊重 CPU 亲和性、实时优先级等约束
3.5 锁与并发控制:Spinlock / RCU / seqlock
3.5.1 自旋锁基础实现
文件大致位置:kernel/locking/spinlock.c 或 arch/*/include/asm/spinlock.h
c
typedef struct {
volatile int locked;
} raw_spinlock_t;
// 简化版 test-and-set 自旋锁
void raw_spin_lock(raw_spinlock_t *lock)
{
while (1) {
// 原子交换, xchg 返回旧值
if (xchg(&lock->locked, 1) == 0)
break; // 拿到锁
// 没拿到锁, 就自旋等一会
cpu_relax(); // 提示 CPU 做 pause 等优化指令
}
smp_mb(); // 获取锁后的内存屏障
}
void raw_spin_unlock(raw_spinlock_t *lock)
{
smp_mb(); // 释放前的屏障
lock->locked = 0; // 简化:写 0 即释放
}
这里要点:
xchg是硬件提供的原子指令(如 x86 上的lock xchg)- 在多核下, 自旋锁保证同一时刻只有一个 CPU 进入临界区
smp_mb()等一类屏障防止指令和内存访问在锁操作前后非法重排序
3.5.2 RCU / seqlock 简要对比
| 机制 | 使用场景 | 读路径开销 | 写路径开销 | 典型特点 |
|---|---|---|---|---|
| Spinlock | 读写都需要互斥 | 中 | 中 | 简单直接, 容易死锁 |
| RW lock | 读多写少 | 低(多读共享) | 高(独占写) | 写时阻塞所有读 |
| Seqlock | 读多写少, 读可重试 | 无需锁, 但可能重试 | 中 | 读不阻塞写, 适合时间戳类 |
| RCU | 极端读多写少 | 非常低 | 高且复杂 | 读几乎无锁, 写需要分期回收 |
例如 seqlock 的简化版:
c
typedef struct {
volatile unsigned seq;
raw_spinlock_t lock;
} seqlock_t;
unsigned read_seqbegin(seqlock_t *sl)
{
unsigned s;
do {
s = sl->seq;
} while (s & 1); // 奇数表示正在写
smp_rmb();
return s;
}
int read_seqretry(seqlock_t *sl, unsigned start)
{
smp_rmb();
return (start != sl->seq);
}
void write_seqlock(seqlock_t *sl)
{
raw_spin_lock(&sl->lock);
sl->seq++;
smp_wmb();
}
void write_sequnlock(seqlock_t *sl)
{
smp_wmb();
sl->seq++;
raw_spin_unlock(&sl->lock);
}
读者不加锁, 只是检查 seq 是否在读期间发生变化;如果变了, 就再读一遍. 典型应用是时间戳、jiffies、stat 之类的只读大多数
3.6 内存一致性与屏障
多核系统中, 即便有硬件缓存一致性协议, 仍然会因为:
- CPU 的乱序执行
- 编译器优化重排序
导致**"代码顺序" ≠ "真正生效的内存访问顺序"**
Linux 提供了跨架构统一的屏障接口:
smp_mb():全内存屏障smp_rmb():读屏障smp_wmb():写屏障- 以及带依赖的、RCU 专用的屏障等
典型例子:发布-订阅(producer-consumer) 模式
c
// 生产者 CPU
data = new_value;
smp_wmb(); // 确保 data 写入在前
ready = 1; // 通知消费者
// 消费者 CPU
while (!ready)
cpu_relax();
smp_rmb(); // 确保下面读 data 不会被提前
use(data);
如果没有屏障, 可能出现消费者看到 ready == 1, 但 data 仍是旧值的情况
用数学一点的说法, smp_wmb() 和 smp_rmb() 建立了一个偏序关系 :
write(data)→wmb/rmbread(data) \text{write}(data) \xrightarrow{\text{wmb/rmb}} \text{read}(data) write(data)wmb/rmb read(data)
防止这条"因果链"被硬件/编译器打乱
3.7 Per-CPU 数据与 cache 友好设计
问题:多 CPU 同时频繁读写同一个全局变量, 会导致:
- cache line 不断在 CPU 间迁移(false sharing);
- 性能抖动、锁竞争严重
解决:将一些变量改成 per-CPU 形式, 每个 CPU 维护一份, 必要时再合并
c
// 声明一个 per-CPU 计数器 (概念性示例)
DEFINE_PER_CPU(unsigned long, packets_processed);
// 在某个 CPU 上增加
void inc_packets(void)
{
unsigned long *cnt = this_cpu_ptr(&packets_processed);
(*cnt)++;
}
// 汇总所有 CPU 上的计数
unsigned long sum_packets(void)
{
unsigned long total = 0;
int cpu;
for_each_online_cpu(cpu) {
total += per_cpu(packets_processed, cpu);
}
return total;
}
这样的设计:
- 常规路径(increment)完全本地化, 无需锁、无竞争;
- 汇总路径较少调用, 即使需要遍历所有 CPU, 也能接受
4. 设计思想与架构
4.1 为什么采用这样的 SMP 方案?
核心目标可以总结为三点:
-
对上保持简单的"统一系统"抽象:
- 对用户态和大多数内核子系统而言, 看起来就像一个更快的单机,
- 不要求应用程序显式感知多核(除非做亲和性/NUMA 优化)
-
对下充分榨干硬件能力:
- 利用 APIC / IPI / per-CPU / cache 等各种硬件特性
- 尽量减少跨 CPU 共享、避免 cache line 抖动
-
在可维护性和性能之间取平衡:
- 一味上锁很简单但性能糟糕;
- 完全无锁(lock-free)算法虽爽, 但难以验证正确性并普及
- Linux 采用"多种工具组合":自旋锁、RW锁、RCU、seqlock、per-CPU 等, 针对不同场景选最合适的
4.2 解决了哪些痛点?
- 从单核向多核横向扩展:充分利用多核 CPU, 提升吞吐
- 避免全局大锁(BKL)成为瓶颈:通过细粒度锁和 per-CPU 数据, 把锁粒度拆小
- 在高并发 IO / 网络场景中, 能让多个 CPU 并行处理不同连接、不同 softirq
4.3 局限性与代价
-
复杂性:
- SMP 相关路径极其复杂, 涉及调度、内存管理、锁、架构细节
- bug 一般为竞态条件和死锁, 复现与调试都异常困难
-
NUMA 下的扩展问题:
- SMP 模型在统一内存延迟假设下比较自然,
- 但在 NUMA 大规模多核上, 单纯 SMP 抽象已不足, 需要额外的调度域和内存亲和策略
-
调试难度:
- 多核竞态可能只在极端高负载、特定拓扑、罕见时序下触发,
- 传统的"单步调试"手段很难直接用
4.4 替代方案对比
| 方案 | 思想 | 优点 | 缺点 |
|---|---|---|---|
| 大内核锁 (BKL) | 整个内核一把大锁 | 实现简单 | 完全无法扩展, 多核几乎浪费 |
| 细粒度锁 + per-CPU | 当前 Linux 内核路径 | 折中可维护性与性能 | 设计难度高 |
| 微内核 + 消息传递 | 通过 IPC 串联服务 | 模块隔离好 | 上下文切换/IPC 成本高 |
| 用户态多线程 + 单核内核 | 内核不支持 SMP | 内核简化 | 性能受限, 应用负担大 |
Linux 的路线:以单体内核 + 细粒度并发为主轴, 渐进式演化
5. 实践示例:构造一个极简"多核友好计数器"
下面给一个小型示例, 用 per-CPU 计数器 + IPI 的方式, 模拟"在所有 CPU 上统计某事件次数"的场景. 示例代码是可编译运行的内核模块伪代码风格(但删减了宏和 API 细节, 仅示意结构)
5.1 场景描述
- 每个 CPU 本地计数一个事件(比如一次软中断)
- 用户通过
/proc或debugfs读到全局总和 + 每 CPU 分布 - 当某个 CPU 发起"刷新", 通过
smp_call_function()让其他 CPU 把本地计数刷到一个共享数组中, 然后统一打印
5.2 核心代码
c
// 示例文件: kernel/smp_demo.c (假想路径)
#define NR_CPUS 64
static DEFINE_PER_CPU(unsigned long, local_cnt);
static unsigned long snapshot[NR_CPUS];
static void flush_local_cnt(void *info)
{
int cpu = smp_processor_id();
unsigned long v = this_cpu_read(local_cnt);
snapshot[cpu] = v;
}
void event_occurs(void)
{
this_cpu_inc(local_cnt);
}
// 由某个 CPU 发起全局刷新
void flush_all_cpus(void)
{
// 先刷新自己
flush_local_cnt(NULL);
// 再让其他 CPU 刷
smp_call_function(flush_local_cnt, NULL, 1);
// 此时 snapshot[] 中就是所有 CPU 的值
// 可以安全地在当前 CPU 上做统计/输出
unsigned long total = 0;
int cpu;
for_each_online_cpu(cpu)
total += snapshot[cpu];
pr_info("total=%lu\n", total);
}
真正的内核模块需要
module_init/module_exit、procfs/debugfs接口、错误处理等, 这里略去不表, 只保留与 SMP 相关的刻画
5.3 编译运行思路
在真正内核树中, 可以:
- 将上述文件加入 Makefile
- 启用 SMP 配置编译内核或作为模块编译
- 在运行的 SMP Linux 系统上加载模块, 通过某个触发接口(如
echo 1 >/sys/.../flush)调用flush_all_cpus()
预期效果:
- 在多核系统上无论跑多少次
event_occurs(), - 每次
flush_all_cpus()都能统计到当前时刻各 CPU 上的计数之和
6. 工具与调试
调试 SMP 问题离不开一些经典工具:
| 工具/命令 | 用途 | 示例 |
|---|---|---|
top / htop |
观察多核利用率 | htop, 按 1 展开 per-CPU |
perf |
性能采样、锁竞争分析 | perf record -g -a sleep 10 |
ftrace / trace-cmd |
内核函数/事件跟踪 | trace-cmd record -e sched* |
lockstat / perf lock |
锁竞争统计 | perf lock record ./app |
gdb + qemu |
内核单步/断点调试 | 在 qemu 中跑 SMP 内核, gdb 远程连接 |
sysctl kernel.sched_* |
调整调度器参数 | sysctl kernel.sched_migration_cost_ns |
在调试并发问题时, 常用套路:
- 利用
ftrace记录sched_switch、IPI 处理、RCU 回调等关键事件 - 用
perf或lockstat看某个自旋锁是否成为热点 - 在 qemu/kvm 的测试环境中重现竞态, 配合
CONFIG_DEBUG_LOCKDEP、CONFIG_PROVE_LOCKING等内核调试选项
7. 架构总览
最后用一张整体架构图, 把前面拆开的点串起来
Kernel
Hardware
CPU0
CPU1
CPU2
共享内存
Local & IO APIC
调度器\nper-CPU rq
锁子系统\nspinlock/RW/RCU/seqlock
内存管理\nTLB, 页表, TLB shootdown
per-CPU 数据\n统计/状态
SMP 核心\nCPU 启动/拓扑/CPU hotplug
逻辑连接可以简化为:
- SMP 核心 (SMPCore) 负责:
- 多 CPU 启动、CPU 在线/下线、拓扑信息提供、IPI 基础设施
- 调度器 (Sched) :
- 使用 per-CPU 运行队列和锁, 基于拓扑做负载均衡
- 内存子系统 (MM) :
- 通过 IPI 做 TLB shootdown, 使用锁/RCU 保证映射更新一致
- 锁子系统 (Locking) :
- 提供自旋锁/RCU 等并发原语, 供全内核使用
- per-CPU 子系统 (PerCPU) :
- 提供性能友好的本地化存储区域, 减少共享冲突
8. 总结
最后用一个小表格, 把本文覆盖的关键技术点做个回顾:
| 技术点 | 关键作用 | 你需要记住什么 |
|---|---|---|
| CPU 启动 & APIC | 多 CPU 上电和初始化 | BSP 唤醒 AP, APIC 负责 IPI 与中断路由 |
| IPI & smp_call_function | CPU 间通信 | 让某个函数在另一 CPU 上执行, 是构造高层同步的基础设施 |
| 多队列调度器 | 多核负载均衡 | 每 CPU 一条就绪队列 + 周期性 work stealing |
| 自旋锁 / seqlock / RCU | 并发控制 | 根据读写比例和实时性选择合适机制 |
| 内存屏障 | 时序保证 | 防止 CPU/编译器把关键读写重排序 |
| per-CPU 数据 | 性能优化 | 把"共享变量"拆散成"每 CPU 各一份"降低竞争 |
| CPU 拓扑 & NUMA | 扩展到多插槽多节点 | 调度器和内存分配必须感知物理拓扑 |
归纳几点:
- Linux SMP 实现不是单一模块, 而是一整套从CPU 启动、APIC、IPI 到调度器、锁、内存模型的协同设计
- 真正的难点在于在性能、复杂性和可维护性之间找到平衡点, 而不是简单地加几把锁
- per-CPU 数据、细粒度锁、RCU、seqlock 等机制, 分别对应不同场景, 是 Linux 多年演化的产物
- 调度器的"每 CPU 运行队列 + 拓扑感知负载均衡"设计, 是现代 SMP 利用度的关键
- 内存屏障和内存模型是所有并发原语的底层基石, 一旦理解不透, 后面一切都容易出错
- 在调试和优化 SMP 性能时, 要善用
perf、ftrace、lockdep 等工具, 而不是盲目猜测