2654 字
约 8 分钟
13
S10-J03 · GIL、多线程与多进程:Python 并发的真实边界

一个 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. 自测

  1. GIL 保护哪些解释器状态?为什么不能替代业务锁?
  2. 线程阻塞在 socket.recv 时 GIL 处于什么状态?换成 re.match 呢?
  3. 把 setswitchinterval 从 5ms 调到 1ms,吞吐与尾延迟各往哪走?
  4. fork 与 spawn 在"子进程能否直接用父进程的数据库连接"上差别在哪?
  5. 3.14 之前把十万条任务丢进 map,内存里发生了什么?
  6. python3.13t 与默认构建是什么关系?为什么不能默认它没有 GIL?
S10-J03 · GIL、多线程与多进程:Python 并发的真实边界
https://www.clxhxhhr.top/posts/6032/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。