第 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、等待信号量)

三者的对比:

软中断 softirqtaskletworkqueue
运行上下文软中断上下文软中断上下文进程上下文
能否睡眠不能不能能
并发性多 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. 快慢分离(上半部快,下半部慢)
  2. 推拉结合(低流量中断/推,高流量轮询/拉,动态切换)
  3. 亲和性(相关的事在相近的地方处理)
  4. 用”同步化”的抽象包装异步(让异步好用)

而它的固有难题(状态管理、时序、错误处理、优先级、可观测性), 会以各种形式,出现在每一个异步系统里。


思考题

思考题 1

面试官问:“什么是中断?中断和轮询有什么区别?什么情况下用中断,什么情况下用轮询?“请你用”网卡收包”的例子回答,并说明 Linux 的 NAPI 是怎么把两者结合起来的。

参考答案

核心答案:中断是”设备主动通知 CPU”,轮询是”CPU 主动查设备”。中断适合低流量,轮询适合高流量,NAPI 把两者结合。

什么是中断:

设备就绪(网卡收到包、磁盘读完)→ 发中断信号 → CPU 暂停当前工作 → 
处理中断(上半部)→ 恢复工作

特点:
  - 事件驱动:设备"推"给 CPU
  - 低流量时高效(没事不打扰)
  - 响应快(设备就绪立刻处理)

什么是轮询:

CPU 定期检查设备状态:"有数据吗?有吗?"

特点:
  - 主动拉取:CPU"拉"数据
  - 高流量时高效(批量处理)
  - 低流量时浪费(一直问,但大多时候没事)

对比(网卡收包):

中断轮询
低流量好(零开销)差(空轮询浪费)
高流量差(中断风暴)好(批量处理)
响应延迟低(立刻)取决于轮询间隔
CPU 开销每包一次中断开销批量,摊薄

什么时候用中断,什么时候用轮询:

事件频率低 → 中断(按需响应,零浪费)
事件频率高 → 轮询(批量处理,摊薄开销)

判断标准:事件到达的频率 vs 中断处理的开销
如果中断开销 > 事件处理本身,就该考虑轮询/批量

NAPI 怎么结合两者:

NAPI 的核心:根据流量动态切换

1. 低流量:中断模式
   - 来一个包,触发一次中断,处理一个
   - 响应快,零开销

2. 检测到高流量(一个中断处理完,发现还有包):
   - 关掉中断,切换到轮询模式
   - 注册到软中断的轮询队列

3. 轮询模式:CPU 批量收包
   - 一次轮询处理 budget 个包(比如 64 个)
   - 无中断风暴,摊薄开销

4. 轮询队列清空后:切回中断模式

NAPI 的精妙之处:

它不是"选中断"或"选轮询",而是"自适应":
  - 低流量用中断(响应快)
  - 高流量用轮询(批处理)
  - 自动切换,无需人工干预

这体现了"推拉结合 + 动态切换"的设计思想。

追问:为什么不能纯轮询(不管流量)?

因为低流量时,纯轮询会浪费 CPU(一直问”有数据吗”,但大多时候没有)。中断在低流量时是零开销的(没事不打扰)。

追问:为什么不能纯中断(不管流量)?

因为高流量时,纯中断会导致”中断风暴”——每个包一次中断,中断开销占满 CPU,甚至导致活锁(一直在响应中断,没时间处理数据)。

这道题考察什么:

  1. 中断和轮询的本质区别(推 vs 拉)
  2. 各自的适用场景(低流量 vs 高流量)
  3. 知不知道 NAPI 的”自适应”设计
  4. 能不能说出”纯中断”和”纯轮询”各自的问题

思考题 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 控制"谁先超时"

这道题考察什么:

  1. 知不知道 Go 的”简单、公平”设计哲学
  2. 知不知道优先级会带来的问题(反转、饥饿、复杂度)
  3. 能不能说出”实现优先级”的具体困难(不是泛泛而谈)
  4. 知不知道”优先级”这个特性是有代价的,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. 并发是"结构",异步是"等待"——别搞混                        │
│                                                              │
│  【一句话】                                                     │
│    世界是异步的,操作系统的中断机制是应对异步的范本;              │
│    它的精华是"快慢分离"和"推拉结合",它的代价是"复杂度"。          │
│                                                              │
│  【第二部完 · 第三部预告】                                        │
│    并发和秩序搞清楚了,但"正确"不等于"快"——                       │
│    下一部讲性能:缓存、零拷贝、调度、摊销,                        │
│    以及"吞吐和延迟"这场永恒的战争。                               │
└──────────────────────────────────────────────────────────────┘