一个 CPU 密集的循环套上 ThreadPoolExecutor(4),墙钟时间没降,有时更长。这不是代码写错了,是 GIL 在按设计工作。要判断该用哪种并发,只需回答三个问题:瓶颈是 CPU 还是 IO、任务粒度是否大于 IPC 开销、共享状态能不能不共享。
1. GIL 锁的到底是什么
GIL 是 CPython 内部的全局互斥量,保护的是解释器状态:对象头的引用计数、pymalloc 的 arena 链表、字节码求值栈、类型方法缓存、interned 字符串表。它不是给你的业务变量加的锁。
| GIL 保证的事 | GIL 不保证的事 |
|---|---|
| 同一时刻只有一个线程执行字节码 | n += 1 是原子的(LOAD / BINARY_OP / STORE 三步) |
| 引用计数增减不互相踩踏 | 复合操作的顺序与可见性(判长度再 append 仍有竞态) |
| 解释器内部结构不被并发破坏 | 跨进程互斥——GIL 是进程内的 |
| 单条 C 实现的内建操作通常不被打断 | 扩展自己 release GIL 期间的安全(NumPy 大数组) |
import sys, threading
print(sys.getswitchinterval()) # CPython 3.x 默认 0.005
counter = 0
def bump():
global counter
for _ in range(1_000_000):
counter += 1 # 切换点可能插在这四字节码之间
ts = [threading.Thread(target=bump) for _ in range(4)]
[t.start() for t in ts]; [t.join() for t in ts]
print(counter) # 常等于 4_000_000,但这是运气不是契约
它"看起来正确"是因为切换间隔 5ms、每次只做几条字节码,撞窗口概率低——概率低不等于保证。判据:跨两条以上字节码且有共享写,就必须显式加锁。
2. "IO 有效、CPU 无效"的原因链
CPython 在进入阻塞系统调用前用 Py_BEGIN_ALLOW_THREADS 释放 GIL,从内核返回后再重新获取。线程等 IO 时别人能真跑;线程算数时 GIL 始终在它手里。
| 操作 | 释放 GIL | 说明 |
|---|---|---|
socket.recv / send / accept |
是 | 阻塞期间完全让出 |
time.sleep |
是 | 最常见的"假并发"来源 |
文件 read / write |
是(syscall 期间) | Python 层缓冲与解析仍持 GIL |
| 纯 Python 循环、字符串拼接 | 否 | CPU 时间被串行化 |
zlib.compress、大块 hashlib |
是(超阈值时) | 阈值按版本核对 |
re 匹配、json.dumps |
否 | 纯 C 但运行期间持 GIL |
于是多线程在 CPU 密集场景的墙钟时间 ≈ 单线程时间 ×(1 + 切换损耗)。纯 IO 场景相反:每次阻塞都让出,线程数可以远大于核数,直到内存或下游连接数成为瓶颈。
切换间隔的代价:3.9 起 sys.setcheckinterval 被移除,只剩按时间计的开关。切换本身在微秒量级,真正的成本是缓存失效与 convoy——工作集刚进 L1,GIL 被抢走,回来时缓存已凉。加线程反而变慢是常见现象,拐点在目标机器上测。
import sys
sys.setswitchinterval(0.001) # 更频繁:公平性↑,吞吐↓
sys.setswitchinterval(0.05) # 更稀疏:吞吐↑,尾延迟↑
3. 三种模型对照
| 模型 | 适合 | 不适合 | 典型失败模式 | 内存 |
|---|---|---|---|---|
threading |
阻塞 IO、已有同步代码 | CPU 密集计算 | GIL 串行化,加线程不提速 | 每线程 MB 级栈 |
multiprocessing |
CPU 密集、可切分、要隔离 | 小任务、大对象 | pickling 压过计算本身 | 每进程一份解释器 |
asyncio |
高并发网络 IO、长连接 | 任何阻塞调用、CPU 计算 | 一次阻塞冻结整个循环 | 每任务 KB 级 |
判据顺序:先 profile 看热点是 CPU 还是等待(py-spy top、cProfile),再定任务粒度与模型。
4. 多进程:启动方式与传递代价
| 启动方式 | 语义 | 平台默认 | 代价 |
|---|---|---|---|
fork |
复制地址空间,只保留调用线程 | Linux 至 3.13 | 最快;继承 fd、锁、连接,危险 |
spawn |
全新解释器,重新 import 主模块 | Windows、macOS 3.8+ | 启动百毫秒量级;必须 __main__ 守卫 |
forkserver |
干净服务进程再 fork | 3.14 起 Linux 默认(按版本核对) | 折中,首次略慢,后续干净 |
import multiprocessing as mp
if __name__ == "__main__": # spawn / forkserver 必需
ctx = mp.get_context("spawn") # 显式指定,不依赖默认值
with ctx.Pool(4) as pool:
print(pool.map(abs, [-1, -2, -3]))
set_start_method() 每进程只生效一次,再调抛 RuntimeError;库代码别调它,用 get_context()。
时间大量花在 pickle 上:参数序列化进子进程,返回值再序列化回来,单次往返数十到数百微秒量级。推论:任务耗时低于毫秒级时,多进程通常比单进程更慢。 传大数组用 shared_memory,别当参数塞。
5. 进程池的三类误用
def init_worker():
global conn
conn = make_conn() # 连接在子进程内建立
if __name__ == "__main__":
with mp.Pool(8, initializer=init_worker) as pool:
pool.map(task, items)
| 误用 | 现象 | 原因 |
|---|---|---|
| fork 前建好 DB 连接/socket,子进程直接用 | 协议错、读到别人的响应 | 继承同一 fd,共享内核读写位置 |
| 依赖模块级全局字典累加 | 父进程始终是空值/旧值 | 每进程一份副本,写不回流 |
| 池里的任务再开池 | 进程数爆炸,机器被拖死 | 嵌套创建,cpu_count() 没算父池占用 |
fork 还有个隐蔽前提:只复制调用 fork 的那个线程。父进程别的线程若持锁,子进程里这把锁永久锁住,acquire 挂死——"fork 后子进程莫名卡住"的常见根因。
6. map 与 submit 的差别
| 维度 | executor.map |
executor.submit |
|---|---|---|
| 返回 | 结果迭代器(按入参顺序) | Future |
| 提交时机 | 一次性全部提交(3.14 前无背压) | 自己控节奏 |
| 异常 | 迭代到该项才抛出 | future.exception() 单独取 |
| 完成顺序 | 固定为输入顺序 | 配 as_completed 按完成顺序处理 |
| 适用 | 批量同构、顺序有意义 | 重试、超时、取消、异构任务 |
import concurrent.futures as cf
with cf.ThreadPoolExecutor(8) as ex:
futs = {ex.submit(fetch, u): u for u in urls}
for f in cf.as_completed(futs, timeout=30):
try:
handle(futs[f], f.result())
except Exception as e: # 单任务失败不拖垮整批
retry_later(futs[f], e)
map 到 3.14 才有 buffersize;此前十万条任务丢进去,等于十万个 Future 挂在内存里。
7. 共享状态:三种做法与坑
| 做法 | 机制 | 坑 |
|---|---|---|
Queue / Pipe |
消息传递,值被 pickle | 大对象慢;Queue 有 feeder 线程,退出前 join_thread |
Manager() 的 dict/list |
管理进程 + 代理对象 | 每次读写都是一次 RPC,循环里用慢一到两个数量级 |
shared_memory / Value/Array |
同一块物理内存 | 自己加锁;不 unlink 会泄漏 /dev/shm |
from multiprocessing import Process, Queue, shared_memory
import numpy as np
def producer(q): q.put({"rows": 1000})
if __name__ == "__main__":
q = Queue(); p = Process(target=producer, args=(q,)); p.start()
print(q.get()); p.join()
shm = shared_memory.SharedMemory(create=True, size=8 * 1000)
arr = np.ndarray((1000,), dtype=np.int64, buffer=shm.buf)
arr[:] = 0
shm.close(); shm.unlink() # 忘掉这行,共享段会一直留着
8. asyncio 与线程混用的边界
事件循环是单线程的,任何一次阻塞调用都会让所有协程停摆——"异步服务偶发整体卡几秒"的头号原因。
import asyncio, concurrent.futures as cf
async def handler():
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(None, blocking_read, path)
data2 = await asyncio.to_thread(blocking_read, path) # 3.9+
with cf.ProcessPoolExecutor(4) as pe: # CPU 任务必须走进程
out = await loop.run_in_executor(pe, heavy_compute, data)
return out, data2
边界:默认线程数是 min(32, cpu+4) 而非无限;别在线程池里 asyncio.run;run_in_executor 的 Future 取消后底层线程仍在跑;asyncio.Lock 与 threading.Lock 不能互换。
9. 3.12 / 3.13 的变化方向
| 版本 | 变化 | 实际含义 |
|---|---|---|
| 3.12 | PEP 684 每解释器 GIL | 子解释器各有独立 GIL 可并行;扩展隔离支持有限,生态未普及 |
| 3.13 | PEP 703 自由线程构建(实验) | 单独的 python3.13t;sys._is_gil_enabled() 可查;C 扩展需按新 ABI 重编 |
| 3.13 | PYTHON_GIL=0 |
只在自由线程构建生效,普通构建无效 |
别理解成"3.13 起没有 GIL":默认构建依然有。自由线程构建单线程性能通常低于 GIL 构建(个位数到两位数百分比,按版本与本机核对),内存上升,依赖未适配时收益打折。生产基准仍是 GIL 构建。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 加线程后 CPU 密集任务不变快 | GIL 串行化 | 换 ProcessPoolExecutor,或把热点下沉到释放 GIL 的库 |
| 线程里抛异常主流程无感 | 异常留在 Future/线程里没人取 |
3.8+ 设 threading.excepthook;统一 result() |
spawn 下反复重建、日志刷屏 |
缺 if __name__ == "__main__": |
加守卫,入口写成函数 |
| 几千条小任务比单进程更慢 | pickle 往返压过计算 | 合并任务提高粒度,或改线程 |
| 协程服务周期性停顿数秒 | 循环里混入阻塞调用 | 排查 requests/open/sleep,改 to_thread |
11. 边界
| 场景 | 首选 | 理由 |
|---|---|---|
| 抓几百个 URL | asyncio + aiohttp |
任务开销最小,连接复用 |
| 解析上万份文件(CPU 重) | ProcessPoolExecutor |
绕过 GIL,按文件切分天然无共享 |
| 已有阻塞 SDK 不想重写 | ThreadPoolExecutor |
阻塞调用释放 GIL,改造成本最低 |
| 跨进程计数 | 单进程加锁或 Manager | RPC 成本容易被低估 |
| 几 GB 数组传给子进程 | shared_memory + 显式锁 |
pickle 大对象同时打爆内存与时间 |
12. 自测
- GIL 保护哪些解释器状态?为什么不能替代业务锁?
- 线程阻塞在
socket.recv时 GIL 处于什么状态?换成re.match呢? - 把
setswitchinterval从 5ms 调到 1ms,吞吐与尾延迟各往哪走? fork与spawn在"子进程能否直接用父进程的数据库连接"上差别在哪?- 3.14 之前把十万条任务丢进
map,内存里发生了什么? python3.13t与默认构建是什么关系?为什么不能默认它没有 GIL?