第 08 章 中断与异步:世界不是顺序执行的
前面几章,我们讲的都是”执行流”如何运行、如何协调。 这一章,我们面对一个更根本的现实:世界不是顺序执行的。
设备随时可能”打扰”你(数据到了、定时器到了、错误发生了),而你无法预测它什么时候来。操作系统处理这个”无法预测的异步”的机制,就是中断。
理解中断,你就理解了:
- 为什么前端要事件循环,后端要消息队列,操作系统要中断——它们是同一件事
- 为什么”回调地狱”和”中断嵌套”是同一个难题的两种表现
- 以及,Go 的抢占式调度,本质上是”用中断思想”解决”协程霸占 CPU”的问题
引子:一个 CPU 同时要伺候 100 个设备,怎么办?
设想你是一个 CPU,同时要伺候:网卡(随时可能来数据)、键盘(用户随时可能敲键)、硬盘(随时可能读完数据)、定时器(每毫秒提醒一次)……
你有两个选择:
选择一:轮询(polling)。 每隔一段时间,挨个问一遍”你有事吗?”
问题:
1. 浪费:大部分时候设备都"没事",你白问
2. 延迟:你问的间隔决定响应速度,问太勤浪费,问太稀响应慢
3. 顾此失彼:你正在伺候网卡,键盘那边错过了怎么办?
选择二:中断(interrupt)。 设备有事,主动通知你。
设备就绪 → 发中断信号 → 你暂停手头的事 → 处理设备 → 恢复手头的事
好处:
1. 不浪费:没事不打扰
2. 响应快:设备就绪立刻处理
3. 有优先级:重要的中断先处理
中断,就是”事件驱动”在硬件层的实现。
而这一章,我们要拆解中断的完整机制,以及它带来的一个核心矛盾:
中断处理要”快”(否则会丢后续中断、拖慢系统),但有些处理天然”慢”(比如网络包要解析协议)。
这个矛盾,逼出了中断处理最精妙的设计:上半部(快)和下半部(慢)的分离。
本章路线图
第一幕:中断的本质
Q1 一次中断,从发生到处理,完整流程是什么?
Q2 为什么中断处理必须"快"?慢了会怎样?
第二幕:上半部与下半部
Q3 什么是"上半部"和"下半部"?为什么要分离?
Q4 下半部的三种实现(软中断、tasklet、workqueue)有什么区别?
第三幕:中断的高级话题
Q5 中断亲和性是什么?多核下中断怎么分配?
Q6 中断风暴怎么破?(回顾 NAPI 的中断/轮询切换)
第四幕:异步的普适性
Q7 为什么说"前端事件循环 = 后端消息队列 = 操作系统的中断"?
Q8 回调地狱和中断嵌套,是不是同一个难题?
第五幕:Go 的异步
Q9 Go 的抢占式调度,是怎么用"中断思想"解决"协程霸占 CPU"的?
Q10 这套异步机制面临哪些结构性的问题?
第一幕:中断的本质
Q1 一次中断,从发生到处理,完整流程是什么?
以”网卡收到一个数据包”为例,完整走一遍中断的流程:
步骤 1:网卡收到数据包
网卡通过 DMA 把数据包写入内存(第 4 章讲过 DMA)
步骤 2:网卡触发中断
网卡向中断控制器(APIC)发一个中断信号(IRQ)
步骤 3:中断控制器通知 CPU
APIC 把中断转发给某个 CPU(可能是固定的,也可能是动态分配的)
步骤 4:CPU 响应中断
CPU 检查"是否屏蔽了这个中断"(中断屏蔽)
如果没屏蔽,CPU 暂停当前执行的指令流
步骤 5:保存现场
CPU 自动保存当前程序的"关键状态"(PC、标志寄存器等)
切换到内核栈
步骤 6:执行中断处理程序(上半部)
内核根据中断号,找到对应的中断处理程序(handler)
执行 handler:比如"把网卡的数据包标记为待处理"
步骤 7:恢复现场
处理完上半部,恢复被中断的程序的状态
步骤 8:返回被中断的程序
被中断的程序继续执行,仿佛什么都没发生
关键点:
1. 中断是"抢占式"的:CPU 正在干别的事,中断强行插进来
2. 中断处理要"快":因为处理期间,可能屏蔽了其他中断
3. 中断的"现场保存/恢复"是硬件的:CPU 自动做,很快
中断 vs 系统调用(一个重要对比):
系统调用(syscall):
程序主动陷入内核(int 0x80 / syscall 指令)
是"同步"的:程序知道自己在调用内核
中断(interrupt):
硬件主动打断 CPU
是"异步"的:程序不知道中断什么时候来
两者都让 CPU 从用户态切到内核态,但触发方不同:一个是程序主动,一个是硬件强行。
Q2 为什么中断处理必须”快”?慢了会怎样?
这是理解”上半部/下半部分离”的前提。
原因一:中断处理期间,通常屏蔽了同级/低级中断。
如果处理中断 A 期间,屏蔽了中断 B:
中断 A 处理慢了 → 中断 B 被延迟 → B 对应的设备可能"丢数据"或"超时"
比如:
网卡中断处理慢了 → 后续的数据包中断被延迟 → 网卡的接收缓冲区溢出 → 丢包
原因二:中断处理期间,可能不能睡眠。
中断上下文(interrupt context)里,不能睡眠、不能调用可能阻塞的函数。
因为中断处理是"借"了某个进程的内核栈,睡眠会让这个进程"莫名其妙地卡住"。
(这是"中断上下文"和"进程上下文"的关键区别)
原因三:中断处理占用的是”别人的时间”。
中断打断的是某个进程的执行。中断处理越久,被中断的进程"损失"的时间越多。
如果中断处理是"不可抢占"的(高优先级中断),那它拖得越久,系统响应越差。
所以中断处理的核心原则是:
上半部做”最少必要的工作”,把”耗时的活”推迟到下半部。
上半部(top half / hardirq):
- 响应中断,保存必要信息
- 标记"有数据要处理"
- 调度下半部
- 越快越好(微秒级)
下半部(bottom half):
- 做耗时的处理(解析协议、处理数据、唤醒进程)
- 在更宽松的上下文里执行
- 可以慢一点(但也要尽快)
这个”快慢分离”的思想,你在工程里天天用:
- 消息队列消费者:收到消息先”快速 ack”(上半部),再”慢慢处理”(下半部)
- HTTP 接口:先”快速返回 202 Accepted”(上半部),再”异步处理”(下半部)
- 日志系统:先”快速写入内存缓冲”(上半部),再”异步刷盘”(下半部)
核心都是:把”必须立刻响应”和”可以慢慢做”分开,让”快的部分”不被”慢的部分”拖累。
第二幕:上半部与下半部
Q3 什么是”上半部”和”下半部”?为什么要分离?
用网卡收包这个最经典的例子来说明:
上半部(hardirq 处理函数):
// 网卡中断处理程序(上半部),必须极快
static irqreturn_t nic_interrupt(int irq, void *dev_id)
{
struct nic *nic = dev_id;
// 1. 读网卡状态寄存器,确认是"收包"中断
// 2. 把网卡接收队列里的数据包,挂到一个"待处理队列"
// 3. 标记"需要调度下半部"
// 4. 调度下半部(软中断)
napi_schedule(&nic->napi);
return IRQ_HANDLED; // 返回,处理结束
}上半部只做三件事:确认中断、收集数据、调度下半部。不解析协议、不拷贝数据、不做复杂逻辑。
下半部(NAPI 轮询):
// 下半部:真正处理数据包
static int nic_poll(struct napi_struct *napi, int budget)
{
// 1. 从网卡接收队列读数据包(批量,一次读多个)
// 2. 解析以太网头、IP 头、TCP 头
// 3. 把数据交给协议栈,最终唤醒等待的进程
// 4. 处理完 budget 个包就返回(不能无限处理)
}下半部做耗时的工作,但在更宽松的上下文里,可以”分批”处理。
为什么要分离?核心原因总结:
1. 上半部要"快"(否则丢中断),下半部可以"慢"
2. 上半部在"硬中断上下文"(不能睡眠),下半部在"软中断/进程上下文"(可以更灵活)
3. 上半部要"响应所有中断",下半部可以"批处理"(摊薄开销)
关键理解:上半部和下半部是”时间上的分离”,不是”空间上的分离”。
上半部:中断发生的瞬间,立刻处理(快,但受限)
下半部:中断处理完之后的某个时间,异步处理(慢,但灵活)
Q4 下半部的三种实现(软中断、tasklet、workqueue)有什么区别?
Linux 的下半部有三种实现,它们解决同一个问题(延迟处理),但在”运行上下文”和”并发性”上不同。
软中断(softirq):
特点:
- 运行在"软中断上下文",不能睡眠
- 同一个软中断可以在多个 CPU 上同时运行(需要自己处理并发)
- 编译时静态定义(数量固定,比如 NET_RX_SOFTIRQ、TIMER_SOFTIRQ)
适用:高频、性能关键的场景(网络收发、定时器)
tasklet:
特点:
- 基于软中断实现,但同一时刻同一个 tasklet 只在一个 CPU 上运行
- 不能睡眠
- 动态创建,比软中断简单
适用:不那么高频、需要简单并发的场景
工作队列(workqueue):
特点:
- 运行在"进程上下文"(内核线程里),可以睡眠!
- 有内核线程专门执行,可以调用"可能阻塞"的函数
- 最灵活,但开销最大(要调度到内核线程)
适用:需要睡眠、耗时较长的处理(比如磁盘 I/O、等待信号量)
三者的对比:
| 软中断 softirq | tasklet | workqueue | |
|---|---|---|---|
| 运行上下文 | 软中断上下文 | 软中断上下文 | 进程上下文 |
| 能否睡眠 | 不能 | 不能 | 能 |
| 并发性 | 多 CPU 并行 | 单 CPU 串行 | 多 CPU 并行 |
| 开销 | 最低 | 低 | 高 |
| 适用 | 高频、极致性能 | 中等频率 | 需要睡眠、灵活 |
这个分层给你的启示:
“延迟处理”不是一个东西,是一组”运行上下文”的选择。
选择哪个,取决于你的”延迟处理”需要什么:
- 需要极致性能、不能睡眠 → 软中断
- 需要简单、不需要睡眠 → tasklet
- 需要睡眠、灵活 → workqueue
这反映了一个通用的设计原则:异步任务的执行,需要明确”它在哪里跑、能不能阻塞、能不能并行”。
第三幕:中断的高级话题
Q5 中断亲和性是什么?多核下中断怎么分配?
问题:多核机器上,一个设备的中断,由哪个 CPU 处理?
中断亲和性(IRQ affinity):指定某个中断由哪个(哪些)CPU 处理。
# 查看网卡中断在哪个 CPU 上
cat /proc/interrupts
# CPU0 CPU1 CPU2 CPU3
# 网卡 IRQ 123 0 0 0 ← 网卡中断全在 CPU0
# 设置中断亲和性(把 IRQ 123 绑定到 CPU 1)
echo 2 > /proc/irq/123/smp_affinity # 2 = 二进制 10 = CPU1为什么要关心中断亲和性?
1. 负载均衡:把不同设备的中断分散到不同 CPU,避免单个 CPU 成为瓶颈
(比如网卡中断全在 CPU0,CPU0 忙死,CPU1-3 闲着)
2. 缓存亲和性:让中断和"处理它的进程"在同一个 CPU
(避免数据在 CPU 之间迁移,减少缓存失效)
3. NUMA 亲和性:让中断在"靠近设备内存"的 CPU 上
(第 1 章讲过 NUMA)
中断亲和性的演进:
早期:所有中断都发给 CPU0(简单,但 CPU0 成为瓶颈)
现代:irqbalance 守护进程动态分配中断,或者手动绑定
高性能场景(DPDK):手动绑定中断到特定 CPU,
且把"收包的 CPU"和"处理数据的 CPU"分开
一个真实的调优场景:
高流量网络服务器,网卡中断默认都挤在 CPU0:
→ CPU0 忙于处理网卡中断,成为瓶颈
→ 其他 CPU 空闲
优化:
1. 用 irqbalance 或手动把网卡中断分散到多个 CPU
2. 把"网卡中断"和"业务处理"绑定到不同 CPU(或用 CPU 隔离 isolcpus)
效果:中断负载均衡后,吞吐提升明显
中断亲和性的启示:
“谁来处理事件”,和”事件本身”一样重要。
分布式系统里也有同样的问题:
- 消息队列的分区,分配给哪个消费者?
- K8s 的 Pod,调度到哪个节点?
- 微服务的流量,路由到哪个实例?
核心都是”亲和性”:尽量让”相关的事情”在”相近的地方”处理,减少迁移成本(缓存、网络、上下文)。
Q6 中断风暴怎么破?(回顾 NAPI 的中断/轮询切换)
第 4 章讲过 NAPI,这里从”中断”的角度再深化一次。
中断风暴(interrupt storm)的根源:
高流量场景,每个数据包都触发一次中断:
每秒 100 万个包 = 100 万次中断
每次中断的开销:
- 保存/恢复现场
- 中断处理程序的执行
- 上下文切换
100 万次中断,光中断处理就占满 CPU,没时间处理真正的数据。
更糟的情况:活锁(livelock)。
中断多到 CPU 一直在"响应中断",没时间"处理数据"
→ 数据积压 → 更多中断 → 更没时间处理 → 恶性循环
→ 系统"活着",但一个请求都处理不了(第 6 章讲的活锁)
NAPI 的解法:中断 + 轮询的动态切换。
正常(低流量):
中断模式:来一个包,处理一个(响应快)
高流量:
轮询模式:关掉中断,CPU 主动批量拉包
- 一次轮询处理几百个包(摊薄开销)
- 没有"每个包一次中断"的开销
流量下降:
切回中断模式
关键:NAPI 不是”用轮询替代中断”,而是”根据负载动态切换”。
中断的优势:低负载时响应快、零开销
轮询的优势:高负载时批量处理、无中断风暴
NAPI = 自适应:低流量用中断,高流量用轮询
这个”中断/轮询”的权衡,是”推/拉”模型的核心(第 4 章讲过):
- 推(中断):事件少时高效,事件多时风暴
- 拉(轮询):事件多时高效,事件少时浪费
而最优解是”自适应”:根据事件频率切换。
这个思想在工程里的应用:
- Kafka 消费者:低频用 push(推),高频用 pull(拉)
- 前端:低频用 WebSocket(推),高频用轮询
- 限流:令牌桶(固定速率)+ 突发处理
第四幕:异步的普适性
Q7 为什么说”前端事件循环 = 后端消息队列 = 操作系统的中断”?
这一问是全章的”升华”,帮你看到”异步”的普适本质。
操作系统:中断 + 下半部。
事件:设备就绪(网卡收到包、磁盘读完)
处理:中断(上半部,快)→ 下半部(慢)
模式:事件驱动,异步处理
前端:事件循环(event loop)。
// 浏览器的事件循环
// 事件:用户点击、定时器、网络响应
// 处理:事件回调(handler)
button.addEventListener('click', () => { // 注册"点击事件"的处理器
// 处理点击
});
setTimeout(() => { // 注册"定时器事件"的处理器
// 处理定时器
}, 1000);
// 事件循环:不断从"事件队列"取事件,执行对应的回调
while (true) {
event = eventQueue.dequeue(); // 取下一个事件
event.handler(event); // 执行处理器
}后端:消息队列。
事件:消息到达(生产者发消息)
处理:消费者消费消息
模式:事件驱动,异步解耦
生产者 → 消息队列 → 消费者
(事件) (处理器)
三者的对应关系:
| 概念 | 操作系统 | 前端 | 后端 |
|---|---|---|---|
| 事件源 | 设备(网卡、磁盘) | 用户操作、网络、定时器 | 生产者(业务) |
| 事件 | 中断 | 事件(click、timer) | 消息 |
| 事件队列 | 中断队列/软中断队列 | 事件队列(task queue) | 消息队列(broker) |
| 处理器 | 中断处理程序 | 事件回调 | 消费者 |
| 调度 | 中断控制器 + 调度器 | 事件循环 | 消费者组 |
它们的共同本质:
“事件驱动”模型 = 事件源产生事件 → 事件排队 → 处理器异步处理。
这个模型的核心优势是:处理者不用”轮询”事件源,而是”等”事件来,来了才处理。 这让”处理者”能把时间花在”处理”上,而不是”等待”上。
为什么这个模型如此普遍?
因为世界本来就是异步的:用户什么时候点击、数据什么时候到达、网络什么时候响应,你都控制不了。与其”同步等待”(阻塞),不如”注册回调,异步处理”。
Q8 回调地狱和中断嵌套,是不是同一个难题?
这是个很有意思的类比,能帮你理解”异步”的固有复杂度。
中断嵌套(interrupt nesting):
中断处理程序 A 正在执行,突然来了更高优先级的中断 B:
A 被 B 打断,B 执行
B 执行完,A 继续
如果 B 又被更高优先级的中断 C 打断:
C 打断 B 打断 A
→ 嵌套越来越深
问题:
1. 状态管理复杂:每个被中断的处理器,都要保存自己的状态
2. 优先级反转风险(第 6 章讲过)
3. 难以推理:谁在什么时候执行,变得难以预测
回调地狱(callback hell):
// 异步操作的嵌套
fetchUser(userId, (user) => {
fetchOrders(user.id, (orders) => {
fetchDetails(orders[0].id, (details) => {
fetchAddress(details.addressId, (address) => {
// 四层嵌套,越来越深
console.log(address);
});
});
});
});问题:
1. 代码嵌套深,可读性差("金字塔")
2. 错误处理困难(每一层都要处理错误)
3. 难以组合(多个异步操作,顺序、并行、条件,都很难表达)
两者的共同本质:
都是”异步操作的嵌套”带来的复杂度。
中断嵌套:异步的中断,嵌套打断 回调地狱:异步的回调,嵌套调用
核心难题是同一个:当”控制流”不再线性(被异步打断/回调),如何管理”状态”和”顺序”?
两者的解法也是类似的:
中断嵌套的解法:
- 限制嵌套深度(中断优先级分层,禁止过多嵌套)
- 把复杂处理推迟到下半部("扁平化"处理)
回调地狱的解法:
- Promise / async-await(把嵌套"扁平化")
- 协程(用同步写法表达异步逻辑)
- 状态机(显式管理状态)
对应关系:
| 中断嵌套 | 回调地狱 | |
|---|---|---|
| 问题 | 中断嵌套打断,状态难管 | 回调嵌套调用,代码难读 |
| 解法 | 上下半部分离(扁平化) | async/await、协程(扁平化) |
| 本质 | 让”异步”看起来像”同步” | 让”异步”看起来像”同步” |
深刻的洞察:
“异步”是现实的,但”异步”难以推理。所以所有”异步编程”的进化方向,都是”把异步变得像同步一样好理解”。
- Go 的 goroutine:异步底层 + 同步写法
- JS 的 async/await:异步 Promise + 同步写法
- 中断的上下半部:异步中断 + 简化的处理流程
这是一个普遍的规律:复杂的底层机制,最终会被”同步化”的抽象包装起来,让人能用简单的同步思维使用它。 这和第 4 章讲的”Go 用阻塞写法获得异步性能”是同一个道理。
第五幕:Go 的异步
Q9 Go 的抢占式调度,是怎么用”中断思想”解决”协程霸占 CPU”的?
这一问把”中断”和”Go 调度”连起来,是个很漂亮的知识缝合。
问题:Go 的 goroutine 是”协作式”调度,一个 goroutine 死循环会霸占 CPU。
func hog() {
for {
// 死循环,没有函数调用,没有让出点
// 在 Go 1.14 之前,这个 goroutine 会霸占 P,
// 其他 goroutine 饿死
}
}为什么?因为协作式调度只在”让出点”切换。
协作式调度:goroutine 主动让出(调用 runtime.Gosched、channel 阻塞、加锁阻塞)
如果 goroutine 从不"让出",调度器无法介入
→ 死循环霸占 P
Go 1.14 之前的解法:在函数调用点插入抢占检查。
// 编译器在"函数入口"插入检查
func hog() {
for {
// 死循环,但没有函数调用!
// 即使有抢占检查,也永远到不了检查点
}
}问题:如果死循环里没有函数调用,抢占检查就永远不触发。
Go 1.14 的解法:异步抢占(基于信号,本质是”中断”)。
核心思想:用"信号"(signal)实现抢占。
1. 监控 goroutine 的运行时间
2. 如果一个 goroutine 运行超过 10ms,发送一个 SIGURG 信号
3. 信号处理器(相当于"中断处理程序"):
- 保存 goroutine 的上下文
- 检查是否在"安全点"(safe point)
- 是 → 抢占,调度别的 goroutine
- 否 → 标记"需要抢占",等它到安全点再抢
这不就是”中断”吗?
操作系统中断:硬件发中断 → CPU 暂停 → 中断处理程序 → 恢复
Go 异步抢占:runtime 发信号 → goroutine 暂停 → 信号处理器 → 恢复/调度
信号(signal)在 Unix 里,就是"软件中断"(software interrupt)。
Go 用"软件中断"实现了"用户态调度器的抢占"。
这个设计的精妙之处:
1. 复用了 Unix 的信号机制(软件中断)
2. 信号处理器就是"上半部"(快,只做标记和抢占)
3. 真正的调度是"下半部"(在安全点执行)
这是"上下半部分离"思想在 Go runtime 里的应用。
异步抢占的价值:
1. 解决"死循环霸占 CPU":运行超时的 goroutine 被强制让出
2. 降低调度延迟:即使有 goroutine 不配合,也能切换
3. 提高公平性:没有 goroutine 能"无限霸占"P
但仍然有”安全点”的限制:
异步抢占只能在"安全点"(safe point)真正执行,
安全点 = 编译器标记的、可以安全暂停的位置(比如函数边界、循环回边)。
如果一段代码"没有安全点"(比如紧凑的汇编循环),
即使发了信号,也要等到安全点才能抢占。
这个例子完美展示了”中断思想”的普适性:
“抢占” = “在运行中的执行流上,强制插入一个调度点”。
- 操作系统的抢占式调度:用”时钟中断”实现
- Go 的异步抢占:用”信号(软件中断)“实现
- 前端的事件循环:用”微任务/macrotask”实现
本质都是:让”正在运行的东西”能被打断,以便调度其他东西。 而这个”打断”,就是”中断”。
Q10 这套异步机制面临哪些结构性的问题?
最后泼冷水。中断/异步这套机制,有几个根子上的难题:
问题一:异步的”状态管理”复杂度。
同步:代码从上到下,状态在栈里,好理解
异步:控制流被打断、恢复,状态要"显式保存"
这就是"回调地狱"、"状态机爆炸"、"async 传染"的根源。
(async 传染:一个函数 async 了,调用它的也得 async,层层传染)
问题二:异步的”时序”不确定性。
中断什么时候来?回调什么时候执行?消息什么时候被消费?
——你无法确定。
这导致:
- 难以复现 bug(时序相关)
- 难以测试(要模拟各种时序)
- 难以推理(要考虑所有可能的交错)
问题三:异步的”错误处理”难题。
同步:错误通过返回值/异常传播,路径清晰
异步:错误发生在"另一个执行流",要跨执行流传播
中断的错误、回调的错误、消息处理失败——怎么传播?谁负责?
(Go 的 panic 在 goroutine 里,不会自动传到主 goroutine)
问题四:异步的”优先级”难题。
中断有优先级,但异步任务呢?
前端:微任务 vs 宏任务(微任务优先)
Go:goroutine 没有优先级(大家平等)
消息队列:消息有优先级吗?(大多没有,或要额外实现)
没有优先级 → 关键任务可能被非关键任务"堵住"
问题五:异步的”可观测性”难题(呼应第 20 章)。
同步:调用栈清晰,一眼看到"谁调用了谁"
异步:调用栈断裂,回调/中断/消息打断了"因果链"
排查异步问题,往往要"跨执行流"追踪,
这是分布式追踪(tracing)要解决的难题。
总结这一章:
异步是”现实”(世界本来就是异步的),但异步是”难题”(难以理解、推理、调试)。
操作系统的中断机制,是所有”异步处理”的鼻祖和范本。它教会我们:
- 快慢分离(上半部快,下半部慢)
- 推拉结合(低流量中断/推,高流量轮询/拉,动态切换)
- 亲和性(相关的事在相近的地方处理)
- 用”同步化”的抽象包装异步(让异步好用)
而它的固有难题(状态管理、时序、错误处理、优先级、可观测性), 会以各种形式,出现在每一个异步系统里。
思考题
思考题 1
面试官问:“什么是中断?中断和轮询有什么区别?什么情况下用中断,什么情况下用轮询?“请你用”网卡收包”的例子回答,并说明 Linux 的 NAPI 是怎么把两者结合起来的。
参考答案
核心答案:中断是”设备主动通知 CPU”,轮询是”CPU 主动查设备”。中断适合低流量,轮询适合高流量,NAPI 把两者结合。
什么是中断:
设备就绪(网卡收到包、磁盘读完)→ 发中断信号 → CPU 暂停当前工作 →
处理中断(上半部)→ 恢复工作
特点:
- 事件驱动:设备"推"给 CPU
- 低流量时高效(没事不打扰)
- 响应快(设备就绪立刻处理)
什么是轮询:
CPU 定期检查设备状态:"有数据吗?有吗?"
特点:
- 主动拉取:CPU"拉"数据
- 高流量时高效(批量处理)
- 低流量时浪费(一直问,但大多时候没事)
对比(网卡收包):
| 中断 | 轮询 | |
|---|---|---|
| 低流量 | 好(零开销) | 差(空轮询浪费) |
| 高流量 | 差(中断风暴) | 好(批量处理) |
| 响应延迟 | 低(立刻) | 取决于轮询间隔 |
| CPU 开销 | 每包一次中断开销 | 批量,摊薄 |
什么时候用中断,什么时候用轮询:
事件频率低 → 中断(按需响应,零浪费)
事件频率高 → 轮询(批量处理,摊薄开销)
判断标准:事件到达的频率 vs 中断处理的开销
如果中断开销 > 事件处理本身,就该考虑轮询/批量
NAPI 怎么结合两者:
NAPI 的核心:根据流量动态切换
1. 低流量:中断模式
- 来一个包,触发一次中断,处理一个
- 响应快,零开销
2. 检测到高流量(一个中断处理完,发现还有包):
- 关掉中断,切换到轮询模式
- 注册到软中断的轮询队列
3. 轮询模式:CPU 批量收包
- 一次轮询处理 budget 个包(比如 64 个)
- 无中断风暴,摊薄开销
4. 轮询队列清空后:切回中断模式
NAPI 的精妙之处:
它不是"选中断"或"选轮询",而是"自适应":
- 低流量用中断(响应快)
- 高流量用轮询(批处理)
- 自动切换,无需人工干预
这体现了"推拉结合 + 动态切换"的设计思想。
追问:为什么不能纯轮询(不管流量)?
因为低流量时,纯轮询会浪费 CPU(一直问”有数据吗”,但大多时候没有)。中断在低流量时是零开销的(没事不打扰)。
追问:为什么不能纯中断(不管流量)?
因为高流量时,纯中断会导致”中断风暴”——每个包一次中断,中断开销占满 CPU,甚至导致活锁(一直在响应中断,没时间处理数据)。
这道题考察什么:
- 中断和轮询的本质区别(推 vs 拉)
- 各自的适用场景(低流量 vs 高流量)
- 知不知道 NAPI 的”自适应”设计
- 能不能说出”纯中断”和”纯轮询”各自的问题
思考题 2
你在设计一个”订单通知系统”:下单成功后,要通知用户(发短信/推送),同时要更新库存、记录日志、发消息给下游。你希望”下单接口”快速返回(不让用户等),这些后续操作异步完成。你会怎么设计?用消息队列?还是 goroutine?如果”通知用户”这个操作失败了,怎么办?
参考答案
这是一道”异步架构”设计题,核心是”上半部/下半部分离”思想的落地。
需求分析:
下单接口(用户可见,要快)→ 核心:扣库存、创建订单(必须同步,保证一致)
后续操作(用户不可见,可慢)→ 通知用户、记日志、发消息(可以异步)
关键区分:哪些”必须同步”,哪些”可以异步”?
必须同步(在下单接口里完成):
- 扣库存(不扣就超卖)
- 创建订单(订单是核心数据)
可以异步(下单返回后再做):
- 通知用户(短信/推送,慢,且失败可重试)
- 记录日志(不影响业务)
- 发消息给下游(下游可以稍后处理)
方案一:goroutine 异步(简单场景)。
func placeOrder(order Order) error {
// 上半部:同步做核心操作(快)
if err := deductStock(order); err != nil {
return err
}
if err := createOrder(order); err != nil {
return err
}
// 下半部:异步做后续操作
go func() {
notifyUser(order) // 通知用户
writeLog(order) // 记日志
publishToDownstream(order) // 发消息给下游
}()
return nil // 快速返回
}问题:goroutine 方案有几个坑。
1. 进程崩溃,goroutine 里的任务丢失
→ 通知用户、发消息都没了
2. 没有重试机制
→ 通知失败就失败了,没人管
3. 没有持久化
→ 无法追踪"哪些订单还没通知"
4. goroutine 泄漏风险
→ 如果 notifyUser 阻塞,goroutine 挂着
5. 无法监控
→ 有多少通知待处理?多少失败?看不到
方案二:消息队列(生产级)。
下单接口(上半部):
1. 扣库存、创建订单(同步)
2. 发送"订单已创建"消息到消息队列(快速,异步)
3. 返回
消费者(下半部):
消费者 1:消费"订单已创建"→ 通知用户
消费者 2:消费"订单已创建"→ 记录日志
消费者 3:消费"订单已创建"→ 发消息给下游
消息队列的好处:
1. 持久化:消息在队列里,进程崩溃不丢
2. 重试:消费失败可以重试(配合死信队列)
3. 解耦:通知、日志、下游,各自独立消费
4. 削峰:下游慢,消息在队列里排队,不影响下单
5. 可监控:队列长度、消费速率、失败数,都可见
“通知用户”失败了怎么办?这是必答题:
1. 重试(自动):消费者失败 → 自动重试 N 次
- 带指数退避(第 6 章讲的退避)
2. 重试仍失败 → 进入死信队列(DLQ)
- 人工介入,或者告警
3. 幂等(关键):通知可能重试多次,要保证"只通知一次"
- 用"订单 ID + 通知类型"做幂等键
- 通知前检查"这个订单已经通知过了吗"
4. 降级:通知失败不影响核心(订单已经创建成功)
- 用户可以稍后收到通知,或者查询订单状态
5. 兜底:如果通知系统整体挂了
- 记录"待通知"状态,用户下次查询订单时补发
关键设计原则(这道题的核心):
“上半部”(同步)保证核心一致性,“下半部”(异步)保证最终一致。
- 上半部:扣库存、建订单,必须同步、必须成功
- 下半部:通知、日志、下游,可以异步、可以重试、最终一致
这个”核心同步 + 周边异步”的划分,是异步架构的第一原则。
而”异步”不等于”不管”:
- 要持久化(消息队列)
- 要重试(带退避)
- 要幂等(防止重复)
- 要监控(死信、积压、失败率)
异步的代价,就是”最终一致”——你要接受”通知可能晚一点到”,并设计好”晚了怎么办”。
一个更深的追问:什么情况下”必须全同步”?
如果业务要求"下单成功 = 通知一定成功"(强一致),
那就不能异步,必须同步通知(或至少同步确认通知被持久化)。
但现实中,通知用户这种"周边操作"很少需要强一致,
所以异步 + 最终一致是主流选择。
判断标准:
- 操作是"核心"还是"周边"?核心同步,周边异步
- 失败能否"稍后补偿"?能就异步,不能就同步
- 用户能否接受"延迟"?能就异步,不能就同步
思考题 3
“为什么 Go 的 goroutine 没有优先级?“这个问题背后反映了 Go 的什么设计哲学?如果让你给 goroutine 加优先级,会遇到哪些困难?
参考答案
先回答”为什么没有优先级”:
Go 的设计哲学是”简单、可预测”。给 goroutine 加优先级,会引入一系列复杂性和不可预测性。
原因一:优先级会破坏 Go 的调度公平性。
Go 的调度器追求"公平":每个 goroutine 都能分到 CPU。
如果引入优先级:
- 高优先级的 goroutine 可能"饿死"低优先级的(饥饿,第 6 章)
- 优先级反转问题(第 6 章讲过 Mars Pathfinder)
- 调度器变复杂(要处理优先级继承等)
原因二:Go 面向的是”服务端通用编程”,不是”实时系统”。
实时系统(RTOS、航空软件):需要精确的优先级调度
Go 的定位:通用服务端(Web 服务、后端逻辑)
在服务端,goroutine 之间通常没有"谁更重要"的明确优先级,
大家都是"处理请求的",应该公平竞争 CPU。
原因三:优先级是”稀缺资源”的分配机制,而 goroutine 是”便宜”的。
线程(OS):数量有限(受内存限制),是"稀缺资源",需要优先级分配
goroutine:极便宜(几 KB),可以随便创建几十万个
如果 goroutine 便宜到"不用省着用",那"优先级"(分配稀缺资源)的意义就小了。
原因四:让程序员自己管理优先级,容易出 bug。
如果暴露优先级 API,程序员会:
- 滥用优先级(把什么都设成高优先级,等于没优先级)
- 写出优先级反转的 bug
- 依赖"特定 goroutine 的优先级"写出脆弱代码
Go 团队的经验是:这些复杂性带来的 bug,比优先级带来的收益多。
如果强行加优先级,会遇到什么困难:
困难一:优先级反转。
高优先级 goroutine 等低优先级 goroutine 持有的资源(锁、channel)
低优先级被中优先级抢占
→ 高优先级饿死
解法:优先级继承(第 6 章),但 Go 的锁、channel、调度器都要改造
→ 复杂度爆炸
困难二:优先级和抢占的交互。
Go 是"抢占式"调度(第 8 章讲的异步抢占)。
如果 goroutine 有优先级,抢占逻辑要改成:
- 低优先级 goroutine 运行中,来了高优先级 goroutine → 抢占
- 抢占要判断"是否值得"(高优先级 vs 正在运行的低优先级)
这会让调度器的"抢占决策"复杂化。
困难三:优先级的”传递”问题。
goroutine A(高优先级)等待 goroutine B(低优先级)通过 channel 发送数据
B 需要"提升"优先级才能尽快发送
→ 优先级要在 channel 通信中"传递"
→ 类似优先级继承,但要处理"channel 的所有权转移"
困难四:饥饿的检测和处理。
有优先级,就要防止"低优先级饿死"
Go mutex 已经有"饥饿模式"(第 5 章),goroutine 调度也要类似的机制
→ 要检测"低优先级 goroutine 等待多久了",超时提升优先级
→ 又是一个复杂度来源
Go 社区对优先级的替代方案:
虽然没有 goroutine 优先级,但可以用其他方式实现"优先级":
1. 用多个 GOMAXPROCS 的进程,给关键服务独立进程(进程级隔离)
2. 用 worker pool 分层:关键任务用独立的 pool(更多 worker)
3. 用 channel 的 buffering 和调度顺序控制"先处理谁"
4. 用 context 的 deadline 控制"谁先超时"
这道题考察什么:
- 知不知道 Go 的”简单、公平”设计哲学
- 知不知道优先级会带来的问题(反转、饥饿、复杂度)
- 能不能说出”实现优先级”的具体困难(不是泛泛而谈)
- 知不知道”优先级”这个特性是有代价的,Go 选择不要这个代价
思考题 4(开放题)
“异步”和”并发”有什么区别?很多人把它们混为一谈。请用一个清晰的例子说明区别,并说明为什么”理解这个区别”对架构设计很重要。
参考答案
核心区别:并发(concurrency)是关于”结构”,异步(asynchrony)是关于”等待”。
定义:
并发(concurrency):
多个任务"在时间上重叠"地执行(可能真并行,也可能交替执行)
关注的是:"怎么组织多个任务同时推进"
关键词:任务、调度、并行
异步(asynchrony):
一个任务在"等待某个事件"时,不阻塞,而是继续做别的事(或让出)
关注的是:"怎么在等待时不浪费"
关键词:等待、回调、非阻塞
一个清晰的例子:
场景:你要做三件事——写代码(30 分钟)、等外卖(30 分钟)、打电话(10 分钟)
同步串行(既无并发也无异步):
写代码 30 分钟 → 等外卖 30 分钟(傻等)→ 打电话 10 分钟
总耗时:70 分钟,且等外卖时你啥也没干
异步但不并发(单线程):
点外卖(异步,外卖来了通知你)→ 写代码 → 外卖到了 → 停下来吃饭 → 继续写代码 → 打电话
总耗时:还是 70 分钟(因为就你一个人,一次只能做一件事)
但"等外卖"的 30 分钟,你用来写代码了(没有傻等)
并发但不异步(多线程,但同步阻塞):
你(线程 1)写代码,同时雇人(线程 2)打电话
但你(线程 1)还是傻等外卖(同步阻塞)
总耗时:写代码+等外卖 60 分钟(和打电话 10 分钟并行)
并发 + 异步(最佳):
你写代码,雇人打电话,外卖到了通知你
总耗时:30 分钟(写代码和打电话并行,等外卖不浪费时间)
用技术语言表述:
异步(单线程 + 事件循环):
JavaScript 的 setTimeout、Promise
Node.js 的单线程事件循环
→ 一个线程,通过"不阻塞等待"来处理多个任务
并发(多线程):
Java 的多线程、Go 的 goroutine
→ 多个执行流,同时推进
异步 + 并发:
Go 的 goroutine + channel(异步等待 + 并发执行)
Node.js 的 worker threads(并发) + 事件循环(异步)
为什么理解这个区别很重要?
原因一:它决定了你的架构选型。
任务多、且需要"同时推进"(CPU 密集)→ 并发(多线程/多进程)
任务多、且大部分时间在"等待 I/O" → 异步(事件循环/协程)
搞混了,就会:
- 用多线程处理大量 I/O 等待 → 线程爆炸、上下文切换浪费
- 用单线程事件循环处理 CPU 密集 → 一个任务卡死整个循环
原因二:它决定了你的”性能上限”和”瓶颈”。
异步解决的是"等待的浪费"(I/O 密集),
但它不解决"计算的并行"(CPU 密集)。
如果你的程序:
- 瓶颈在 I/O 等待 → 异步能大幅提升
- 瓶颈在 CPU 计算 → 异步没用,要并发(多核)
原因三:它影响你的”代码组织方式”。
并发:代码要处理"共享状态"(锁、竞态)
异步:代码要处理"回调/状态机"(回调地狱、async 传染)
不同的复杂度,需要不同的设计模式。
一个经典的反例(搞混两者的后果):
误解:"用 Node.js(异步)就能高性能处理所有任务"
真相:
Node.js 的异步,擅长"I/O 密集"(大量网络请求、文件读写)
但如果任务是"CPU 密集"(图像处理、加密、压缩),
单线程的 Node.js 会被一个 CPU 密集任务卡死整个事件循环。
所以 Node.js 处理 CPU 密集任务,还是要用 worker threads(并发),
而不是靠"异步"。
总结:
并发 = 关于"同时做多件事"(结构问题)
异步 = 关于"等待时不浪费"(等待问题)
两者正交:
- 可以"并发但不异步"(多线程 + 同步阻塞)
- 可以"异步但不并发"(单线程 + 事件循环)
- 可以"既并发又异步"(Go 的 goroutine + 非阻塞 I/O)
- 可以"既不并发也不异步"(同步串行)
而现代高性能系统的方向,是"既并发又异步":
并发利用多核,异步避免等待浪费。
这道题的深层价值:
“并发”和”异步”的混淆,会导致架构决策的错误。
很多技术选型的争论(“Node.js 还是 Go”、“多线程还是事件循环”), 本质是没搞清楚:我的负载是”I/O 密集”(要异步)还是”CPU 密集”(要并发)?
搞清楚了负载性质,选型就清晰了。 而这,正是第 2 章(执行流)和第 4 章(I/O 范式)反复强调的那个判断: 先搞清楚瓶颈在哪,再选择工具。
一页纸总结
┌──────────────────────────────────────────────────────────────┐
│ 第 08 章 · 中断与异步:世界不是顺序执行的 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 【中断的本质】 │
│ 事件驱动在硬件层的实现:设备主动通知 CPU │
│ 中断 = 抢占式的、异步的、不可预测的 │
│ │
│ 【上半部 vs 下半部】 │
│ 上半部(hardirq):快,响应中断,做最少工作 │
│ 下半部(softirq/tasklet/workqueue):慢,做耗时处理 │
│ 核心:把"必须立刻响应"和"可以慢慢做"分开 │
│ │
│ 【中断 vs 轮询】 │
│ 中断 = 推(低流量高效),轮询 = 拉(高流量高效) │
│ NAPI = 自适应切换(中断风暴的解药) │
│ │
│ 【异步的普适性】 │
│ 前端事件循环 = 后端消息队列 = 操作系统中断 │
│ 本质:事件源 → 事件队列 → 处理器(异步处理) │
│ 回调地狱 ≈ 中断嵌套:都是"异步嵌套"的复杂度 │
│ │
│ 【Go 的异步抢占】 │
│ 用"信号(软件中断)"实现"用户态调度器的抢占" │
│ 解决"死循环 goroutine 霸占 CPU" │
│ = 中断思想在 Go runtime 的应用 │
│ │
│ 【异步的代价】 │
│ 状态管理复杂、时序不确定、错误难传播、优先级难、难观测 │
│ 解法:快慢分离、推拉结合、同步化抽象(async/await、goroutine) │
│ │
│ 【可迁移的原则】 │
│ 1. 核心同步,周边异步(上半部/下半部) │
│ 2. 异步 ≠ 不管:要持久化、重试、幂等、监控 │
│ 3. 并发是"结构",异步是"等待"——别搞混 │
│ │
│ 【一句话】 │
│ 世界是异步的,操作系统的中断机制是应对异步的范本; │
│ 它的精华是"快慢分离"和"推拉结合",它的代价是"复杂度"。 │
│ │
│ 【第二部完 · 第三部预告】 │
│ 并发和秩序搞清楚了,但"正确"不等于"快"—— │
│ 下一部讲性能:缓存、零拷贝、调度、摊销, │
│ 以及"吞吐和延迟"这场永恒的战争。 │
└──────────────────────────────────────────────────────────────┘