适用读者:写过多线程但说不清"线程到底共享什么"的人;被"开更多线程会更快"坑过的人。
前置:S02-B01。
实测环境:Ubuntu 24.04.1 / 8 逻辑核 / Python 3.12.3;实测日期 2026-11-05。
覆盖旧文:A-1615(进程与线程的区别,103 点)。
覆盖扩展:E-S02-002(调度与 CFS)、E-S02-003(线程模型与协程)、E-S02-004(上下文切换成本)。
上一篇:S02-B01 · 从进程到网卡:一台机器如何把数据发出去
下一篇:S02-B03 · TCP 三次握手与四次挥手。
1. 一句话结论
进程是"资源分配的单位",线程是"CPU 调度的单位"。
这句话之所以重要,是因为它解释了两个最常见的事实:
| 事实 | 原因 |
|---|---|
| 开 4 个线程跑 CPU 密集任务,不会快 4 倍(除非真有 4 个核且没有串行化) | 线程是调度单位,核数才是上限 |
| 线程间共享变量会出问题,进程间不会 | 线程共享地址空间,进程各有独立地址空间 |
2. 一张表说清"共享什么"
| 资源 | 同进程内的两个线程 | 两个不同进程 |
|---|---|---|
| 地址空间(堆、全局变量、代码段) | 共享 | 独立 |
| 栈 | 各自独立 | 各自独立 |
| 寄存器/程序计数器 | 各自独立(切换时保存) | 各自独立 |
| 打开的文件/网络连接 | 共享(同一个 fd 表) | 独立 |
| 信号处理 | 共享(进程级) | 独立 |
| 崩溃的影响面 | 一个线程崩 → 整个进程崩 | 只影响自己 |
两个直接推论:
- 线程间通信不需要内核(直接读写同一块内存),所以"快",但也"危险"(需要同步)。
- 进程间通信必须经过内核(管道、socket、共享内存、信号),所以"安全",但"贵"。
旧文 A-1615 的面试口径是"进程是资源分配的最小单位,线程是 CPU 调度的最小单位"。这个说法是对的,但只记住它不够——要能推导出上表。
3. 实测:线程数与 CPU 数不是一回事
8 逻辑核机器上,跑 4 份 CPU 密集任务(每份约 0.97 秒):
| 方式 | 实测耗时 | 相对单份 |
|---|---|---|
| 串行跑 1 份 | 0.97 s | 1.0× |
| 串行跑 4 份 | 3.63 s | 3.7× |
| 4 线程 | 3.86 s | 3.98×(没有加速) |
| 4 进程 | 1.09 s | 1.12×(约 3.3 倍加速) |
3.1 怎么读这张表
- 4 线程没有加速:这是 Python GIL 的结果——同一时刻只允许一个线程执行字节码。这是语言层的限制,不是操作系统的限制。
- 4 进程加速 3.3 倍(不到理想 4 倍):进程创建、内存复制、结果回收都有开销;8 核跑 4 个进程理论上可接近 4×.
- 串行 4 份 ≈ 4 倍时间:说明任务本身是纯 CPU 计算,没有 I/O 等待可重叠。
3.2 换成 Java 会怎样
Java/java.util.concurrent 没有 GIL,4 线程在 4 个空闲核上会接近线性加速。所以在 Java 里"开线程"确实能用到多核;在 Python 里 CPU 密集要用多进程。
关键结论:"多线程能不能加速"取决于三件事——有没有空闲核、有没有全局锁、任务是不是 CPU 密集。
| 任务类型 | 多线程有效吗 | 说明 |
|---|---|---|
| CPU 密集(无全局锁) | ✅(核数上限) | Java/Go/Rust |
| CPU 密集(有 GIL) | ❌ | Python |
| I/O 密集(等网络/磁盘) | ✅(远超核数也有效) | 线程在等待时不占 CPU |
| 大量并发短连接 | ⚠️ 协程/事件循环更优 | 线程栈开销大(见 §5) |
4. 上下文切换:到底贵在哪
一次切换要保存/恢复:寄存器、程序计数器、栈指针、页表基址(切进程时),还要刷 TLB 缓存、可能污染 CPU cache。
| 切换类型 | 大致代价(量级) | 说明 |
|---|---|---|
| 同进程内线程切换 | 亚微秒~微秒 | 不用换页表 |
| 跨进程切换 | 微秒级 + TLB/cache 失效 | 要换页表 |
| 系统调用(用户→内核→用户) | 百纳秒级 | 不是"切换",但是"过门" |
| 协程切换(用户态) | 几十纳秒 | 不经过内核 |
测量方法(E-S02-004):
# 观察每秒上下文切换次数(cs 列)
vmstat 1 3
# 观察特定进程的切换
pidstat -w -p <PID> 1 3
判据:cs 很高(例如 > 10 万/秒)而 CPU 利用率不高 → 说明在"忙着切换"而不是"忙着干活",此时应减少锁竞争或改用协程/批处理。
5. 线程模型:1:1、N:1、M:N(E-S02-003)
| 模型 | 含义 | 代表 | 优缺点 |
|---|---|---|---|
| 1:1 | 1 用户线程 ↔ 1 内核线程 | Java(HotSpot)、C++ std::thread | 简单、能真并行;线程栈大(默认 1 MB) |
| N:1 | N 用户线程 ↔ 1 内核线程 | 早期协程库、Python 线程(受 GIL) | 切换便宜;不能真并行 |
| M:N | M 用户线程 ↔ N 内核线程 | Go goroutine、Java 21 虚拟线程 | 兼顾并发量与并行;实现复杂 |
5.1 协程为什么"轻"
| 项 | 线程(1:1) | 协程 / 虚拟线程 |
|---|---|---|
| 栈 | 默认 1 MB(可调) | 几 KB 起步,按需增长 |
| 切换 | 内核调度,微秒级 | 用户态切换,纳秒级 |
| 数量级 | 千级 | 十万~百万级 |
| 阻塞行为 | 阻塞整个线程 | 挂起当前协程,线程继续跑别的 |
推论:"十万并发连接"用线程做不到(内存就爆了),用协程/事件循环才行——这条在 S02-J03 会做容量估算。
6. 进程创建:fork、exec 与写时复制
| 动作 | 发生什么 |
|---|---|
fork() |
复制父进程地址空间(写时复制:先共享物理页,写入才复制) |
exec() |
用新程序替换当前进程的代码段/数据段 |
clone() |
Linux 实际创建线程/进程的底层调用(决定共享哪些部分) |
为什么 fork 之后常跟 exec:fork 给了"复制一份自己"的能力,exec 才换程序。经典组合 fork + exec = 起一个新进程。
常见坑:fork 之后子进程只拿到"当前线程"的副本,其他线程在子进程中不存在——在多线程程序里 fork 后立刻 exec 才是安全的(这也是"多线程程序不要随便 fork"的原因)。
7. 调度:谁决定"下一个跑谁"(E-S02-002)
| 调度算法 | 思路 | 问题 |
|---|---|---|
| FCFS | 先来先服务 | 长任务阻塞短任务 |
| SJF | 最短作业优先 | 需要预知时长;长任务饿死 |
| 时间片轮转 | 每人跑一小段 | 时间片太小 → 切换开销大 |
| 优先级 | 高优先级先跑 | 低优先级饿死 |
| 多级反馈队列 | 按行为动态调优先级 | 实现复杂(通用 OS 常用) |
Linux 的 CFS(完全公平调度):不按"时间片"而是按"虚拟运行时间"分配 CPU,交互型任务(睡眠多)自动获得更高优先级。相关参数在 /proc/sys/kernel/sched_*。
实践含义:你无法在应用层"指定优先级",只能控制线程数量与阻塞行为。 想让交互任务快,就少开无谓的忙等线程。
8. 五个误区
| 误区 | 真相 |
|---|---|
| "线程越多越快" | 超过核数后,CPU 密集任务只会增加切换开销;I/O 密集才靠数量 |
| "进程比线程安全" | 进程隔离的是崩溃影响面,不是"不会出错"——共享内存照样能写坏 |
| "加锁就安全了" | 锁粒度、顺序、可见性都可能出错(S05 详讲) |
| "协程一定比线程快" | 单任务切换更快;但协程调度器本身要 CPU,且阻塞式系统调用仍会卡住线程 |
| "线程栈 1 MB 是浪费" | 可调(-Xss),但调太小会栈溢出;协程才是解决"多"的方向 |
9. 自检
| # | 检查 | 做法 | 通过标准 |
|---|---|---|---|
| 1 | 能说出共享/独立项 | 不看表复述 §2 | 至少 4 项正确 |
| 2 | 能解释线程为何不加速 | 用 GIL 与核数两点解释 | 两点都提到 |
| 3 | 能测上下文切换 | vmstat 1 3 看 cs |
能读出数值并解释 |
| 4 | 能选模型 | 十万连接该用什么 | 说出协程/事件循环及原因 |
10. 下一篇
S02-B03 · TCP 三次握手与四次挥手:为什么是三次不是两次
进程与线程解决"本机怎么并行",下一篇进入"两台机器怎么可靠地开始说话":三次握手的每一步在解决什么问题、TIME_WAIT 为什么必须存在(本文实测过 TIME_WAIT 会随主动关闭累加),以及半连接队列与 SYN Flood 的关系。
本篇属于系列《S02 · 计算机与网络基础》。旧文来源:A-1615;扩展点 E-S02-002/003/004。线程/进程对照为 2026-11-05 实测(8 核 / Python 3.12.3)。