跳转至

进程与线程⚓︎

摘要

本文从一般程序的执行模型出发,说明进程地址空间、forkexec、写时复制、线程共享边界、用户线程与内核线程,以及上下文切换成本如何共同决定系统中的执行实体组织方式。

问题背景⚓︎

理解进程与线程时,最重要的问题不是术语区别本身,而是“谁来执行这些逻辑”。只要任务足够多、等待足够频繁或共享足够复杂,执行实体的数量、共享方式和切换频率就会直接影响系统表现。

因此,进程与线程并不是两个孤立名词,而是程序运行模型的两个基本选择。真正需要回答的问题是:

\text{一个任务应当绑定到多少执行实体,实体之间又应共享什么资源}

进程首先解决的是隔离⚓︎

进程是资源分配的基本单位。一个进程通常拥有独立的虚拟地址空间、打开的文件描述符集合、页表、信号处理状态以及其他内核资源。抽象地看,可以写成:

\text{Process} = \text{Address Space} + \text{Open Files} + \text{Kernel State}

这意味着进程模型首先提供的是隔离性。某个进程的内存越界通常不会直接破坏其他进程的地址空间,权限边界也更自然。因此 shell、服务进程、批处理程序乃至图形应用都天然以进程为基本承载单位。

但这种隔离并不是免费的。每增加一个进程,就会增加地址空间管理、页表维护、调度状态和资源复制成本。当任务规模不断上升时,隔离性带来的收益未必能覆盖执行实体膨胀的代价。

forkexec 是两种不同动作⚓︎

fork 复制当前进程,得到一个新的子进程;exec 则在当前进程上下文中装入新的程序映像,用新的代码段、数据段和入口点替换原来的地址空间内容。它们经常一起出现,但职责完全不同。

在 Unix 风格系统里,fork 常被用来派生子进程,exec 则更常见于 shell、作业系统或需要启动新程序的场景。理解这一区别很重要,因为很多“进程模型很重”的判断,其实针对的是频繁 fork 与执行体数量失控,而不是 exec 语义本身。

写时复制降低了 fork 的初始代价⚓︎

Linux 的 fork 并不会在调用瞬间把整个地址空间的物理页逐页拷贝一份。它依赖写时复制(Copy-On-Write, COW)机制,让父子进程先共享同一批物理页,并在页表中把对应页标记为只读共享。只有当任意一方尝试写入时,才会触发缺页异常,由内核为写入方分配新页并复制旧内容。

因此,fork 的初始成本并不是:

O(\text{全部物理内存})

而更接近页表复制与进程元数据创建的成本。这也是为什么预 fork 服务、进程池模型在某些场景仍然可行。

但 COW 只是把复制成本延后,并没有消除复制成本。如果子进程启动后很快写入大量堆内存、缓存或全局状态,那么这些共享页仍然会被逐步拆开,实际代价仍然会发生。要理解这一点背后的页级机制,需要回到 虚拟内存

线程把重点从隔离转向共享⚓︎

线程是调度的基本单位。与进程不同,同一进程内的多个线程共享地址空间、打开文件、堆和大多数进程级资源,但每个线程仍然保留自己的寄存器上下文、栈和调度状态。可以近似写成:

\text{Thread} = \text{Shared Resources} + \text{Private Execution Context}

这使线程之间共享数据与通信更加直接,不需要像多进程那样显式经过 IPC 或跨地址空间复制。因此线程更容易实现线程池、任务队列、共享缓存和统一状态管理。

但共享带来的并不只是高效,也意味着多个执行流会同时读写同一批状态。于是问题从“如何复制资源”变成“如何控制并发访问”。这正是 锁与同步 一文讨论的核心。

用户线程、内核线程与协作边界⚓︎

用户线程由用户态运行时管理,切换成本较低,也更容易和协程、调度器结合;但如果底层阻塞调用无法被运行时接管,那么一个阻塞点就可能拖住整个调度实体。内核线程由操作系统直接感知和调度,能够与阻塞系统调用自然配合,但创建、销毁和切换成本更高。

现代程序常见的组合并不是二选一,而是“以内核线程提供与内核交互的基础执行体,再在用户态叠加线程池、协程或其他调度结构”。因此讨论线程模型时,更准确的问题不是“用户线程好还是内核线程好”,而是“应用层调度与内核调度分别负责什么”。

线程栈与实体规模⚓︎

每个线程都需要独立的栈,用来保存函数调用帧、返回地址和局部变量。当线程数量上升时,栈空间开销会线性增长:

\text{Total Stack} \approx \text{Thread Count} \times \text{Stack Size}

这也是“任务数远大于线程数”经常成为必要设计的原因之一。即使线程大部分时间只是在等待 I/O,没有真正执行很多计算,栈空间和调度状态仍然必须长期存在。

因此,控制执行实体数量不是一种优化技巧,而是程序设计中的基础约束。某些 I/O 模型之所以重要,就在于它们让线程数不必与等待对象数量直接绑定,参见 I/O 模型

上下文切换为什么会成为系统成本⚓︎

无论是进程切换还是线程切换,系统都需要保存旧执行流的寄存器状态,恢复新执行流的上下文,并付出调度器参与、缓存污染和可能的 TLB 影响。进程切换通常更重,因为地址空间相关状态也会发生变化;线程切换虽然更轻,但频率一高,同样会侵蚀吞吐。

因此需要关注的并不是“线程切换比进程切换轻”这一句结论本身,而是:

\text{实体数量} \times \text{切换频率}

是否仍然处于系统可承受范围内。对高并发程序而言,这往往比单个切换动作的微观开销更重要。

与系统设计的关系⚓︎

从系统设计角度看,进程与线程这一主题真正回答的是:在隔离、共享和调度成本之间,程序应当如何组织执行实体。

  • 如果优先考虑隔离、崩溃边界和权限控制,多进程模型更自然。
  • 如果优先考虑共享内存、降低跨实体通信成本,多线程模型更直接。
  • 如果任务规模远大于 CPU 核数,通常需要把执行实体数量控制在远小于任务数量的级别,并结合线程池、协程或其他调度方式。

因此,进程与线程并不是纯粹的“操作系统基础概念”,而是程序如何在隔离性、共享性和调度成本之间取得平衡的前提条件。继续往下看共享状态的协调成本,可阅读 锁与同步

评论