一句话概括这对概念:进程是操作系统分配资源、隔离故障的单位,线程才是真正被调度器放到 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
if (pid == 0) {
execl("/bin/ls", "ls", NULL); // 用新程序替换当前进程映像
}
waitpid(pid, NULL, 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 等待上协程或虚拟线程。