进程与线程:操作系统到底在管理什么?

说实话,很多做并发编程的同学,上来就怼锁、怼队列、怼线程池。但问一句「线程切换到底干了什么」,往往就卡住了。我个人习惯是,先搞懂操作系统那层怎么干活,再谈上层怎么优化。这一节,我们就从底层扒一扒进程和线程的老底。

进程:资源分配的最小单位

进程是什么?说白了,就是一个正在运行的程序实例。它有自己的地址空间、文件描述符、信号处理器、环境变量……嗯,几乎一切资源。

我当年刚入行时,以为进程就是个「运行中的程序」。后来线上一个服务 OOM 了,我查了半天才发现,是另一个进程把系统内存吃光了。那一刻我才真正理解:进程之间是隔离的,它们不共享地址空间。

进程的核心组成:

  • 虚拟地址空间:代码段、数据段、堆、栈,各进程独立
  • 进程控制块(PCB):内核中记录进程状态、优先级、寄存器上下文的数据结构
  • 资源句柄表:打开的文件、网络连接、信号量等

线程:CPU 调度的最小单位

线程是进程内的一个执行流。同一个进程的线程,共享地址空间和大部分资源。但每个线程有自己的栈和寄存器上下文。

你想想看,如果每次切换都要换整个地址空间,那代价得多大?线程的出现,就是为了解决这个问题——线程切换不涉及地址空间切换,所以比进程切换快得多。

对比维度 进程 线程
地址空间 独立 共享
资源开销 大(创建需复制页表、文件表等) 小(仅分配栈和TCB)
切换代价 高(TLB刷新、页表切换) 低(寄存器保存/恢复)
通信方式 IPC(管道、消息队列、共享内存) 直接读写共享变量
安全性 高(隔离性强) 低(一个线程崩溃可能影响整个进程)

我的经验:在 Go 或 Java 里,我们通常用线程池来管理线程。但如果你在做嵌入式或高性能网络库,有时候用进程模型反而更安全——一个 worker 挂了不会拖垮整个服务。我曾经在金融交易系统里见过用多进程 + 共享内存的方案,隔离性和性能都兼顾了。

上下文切换:到底在切什么?

上下文切换,就是 CPU 暂停当前任务,去执行另一个任务的过程。这个过程不是免费的,而且代价比你想象的大。

我举个例子。你正在写代码,突然电话响了。你停下打字,记住刚才写到哪一行(保存上下文),接电话(执行新任务),挂电话后找回刚才的代码位置(恢复上下文)。这个过程就是上下文切换。

在操作系统里,一次完整的上下文切换包括:

  1. 保存当前线程/进程的寄存器状态(PC、SP、通用寄存器等)到 PCB/TCB
  2. 切换内核栈(每个线程有自己的内核栈)
  3. 切换地址空间(如果是进程切换,需要刷新 TLB)
  4. 加载新任务的寄存器状态
  5. 跳转到新任务的指令指针

注意:上下文切换的开销不仅仅是 CPU 时间。还有缓存失效(L1/L2 cache miss)、TLB 刷新、内存访问延迟。我曾经在一个高并发网关项目里,把线程数从 64 降到 8,吞吐量反而提升了 3 倍。原因就是上下文切换太频繁,CPU 都在「切来切去」,根本没时间干正事。

CPU 时间片调度:谁来决定谁先跑?

操作系统用调度器来决定哪个线程/进程获得 CPU 时间。时间片(time slice / quantum)就是每个任务一次能跑多久。

常见的调度策略:

  • CFS(完全公平调度器):Linux 默认策略。它维护一个红黑树,按虚拟运行时间排序。每次选 vruntime 最小的任务执行。说白了,就是「谁跑得少,谁先跑」。
  • 实时调度(FIFO / RR):优先级高的先跑,直到主动让出或被更高优先级抢占。
  • 多级反馈队列:Windows 和部分 Unix 用。新任务进高优先级队列,时间片短;如果频繁让出 CPU,降级到低优先级队列,时间片变长。

时间片的大小很关键。太短了,切换开销占比高;太长了,交互式任务响应慢。Linux 默认时间片通常是 4ms 到 10ms 左右。

关键指标:

  • 调度延迟:从任务就绪到真正执行的时间
  • 吞吐量:单位时间内完成的任务数
  • 公平性:每个任务获得 CPU 时间的比例

一张图看懂进程、线程与调度

下面这张 SVG 图,把进程与线程的关系、上下文切换的流程、CPU 时间片调度串起来了。我建议你仔细看看,尤其是箭头指向的部分。

进程、线程与CPU调度关系图 进程、线程与CPU调度关系图 进程 A 地址空间独立 线程 A1 线程 A2 线程 A3 进程 B 地址空间独立 线程 B1 线程 B2 CPU 调度器 CFS / 时间片分配 上下文切换 保存/恢复寄存器 + TLB 时间片轮转 4ms ~ 10ms 一次切换 线程切换:仅保存/恢复寄存器,不切换地址空间 → 开销小 进程切换:需要切换地址空间 + 刷新 TLB → 开销大 CPU 时间片调度示例(4ms 时间片) A1 B1 A2 B2 A3 B1 A1 B2 A2 B1 A3 0ms 4ms 8ms 12ms 16ms 20ms 24ms 28ms

避坑指南:我踩过的那些坑

讲几个我亲身经历过的教训,希望能帮你少走弯路。

坑一:线程数越多越好?

我曾经维护过一个 Java 服务,线程池配了 200 个线程。结果 CPU 使用率只有 30%,但系统响应时间却很高。用 perf 一看,70% 的 CPU 时间花在了上下文切换上。后来把线程数降到 16,配合异步 IO,吞吐量翻了 4 倍。

教训:线程数 = CPU 核心数 × (1 + 等待时间/计算时间)。IO 密集型可以多一些,但别盲目。

坑二:进程切换比线程切换「贵」多少?

有一次我在做性能压测,发现多进程模型比多线程模型慢了将近一倍。用 strace 跟踪,发现每次进程切换都触发了 TLB flush。而线程切换因为共享地址空间,TLB 条目大部分还能用。

教训:如果线程间需要频繁通信,用线程;如果强调隔离性,用进程。但别混用,否则调试起来想哭。

坑三:时间片设置不当的后果

我在一个实时音视频项目里,把 Linux 的时间片调成了 1ms,想提高响应速度。结果切换开销占比太高,反而导致帧率下降。后来改回默认值,配合 SCHED_FIFO 实时调度,才解决问题。

建议:除非你做实时系统,否则别动时间片。Linux 默认的 CFS 调度器已经优化得很好了。

小结

这一节我们聊了进程和线程的本质区别、上下文切换到底在干什么、CPU 时间片怎么分配。这些东西看起来偏底层,但理解透了,你写并发代码时就会知道:什么时候该用线程池、线程数配多少、为什么不能无脑开线程。