一、引言:CPU 热插拔不只是"笔记本功能"
提到 CPU 热插拔(CPU Hotplug),很多人第一反应是笔记本的 CPU 可更换设计。但在服务器领域,它是一项关键的底层能力:云服务器在不停机的前提下弹性分配 vCPU、高可用系统在核心故障时自动隔离坏核、实时系统通过 CPU 隔离为关键任务独占算力。Linux 的 cpuhp(CPU Hotplug)子系统承载了所有这些场景。
要真正掌握 CPU 热插拔,需要理解两个核心问题:第一,多核 CPU 是如何从冰冷的硬件一点一点"醒来"并被内核接管的(SMP 启动);第二,如何在系统运行中有序地拉入和拉出核心(Hotplug 状态机)。这两个问题合在一起,就是本文的主题。
二、SMP 启动:从 BSP 到所有 AP 的唤醒之路
2.1 启动的起点:BSP 一方独大
系统加电后,硬件复位将所有 AP(Application Processor,非引导处理器)置于休眠状态,只有 BSP(Bootstrap Processor,引导处理器,通常是 CPU0)开始执行 BIOS/UEFI 固件,随后跳转到内核的 _start 入口。此时内核可用的只有 CPU0,所有数据结构都在 CPU0 的本地内存(NUMA node 0)上创建。
2.2 内核引导 CPU 的核心流程
从汇编入口 _start 到 C 代码 start_kernel(),再到 smp_init(),大致经历以下阶段:
_start (arch/x86/kernel/head_64.S)
↓ 早期分页设置、64位模式切换
↓ 跳转到 x86_64_start_kernel()
start_kernel (init/main.c)
↓ setup_per_cpu_areas() — 分配 per-cpu 变量区域
↓ smp_prepare_boot_cpu() — 初始化 CPU0 的 per-cpu 数据
↓ boot_cpu_init() — 标记 CPU0 为已启动
↓ setup_arch() — 解析 ACPI MADT/UEFI 获取 CPU 拓扑
↓ ...
↓ rest_init() → kernel_init()
↓ smp_init() — 关键:启动所有 secondary CPUs
2.3 smp_init:secondary CPU 的逐核唤醒
smp_init() 遍历所有可能的 CPU,对每一个标记为 present 但不是 online 的 CPU,调用 cpu_up(cpu, CPUHP_BP_ONLINE) 将其拉起:
// 简化版 smp_init()
void __init smp_init(void)
{
unsigned int cpu;
for_each_present_cpu(cpu) {
if (!cpu_online(cpu))
cpu_up(cpu, CPUHP_BP_ONLINE);
}
}
每个 secondary CPU 被唤醒的底层机制在 x86 上是 wake_cpu(),它通过发送 INIT-SIPI-SIPI 中断序列(startup inter-processor interrupt),让目标 CPU 从实模式开始执行 trampoline_start64(位于 arch/x86/kernel/trampoline_64.S),建立临时分页后调用 secondary_startup_64,最终进入 start_secondary() 完成 per-cpu 数据初始化,调用 cpu_startup_entry(),从此该 CPU 进入空闲调度循环(idle loop)。
2.4 CPU 拓扑发现:内核怎么知道有几个核?
内核通过 ACPI MADT(Multiple APIC Description Table)获取完整的 CPU 拓扑信息:
// ACPI MADT 中的处理器条目
struct acpi_subtable_header {
u8 type; // ACPI_MADT_TYPE_LOCAL_APIC 或 ACPI_MADT_TYPE_LOCAL_X2APIC
u8 length;
...
u32 lapic_flags; // 是否 enabled
u32 lapic_uid; // APIC ID(唯一标识)
};
内核解析 MADT 后构建:
- cpu_possible_mask:物理上存在(无论能否启用)的 CPU 集合
- cpu_present_mask:已被内核识别的 CPU 集合
- cpu_online_mask:已加入调度器的 CPU 集合
三者关系:possible ⊇ present ⊇ online。possible_mask 由硬件决定,present_mask 是当前存在的,online_mask 是参与调度的。
三、cpuhp 状态机:CPU 上线/下线的完整转移路径
3.1 状态机总览
Linux 4.10 引入了统一的 cpuhp 状态机,替代了旧的分散式 CPU 通知链。cpuhp 定义了两组状态路径:
BP(Boot Processor,引导处理器)方向 — CPU 上线:
CPUHP_OFFLINE (初始态)
↓ CPUHP_TEARDOWN_DYING (可选,仅热拔时回退)
↓
CPUHP_BP_PREPARE_DYING (BP 侧:通知子系统准备关闭)
↓
CPUHP_BP_ONLINE (BP 侧:底层启动完成,等待 AP 初始化)
↓
CPUHP_AP_IDLE_DEAD = AP 侧:目标进入 idle 状态
↓
CPUHP_AP_SCHED_STARTING = AP 侧:调度器开始创建运行队列
↓
CPUHP_AP_ONLINE_DYING (AP 侧:通知子系统可以激活)
↓
CPUHP_AP_ONLINE = AP 侧:子系统就绪
↓
CPUHP_ONLINE (最终态:在线,可调度)
CPU 下线方向 — 反向通过上述状态:
CPUHP_ONLINE
↓ CPUHP_TEARDOWN_DYING (通知子系统停止)
↓ CPUHP_AP_ONLINE_DYING ... CPUHP_BP_PREPARE_DYING
↓ CPUHP_BP_ONLINE (底层面通知)
↓ CPUHP_OFFLINE
3.2 注册 cpuhp 回调的正确方式
任何子系统如果需要在 CPU 上线/下线时清理资源,需要注册一个 cpuhp 回调:
#include
static struct cpuhp_cpu_state *my_state;
static int my_cpu_online(unsigned int cpu)
{
// 分配 per-cpu 数据、创建 proc 节点等
allocate_cpu_resources(cpu);
return 0;
}
static int my_cpu_offline(unsigned int cpu)
{
// 释放该 CPU 的 per-cpu 资源、迁移数据
free_cpu_resources(cpu);
return 0;
}
// 模块初始化时注册
static int __init my_init(void)
{
// CPUHP_AP_ONLINE_DYING 是一个锚点状态,表示"大多数子系统已经在线"
// 在此状态之前上线,之后下线
cpuhp_setup_state(CPUHP_AP_ONLINE_DYING, "my_module:online",
my_cpu_online, // 上线时调用(参数名为 second)
my_cpu_offline); // 下线时调用(参数名为 first)
return 0;
}
cpuhp_setup_state() 返回 0 表示成功,失败的 errno 意味着该状态点已过,不能再注册。这要求注册时机必须在内核启动的合适阶段。
3.3 自动启动(CPUHP_AP_ONLINE 之后的子系统)
如果一个模块希望内核在启动smp_init()时自动让其上线(通常驱动模型如此),可以使用 cpuhp_setup_state_nocalls() 配合 cpuhp_apply_state() 或更通用的方式:
// 自动触发的回调注册方式(推荐)
cpuhp_setup_state(CPUHP_AP_ONLINE_DYING, "module_name:online",
my_online, my_offline);
对于需要在 device_initcall 阶段之后的模块,则使用传统方式 + cpuhp_setup_state_nocalls()。
四、生产环境的 Hotplug 实战
4.1 sysfs 接口:手动上下线 CPU
Linux 暴露了简洁的 sysfs 接口用于手动热插拔 CPU:
# 查看在线 CPU
cat /sys/devices/system/cpu/online
# 输出:0-7 (表示 8 核全部在线)
# 查看可能存在的 CPU
cat /sys/devices/system/cpu/possible
# 输出:0-15 (例如支持热插拔到 16 核)
# 下线 CPU 5
echo 0 > /sys/devices/system/cpu/cpu5/online
# 上线 CPU 5
echo 1 > /sys/devices/system/cpu/cpu5/online
4.2 isolcpus:启动时隔离 CPU
通过内核参数 isolcpus 可以在系统启动时隔离指定 CPU,使其不被普通调度器使用:
# 在 /etc/default/grub 中设置 GRUB_CMDLINE_LINUX
isolcpus=2,3,4 nohz_full=2,3,4 rcu_nocbs=2,3,4
这些参数的含义:
- isolcpus=2,3,4:CPU 2/3/4 不参与普通负载均衡,只有显式绑核的任务才能运行
- nohz_full=2,3,4:在这三个 CPU 上关闭 tick 周期性时钟,减少定时中断干扰
- rcu_nocbs=2,3,4:将 RCU 回调迁移到其他 CPU,避免被隔离的 CPU 执行 RCU 操作
这是NFV(网络功能虚拟化)和实时系统的标配配置,通常与 DPDK、io_uring 轮询模式配合使用。
4.3 cpuset:细粒度的 CPU 分组
cpuset cgroup 提供了运行时动态调整 CPU 集合的能力:
# 创建 cpuset 目录
mkdir /dev/cpuset
# 挂载 cgroup(如果尚未挂载)
mount -t cpuset cpuset /dev/cpuset
# 为关键应用分配 CPU 4-7
mkdir /dev/cpuset/realtime
echo 4-7 > /dev/cpuset/realtime/cpuset.cpus
echo 0 > /dev/cpuset/realtime/cpuset.mems
echo 1 > /dev/cpuset/realtime/cpuset.cpu_exclusive
# 将 PID 绑定到该 cpuset
echo 1234 > /dev/cpuset/realtime/tasks
4.4 中断亲和性(IRQ Affinity)
CPU 隔离后,还需将中断重定向到非隔离 CPU,否则中断处理仍可能打到隔离核:
# 查看网卡中断的 smp_affinity
cat /proc/irq/32/smp_affinity
# 输出:ffffffff (所有 CPU 都可能处理)
# 将中断绑定到 CPU 0(隔离核以外的 CPU)
echo 1 > /proc/irq/32/smp_affinity
# 1 的二进制是 00000001,即 CPU 0
# irqbalance 服务在隔离环境下应关闭
systemctl disable --now irqbalance
对于高性能网卡,更精细的做法是每队列中断绑定不同 CPU(RSS/RPS 模式):
# 查看网卡多队列
ethtool -l eth0
# Combined: 8
# 每个队列绑定一个 CPU
echo 01 > /proc/irq/100/smp_affinity # Queue 0 → CPU 0
echo 02 > /proc/irq/101/smp_affinity # Queue 1 → CPU 1
echo 04 > /proc/irq/102/smp_affinity # Queue 2 → CPU 2
五、Notify 回调:订阅 CPU 事件
除了 cpuhp 状态机,内核还提供了传统的 CPU 通知链(cpu_chain):
#include
#include
static int my_cpu_notifier(struct notifier_block *nb,
unsigned long action, void *v)
{
unsigned int cpu = (unsigned long)v;
switch (action) {
case CPU_UP_PREPARE:
// CPU 即将上线,可以失败返回 NOTIFY_OK
...
break;
case CPU_ONLINE:
// CPU 已成功上线
pr_info("CPU%d is now online\n", cpu);
break;
case CPU_DOWN_PREPARE:
// CPU 即将下线
...
break;
case CPU_DEAD:
// CPU 彻底离线
pr_info("CPU%d is now dead\n", cpu);
break;
}
return NOTIFY_OK;
}
static struct notifier_block my_nb = {
.notifier_call = my_cpu_notifier,
};
register_cpu_notifier(&my_nb);
cpuhp 状态机和 cpu_notifier 的主要区别:
- cpuhp 状态机:现代内核推荐,支持同步等待(AP 侧完成上层初始化后再继续 BP 路径),有明确的状态依赖和回滚语义
- CPU notifier:简单但无同步保证,适合生命周期早期或"通知类"场景
六、性能与功耗权衡:热插拔不是免费的
6.1 直接下线 CPU 的两个副作用
(1)负载均衡器不再分发任务: CFS 调度器的 runqueue 不再包含该 CPU,已运行的任务被迁移到其他 CPU。
(2)定时器迁移: 每个 CPU 维护本地的定时器轮(timer wheel),下线时所有定时器必须合并到其他 CPU,消耗一次性的时间。
6.2 真正的功耗节省来自 C-state + P-state
只是下线一个 CPU,并不会让系统"省电" —— 现代 CPU 的每个核心都有独立的电源门控(core-level C-states),一个空闲核心可以自动进入 C1/C3/C6 休眠。真正的耗电大户是:
- CPU 活跃的 P-state(频率/电压)
- 未使用设备的时钟/内存刷新
因此,对大多数生产工作负载,不需要主动下线 CPU。真正的热插拔适用场景是:
- 云服务器弹性:vCPU 由宿主内核管理,支持 find/unplug
- 热迁移:迁移前下线 CPU,迁移后上线
- 硬件故障隔离:MCE 检测到 L3 缓存错误,主动下线该核
- 实时隔离:为 DPDK 应用独占 CPU
6.3 微秒级的上下线开销
实测一台 2-socket AMD EPYC 7763(128 核)服务器:
# 下线一个核心
time echo 0 > /sys/devices/system/cpu/cpu127/online
# real 0m0.045s (45ms)
# 上线一个核心
time echo 1 > /sys/devices/system/cpu/cpu127/online
# real 0m0.052s (52ms)
上下线开销包括:调度器同步(更新 rd->span)、cpuset 集合同步、rcu 同步。对于 128 核服务器,单次上下线约 50ms —— 不算低,但远不需要中断业务(只要在业务迁移完成后操作)。
七、故障排查案例
案例 1:CPU 上线后 RCU stall
现象:某双路服务器中,手动上线 CPU 47 后,系统发生 RCU stall,dmesg 显示:
[ +0.000000] INFO: rcu_sched detected stalls on CPUs/tasks:
[ +0.000000] 47: (0 ticks thisGP) idle=...
根因:某驱动在 CPUHP_AP_ONLINE_DYING 状态下注册了回调,但执行了阻塞操作(如 mutex_lock 可能被同 CPU 上的 RCU 回调持有)。cpuhp 的设计假设回调是非阻塞的。
解决:将该驱动回调移至更早的状态(如 CPUHP_AP_SCHED_STARTING),或将阻塞操作放到 workqueue 中延迟执行。
案例 2:isolcpus 导致 irqbalance 死锁
现象:使用 isolcpus=4-7 后,irqbalance 进程偶发占用 100% CPU 不退出。
根因:irqbalance 默认会绕过 isolcpus 隔离核重新平衡中断,当网卡所有队列的中断都被迁移到隔离核后,NAPI 软中断也在隔离核运行,但这些核被设置为 nohz_full,定时延迟 NAPI 调度,导致中断处理积压。
解决:生产实时系统应关闭 irqbalance,手动设置中断亲和性:
systemctl disable irqbalance
# 或在 /etc/sysconfig/irqbalance 设置
IRQBALANCE_BANNED_CPUS=0000_00f0 (将 CPU 4-7 加入 ban 列表)
八、最佳实践速查表
| 场景 | 配置方案 | 注意事项 |
|---|---|---|
| DPDK 应用 | isolcpus + rcu_nocbs + nohz_full + 每队列中断绑核 | 必须关闭 irqbalance |
| 实时 SAP HANA | 使用 tuned 配置文件 + cpuset 隔离 | |
| 云计算弹性 vCPU | 通过 QEMU CPU hotplug 接口,配合 cpuhp | 热迁移前 before 下线 |
| 数据库(OLTP) | 不隔离,调整 sched_migration_cost_ns 即可 | 隔离 CPU 可能减少 TLB 命中 |
| 一般 Web 服务器 | 不需要干预 | 内核 C-state 自动省电 |
九、深度追踪:cpuhp 在内核源码中的组织
理解 cpuhp 子系统的源码结构有助于调试和性能优化:
kernel/cpu.c — cpuhp 状态机核心 (cpuhp_invoke_ap/+bp)
kernel/cpuhotplug.c — 供驱动调用的封装函数
arch/x86/kernel/smpboot.c — x86 特定的 secondary CPU 启动 (start_secondary)
include/linux/cpuhotplug.h — cpuhp_state 枚举定义
cpuhp_state 枚举中的重要状态(按顺序):
enum cpuhp_state {
CPUHP_OFFLINE = 0,
CPUHP_CREATE_THREADS, // kthread
CPUHP_PERF_PREPARE,
CPUHP_PERF_X86_PREPARE,
CPUHP_WORKQUEUE_PREP,
CPUHP_HRTIMER_PREPARE,
CPUHP_SMPCFD_PREPARE,
CPUHP_RELAY_PREPARE,
CPUHP_SLAB_PREPARE,
CPUHP_RCUTREE_PREP,
CPUHP_CPUIDLE, // CPU 空闲管理
CPUHP_AP_ARM64_BTI_HEAP,
CPUHP_AP_X86_CPUID4,
CPUHP_AP_SMP_NOTFIER, // 通知 AP 已上线
CPUHP_AP_X86_HYPERV,
CPUHP_AP_X86_KVM_CLK,
CPUHP_AP_DUMMY_TIMER,
CPUHP_AP_ARM_XEN_BUS,
CPUHP_AP_ONLINE_DYING, // 大多数驱动注册的锚点
CPUHP_AP_ARM_XEN,
CPUHP_AP_ONLINE, // 子系统就绪
CPUHP_TEARDOWN_DYING, // 通知准备关闭
CPUHP_BP_PREPARE_DYING,
CPUHP_BP_ONLINE,
CPUHP_ONLINE,
};
十、总结
Linux 的 SMP 启动和 CPU Hotplug 是一个优雅的状态机设计:从冷启动到多核在线,每个阶段通过 cpuhp_state 状态保证正确的初始化顺序;从正常运行到 CPU 下线,通过反向状态机安全清理。深入理解这个子系统,对于构建高可用、低延级、弹性伸缩的服务器系统至关重要。
记住三个核心原则:初始化顺序不可逆(上线从 BP 到 AP,下线从 AP 到 BP),回调必须非阻塞(cpuhp 是同步状态机),隔离要配套(isolcpus 必须配合中断亲和性和 RCU 参数)。

发表评论 取消回复