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 外部

总线

CPU 内部

寄存器组
PC / SP / AX...

L1 Cache

L2 Cache

系统总线(地址总线 + 数据总线 + 控制总线)

主内存 DRAM

内存映射 IO 区域
(网卡、磁盘控制器、显卡)

CPU 读写数据就两步:

  1. 把地址扔到地址总线上 → “我要读这个地址的东西”
  2. 从数据总线上拿数据回来 → 或者把数据扔上去写出去

💡 关键概念:内存和外设是统一编址的。也就是说,你读写 0xffffa000 这个地址,可能是在操作物理内存,也可能是在操作网卡的寄存器——靠地址范围来区分。这叫 MMIO(Memory-Mapped I/O)。

这个设计非常简洁:CPU 不需要专门的"读网卡"指令,只要往特定地址写数据,网卡硬件就收到了。

1.2 伪共享(False Sharing)再深入:到底有多坑?

上一篇我们提到过伪共享,这里展开讲讲它的底层机制。

渲染错误: Mermaid 渲染失败: Parse error on line 2: ...bgraph Cache Line 0 (64字节) A["变量 -----------------------^ Expecting 'SEMI', 'NEWLINE', 'SPACE', 'EOF', 'GRAPH', 'DIR', 'subgraph', 'SQS', 'end', 'AMP', 'COLON', 'START_LINK', 'STYLE', 'LINKSTYLE', 'CLASSDEF', 'CLASS', 'CLICK', 'DOWN', 'UP', 'NUM', 'NODE_STRING', 'BRKT', 'MINUS', 'MULT', 'UNICODE_TEXT', got 'PS'

为什么叫"伪"共享? 因为两个变量 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 倍
避免伪共享的四种方法:
  1. Cache Line 对齐填充(最直接)

    • 高频独立变量之间塞 56 字节 padding
    • Go 里标准库 sync.Pool、runtime 内部都这么干
  2. 把高频修改的字段拆开

    • 不要把"每个核心独立计数"的字段放在同一个 struct 里
    • 用 per-CPU 数组:每个核心一个独立的计数器
  3. 使用 __cacheline_aligned / alignas(64) 编译器属性

    • C/C++:struct foo { ... } __attribute__((aligned(64)));
    • Go:标准库 runtime/internal/cacheline 有常量
  4. 减少共享数据

    • 最优解:能不共享就不共享
    • 用消息传递(channel)代替共享内存,从根上消除伪共享

🔑 判断口诀:两个变量会被不同核心同时写 → 就要警惕伪共享。只读变量没事,多写才是问题。


1.3 CPU 如何选择线程?—— 调度器登场

现在问题来了:系统里同时跑着成百上千个线程,CPU 核心只有几个。谁先跑?谁后跑?跑多久?

这就是 CPU 调度器(Scheduler) 的工作。

Linux 调度器不是"一个"调度器,而是一组调度类按优先级排队:

Linux 调度类(从高到低)

stop 调度类
最高优先级
用于停机/迁移 CPU

deadline 调度类
硬实时,截止时间保证
(音频、视频)

realtime 调度类
软实时
SCHED_FIFO / SCHED_RR

CFS 完全公平调度类
普通进程都在这
(你写的 Go/Java/Python 全在这)

idle 调度类
最低优先级
空闲时做背景任务

调度原则:高优先级调度类里有任务,就永远不会跑低优先级的。就像急诊室——急救病人没处理完,普通挂号就得等着。

1.4 CFS:完全公平调度器

我们日常写的普通进程(包括你的 Go 程序),都在 CFS(Completely Fair Scheduler) 这个调度类里。它是 Linux 2.6.23 之后的默认调度器,也是最有意思的一个。

CFS 的核心思想非常朴素:

让每个进程都觉得自己独占了 CPU,而且大家分得一样多。

怎么做到"公平"?答案是 vruntime(虚拟运行时间)。

实际运行时间

vruntime = 实际时间 / 权重

权重越大 → vruntime 涨得越慢 → 跑的时间越多

CFS 每次选 vruntime 最小的那个进程来跑

每个进程都有一个 vruntime,表示它"已经在 CPU 上跑了多少虚拟时间"。CFS 做的事情就是:每次挑 vruntime 最小的那个进程来运行。

渲染错误: Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ... subgraph CFS 就绪队列(红黑树) direct -----------------------^

💡 CFS 用红黑树来组织就绪队列,插入、删除、找最小值都是 O(log n),非常高效。

权重从哪来?—— nice 值

Linux 给每个进程一个 nice 值,范围是 -20 ~ +19,默认是 0。

nice 值权重CPU 占比
-2088761最高优先级
-10272…
01024基准
+1025…
+1915最低优先级

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 核心 3

CPU 核心 2

CPU 核心 1

CPU 核心 0

负载均衡
任务迁移

负载均衡

负载均衡

运行队列0
(CFS 红黑树 + RT 队列 + DL 队列)

运行队列1

运行队列2

运行队列3

为什么每个 CPU 一个队列?

  • 避免多个 CPU 抢同一个队列的锁 → 性能差
  • 每个 CPU 自己调度自己,缓存局部性好(线程一直在同一个核心跑,数据都在 L1/L2 里)

负载均衡(Load Balancing):

  • 后台有个周期性任务,定期检查各个 CPU 队列的负载
  • 如果某个核心队列里任务太多,就迁几个到闲的核心上去
  • 迁移有代价:线程换了核心,原来的缓存就作废了 → 所以不能瞎迁

🔑 实战提示:如果你有多核 CPU 但程序只用满了一个核,其他核闲着——这通常是因为你的程序是单线程的,或者线程数比核数少。用 top 看 load average 和每个核的利用率。


第二部分:什么是软中断?

2.1 中断是什么?

先从硬件讲起。CPU 正在一心一意跑你的程序,突然——

“叮!网卡收到了一个数据包!”

CPU 怎么知道的?总不能每隔 1 微秒去问一次网卡吧?那也太浪费了。

答案是:中断(Interrupt)。

操作系统网卡CPU操作系统网卡CPU正在跑用户程序收到数据包,发中断信号!暂停当前指令,保存现场跳转到中断处理函数读网卡数据到内核缓冲区中断处理完,恢复现场继续跑刚才的用户程序

这就是 硬中断(Hardware Interrupt):外设通过电路直接打断 CPU,CPU 必须立刻响应。

💡 类比:你在写代码,突然有人敲门(硬中断)。你必须放下手头的事,去开门。开完门再回来继续写。

2.2 硬中断的问题:不能干太久!

硬中断处理程序运行时,同级别的中断会被屏蔽——也就是说,CPU 在处理网卡中断的时候,键盘中断、磁盘中断都得等着。

如果中断处理程序跑了几毫秒才结束,那这几毫秒里:

  • 其他外设的中断被延迟了 → 数据丢了
  • 用户程序被卡住了 → 卡顿
  • 系统响应变慢了

所以内核规定:硬中断处理要尽可能短,只做最紧急的事,剩下的活推迟做。

这就引出了 下半部(Bottom Half) 的概念。

2.3 软中断:中断的"下半场"

硬中断(上半部)
立刻响应,只做最急的事
1. 保存硬件状态
2. 确认中断
3. 关掉中断
4. 把任务标记为'待处理'

软中断(下半部)
稍后慢慢处理
1. 开中断
2. 做真正的重活
3. 网卡协议栈处理、数据拷贝...

软中断(SoftIRQ) 就是把中断的"重活"推迟到更晚的时候执行——这时候中断已经重新打开了,其他中断可以正常进来,系统不会卡住。

🔑 关键区别:

  • 硬中断:由硬件触发,立刻执行,关中断,要快
  • 软中断:由硬中断"排队"触发,稍后执行,开中断,可以慢慢干

软中断不是操作系统的"线程",它是中断上下文里的一种特殊执行流——它比进程优先级高,但比硬中断低。

2.4 系统里有哪些软中断?

Linux 内核定义了这么几种软中断:

软中断类型干什么的常见场景
HI_SOFTIRQ高优先级 tasklet老版本遗留,现在很少用
TIMER_SOFTIRQ定时器内核定时任务、时间片计算
NET_TX_SOFTIRQ网络发送发包、协议栈发送处理
NET_RX_SOFTIRQ网络接收收包、协议栈接收处理
BLOCK_SOFTIRQ块设备磁盘 IO 完成处理
IRQ_POLL_SOFTIRQIO 轮询存储设备轮询
TASKLET_SOFTIRQ普通 tasklet各种驱动的下半部
SCHED_SOFTIRQ调度跨 CPU 任务调度
HRTIMER_SOFTIRQ高精度定时器纳秒级定时
RCU_SOFTIRQRCU 锁读-拷贝-更新的延迟回收

💡 对后端开发最相关的就是 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
...

🔍 看什么?

  1. 总量:哪个软中断计数最多?
  2. 分布:是不是全挤在一个 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 过高怎么定位?

这是后端面试和线上排障的高频题。给你一套标准排查流程:

是 网络类

是 TIMER

是 RCU

是 BLOCK

发现问题:top 里 si 很高

第一步:看 /proc/softirqs
确认是哪种软中断?

是 NET_RX/TX 吗?

第二步:看网卡队列分布
ethtool -l eth0

第三步:开启 RPS/RFS
把软中断分散到多核

第二步:看哪个定时器多
perf top -e timer:*

第二步:看 RCU 延迟
rcu_sched_state

第二步:看磁盘 IO
iostat -x 1

最常见的场景:网卡软中断打满一个核

问题现象:

  • 服务器网络带宽跑满了(比如 10Gbps)
  • top 看到某个 CPU 核 si 90%+
  • 其他核都闲着

根本原因:

  • 网卡默认只有一个接收队列 → 所有网络中断都路由到同一个 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)。


第三部分:总结一张图

CPU 任务与中断

CPU 读写数据

地址总线 + 数据总线

MMIO 内存映射 IO

伪共享

同一条 Cache Line 被多核心写

MESI 来回传输

解决:对齐填充 / per-CPU 变量 / 消息传递

调度器

调度类优先级:stop > deadline > RT > CFS > idle

CFS 完全公平调度

vruntime 虚拟运行时间

红黑树就绪队列

nice 值 -20~+19

每核独立运行队列

负载均衡任务迁移

软中断

硬中断:硬件触发,立刻执行,要快

软中断:硬中断排队触发,延迟执行

常见类型:NET_RX / NET_TX / TIMER / RCU

定位:top 看 si → /proc/softirqs 看分布

高频问题:网卡软中断打满单核 → RPS/RFS 分散

面试答题速记:

问题一句话答案
伪共享是什么?多个核心写的变量落在同一条 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 分析

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐