目录

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     <- 持续稳定的高负载,是常态还是长期问题?

坑:容器里的 uptimetop 显示的是宿主机的 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

这些是从开机以来的累计值,不是百分比。所以所有工具(topvmstatmpstat)都是取两个时间点的差值再算比例

CPU 使用率 = 1 - (idle_new - idle_old) / (total_new - total_old)

./CPU使用率公式.png

这解释了两个常见困惑

# ① 为什么 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/pidstatps%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.max vs cpu.weight、K8s requests/limits 的取舍、三种修复方案)见 Linux-19 §6

规律:r 列远超核数但 id 仍然很高 —— 这个矛盾组合基本可以断定是 cgroup CPU 限流,第一件事查 cpu.statnr_throttled

8.2 场景:load 20 但 CPU 几乎空闲

现象:告警 load 超过 20(16 核机器),但 topus + 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/snvcswch/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 很低。判断方法:vmstatb 列(阻塞进程数)和 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.statnr_throttledthrottled_usec——如果 nr_throttled / nr_periods 比例很高,说明进程在每个调度周期内很快用完配额然后被强制暂停。宿主机视角看到的使用率低(只用了限额那部分),但容器内的线程大量时间在等配额恢复。根因常常是应用不感知 cgroup 限制:Go 程序的 GOMAXPROCS 默认取宿主机核数,8 核宿主机上起 8 个 P 去抢 2 核的配额,产生大量非自愿上下文切换。解法是引入 go.uber.org/automaxprocs(Java 用 -XX:+UseContainerSupport,JDK 10+ 默认开启),或者放宽 CPU 限额。

Q:ps 里的 %CPUtop 里的为什么不一样?

因为统计区间不同。CPU 使用率必须靠「两个时间点的累计值作差」来算——/proc/stat 里存的是开机以来的累计 jiffies。top 默认每 3 秒采样一次,显示的是最近这个间隔内的使用率;而 ps%CPU 是进程从启动到现在的平均值。所以一个跑了 30 天、昨天打满过 CPU 的进程,ps 可能只显示 0.5%。要当前值用 toppidstatps 只反映历史平均。同理,脚本里用 top -bn1 拿到的第一次刷新数据也不准(没有前一次的值可作差,用的是开机至今的平均),要用 top -bn2 -d1 取第二次的结果。


上一篇:Linux-06 进程管理与作业控制 | 下一篇:Linux-08 信号与进程间通信