Linux-07 进程调度与上下文切换
前置阅读:Linux-06 进程管理与作业控制
上一篇讲了进程是什么,这一篇讲进程怎么轮流上 CPU,以及切换的代价。
这是「load 很高但 CPU 空闲」「CPU 使用率不高但接口很慢」「上下文切换几万次每秒是不是有问题」这类问题的理论基础。面试里也是高频区。
1. 调度器要解决的矛盾
一句话:调度器要在「公平」和「响应速度」之间做权衡,而这两个目标天然冲突。
- 完全公平 → 每个进程严格轮流,那么一个只需要 1ms 就能响应的交互任务,可能要等前面几十个批处理任务各跑完一轮
- 完全优先响应 → 交互任务不断插队,批处理任务被饿死
Linux 2.6.23 之后用的 CFS(Completely Fair Scheduler,完全公平调度器),它的思路很巧妙:不追求「每次都公平」,而是追求「长期累计公平」。
2. CFS 与 vruntime
CFS 的核心只有一个概念:vruntime(虚拟运行时间)。
每个进程有一个 vruntime,记录它「已经用了多少 CPU」
调度器永远挑 vruntime 最小的那个进程来运行
v
跑得少的进程 vruntime 小 -> 优先被选中
跑得多的进程 vruntime 大 -> 排到后面
v
长期下来,所有进程的 vruntime 趋于相等 = 公平
数据结构是红黑树,按 vruntime 排序,所以「找最小的」是 O(1)(最左节点),插入和删除是 O(log n):
// 简化的 CFS 运行队列(内核 kernel/sched/fair.c)
struct cfs_rq {
struct rb_root_cached tasks_timeline; // 红黑树,按 vruntime 排序
struct sched_entity *curr; // 当前运行的
u64 min_vruntime; // 树里最小的 vruntime
};
// 调度时机:挑最左节点
static struct sched_entity *pick_next_entity(struct cfs_rq *cfs_rq) {
return __pick_first_entity(cfs_rq); // 红黑树最左 = vruntime 最小
}
2.1 vruntime 不等于实际运行时间
关键在于「虚拟」两个字。vruntime 的增长速度与进程权重成反比:
vruntime 增量 = 实际运行时间 × (NICE_0_LOAD / 进程权重)
权重高(优先级高)-> vruntime 增长【慢】-> 更容易保持最小 -> 被调度得更频繁
权重低(优先级低)-> vruntime 增长【快】-> 很快排到后面
所以优先级不是「插队」,而是让时间在你这里流得更慢。这个设计的优雅之处在于:高优先级进程也不会饿死低优先级进程——它的 vruntime 终究会累积上去。
2.2 nice 值与权重的换算
nice 的取值范围是 -20(最高优先级)到 19(最低),默认 0。内核有一张权重表:
| nice | 权重 | 相对 nice=0 的 CPU 份额 |
|---|---|---|
| -20 | 88761 | 约 88 倍 |
| -5 | 3121 | 约 3 倍 |
| 0 | 1024 | 1 倍(基准) |
| 5 | 335 | 约 1/3 |
| 10 | 110 | 约 1/10 |
| 19 | 15 | 约 1/68 |
规律:nice 每差 1,CPU 份额差约 1.25 倍;每差 5,差约 3 倍;每差 10,差约 10 倍。
# 启动时指定 nice
nice -n 10 ./batch_job.sh # 降低优先级
nice -n -5 ./important.sh # 提高优先级(需要 root)
# 修改已运行进程
renice -n 10 -p 1234
renice -n 10 -u batchuser # 某用户的所有进程
# 查看
ps -eo pid,ni,pri,comm | head -5
# PID NI PRI COMMAND
# 1 0 19 systemd
# 1234 10 9 batch_job <- NI=10
重要限制:只有 root 能降低 nice 值(提高优先级)。普通用户只能调高 nice(自我降级),这是为了防止用户抢占系统资源。
注意:nice 只在 CPU 竞争时才有意义。CPU 空闲时,nice 19 的进程照样跑满一个核。所以「给备份任务加 nice 就不影响业务」这个说法只在 CPU 是瓶颈时成立——如果瓶颈是磁盘 IO,要用
ionice而不是nice:
# nice 管 CPU,ionice 管磁盘 IO —— 两者独立
ionice -c 3 tar -czf backup.tar.gz /data # -c 3 = idle 级别,只在磁盘空闲时读写
nice -n 19 ionice -c 3 ./backup.sh # 两个都降
2.3 没有固定的时间片
传统调度器给每个进程固定时间片(比如 100ms)。CFS 不是——它算的是**「调度周期内应得的比例」**:
调度周期 = max(sched_latency, 可运行进程数 × sched_min_granularity)
每个进程分到的时间 = 调度周期 × (自己权重 / 总权重)
# 5.13 之前在 /proc/sys/kernel/
cat /proc/sys/kernel/sched_latency_ns # 24000000 = 24ms 目标调度周期
cat /proc/sys/kernel/sched_min_granularity_ns # 3000000 = 3ms 最小运行时间
cat /proc/sys/kernel/sched_wakeup_granularity_ns # 4000000 抢占的最小差距
# 5.13+ 移到了 debugfs(需要挂载 debugfs 且 root)
ls /sys/kernel/debug/sched/
cat /sys/kernel/debug/sched/latency_ns
cat /sys/kernel/debug/sched/min_granularity_ns
注意:内核 6.6 起 CFS 被 EEVDF 调度器取代,
sched_latency_ns这类参数已经不存在了,取而代之的是基于「虚拟截止时间」的调度和每任务的sched_runtime。本节讲的 CFS 模型在 6.6 之前的内核(包括所有当前主流的 LTS 发行版)上仍然准确,理解它也是理解 EEVDF 的基础。
sched_min_granularity 的作用是限制切换频率:可运行进程一多,如果还坚持在 24ms 内轮完所有进程,每个进程只能跑几百微秒,切换开销占比就过高了。所以内核保证每个进程至少跑 min_granularity,代价是调度周期被拉长——进程数越多,单个进程的调度延迟越大。
注意:调度周期的实际计算不是简单的
latency / n。默认sched_tunable_scaling=1(对数式),会按 CPU 核数对latency做对数缩放;而且超过latency / min_granularity个进程后周期才开始随进程数增长。所以「24ms / 100 = 0.24ms」这种算法只是帮助理解量级,别当成精确公式。
2.4 调度类的优先级
CFS 只是其中一个调度类。内核按固定顺序检查:
① stop_sched_class 最高,用于 CPU 热插拔等,用户不可见
② dl_sched_class SCHED_DEADLINE(3.14+),基于截止时间
③ rt_sched_class SCHED_FIFO / SCHED_RR 实时
④ fair_sched_class SCHED_OTHER / SCHED_BATCH / SCHED_IDLE <- 普通进程
⑤ idle_sched_class 最低,只有 idle 任务
只要有一个实时进程可运行,普通进程就完全得不到 CPU——这是绝对优先,不是权重。
# 查看/设置调度策略
chrt -p 1234
# pid 1234's current scheduling policy: SCHED_OTHER
# pid 1234's current scheduling priority: 0
chrt -f 50 ./realtime_app # SCHED_FIFO 优先级 50
chrt -b 0 ./batch_job # SCHED_BATCH,适合纯计算任务
chrt -i 0 ./idle_job # SCHED_IDLE,只在完全空闲时跑
坑:
SCHED_FIFO的进程如果陷入死循环,会把那个 CPU 完全锁死——没有时间片概念,不会被抢占,连 shell 都抢不到 CPU。内核用sched_rt_runtime_us兜底(默认 950ms/1000ms,即最多占 95%),留 5% 给普通进程,否则系统就完全没救了:
cat /proc/sys/kernel/sched_rt_period_us # 1000000
cat /proc/sys/kernel/sched_rt_runtime_us # 950000 <- 实时进程最多占 95%
**业务服务不要用实时优先级。**它解决的是「硬实时」问题(工业控制、音频处理),后端服务用 CFS + cgroup 限制更合适。
3. 上下文切换
3.1 什么是 CPU 上下文
CPU 运行任何任务之前,必须先知道「从哪加载、从哪开始执行」,这些信息由CPU 寄存器和**程序计数器(PC)**提供,它们合称 CPU 上下文。
- CPU 寄存器:CPU 内置的极小极快的存储
- 程序计数器:正在执行、或即将执行的下一条指令的地址
切换任务就是「保存上一个任务的上下文 → 加载下一个任务的上下文 → 跳转执行」。
按切换对象的不同分三种,开销依次递减:
| 类型 | 需要保存/恢复什么 | 典型开销 |
|---|---|---|
| 进程上下文切换 | 寄存器 + 内核栈 + 虚拟内存(页表、TLB) | 3 ~ 5 μs |
| 线程上下文切换(同进程) | 寄存器 + 内核栈(虚拟内存不变) | 1 ~ 2 μs |
| 中断上下文切换 | 仅内核态必需的寄存器和硬件参数 | 亚微秒级 |
3.2 为什么进程切换比线程切换贵
因为多了地址空间切换这一步:
线程切换(同一进程内):
保存寄存器 -> 切换内核栈 -> 恢复寄存器
虚拟内存、页表、TLB 全都不动 <- 便宜的原因
进程切换:
保存寄存器 -> 切换内核栈 -> 切换页表(写 CR3 寄存器)
v
TLB 全部或部分失效
v
之后的内存访问要重新走页表查询
(每次 miss 要访问内存 2~4 次)
v
CPU cache 也大概率失效
真正的代价不是「保存恢复寄存器」那几十纳秒,而是切换后的「冷启动」——TLB 和 CPU cache 都失效了,接下来几千条指令都跑得比平时慢。这部分开销是隐性的,不体现在切换动作本身。
这就是「多线程优于多进程」的一个技术依据,也是 Go 的 goroutine 更快的原因——goroutine 切换发生在用户态,连内核都不用进,只需要保存三个寄存器(PC、SP、DX),开销约 200ns,比线程切换还低一个数量级。
| 切换类型 | 开销量级 | 谁来做 |
|---|---|---|
| goroutine 切换 | ~200 ns | Go runtime(用户态) |
| 线程切换(同进程) | 1 ~ 2 μs | 内核 |
| 进程切换 | 3 ~ 5 μs | 内核 |
| 容器/namespace 切换 | 与进程切换同级 | 内核 |
3.3 系统调用不是进程上下文切换
这是个高频考点,也是很多人混淆的地方。
系统调用只切换「特权级」,不切换进程:
用户态 --陷入--> 内核态 --返回--> 用户态
^ ^
同一个进程,虚拟内存、栈、全局变量全部不变
只保存/恢复了寄存器和 PC
一次系统调用发生两次 CPU 上下文切换(进内核、出内核),但零次进程上下文切换。所以准确的叫法是特权模式切换(privilege mode switch),而不是上下文切换。
# 实测一次空系统调用的开销
cat > /tmp/bench.c <<'EOF'
#include <unistd.h>
int main() {
for (int i = 0; i < 10000000; i++) getpid(); // 最简单的系统调用
return 0;
}
EOF
gcc -O0 -o /tmp/bench /tmp/bench.c && time /tmp/bench
# real 0m1.2s -> 1200ms / 1000万次 = 约 120ns 一次
约 100~200ns 就是一次系统调用的成本。对比一下:一次普通函数调用是 1~2ns。所以「减少系统调用次数」是性能优化的重要方向——批量 write 而不是逐条写、用 sendfile 零拷贝、用 io_uring 批量提交。
注意:开了 Spectre/Meltdown 缓解(KPTI)的机器上,系统调用开销会显著增加(可能翻倍),因为进出内核要切换页表。
cat /sys/devices/system/cpu/vulnerabilities/*能看到启用了哪些缓解措施。
3.4 什么时候会发生进程上下文切换
五个场景,出性能问题时就是从这里找原因:
① 时间片耗尽 -> 被动,vruntime 超过了别人
② 等待资源(IO、锁、内存) -> 主动挂起,让出 CPU
③ 主动 sleep -> 主动
④ 更高优先级进程就绪 -> 被抢占
⑤ 硬件中断 -> 被中断处理程序打断
这五个场景对应到监控指标上,就是下一节的「自愿」和「非自愿」切换。
4. 自愿 vs 非自愿:定位问题的关键区分
pidstat -w 能把上下文切换分成两类,这个区分直接指向不同的问题根源:
| 类型 | 英文 | 含义 | 说明什么问题 |
|---|---|---|---|
| 自愿 | cswch/s (voluntary) |
进程主动让出 CPU,因为拿不到需要的资源 | IO 密集、锁竞争、资源不足 |
| 非自愿 | nvcswch/s (non voluntary) |
进程被强制换下,时间片到了或被抢占 | CPU 竞争激烈,可运行进程太多 |
pidstat -w 1 3
# 20:15:01 UID PID cswch/s nvcswch/s Command
# 20:15:02 0 1234 892.00 12.00 myapp
# ^^^^^^ 自愿切换高 -> 在等 IO 或锁
# 20:15:02 0 5678 3.00 1520.00 cpu_hog
# ^^^^^^^ 非自愿高 -> 在抢 CPU
判断口诀:自愿切换高查 IO 和锁,非自愿切换高查 CPU 竞争。
两个典型场景:
# 场景一:cswch/s 突然涨到几千
# -> 说明进程频繁因为拿不到资源而阻塞
# -> 查磁盘 IO(iostat)、查锁竞争(应用层 profiling)、查网络等待
# 场景二:nvcswch/s 很高,同时 vmstat 的 r 列远大于核数
# -> 可运行进程数远超 CPU 数,大家在抢 CPU
# -> 减少并发数、加机器、或者用 cgroup 隔离
5. vmstat:看系统整体
vmstat 1 5
# procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
# r b swpd free buff cache si so bi bo in cs us sy id wa st
# 2 0 0 7005360 91564 818900 0 0 0 0 25 33 1 0 99 0 0
# ^ ^ ^^ ^^ ^^ ^^ ^^ ^^ ...
必须看懂的列:
| 列 | 含义 | 判读标准 |
|---|---|---|
r |
可运行进程数(R 状态,含正在跑的) | 持续 > CPU 核数 → CPU 不足 |
b |
阻塞进程数(D 状态) | 持续 > 0 → IO 瓶颈 |
si/so |
swap 换入/换出(KB/s) | 不为 0 就是危险信号 |
bi/bo |
块设备读/写(KB/s) | 与 iostat 对照 |
in |
每秒中断次数 | 突增查网卡、定时器 |
cs |
每秒上下文切换次数 | 结合下面的判断 |
us |
用户态 CPU% | 高 → 业务代码、GC |
sy |
内核态 CPU% | 高 → 系统调用多、上下文切换多、中断多 |
wa |
IO 等待 CPU% | 高 → 磁盘慢 |
st |
被虚拟化层偷走的时间 | 云主机上 > 5% → 宿主机超卖 |
st(steal time)在云上尤其重要。它表示 vCPU 想运行但物理 CPU 被别的虚拟机占用了。持续 st > 10% 说明你和邻居抢资源,应用侧怎么优化都没用,只能换机型或迁移实例。
5.1 cs 多少算高
**没有绝对阈值,必须结合上下文判断。**几个参考:
数千 /s 正常,多数服务都在这个量级
数万 /s 偏高,但如果 sy 不高、业务正常,可以接受
数十万 /s 基本确定有问题(除非是极高并发的网络服务)
关键是看趋势和相关性:
cs 突增 + sy 升高 + 业务延迟升高 -> 确定是问题
cs 一直很高但业务指标正常 -> 可能就是业务特性(如高并发短连接)
更有意义的指标是「每个 cs 对应多少业务处理」:
# 计算平均每次上下文切换处理了多少请求
# QPS / cs = 每次切换的产出,这个比值下降说明切换在做无用功
6. 平均负载:最容易被误解的指标
uptime
# 20:14:02 up 42 days, 3:11, 2 users, load average: 0.52, 1.61, 2.58
# ^^^^ ^^^^ ^^^^
# 1分钟 5分钟 15分钟
Linux 的平均负载 = 单位时间内处于「可运行状态 R」和「不可中断状态 D」的平均进程数。
注意 D 状态也算进去——这是 Linux 与其他 Unix 的重要区别(其他 Unix 只算 R)。这一点解释了最经典的困惑:
6.1 为什么 load 高但 CPU 空闲
uptime
# load average: 8.52, 8.61, 8.58 <- 8 核机器,load 8.5 看似满载
top
# %Cpu(s): 2.1 us, 1.3 sy, 0.0 ni, 30.2 id, 66.4 wa
# ^^^^^^^ 30% 空闲
# ^^^^^^^ 66% 在等 IO!
原因:一堆进程卡在 D 状态等磁盘,它们计入了 load,但不消耗 CPU。
# 确认:数一下 D 状态进程
ps -eo stat | grep -c '^D'
# 8 <- 正好对应 load 8.5
规律:load ≈ CPU 核数 但 wa 很高、us + sy 很低 → 瓶颈在磁盘 IO,不是 CPU。
6.2 三种 load 高的情形
| 情形 | load | us+sy | wa | r 列 | b 列 |
|---|---|---|---|---|---|
| CPU 密集 | 高 | 高 | 低 | 高 | 0 |
| IO 密集 | 高 | 低 | 高 | 低 | 高 |
| 进程太多抢 CPU | 很高 | 高 | 低 | 远超核数 | 0 |
# 一条命令同时拿到判断依据
uptime; echo; vmstat 1 3 | tail -3; echo; nproc
6.3 判断标准
load / CPU核数:
< 0.7 健康
0.7 ~ 1.0 开始需要关注
> 1.0 有进程在排队等待
> 5.0 严重过载,响应会明显变慢
看三个数字的趋势比看绝对值更重要:
load average: 8.0, 2.0, 1.0 <- 1分钟 >> 15分钟,负载【正在飙升】,要立刻查
load average: 1.0, 2.0, 8.0 <- 1分钟 << 15分钟,高峰【已经过去】,在恢复
load average: 4.0, 4.1, 4.0 <- 持续稳定的高负载,是常态还是长期问题?
坑:容器里的
uptime和top显示的是宿主机的 load,不是容器自己的。因为/proc/loadavg没有被 namespace 隔离。所以在 K8s Pod 里看 load 毫无意义——要看 cgroup 的cpu.stat里的throttled_time(第 19 篇讲)。
7. CPU 使用率是怎么算出来的
内核在 /proc/stat 里维护每个 CPU 的累计时间(单位是 jiffies,通常 1/100 秒):
cat /proc/stat | head -2
# cpu 2255024 3432 512376 41234567 12034 0 8234 0 0 0
# user nice system idle iowait irq softirq steal guest guest_nice
这些是从开机以来的累计值,不是百分比。所以所有工具(top、vmstat、mpstat)都是取两个时间点的差值再算比例:
CPU 使用率 = 1 - (idle_new - idle_old) / (total_new - total_old)

这解释了两个常见困惑:
# ① 为什么 top 第一次刷新的数据不准?
top -bn1 | grep Cpu
# 第一次没有「上一次的值」可以作差,用的是【开机至今】的平均值
# ✅ 所以脚本里要用 -n2 取第二次
top -bn2 -d1 | grep Cpu | tail -1
# ② 为什么不同工具显示的 CPU 使用率不一样?
# 因为采样间隔不同:top 默认 3s,vmstat 由你指定,ps 显示的是【进程生命周期内的平均值】
ps 的 %CPU 是个陷阱:
ps aux | grep myapp
# app 1234 0.5 12.3 ... java -jar myapp.jar
# ^^^ 这是进程【从启动到现在】的平均 CPU,不是当前值
# 一个跑了 30 天、昨天打满过 CPU 的进程,现在显示可能只有 0.5%
规律:要当前值用 top/pidstat,ps 的 %CPU 只反映历史平均。
7.1 各列的含义
| 列 | 含义 | 高了说明 |
|---|---|---|
us (user) |
用户态 | 业务计算、GC、序列化 |
sy (system) |
内核态 | 系统调用、上下文切换、中断处理 |
ni (nice) |
被调过 nice 的用户态进程 | 低优先级任务在跑 |
id (idle) |
空闲 | — |
wa (iowait) |
等待 IO | 磁盘慢或 IO 过多 |
hi (hardirq) |
硬中断 | 网卡收包量大 |
si (softirq) |
软中断 | 网络包处理、定时器 |
st (steal) |
被虚拟化层偷走 | 宿主机超卖 |
si(软中断)值得单独说。高并发网络服务的 si 会很高,因为网络包的协议栈处理在软中断上下文里跑:
# 看软中断分布
cat /proc/softirqs | head -5
# CPU0 CPU1 CPU2 CPU3
# NET_RX: 12034521 8234012 9123401 7823901
# ^^^^^^^^ 网络接收,高并发服务的主要来源
# 如果集中在一个 CPU 上 -> 中断没有分散,需要开 RPS/RSS
mpstat -P ALL 1
# 08:15:01 CPU %usr %sys %soft %idle
# 08:15:02 0 12.0 8.0 78.0 2.0 <- CPU0 软中断打满
# 08:15:02 1 10.0 5.0 1.0 84.0 <- 其他核空闲
这是单核软中断瓶颈的典型特征——总体 CPU 使用率看着不高,但某一个核已经满了,吞吐上不去。解法是开启 RPS/RSS 把网卡中断分散到多核。
8. 实战:三个真实场景
8.1 场景:接口变慢,CPU 使用率却只有 30%
现象:8 核机器,接口 P99 从 100ms 涨到 800ms,top 显示 CPU 只用了 30%。
uptime
# load average: 12.35, 11.82, 10.44 <- 8 核,load 12 = 过载
vmstat 1 3
# procs ---------cpu---------
# r b ... us sy id wa st
# 24 0 ... 8 22 68 0 0
# ^^ 24 个可运行进程,远超 8 核!
# ^^ sy 22% 偏高
# ^^ 但 id 还有 68%
这里有个明显的矛盾:r=24 说明有大量进程在排队等 CPU,但 id=68% 说明 CPU 有 68% 的时间是空闲的。既然 CPU 闲着,为什么还有 24 个进程排队?
答案是它们被 cgroup 限流了——进程处于「可运行」状态(计入 r),但配额已经用完,内核不给它们上 CPU。宿主机视角看到的是「CPU 空闲」,容器内的进程却在挨饿。
cat /sys/fs/cgroup/cpu.stat
# nr_periods 234012
# nr_throttled 198234 <- 85% 的调度周期都被限流
# throttled_usec 82340123
根因是运行时的并发度没有按 cgroup 配额设置(如 Go 的 GOMAXPROCS 取了宿主机核数)。
完整的机制分析(配额消耗时序、
cpu.maxvscpu.weight、K8s requests/limits 的取舍、三种修复方案)见 Linux-19 §6。
规律:r 列远超核数但 id 仍然很高 —— 这个矛盾组合基本可以断定是 cgroup CPU 限流,第一件事查 cpu.stat 的 nr_throttled。
8.2 场景:load 20 但 CPU 几乎空闲
现象:告警 load 超过 20(16 核机器),但 top 里 us + sy 不到 5%。
排查:
vmstat 1 3
# r b swpd free ... bi bo in cs us sy id wa st
# 1 19 0 234560 ... 4096 89234 2341 4523 2 3 8 87 0
# ^^ 19 个进程阻塞在 D 状态
# ^^ wa 87%!
# 找出是谁在等 IO
ps -eo pid,stat,wchan:25,comm | awk '$2 ~ /^D/'
# PID STAT WCHAN COMMAND
# 8821 D io_schedule mysqld
# 8822 D io_schedule mysqld
# ...(19 个)
# 确认磁盘状况
iostat -x 1 3
# Device r/s w/s rkB/s wkB/s await %util
# vda 12.0 892.0 480.0 89234.0 245.3 99.8
# ^^^^^ 平均等待 245ms(正常应 < 10ms)
# ^^^^ 磁盘打满
原因:磁盘写入打满,MySQL 的写请求全部堆积在 D 状态。load 反映的是「等 IO 的进程数」,不是 CPU 忙。
修复方向:这是 IO 问题,跟 CPU 无关——查慢 SQL、检查是否有大事务、考虑换 SSD 或分库。加 CPU 完全没用。
规律:wa 高 + b 列高 + %util 接近 100 → 磁盘瓶颈。第 11 篇讲 iostat 的详细判读,完整的案例复盘见 Linux-20 §3。
8.3 场景:上下文切换突增到 50 万/s
现象:cs 从平时的 8000 涨到 50 万,sy 从 5% 涨到 60%。
排查:
# ① 定位到进程和切换类型
pidstat -w 1 3 | sort -k4 -rn | head -5
# UID PID cswch/s nvcswch/s Command
# 0 1234 234012.0 892.0 myapp
# ^^^^^^^^ 自愿切换极高 -> 在频繁等待某个资源
# ② 自愿切换高 -> 查它在等什么
strace -c -p 1234 -f 2>&1 | head -10
# % time seconds calls syscall
# 68.2 12.340 892340 futex <- 68% 时间在 futex!
# 15.1 2.734 234012 epoll_wait
futex 就是用户态锁的内核实现。大量 futex 调用意味着锁竞争——线程拿不到锁就调用 futex 睡眠(产生一次自愿上下文切换),锁释放时被唤醒(又一次切换)。
# ③ 确认线程数
ps -o nlwp= -p 1234
# 512 <- 512 个线程抢同一把锁
原因:连接池的锁粒度太粗,512 个线程争抢一把全局锁,绝大部分时间在睡眠-唤醒循环里,真正干活的时间很少。
修复:减小锁粒度(分段锁)、减少线程数(改成 IO 多路复用)、或换成无锁数据结构。
结果:线程数降到 64,cs 回落到 12000,sy 降到 8%,吞吐提升 3 倍。
规律:cs 高 + sy 高 + strace 显示 futex 占大头 → 锁竞争。线程数不是越多越好。
9. 一些量级感
| 项 | 量级 |
|---|---|
| 一次系统调用 | 100 ~ 200 ns |
| goroutine 切换(用户态) | ~200 ns |
| 线程上下文切换(同进程) | 1 ~ 2 μs |
| 进程上下文切换 | 3 ~ 5 μs |
| TLB miss 后重填页表 | 100 ~ 300 ns |
| CFS 默认调度周期 | 24 ms |
| 单进程最小运行时间 | 3 ms |
正常服务的 cs |
数千 ~ 数万 /s |
正常的 load / 核数 |
< 0.7 |
用法举例:如果 cs 是 50 万/s,按每次 3μs 算,光切换就消耗 500000 × 3μs = 1.5s 的 CPU 时间——在 8 核机器上等于占掉了近 20% 的总算力,而这部分完全是无产出的开销。
10. 面试题
Q:CFS 是怎么实现「完全公平」的?
核心是 vruntime(虚拟运行时间):每个进程记录自己累计用了多少 CPU,调度器永远挑 vruntime 最小的进程运行,数据结构是按 vruntime 排序的红黑树,取最左节点是 O(1)。关键在于 vruntime 的增长速度与进程权重成反比——高优先级进程的 vruntime 涨得慢,因此更容易保持最小、被更频繁调度。所以优先级不是「插队」,而是「让时间在你这里流得更慢」。这个设计保证了高优先级进程也不会饿死低优先级进程,因为 vruntime 终究会累积上去。CFS 也没有固定时间片,而是按「调度周期 × 权重占比」动态计算。
Q:nice 值调整了会有多大影响?为什么普通用户只能调高不能调低?
nice 范围是 -20 到 19,内核维护一张权重表:nice 0 对应权重 1024,每差 1 约 1.25 倍,每差 5 约 3 倍,每差 10 约 10 倍。所以 nice 19 的进程只能拿到 nice 0 进程约 1/68 的 CPU 份额。普通用户只能调高 nice(自我降级)不能调低,是为了防止用户抢占系统资源——降低 nice 需要 root 或 CAP_SYS_NICE。注意:nice 只在 CPU 竞争时才生效,CPU 空闲时 nice 19 的进程照样跑满一个核;而且 nice 只影响 CPU 调度,不影响磁盘 IO——限制 IO 要用 ionice。
Q:进程上下文切换和线程上下文切换有什么区别?为什么后者更便宜?
线程切换(同一进程内)只需要保存/恢复寄存器和内核栈,虚拟内存、页表都不变;进程切换额外要切换地址空间(写 CR3 寄存器),导致 TLB 失效,之后的内存访问要重新走页表查询,CPU cache 也大概率失效。所以真正的代价不是保存寄存器那几十纳秒,而是切换后的「冷启动」——接下来几千条指令都比平时慢,这部分是隐性开销。量级上:进程切换 3~5μs,线程切换 1~2μs,而 goroutine 切换发生在用户态、只保存三个寄存器,约 200ns,比线程切换低一个数量级。
Q:一次系统调用会发生进程上下文切换吗?
不会。系统调用只切换特权级(用户态 ↔ 内核态),前后一直是同一个进程在运行,虚拟内存、栈、全局变量全部不变,只保存/恢复寄存器和程序计数器。所以准确叫法是特权模式切换,不是上下文切换。不过一次系统调用确实会发生两次 CPU 上下文切换(进内核、出内核),成本约 100~200ns——对比普通函数调用的 1~2ns,这就是「减少系统调用次数」成为性能优化方向的原因。注意:开启了 Spectre/Meltdown 缓解(KPTI)的机器上这个开销会显著增加,因为进出内核要切换页表。
Q:cswch/s 和 nvcswch/s 有什么区别?分别说明什么问题?
自愿上下文切换(cswch/s)是进程主动让出 CPU,因为拿不到需要的资源——等 IO、等锁、等内存。它高说明资源不足或锁竞争。非自愿上下文切换(nvcswch/s)是进程被强制换下,时间片用完或被更高优先级抢占。它高说明CPU 竞争激烈,可运行进程数远超核数。判断口诀:自愿高查 IO 和锁,非自愿高查 CPU 竞争。实际排查中,自愿切换极高时用 strace -c 看是什么系统调用占大头——如果是 futex,就是用户态锁竞争。
Q:平均负载是什么?为什么 load 很高但 CPU 使用率很低?
Linux 的平均负载是「单位时间内处于可运行状态 R 和不可中断状态 D 的平均进程数」。关键在于 D 状态也算进去——这是 Linux 与其他 Unix 的重要区别。所以一堆进程卡在 D 状态等磁盘 IO 时,load 会很高,但它们不消耗 CPU,CPU 使用率反而很低,表现为 wa 很高、us+sy 很低。判断方法:vmstat 的 b 列(阻塞进程数)和 wa 列同时高,再用 ps -eo stat,wchan 确认 D 状态进程卡在哪个内核函数。这种情况加 CPU 完全无效,要解决的是磁盘 IO。
Q:怎么判断平均负载是否过高?看哪个数字?
要先知道 CPU 核数(nproc),然后看 load / 核数:小于 0.7 健康,超过 1.0 说明有进程在排队,超过 5.0 是严重过载。三个数字的趋势比绝对值更重要:8.0, 2.0, 1.0 表示负载正在飙升要立刻查;1.0, 2.0, 8.0 表示高峰已过在恢复。注意一个大坑:容器里的 uptime 显示的是宿主机的 load,因为 /proc/loadavg 没有被 namespace 隔离,所以在 K8s Pod 里看 load 毫无意义——要看 cgroup 的 cpu.stat。
Q:容器里 CPU 使用率只有 30%,为什么接口延迟很高?
最常见的原因是被 cgroup CPU 限流(throttling)。检查 /sys/fs/cgroup/cpu.stat 的 nr_throttled 和 throttled_usec——如果 nr_throttled / nr_periods 比例很高,说明进程在每个调度周期内很快用完配额然后被强制暂停。宿主机视角看到的使用率低(只用了限额那部分),但容器内的线程大量时间在等配额恢复。根因常常是应用不感知 cgroup 限制:Go 程序的 GOMAXPROCS 默认取宿主机核数,8 核宿主机上起 8 个 P 去抢 2 核的配额,产生大量非自愿上下文切换。解法是引入 go.uber.org/automaxprocs(Java 用 -XX:+UseContainerSupport,JDK 10+ 默认开启),或者放宽 CPU 限额。
Q:ps 里的 %CPU 和 top 里的为什么不一样?
因为统计区间不同。CPU 使用率必须靠「两个时间点的累计值作差」来算——/proc/stat 里存的是开机以来的累计 jiffies。top 默认每 3 秒采样一次,显示的是最近这个间隔内的使用率;而 ps 的 %CPU 是进程从启动到现在的平均值。所以一个跑了 30 天、昨天打满过 CPU 的进程,ps 可能只显示 0.5%。要当前值用 top 或 pidstat,ps 只反映历史平均。同理,脚本里用 top -bn1 拿到的第一次刷新数据也不准(没有前一次的值可作差,用的是开机至今的平均),要用 top -bn2 -d1 取第二次的结果。
上一篇:Linux-06 进程管理与作业控制 | 下一篇:Linux-08 信号与进程间通信
xingliuhua