1000万字以内最好的后端八股:CPU 调度与软中断
CPU 调度与软中断:你的程序在 CPU 眼里到底是个什么东西?
上一篇我们聊了 CPU 缓存的"就近原则"——数据离 CPU 越近,跑得越快。
但还有一个更根本的问题:CPU 凭什么跑你的程序?它怎么决定先跑谁、后跑谁?那些网卡、磁盘发过来的"意外事件",CPU 又是怎么应付的?
今天我们就钻进操作系统内核,看看 CPU 任务调度和中断处理这两台大戏。
引子:一个线程的"一生"
想象你写了一个 Go 程序,启动后有 4 个 goroutine 在跑。你的电脑有 8 核 CPU。
这一刻正在发生什么?
- 4 个 goroutine → 变成 4 个内核线程 → 挂在 8 个 CPU 的运行队列里排队
- 每个 CPU 核心自己挑一个线程来跑,跑一段时间(~几毫秒)就切下来,换下一个
- 网卡收到一个数据包 → 触发一个硬中断 → CPU 暂停当前线程,跳去执行中断处理程序 → 处理完再回来
- 中断处理不能做太久,不然其他线程饿死 → 于是把"重活"推迟到软中断里慢慢干
这一整套机制,就是今天要讲的全部。
第一部分:CPU 是如何执行任务的?
1.1 CPU 如何读写数据?
先回到最底层:CPU 要和内存、外设打交道,靠的是什么?
CPU 读写数据就两步:
- 把地址扔到地址总线上 → “我要读这个地址的东西”
- 从数据总线上拿数据回来 → 或者把数据扔上去写出去
💡 关键概念:内存和外设是统一编址的。也就是说,你读写
0xffffa000这个地址,可能是在操作物理内存,也可能是在操作网卡的寄存器——靠地址范围来区分。这叫 MMIO(Memory-Mapped I/O)。
这个设计非常简洁:CPU 不需要专门的"读网卡"指令,只要往特定地址写数据,网卡硬件就收到了。
1.2 伪共享(False Sharing)再深入:到底有多坑?
上一篇我们提到过伪共享,这里展开讲讲它的底层机制。
为什么叫"伪"共享? 因为两个变量 a 和 b 在代码层面完全独立、互不相关,但因为它们落在同一条 64 字节的 Cache Line 里,MESI 协议会让这条 Cache Line 在两个核心之间反复传来传去——就像两个人抢同一支笔,其实各写各的字。
一个实测案例:
// ❌ 伪共享版本
type Counter struct {
a int64 // 核心0写
b int64 // 核心1写
}
// ✅ 对齐填充版本
type Counter struct {
a int64
padding [56]byte // 64 - 8 = 56 字节填充
b int64
}
实测结果(两个核心各自疯狂自增 1 亿次):
| 版本 | 耗时 | 速度 |
|---|---|---|
| 伪共享 | ~1800 ms | 慢 |
| 填充对齐 | ~300 ms | 快 6 倍 |
避免伪共享的四种方法:
-
Cache Line 对齐填充(最直接)
- 高频独立变量之间塞 56 字节 padding
- Go 里标准库
sync.Pool、runtime内部都这么干
-
把高频修改的字段拆开
- 不要把"每个核心独立计数"的字段放在同一个 struct 里
- 用 per-CPU 数组:每个核心一个独立的计数器
-
使用
__cacheline_aligned/alignas(64)编译器属性- C/C++:
struct foo { ... } __attribute__((aligned(64))); - Go:标准库
runtime/internal/cacheline有常量
- C/C++:
-
减少共享数据
- 最优解:能不共享就不共享
- 用消息传递(channel)代替共享内存,从根上消除伪共享
🔑 判断口诀:两个变量会被不同核心同时写 → 就要警惕伪共享。只读变量没事,多写才是问题。
1.3 CPU 如何选择线程?—— 调度器登场
现在问题来了:系统里同时跑着成百上千个线程,CPU 核心只有几个。谁先跑?谁后跑?跑多久?
这就是 CPU 调度器(Scheduler) 的工作。
Linux 调度器不是"一个"调度器,而是一组调度类按优先级排队:
调度原则:高优先级调度类里有任务,就永远不会跑低优先级的。就像急诊室——急救病人没处理完,普通挂号就得等着。
1.4 CFS:完全公平调度器
我们日常写的普通进程(包括你的 Go 程序),都在 CFS(Completely Fair Scheduler) 这个调度类里。它是 Linux 2.6.23 之后的默认调度器,也是最有意思的一个。
CFS 的核心思想非常朴素:
让每个进程都觉得自己独占了 CPU,而且大家分得一样多。
怎么做到"公平"?答案是 vruntime(虚拟运行时间)。
每个进程都有一个 vruntime,表示它"已经在 CPU 上跑了多少虚拟时间"。CFS 做的事情就是:每次挑 vruntime 最小的那个进程来运行。
💡 CFS 用红黑树来组织就绪队列,插入、删除、找最小值都是 O(log n),非常高效。
权重从哪来?—— nice 值
Linux 给每个进程一个 nice 值,范围是 -20 ~ +19,默认是 0。
| nice 值 | 权重 | CPU 占比 |
|---|---|---|
| -20 | 88761 | 最高优先级 |
| -10 | 272 | … |
| 0 | 1024 | 基准 |
| +10 | 25 | … |
| +19 | 15 | 最低优先级 |
nice 值越大,权重越小,vruntime 涨得越快,分到的 CPU 时间越少。
# 查看进程 nice 值
ps -eo pid,ni,comm
# 调整进程 nice 值(+10 降低优先级)
renice +10 -p 12345
# 以低优先级启动程序
nice -n 10 ./my_program
1.5 CPU 运行队列(Run Queue)
每个 CPU 核心都有自己独立的运行队列:
为什么每个 CPU 一个队列?
- 避免多个 CPU 抢同一个队列的锁 → 性能差
- 每个 CPU 自己调度自己,缓存局部性好(线程一直在同一个核心跑,数据都在 L1/L2 里)
负载均衡(Load Balancing):
- 后台有个周期性任务,定期检查各个 CPU 队列的负载
- 如果某个核心队列里任务太多,就迁几个到闲的核心上去
- 迁移有代价:线程换了核心,原来的缓存就作废了 → 所以不能瞎迁
🔑 实战提示:如果你有多核 CPU 但程序只用满了一个核,其他核闲着——这通常是因为你的程序是单线程的,或者线程数比核数少。用
top看load average和每个核的利用率。
第二部分:什么是软中断?
2.1 中断是什么?
先从硬件讲起。CPU 正在一心一意跑你的程序,突然——
“叮!网卡收到了一个数据包!”
CPU 怎么知道的?总不能每隔 1 微秒去问一次网卡吧?那也太浪费了。
答案是:中断(Interrupt)。
这就是 硬中断(Hardware Interrupt):外设通过电路直接打断 CPU,CPU 必须立刻响应。
💡 类比:你在写代码,突然有人敲门(硬中断)。你必须放下手头的事,去开门。开完门再回来继续写。
2.2 硬中断的问题:不能干太久!
硬中断处理程序运行时,同级别的中断会被屏蔽——也就是说,CPU 在处理网卡中断的时候,键盘中断、磁盘中断都得等着。
如果中断处理程序跑了几毫秒才结束,那这几毫秒里:
- 其他外设的中断被延迟了 → 数据丢了
- 用户程序被卡住了 → 卡顿
- 系统响应变慢了
所以内核规定:硬中断处理要尽可能短,只做最紧急的事,剩下的活推迟做。
这就引出了 下半部(Bottom Half) 的概念。
2.3 软中断:中断的"下半场"
软中断(SoftIRQ) 就是把中断的"重活"推迟到更晚的时候执行——这时候中断已经重新打开了,其他中断可以正常进来,系统不会卡住。
🔑 关键区别:
- 硬中断:由硬件触发,立刻执行,关中断,要快
- 软中断:由硬中断"排队"触发,稍后执行,开中断,可以慢慢干
软中断不是操作系统的"线程",它是中断上下文里的一种特殊执行流——它比进程优先级高,但比硬中断低。
2.4 系统里有哪些软中断?
Linux 内核定义了这么几种软中断:
| 软中断类型 | 干什么的 | 常见场景 |
|---|---|---|
| HI_SOFTIRQ | 高优先级 tasklet | 老版本遗留,现在很少用 |
| TIMER_SOFTIRQ | 定时器 | 内核定时任务、时间片计算 |
| NET_TX_SOFTIRQ | 网络发送 | 发包、协议栈发送处理 |
| NET_RX_SOFTIRQ | 网络接收 | 收包、协议栈接收处理 |
| BLOCK_SOFTIRQ | 块设备 | 磁盘 IO 完成处理 |
| IRQ_POLL_SOFTIRQ | IO 轮询 | 存储设备轮询 |
| TASKLET_SOFTIRQ | 普通 tasklet | 各种驱动的下半部 |
| SCHED_SOFTIRQ | 调度 | 跨 CPU 任务调度 |
| HRTIMER_SOFTIRQ | 高精度定时器 | 纳秒级定时 |
| RCU_SOFTIRQ | RCU 锁 | 读-拷贝-更新的延迟回收 |
💡 对后端开发最相关的就是 NET_RX / NET_TX——你服务器网络带宽打满了,很可能就是这两个软中断在吃 CPU。
2.5 怎么看系统的软中断情况?
方法一:top 命令
top
看这一行:
%Cpu0 : 1.2 us, 0.8 sy, 0.0 ni, 96.0 id, 0.0 wa, 0.5 hi, 1.5 si
us:用户态 CPU 占比sy:内核态 CPU 占比hi:硬中断占比(hardirq)si:软中断占比(softirq) ← 重点看这个
如果 si 很高(比如 20%+),说明软中断吃了不少 CPU。
方法二:/proc/softirqs
cat /proc/softirqs
输出长这样:
CPU0 CPU1 CPU2 CPU3
HI: 0 0 0 0
TIMER: 123456 120000 118000 119000
NET_TX: 100 95 98 102
NET_RX: 50000 100 50 20 ← 全压在 CPU0 上!
BLOCK: 0 0 0 0
...
🔍 看什么?
- 总量:哪个软中断计数最多?
- 分布:是不是全挤在一个 CPU 上?(这叫 RPS/RFS 没开,软中断没有分散到多核)
方法三:mpstat 看每个核的软中断
mpstat -P ALL 1
CPU %usr %sys %soft %irq %idle
0 2.0 3.0 15.0 0.5 79.5 ← CPU0 软中断 15%
1 1.5 2.0 0.1 0.1 96.3
2 1.0 1.5 0.0 0.0 97.5
3 1.2 1.8 0.0 0.0 97.0
一眼就能看出:CPU0 软中断 15%,其他核几乎为 0——网卡中断全压在一个核上了。
2.6 实战:软中断 CPU 过高怎么定位?
这是后端面试和线上排障的高频题。给你一套标准排查流程:
最常见的场景:网卡软中断打满一个核
问题现象:
- 服务器网络带宽跑满了(比如 10Gbps)
top看到某个 CPU 核si90%+- 其他核都闲着
根本原因:
- 网卡默认只有一个接收队列 → 所有网络中断都路由到同一个 CPU
- 那个 CPU 疯狂跑 NET_RX 软中断 → 累死
解决方案:开启多队列 + RPS/RFS
# 1. 查看网卡支持几个接收队列
ethtool -l eth0
# 2. 设置为多队列(假设网卡支持 8 队列)
ethtool -L eth0 combined 8
# 3. 开启 RPS(把软中断分散到多核处理)
# 假设是 CPU0~CPU7,二进制掩码 = ff(8个全1)
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# 4. 开启 RFS(同时做流识别,提升缓存命中)
echo 32768 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
💡 一句话总结:网卡软中断高 = 网络收包处理能力不够 → 要么开多队列分散负载,要么上更快的网卡/更高效的协议栈(如 DPDK、io_uring)。
第三部分:总结一张图
面试答题速记:
| 问题 | 一句话答案 |
|---|---|
| 伪共享是什么? | 多个核心写的变量落在同一条 Cache Line 里,导致 MESI 来回传输,性能暴跌 |
| 怎么避免伪共享? | Cache Line 对齐填充、per-CPU 变量、消息传递代替共享内存 |
| CFS 是什么? | 完全公平调度器,用 vruntime 红黑树选最"闲"的进程跑 |
| nice 值范围? | -20(最高优先级)~ +19(最低),默认 0 |
| 硬中断和软中断区别? | 硬中断硬件触发、立刻执行、关中断;软中断延迟执行、开中断、处理重活 |
| 软中断高怎么排查? | top 看 si → /proc/softirqs 看是哪种 → 网络类就开 RPS/RFS 分散负载 |
参考:Linux 内核源码 sched/ 目录、《Linux 内核设计与实现》(Robert Love)、Red Hat 性能调优指南、IBM Developer: Softirq 分析
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)