深色模式
01 · 进程与线程:谁在跑,跑的时候带着什么
这一篇是 OS 模块的地基。后面的内存、锁、IO,主体都是这里的"进程/线程"。
一句话本质
进程 = 一个正在运行的程序 + 它独占的一套资源(地址空间、文件表)。线程 = 进程内部真正被 CPU 执行的那条流水线。
生活类比:餐厅里的订单
- 程序(存在硬盘上的 exe):菜谱。放在那里不动,不会做菜。
- 进程:一桌客人下的单。有独立的食材(内存)、独立的进度(执行到哪一步)、独立的账单(资源)。
- 线程:这一桌订单里的几道菜。共用同一份食材和同一个操作台,但可以几条流水线同时做。
- PCB(进程控制块):这张订单的小票。内核靠它知道"这桌做到哪了、食材放在哪"。
- 上下文切换:厨师从做这桌换到做那桌。得先把当前这桌的进度记下来(保存寄存器),再把那桌的进度恢复(恢复寄存器)。
关键差别一句话:换一桌客人(换进程)要清空整个操作台;同一桌换一道菜(换线程)不用。
核心机制
一、进程的状态机
一个进程从创建到消亡,只会走下面这几条边:
三个必考点:
- 阻塞 > 就绪,不能直接 > 运行。因为事件到达时 CPU 可能正忙,必须重新排队等调度。这是最高频的细节坑。
- 就绪和阻塞的区别:就绪是"只差 CPU",阻塞是"CPU 给我也没用,我在等别的东西"。
- 运行 > 就绪是时间片用完(被动),运行 > 阻塞是自己发起 IO(主动)。
二、PCB 里存了什么
+-- PID / 父进程 PID
+-- 进程状态(就绪 / 运行 / 阻塞)
+-- 程序计数器 PC(下一条指令)
+-- 寄存器现场(通用寄存器、栈指针)
+-- 内存管理信息(页表基址)
+-- 打开的文件表
+-- 优先级 / 时间片 / 统计信息PCB 是进程存在的唯一标志。所谓"创建进程",内核动作就是"分配并初始化一个 PCB"。
三、线程共享什么、独占什么(必背)
| 独占(每个线程一份) | 共享(同进程内所有线程共用) |
|---|---|
| 程序计数器 PC | 代码段 |
| 寄存器现场 | 全局变量 / 静态变量 |
| 栈(函数调用、局部变量) | 堆(new 出来的对象) |
| 线程 ID、状态 | 打开的文件描述符 |
| 错误码 errno | 信号处理器 |
一句话记忆:栈是私有的,堆是共享的。所以"局部变量天然线程安全,成员变量不是"——这不是语言规定,是内存布局决定的。
四、上下文切换到底切了什么
进程 A 在跑
v 时间片用完 / 主动让出
1. 保存 A 的寄存器到 A 的 PCB < 纯 CPU 操作,很快
2. 切换内核栈
3. 加载 B 的 PCB 里的寄存器
4. 切换页表(CR3 寄存器) < 贵!会刷 TLB
5. 刷新流水线 / 分支预测器 < 贵!
v
进程 B 开始跑进程切换 vs 线程切换的差别就在第 4 步:同进程内的线程共享页表,不用切,所以线程切换比进程切换便宜得多。这是"线程比进程轻量"的真正原因,不是"线程小"这种含糊说法。
五、协程:比线程更轻
线程的切换由内核决定(抢占式),协程的切换由程序自己决定(协作式)。
线程:内核说"你该下了" > 用户程序无法拒绝 > 有内核开销
协程:程序说"我要等 IO 了" > 主动让出 > 纯用户态切换,几十纳秒代价:协程不能利用多核(一个线程内的协程是串行的),而且一个协程如果死循环不让出,整个线程都卡住。
为什么这么设计
为什么要"进程"这层隔离?
反事实:如果没有进程,所有程序共享一个地址空间。
- A 程序写地址
0x1000,B 程序也写0x1000> 互相覆盖 > A 崩了会带崩 B。 - A 程序有个野指针,能直接改掉 B 的数据库连接 > 一个 bug 变成安全事故。
- 一个程序内存泄漏,把整台机器吃光 > 所有程序一起死。
进程通过独立的地址空间(02 篇的虚拟内存)解决这一切:每个进程都以为自己独占 0x0 到 0xFFFFFFFF。
为什么进程里还要有线程?
反事实:如果只有进程,没有线程。
- 一个 Web 服务器要同时处理 1000 个请求 > 得开 1000 个进程 > 每个进程一套独立地址空间 > 内存爆炸,切换开销爆炸。
- 一个进程内想"边算边响应用户点击" > 做不到,只能等算完。
线程让同一份资源被多条流水线共用:1000 个请求共享同一份代码和缓存,切换还便宜。
为什么要协程?
反事实:如果用线程做高并发。
- 每个线程默认栈 1MB,1 万个线程 = 10GB 内存,直接崩。
- 线程切换要走内核,一次几微秒,1 万线程频繁切换,CPU 全花在切换上。
协程把栈做到几 KB 且按需增长,切换在用户态完成。这就是 Go 的 goroutine 能做到百万级并发的原因。
面试怎么问
Q:进程和线程的区别? 别只答"进程是资源分配单位,线程是调度单位"——这是第一层,人人都会。要接上:
更本质的差别在隔离与开销:进程有独立地址空间,所以隔离强、切换贵(要换页表、刷 TLB);线程共享地址空间,隔离弱(一个线程野指针能搞崩整个进程)、切换便宜。
Q:一个进程最多能创建多少线程?
理论上受三样限制:① 虚拟地址空间(每线程栈默认 1~8MB);② 内核参数(
/proc/sys/kernel/threads-max);③ 系统内存。实际瓶颈通常是内存,不是"数量上限"。
Q:线程越多越好吗?
不是。线程数超过 CPU 核数后,多出来的线程只会增加切换开销和锁竞争。CPU 密集型任务,线程数 ≈ 核数;IO 密集型可以多一些(因为线程大部分时间在阻塞)。
Q:fork 之后父子进程共享什么?
共享代码段(只读),不共享数据。但 Linux 用 写时复制(COW):fork 时父子页表指向同一批物理页,且标记为只读;任一方写入时触发缺页,内核才真正复制那一页。 所以 fork 很快,且"读多写少"的场景几乎不花额外内存。
追问链:
进程和线程区别?
> 线程共享什么?(栈私有、堆共享)
> 为什么栈私有?(每个线程要有独立的函数调用链)
> 那局部变量一定线程安全吗?(不一定,如果它是指向堆的引用/被取地址传出去)
> 那怎么保证共享数据安全?(锁 > 03 篇)
> 锁多了会怎样?(死锁 > 03 篇;性能下降 > 锁粒度)和其他知识的连接
- < 来自 操作系统导读:一台机器内部在忙什么:进程是 OS 五篇的主体
- > 影响 虚拟内存:让每个程序都以为自己独占一台机器:每个进程一套独立页表,是"进程隔离"的实现手段
- > 影响 调度与并发同步:CPU 分给谁,共享数据谁先动:线程是调度的基本单位;共享堆导致需要锁
- > 影响 IO 模型与零拷贝:等设备的时候,CPU 在干什么:进程发起 IO 后进入阻塞态,就是本篇状态机里的那条边
- > 影响 内核态与系统调用:用户程序怎么"申请"做事:创建进程(fork)、创建线程(clone)都是系统调用
- > 影响 锁与死锁:数据库连接池的线程模型,本质是这里的权衡
自测 3 问
Q1:为什么"阻塞 > 就绪"不能直接变成"运行"?
点开看答案
因为 CPU 可能正被别的进程占着。IO 完成只是"这个进程具备运行条件了",但能不能立刻上 CPU,得由调度器决定。
如果直接跳到运行,就相当于"事件一到就抢占 CPU",会破坏调度策略(比如优先级、公平性),也容易让刚唤醒的进程立刻又被打断。
所以标准路径是:阻塞 > 就绪 > (排队)> 运行。
Q2:进程切换比线程切换贵在哪一步?
点开看答案
贵在切换页表。
线程共享同一套页表,切换时只要换寄存器现场;进程有各自的页表,切换要改 CR3 寄存器指向新页表,这会导致 TLB(地址翻译缓存)失效——之前缓存的"虚拟页>物理页"映射全作废,接下来一段时间每次访存都要重新查页表,程序明显变慢。
此外流水线和分支预测器也要冲刷。所以进程切换的开销通常是线程切换的几倍到几十倍。
Q3:局部变量一定线程安全吗?
点开看答案
不一定,这里有两个坑:
- 如果局部变量是引用类型,它本身在栈上,但指向的对象在堆上。多个线程拿到同一个引用,就共享了那个对象。
- 如果局部变量的地址被传出去(比如取地址赋给全局变量、或者传给另一个线程),那它就不再"私有"了。
所以准确说法是:栈上的值本身是私有的,但栈上的引用可能指向共享数据。
类比失效处 / 易错点
- "一桌订单"会让人以为进程之间有共享。实际上进程地址空间完全隔离,共享必须显式申请(共享内存
shm)。 - "换菜不用清操作台"不完全对。线程切换虽然不换页表,但寄存器现场必须换,而且如果两个线程跑在不同核上,还会涉及缓存行的迁移(伪共享问题)。
- "线程比进程轻量"不等于"线程可以无限开"。默认栈 1MB,1 万线程就是 10GB 虚拟地址空间,32 位系统直接不够用。
- fork 之后子进程从哪开始执行? 不是从头,是从 fork 返回处。父子进程的 fork 返回值不同(父得到子 PID,子得到 0),这是区分两者的标准做法。
- 僵尸进程 vs 孤儿进程:僵尸是子进程死了但父进程没 wait(占着 PCB);孤儿是父进程先死,子进程被 init/systemd 收养。两者方向相反,别混。
✅ 验收清单
- 能默画进程 5 状态转换图,并说清每条箭头的触发条件(尤其"阻塞只能到就绪")
- 能说出 PCB 里至少 5 项内容,并解释"为什么 PCB 是进程存在的唯一标志"
- 能不看资料列出线程独占的 3 样、共享的 3 样,并说出"栈私有堆共享"的由来
- 能解释进程切换比线程切换贵在哪一步(要说出页表和 TLB,不能只说"开销大")
- 能用"反事实"讲清:没有进程隔离会出什么事、没有线程会出什么事
- 能说出 fork 的写时复制机制,以及它为什么让 fork 很快
- 合上书,用自己的话讲 3 分钟,中途不卡壳