跳至主要內容

OS5 - 进程管理3

codejavaCS约 4285 字大约 14 分钟

OS

中断切换

以线程 A 发起读取磁盘文件为例

第一阶段:发起 I/O 时的“软中断”与主动切换(线程 A 交出 CPU)

当线程 A 执行到读取文件的代码时,它其实是没有权限直接控制底层硬件的,必须向操作系统老大哥(内核)求助。

  • 系统调用(软中断/Trap): 线程 A 调用 read() 函数,这会触发一个软中断(在 x86 架构下通常是 int 0x80 指令或 syscall 指令)。CPU 收到这个信号后,会从“用户态”切换到“内核态”,开始执行操作系统的代码。
  • 下达指令与阻塞: 操作系统内核接收到请求,把读取任务交给 DMA 和磁盘控制器。因为此时数据还没准备好,操作系统会将线程 A 的状态标记为 BLOCKED(阻塞状态),并把它移出 CPU 的运行队列。
  • 第一次上下文切换(Context Switch): 既然线程 A 被挂起了,CPU 不能闲着。操作系统的调度器(Scheduler)会立刻介入,从就绪队列里挑出一个线程 B,把 CPU 的控制权交给它。
    • 此时,DMA 在默默搬运硬盘数据,而 CPU 正在全速运行线程 B 的代码。两者互不干扰。

第二阶段:I/O 完成时的“硬中断”与被动打断(硬件通知 CPU)

经过了漫长的等待(对 CPU 而言),DMA 终于把磁盘里的数据全都搬到了内存缓冲区里。这个时候,硬件需要告诉 CPU:“活儿干完了!”

  • 发出硬中断信号: DMA 控制器会向 CPU 的中断控制器(如 APIC)发送一个真实的电信号(硬件中断)。
  • 打断当前线程(强制暂停): CPU 只要启用了中断响应,它在执行完当前手里这一条机器指令后,会立刻强制暂停正在运行的线程 B。CPU 会把线程 B 当前的执行进度(程序计数器 PC、寄存器状态等)火速保存到栈里。
  • 执行中断服务程序(ISR): CPU 调转枪头,去内核里执行一段专门用来处理 I/O 完成的“中断服务程序(Interrupt Service Routine)”。
  • 唤醒等待的线程: 在这个 ISR 代码中,操作系统发现:“哦!是之前线程 A 要的数据到了!”于是,操作系统把线程 A 的状态从 BLOCKED 改回 READY(就绪状态),把它重新扔进就绪队列里,等待下次被调度。

第三阶段:中断返回与最终的切换(线程 A 夺回 CPU)

硬中断处理完毕后,CPU 接下来该运行谁呢?这取决于操作系统的调度策略。

  • 恢复现场: 通常情况下,如果没有触发更高优先级的抢占,CPU 会恢复刚刚被强制打断的线程 B 的现场,线程 B 就像什么都没发生过一样继续往下跑。
  • 第二次上下文切换(线程 A 回归): 直到未来的某一个时刻(也许是线程 B 的时间片用完了,或者线程 A 的优先级特别高),操作系统的调度器再次介入。它选中了刚刚被唤醒的线程 A。
  • 满血复活: 线程 A 恢复执行,系统调用 read() 成功返回,线程 A 终于拿到了它梦寐以求的数据,继续执行后面的业务逻辑。

CPU

CPU 是计算机的大脑,负责执行指令、做运算、控制数据流动。其他存储和 IO 设备是给它服务的

中断指令就是让他们干活,然后 CPU 去干别的,直到被他们通知干完了

CPU 与 IO 设备的交互方式

轮询

CPU 根本不切换线程,而是写一个死循环,每隔几纳秒就去读一次硬件的寄存器,看看状态位是不是变成了“完成”

缺点:CPU 一直忙等,浪费资源

中断
  • 特点: 硬件主动发送电信号打断 CPU。
  • 优点: CPU 极其轻松。在硬件干活期间,CPU 可以去跑别的线程,一点也不浪费算力。
  • 适用场景: 绝大多数场景,比如读取常规磁盘、普通网络请求、鼠标键盘点击
CPU: 磁盘,去读这个文件(发指令)
磁盘: 收到
CPU: 切换去干别的事

... 时间流逝 ...

磁盘: CPU,我读完了!(发送中断信号)
CPU: 停下手头工作,处理中断
CPU: 好的,我来处理数据

优点:CPU 不用等待,效率高

CPU 必须响应中断吗

不一定,CPU 是有权“延后”甚至“忽略”某些中断的

可以用一个很生活化的场景来理解:你正在聚精会神地写一段核心代码,突然手机响了(收到中断通知)。你可能会马上接起(重要电话),也可能会按静音等会儿再打回去(推销电话),而且就算你要接,你通常也会把正在敲的那行代码敲完再接。

CPU 处理中断的逻辑与此惊人地一致,主要受以下三个规则的严格限制:

1. 执行的颗粒度:必须执行完当前的“一条指令”

CPU 响应中断的最小时间单位是机器指令周期,而不是进程的业务逻辑。

当 CPU 正在执行一条底层的机器指令(比如把寄存器 A 的值加到寄存器 B 里)时,即使电信号已经到达引脚,CPU 也绝对不会在一条机器指令执行到一半时停下来。 它一定会把当前这一条指令完整执行完毕,然后在进入下一条指令之前,专门腾出一个极其短暂的瞬间去检查:“刚才有中断信号来吗?”如果有,才会进行中断上下文切换。

2. CPU 的“免打扰模式”:可屏蔽 vs 不可屏蔽

这是最核心的机制。

中断在硬件层面上被分成了两大类:

  • 可屏蔽中断(Maskable Interrupt, INTR): 绝大多数我们讨论的 I/O 中断(如硬盘读写完成、网卡收到数据、键盘敲击)都属于这一类。 CPU 内部有一个特殊的标志位(在 x86 架构中叫 IF,Interrupt Flag)。当操作系统在执行某些极其核心、绝对不能被打断的代码(比如正在修改系统内核的重要数据结构,或者正在处理另一个更高级别的大中断)时,它会主动把这个标志位关掉(俗称关中断)。 在这个“免打扰模式”下,就算硬盘疯狂发来完成通知,CPU 也会充耳不闻。这些中断信号只能在外部的中断控制器(如 APIC)里排队等着,直到操作系统办完核心大事,重新开中断,CPU 才会去响应它们。
  • 不可屏蔽中断(Non-Maskable Interrupt, NMI): 这是真正的“十万火急”,无视任何免打扰模式。 这类中断通常对应着极其严重的系统级硬件故障。比如:内存条烧了导致奇偶校验错误、主板电源马上就要断电了。一旦出现 NMI,CPU 必须立刻放下手头的一切(不管在干什么)强制跳转去执行紧急处理程序(通常结果就是直接蓝屏死机,或者尝试做最后的内存快照保存)。
3. 中断的“鄙视链”:优先级调度

就算 CPU 当前没有开启免打扰模式,它也遵循严格的优先级。 如果 CPU 正在处理一个普通的网卡中断(正在把网络包拷进内存),此时突然来了一个时钟中断(级别更高)。CPU 会暂停当前正在处理的低级中断,去优先处理高级中断。这就是所谓的“中断嵌套”。 反之,如果正在处理高级中断时来了低级中断,低级中断就只能老老实实在外面排队。

挂起与中断

进程/线程的挂起(阻塞)和被激活(唤醒),本质上全都是由“中断”来驱动的。 只不过,这里的“中断”分为两种不同的流派:硬件中断软件中断(异常/陷阱)。我们可以把进程状态切换的过程,拆解成以下几个具体场景来看:

1. 进程被“挂起”(移出 CPU)是怎么触发的?

进程被挂起,通常有两种情况,分别对应软件中断和硬件中断:

  • 主动挂起(软件中断/系统调用): 当您的代码里写了 Thread.sleep(),或者尝试去拿一把已经被别人占用的锁,亦或是发起文件读取时。您的进程会通过一种特殊的汇编指令(如 syscall),主动触发一次软件中断(Trap)。 操作系统收到这个软件中断后,一看:“哦,你要睡觉/等数据啊,那你别占着茅坑了。”于是,操作系统把您的进程状态改成 BLOCKED,把它踢出 CPU。
  • 被动挂起(硬件中断/时钟中断): 如果您写了一个死循环 while(true),进程根本不想让出 CPU 怎么办?这就得靠时钟中断(Timer Interrupt)了。 计算机主板上有一个晶体振荡器,它每隔几毫秒就会向 CPU 发送一次硬件中断。CPU 收到信号后强制暂停当前进程,操作系统的调度器借机接管 CPU,一看:“你的时间片用完了,下去休息吧。”于是强制把该进程挂起(状态变为 READY)。

2. 进程被“激活通知”(唤醒)是怎么触发的?

当一个进程在后台眼巴巴地等数据或等锁时,唤醒它的信号同样来自中断:

  • 外部设备唤醒(硬件中断): 就像我们之前聊的,如果是等硬盘数据或网络数据,当底层的网卡/磁盘把数据准备好后,会给 CPU 发送一个硬中断。操作系统的中断处理程序介入,找到那个正在等数据的进程,把它从挂起状态“激活”,重新塞回就绪队列。
  • 其他进程唤醒(软件中断): 假设您的进程 A 在等一把锁,而进程 B 刚刚用完释放了这把锁。进程 B 在释放锁的代码底层,同样会发起一次系统调用(软件中断),告诉操作系统:“老大哥,我用完了,你去通知一下那个正在等锁的倒霉蛋吧。” 操作系统随即把进程 A 激活。

3. 一个特殊的补充:Linux 里的“信号(Signal)”

在操作系统层面,还有一种专门用来通知进程的机制叫做信号(Signal),比如我们在终端里敲击 Ctrl+C 或者输入 kill -9 PID 去杀掉一个进程。

这种机制在 Linux 源码里,经常被直接称为**“软中断机制(Software Interrupt Mechanism)”**。它是操作系统模仿硬件中断,在纯软件层面上为进程设计的一套“异步通知”系统。当进程收到信号时,它也必须放下手头的工作,去执行对应的信号处理函数。

CPU 时间片

一旦指令交到了硬盘控制器手里,操作系统就彻底失去控制权了,只能乖乖等硬件发回中断信号

“时间片”(Time Slice)是操作系统的CPU 调度器发明的一个概念。它的唯一作用,是限制一个线程占用 CPU 计算资源的时间。

  • 时间片 = “你能在 CPU 上运行多久”。
  • 只要线程离开了 CPU(比如进入了阻塞/等待状态),“时间片”这个概念对它就不生效了。

时间片是操作系统用来管理 CPU 计算资源 的概念,它只针对运行在 CPU 上的线程

而硬盘、网卡等硬件,完全是另一个独立运作的“平行世界

  • CPU 的时间片: 操作系统是 CPU 的大管家。它把 CPU 的时间切成几十毫秒一段,分给不同的线程,这叫时间片轮转。
  • 硬盘的独立运作: 硬盘内部有自己的“小 CPU”(叫磁盘控制器,Disk Controller),还有自己的内存(缓存)。当操作系统把读写指令通过总线发给磁盘控制器后,磁盘控制器就开始按照自己的节奏干活了。它根本不知道,也不在乎操作系统里的“时间片”是什么东西

比喻

  • CPU(厨师)与时间片: 厨师在厨房里切菜、炒菜,厨师长(操作系统)规定他每道菜最多只能连续炒 10 分钟(时间片),到了 10 分钟必须去炒下一道菜。
  • 硬盘(供应商)与 I/O 操作: 厨师发现缺了土豆,于是给农场供应商(硬盘)打了个电话(发起 I/O 请求)。
  • 核心逻辑: 供应商在高速公路上开车送土豆的过程(硬盘读取过程),受厨师长规定的那“10分钟”限制吗?完全不受! 供应商开得快慢,只取决于卡车性能和路况(硬件物理极限)。卡车可能开 1 分钟就到了,也可能堵车开了 30 分钟。在这期间,厨师早就去切别的菜了。直到卡车到了按响门铃(硬中断),厨师才会去处理送来的土豆。

软中断

由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行

中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。

那 Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」。

  • 上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
  • 下半部用来延迟处理上半部未完成的工作,一般以「内核线程」的方式运行。

前面的外卖例子,由于第一个配送员长时间跟我通话,则导致第二位配送员无法拨通我的电话,其实当我接到第一位配送员的电话,可以告诉配送员说我现在下楼,剩下的事情,等我们见面再说(上半部),然后就可以挂断电话,到楼下后,在拿外卖,以及跟配送员说其他的事情(下半部)。

再举一个计算机中的例子,常见的网卡接收网络包的例子。

网卡收到网络包后,通过 DMA 方式将接收到的数据写入内存,接着会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来处理该事件,这个事件的处理也是会分成上半部和下半部。

上部分要做的事情很少,会先禁止网卡中断,避免频繁硬中断,而降低内核的工作效率。接着,内核会触发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。

所以,中断处理程序的上部分和下半部可以理解为:

  • 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行;
  • 下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;

还有一个区别,硬中断(上半部)是会打断 CPU 正在执行的任务,然后立即执行中断处理程序,而软中断(下半部)是以内核线程的方式执行,并且每一个 CPU 都对应一个软中断内核线程,名字通常为「ksoftirqd/CPU 编号」,比如 0 号 CPU 对应的软中断内核线程的名字是 ksoftirqd/0

不过,软中断不只是包括硬件设备中断处理程序的下半部,一些内核自定义事件也属于软中断,比如内核调度等、RCU 锁(内核里常用的一种锁)等

上次编辑于: