进程与线程详解:区别、状态、通信、上下文切换与虚拟线程
一句话概括这对概念:进程是操作系统分配资源、隔离故障的单位,线程才是真正被调度器放到 CPU 上跑的单位。本文顺着”为什么需要这两层抽象、切换贵在哪、协程解决了什么、工程上怎么选”这条线展开。
它解决什么问题
一台机器只有几颗 CPU,却要同时跑几十上百个程序。操作系统引入进程,本质是给每个运行中的程序圈出一块独立领地:各自的虚拟内存、文件句柄、用户权限互不可见,一个程序崩溃不会拖垮邻居。
但光有进程还不够。一个 Web 服务器内部要同时处理成千上万的连接,如果每个连接都开一个进程,内存和切换开销都扛不住,进程之间共享一份缓存还得走 IPC。于是有了线程:在同一个进程的领地里跑多条执行流,共享全部资源,协作成本大幅降低。进程解决”程序之间”的隔离与多路复用,线程解决”程序内部”的并发。
核心原理:资源归进程,执行归线程
判断一样东西属于哪一层,只需问一句:它是资源,还是执行现场?
- 进程持有资源:虚拟地址空间(代码段、数据段、堆、映射区)、打开的文件描述符与 Socket、用户 ID 与权限、信号处理表、环境变量。
- 线程私有执行现场:独立的栈(函数调用链、局部变量)、寄存器快照、程序计数器、线程 ID、线程本地存储(TLS)、调度优先级。
共享带来双面性。好处是通信几乎免费:一个线程往堆里写数据,另一个线程下一行代码就能读到,不用管道也不用序列化。代价同样直接:多个线程交叉读写同一份可变数据就会产生竞态,必须靠锁和原子操作兜底;而且任何一个线程越界写内存、触发段错误,整个进程一起退出——线程之间没有故障隔离。
线程的状态机也围绕”能不能占用 CPU”展开:万事俱备只差 CPU 时是就绪,正在执行是运行,等 I/O、等锁、等事件时是阻塞。时间片耗尽从运行退回就绪,等待的事件完成则从阻塞回到就绪,重新排队。
深入机制
上下文切换的钱花在哪
切换的字面动作只有三步:把当前线程的寄存器、程序计数器、栈指针存进内核里的控制块,调度器选出下一个可运行线程,再把它的现场装回 CPU。真正的隐性成本在后面:
- 缓存与 TLB 污染:新线程的工作集和旧线程不同,CPU 缓存大量失效,刚切换完的一段时间内存访问明显变慢。
- 地址空间切换:跨进程切换还要换页表,TLB 几乎整体失效,这是”进程切换比线程切换重”的主要原因——同进程线程共享地址空间,这部分开销被省掉了。
- 调度与迁移:线程数远超核数时,调度器本身的开销、跨核迁移造成的缓存行失效都会被放大。
所以”线程轻、进程重”是对的,但差距主要来自内存管理,而不是寄存器保存本身。
fork、exec、wait:Unix 的进程三段式
pid_t pid = fork(); // 复制出子进程,父进程拿到子 PID,子进程拿到 0 |
fork 出来的子进程拥有独立的地址空间,但靠写时复制(Copy-on-Write)与父进程共享物理页,任何一方写入时才真正复制,所以创建很快。文件描述符表会被复制一份,但两边指向同一个打开文件描述,文件偏移量共享,这一点常被忽视。子进程退出后若父进程迟迟不 wait,就会残留为僵尸进程。
一个经典陷阱:多线程程序调 fork,子进程里只活下来调用者这一个线程,而其他线程持有的锁、malloc 内部状态却被原样复制——拿到的是一把”永远不会被释放”的锁。因此多线程程序 fork 后通常立刻 exec,中间只允许调异步信号安全的函数。
第三层抽象:协程与虚拟线程
协程(纤程)把调度权拿回用户态:切换只是换栈和寄存器,不陷入内核,成本远低于线程切换,单机可以轻松跑到十万级。代价是内核看不见它们,一个协程发起阻塞式系统调用,承载它的线程就被一起堵死,需要运行时用非阻塞 I/O 配合。
Java 21 的虚拟线程走的就是这条路:大量虚拟线程由 JVM 调度,轮流挂载到少量平台线程(每个平台线程对应一个 OS 线程)上执行;遇到 JDK 支持的阻塞 I/O 时,虚拟线程被卸载,平台线程马上去跑下一个。它提升的是 I/O 密集场景的并发承载量,对 CPU 密集计算没有任何加速,而且在 synchronized 块或 native 调用里阻塞可能发生 pinning,把平台线程一起占住。
工程实践
- 线程数估算:CPU 密集任务的线程数贴近核数即可;I/O 密集可按
核数 × (1 + 等待时间/计算时间)估算。盲目加线程只会换来栈内存膨胀(Linux 下一个线程默认栈约 8 MB)和调度内耗。 - 故障隔离优先选多进程:Chrome 每个标签页一个进程、Nginx 多 worker,一个实例崩溃不影响整体,还能单独重启、单独限权。
- 高频共享状态选多线程:内存缓存、连接池、计数器这类需要低延迟共享的场景,多线程省去 IPC 序列化成本。
- 海量并发连接选协程/虚拟线程:网关、推送这类大部分时间都在等 I/O 的业务,虚拟线程能用同步写法扛住十万级并发,不必回到回调地狱。
对比与权衡
| 维度 | 进程 | 线程 | 协程/虚拟线程 |
|---|---|---|---|
| 调度者 | 内核 | 内核 | 用户态运行时 |
| 地址空间 | 独立 | 同进程共享 | 同进程共享 |
| 单次切换成本 | 高(换页表、TLB 失效) | 中(换现场、污染缓存) | 低(用户态换栈) |
| 通信方式 | IPC:管道、共享内存、Socket | 直接读写共享内存,需同步 | 同左,常用 Channel 约束 |
| 故障影响 | 隔离,崩溃不扩散 | 一个线程崩溃拖垮全进程 | 同线程 |
| 典型量级 | 几十到几百 | 几百到几千 | 上万到几十万 |
多进程与多线程的选型,核心看两点权衡:要隔离性、安全性、可独立伸缩,接受更高的内存与通信成本,选多进程;要低延迟共享、低创建开销,接受竞态与共毁风险,选多线程。现实中常见混合架构,比如 Nginx 多进程保稳定,每个 worker 内部再用事件驱动处理高并发。
面试视角
进程和线程的区别是什么?
进程是资源分配与隔离的单位,拥有独立地址空间和系统资源;线程是调度的单位,共享进程资源,只私有栈、寄存器和程序计数器。再补一句切换成本与故障隔离的差异,就比只背定义完整。
为什么进程切换比线程切换慢?
两者都要保存和恢复执行现场,差距在内存:进程切换要换页表,TLB 大范围失效,新进程的缓存局部性也要重新建立;同进程线程切换跳过了这一层。
有了线程为什么还要协程?
线程的创建和切换都要内核参与,量级上不去;协程在用户态调度,切换不陷入内核,单机可支撑十万级并发。代价是阻塞调用会堵死承载线程,需要运行时把 I/O 改造成可挂起的形式。
fork 之后父子进程共享什么?
靠写时复制共享物理内存页,文件描述符表各自一份但指向相同的打开文件描述(偏移量共享);代码段只读共享。多线程进程 fork 后子进程只剩调用线程,继承的锁状态是危险的。
小结
- 分工一句话:资源与隔离归进程,执行与调度归线程,协程在用户态复用线程。
- 切换成本的大头不是寄存器,而是地址空间切换引发的 TLB 与缓存失效。
- 选型看隔离与共享的权衡:隔离优先多进程,共享优先多线程,海量 I/O 等待上协程或虚拟线程。

