2502 字
约 8 分钟
6
S02-B02 · 进程与线程:为什么"多开一个线程"不等于"多一份 CPU"

适用读者:写过多线程但说不清"线程到底共享什么"的人;被"开更多线程会更快"坑过的人。
前置: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 表) 独立
信号处理 共享(进程级) 独立
崩溃的影响面 一个线程崩 → 整个进程崩 只影响自己

两个直接推论:

  1. 线程间通信不需要内核(直接读写同一块内存),所以"快",但也"危险"(需要同步)。
  2. 进程间通信必须经过内核(管道、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)。

S02-B02 · 进程与线程:为什么"多开一个线程"不等于"多一份 CPU"
https://www.clxhxhhr.top/posts/5183/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。