操作系统第二章:进程、线程与IPC

学完本章需要回答的问题?

  • 程序是如何执行的?
  • 进程状态转换?
  • 如何实现原语的原子性?进程控制相关的原语?
  • 进程通信IPC的三种方式?
  • 信号作用、发送、保存、处理时机、如何处理、特点?
  • 引入线程机制后,在资源分配、调度、并发行、系统开销方面有什么变化?
  • 多线程模型?

进程的概念、组成、转换

主要看是不是缺资源,缺资源的话就得转化成阻塞态。转化成阻塞态的过程也可以是强制的(缺页中断)

进程转换

原子性是防止操作系统管理混乱

进程通信IPC

三大类:共享存储(低级、高级)、消息传递(直接、间接)、管道通信
共享存储:基于存储区的共享开辟了一片共享存储空间,并映射到各自进程的虚拟地址空间,访问比较自由,位置随意。互斥访问:P/V,可多个读,不可多个同时写;基于数据结构的共享,例如开辟了一个int a[10]。
消息传递:
进程控制块(PCB)和大多数核心的操作系统数据结构,都存放在操作系统内核地址空间中
间接通信怎么实现A到B发消息?创建一个专属于B监听的信箱即可
管道通信:内存缓冲区,与共享存储的区别在于,它是循环队列FIFO,必须按顺序来。互斥访问,同时写引发挂起。读后数据就没了。读空和写空都阻塞。

信号(IPC的另一种形式)

信号本质上是硬件中断的一种软件模拟,用于通知进程发生了某个事件。
硬件中断针对CPU,软中断针对进程。

对比维度 ⚡ 硬件中断 (原版) ✉️ 信号/软中断 (软件模拟版)
打断的对象 CPU 硬件 普通用户进程
谁发出的? 外部物理设备 (键盘、网卡、时钟) 操作系统内核、其他进程 (如 kill 命令)
通知的方式 给 CPU 引脚发一个高低电平信号 在进程的 PCB (进程控制块) 里把某一位标记为 1
何时触发? 随时 (电平一到,CPU 下一条指令立刻执行) 随时 (只要进程被调度到,准备从内核态返回用户态时)
去哪里查表? 中断向量表 (IDT),这表是系统全局的 信号处理表,这表是每个进程私有的
谁来处理? 内核的 中断服务程序 (ISR) 开发者自己写的 信号处理函数 (Signal Handler)
处理完怎么办? 执行 iret 硬件指令,恢复现场,回到被打断的程序 执行 sigreturn 系统调用,恢复现场,回到被打断的代码行

信号的生命周期分为三个阶段:发送 -> 注册(挂起) -> 处理。
“软件模拟”的过程,我们想象一个场景:你写了一个死循环的 Python 脚本一直在跑,然后你在终端按下了 Ctrl+C(触发 SIGINT 信号)。
在这个瞬间,操作系统是如何模拟硬件中断,强行改变你 Python 脚本的执行流的?
“挂号” (注册信号):
当你按下 Ctrl+C,键盘触发硬件中断。内核醒来,发现你要终止某个 Python 进程。内核并不会直接杀掉它,而是悄悄跑到这个 Python 进程的“档案袋”(PCB)里,把写着 SIGINT 的那个格子画个勾(挂起信号)。
“强制拦截” (拦截执行流):
内核处理完别的事情,准备把 CPU 还给你的 Python 进程(从内核态退回用户态)时,内核会习惯性地看一眼档案袋:“哟,你头上有个 SIGINT 信号待处理。”
“偷梁换柱” (强行塞入处理函数):
最精彩的“变魔术”来了!内核不会让你的 Python 进程回到刚才死循环的那行代码。相反,内核会强行修改你进程的执行指针(PC寄存器),把它指向 Python 解释器里预设好的“信号处理函数”(比如抛出 KeyboardInterrupt 异常的代码)。
“恢复原状” (如果你没被杀死):
假设你捕获了这个异常并且没有退出。当信号处理函数执行完后,会触发一个特殊的系统调用(sigreturn),内核再次介入,把你当初真正断开的那行代码的地址塞回寄存器,你的脚本继续往下跑。
你看,对于你的 Python 进程来说,它是不是感觉自己就像 CPU 一样,正好好跑着代码,突然被一种“神秘的外部力量”强行打断,逼着去执行了一段处理程序,然后再回来? 这就是为什么叫它“软件模拟”。

线程

注意,同一进程内的不同线程间共享进程的系统资源

用户级线程本质上还是一个进程,用线程库实现的逻辑上的多线程,例如while循环依次执行视频聊天、文字聊天、文件传输。易阻塞

高级编程语言中的线程实践(应用+OS层面)

语言/环境 线程类型 模型 行为特性
Java (JVM) 内核级线程 (KLT) 一对一 充分利用多核 CPU,适用于 CPU 密集型和 I/O 密集型任务。
C/C++ (pthreads, std::thread) 内核级线程 (KLT) 一对一 直接暴露操作系统线程,性能高,完全并行,但编程复杂。
Python (threading, CPython) 内核级线程 (KLT) 近似多对一 线程本身是 KLT,但 GIL 限制了同一时间只有一个线程执行 Python 字节码,无法并行执行 CPU 密集型任务。
Go (Goroutines) 多线程模型 (m:n) 多对多 Goroutines 非常轻量级,可以在少数 OS 线程上实现高并发和高性能的 I/O 密集型服务。
Node.js(Async/Await) 依赖事件循环 无线程 Node.js 是单线程模型,通过异步 I/O 和回调机制模拟高并发,避免了线程管理开销。

机制解析:Go 的 M:N 模型

  • Goroutine (G) 是 ULT: Goroutine 由 Go 运行时(Runtime)管理,而非操作系统内核。它的创建、销毁和切换都在用户空间完成,因此极快且开销极低(仅需几 KB 的栈空间)。

  • 操作系统线程 (M) 是 KLT: 操作系统线程(通常是 pthreads)由操作系统内核管理。

  • 逻辑处理器 (P) 是中介: Go 运行时使用 P 来将 G 绑定到 M 上。P 的数量通常等于 CPU 核心数。

核心原因:为了极高的并发性(Concurrency)和可扩展性。

  1. 极低开销: Go 可以轻松创建数十万甚至数百万个 Goroutine,这是传统的一对一内核级线程模型(如 Java或 C++ 默认线程)无法承受的。

  2. 避免阻塞: 如果一个 Goroutine 发起阻塞的系统调用(例如 I/O),Go 运行时会检测到这个 M 线程即将阻塞。它会:

    • 将该 Goroutine 从 M 上解绑,让 M 带着这个 Goroutine 去阻塞。

    • 将 M 上的 P 转移给一个新的、非阻塞的 M 线程。

    • 其他等待运行的 Goroutine 调度到这个新的 M 上运行。

这个过程被称为 Goroutine 抢占式调度,它避免了一个 Goroutine 阻塞导致整个进程停顿的问题,同时保持了高效的用户态线程管理。

机制解析:Node.js 的 单线程 模型

1. JavaScript 线程(单线程)

这是 Node.js 的核心。负责执行所有用户编写的 JavaScript 代码、事件循环(Event Loop)以及回调函数。

  • 执行环境: 运行在 V8 引擎中,它是严格的单线程

  • 模型归类: Node.js 宣称的单线程模型指的就是这个 JavaScript 主线程。这个线程决定了你的代码不能有耗时的同步阻塞操作。

2. libuv 线程池(多线程 / KLT)

libuv 库是 Node.js 的核心依赖,它负责处理所有的异步 I/O 操作。

  • 线程池作用: libuv 维护了一个内部线程池(通常有 4 个 KLT),用于处理那些操作系统没有提供原生异步接口的操作,例如:

    • 文件系统 I/O(如 fs.readFile)

    • DNS 查询

    • 耗时的 CPU 密集型任务(尽管不推荐,但 libuv 线程池可以用来处理)

  • 通信方式: 当 JavaScript 主线程发起一个文件读取请求时,它将任务委托给 libuv 线程池中的一个 KLT执行,然后 JavaScript 线程立即返回。任务完成后,KLT 通过队列将结果通知事件循环,事件循环再执行相应的 JavaScript 回调函数。


操作系统第二章:进程、线程与IPC
http://example.com/2025/12/10/操作系统第二章:进程、线程与IPC/
作者
Lingkai Shi
发布于
2025年12月10日
许可协议