CPython 运行时 — 一把大锁串行化整个解释器: 线程模型 / 字节码循环 / 受保护的共享内存
GIL 是 CPython 进程级的一把互斥锁: 同一时刻只允许一个线程执行 Python 字节码。它用"锁住整个解释器"的简单方式保护引用计数等共享结构, 代价是 Python 多线程无法利用多核做 CPU 并行; 但 IO 等待期间锁会释放, 所以 并发(而非并行)依然高效。记住判断口诀: IO 密集用 threading/asyncio, CPU 密集用 multiprocessing/C 扩展。
ob_refcnt, 增减不是原子操作。一把大锁串行化所有解释器状态, 避免了给每个对象加细粒度锁。
import sys a = object(); b = a # 关键: b = a 即 INCREF, 原子性靠 GIL 串行化保证 sys.getrefcount(a) # → 3 (a + b + getrefcount 形参) del b; sys.getrefcount(a) # → 2 — 断一个引用, 计数立即减一
sys.getswitchinterval()(默认 5ms)且存在等待线程时, 在下一个字节码边界让出; 遇阻塞系统调用(read/recv/lock.acquire)前主动放锁, 醒后重抢。
import sys, time sys.getswitchinterval() # → 0.005 — 默认 5ms 一个让出窗口 time.sleep(0.1) # 关键: 阻塞系统调用前主动放 GIL, 等待期让给其他线程
import time, threading def burn(): return sum(i*i for i in range(10**7)) t0 = time.perf_counter() ts = [threading.Thread(target=burn) for _ in range(2)] [t.start() for t in ts]; [t.join() for t in ts] time.perf_counter() - t0 # → ≈2× 单线程耗时: GIL 串行, 多核无加速
from multiprocessing import Pool with Pool(4) as p: # 关键: 4 进程 = 4 把独立 GIL, 真并行 p.map(abs, [-1, -2]) # → [1, 2] — 参数与结果都经 pickle 跨进程
Py_BEGIN_ALLOW_THREADS 释放 GIL —— 这就是"Python 慢但 numpy 快"的原因之一。
import numpy as np m = np.random.rand(3000, 3000) m @ m # 关键: BLAS 计算段已放 GIL, 多线程可跑满多核 # 同逻辑的纯 Python 双层 for 全程持锁, 只能单核
import sysconfig sysconfig.get_config_var("Py_GIL_DISABLED") # 普通 CPython → None # 关键: no-GIL 构建 (python3.13t) 里 → 1, GIL 被细粒度锁取代 python3.13t -c "import sys; print(sys._is_gil_enabled())" # → False
import platform platform.python_implementation() # → 'CPython' — 只有 CPython 有 GIL # 关键: Jython/IronPython 无 GIL, 同样的多线程代码可真并行
Flask/Django 这类同步 WSGI 框架, 生产标准部署是 gunicorn 多 worker: 每个 worker 是一个独立进程 = 一把独立 GIL, 用"进程数"换并行度, 天然绕开 GIL:
gunicorn app:app -w 4 -b 0.0.0.0:8000 # 4 worker = 4 进程 = 4 把 GIL 各自并行 # CPU 核数 * 2 + 1 是常见起点; IO 重则再加 worker 而不是线程
若流量是大量长连接/外部 API 调用, 则换 FastAPI + uvicorn(asyncio 单线程事件循环, 无 GIL 争抢): IO 等待时协程挂起, 单进程即可数千并发 in-flight。
报表聚合、图片处理、加密计算——不要用 threading.Pool, 用进程池并行:
from concurrent.futures import ProcessPoolExecutor with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex: # 真并行, 各进程独立 GIL results = list(ex.map(heavy_transform, chunks)) # 注意: heavy_transform 和 chunks 都要可 pickle; 传文件路径而非大对象
数值循环既要留在 Python 又要快, numba 把函数编译成机器码, 计算段还允许并行:
from numba import njit, prange @njit(parallel=True) # 编译为本地码 + 自动并行 for 循环(无 GIL 约束) def moving_avg(prices, w): out = np.empty(len(prices) - w + 1) for i in prange(len(out)): out[i] = prices[i:i+w].mean() return out
IO 密集的同步框架(Django/Flask)最优组合是 gthread worker: 4 进程(4 把 GIL)×8 线程 = 32 并发, 等待 DB/HTTP 时 GIL 释放:
# IO 密集: 多进程 + 多线程组合, 比纯加 worker 省内存数倍 gunicorn app:app -w 4 --threads 8 -k gthread -b 0.0.0.0:8000 # 纯 CPU 服务则退回单线程 worker: -w $(nproc) --threads 1
IO 密集多线程下连接池要跟着 handler 数走; 连接等待同样释放 GIL, 排队表现为吞吐陡降而非死锁:
engine = create_engine(DB_URL,
pool_size=32, # 常驻 ≈ 常驻线程数
max_overflow=16, # 突发缓冲
pool_pre_ping=True) # 拿到连接先探活, 防数据库侧超时断连
疑似 GIL 瓶颈时先采画像再动手 —— GIL 争用的典型特征: 单核 100% 其余核空闲 + 线程大量 gil 等待:
pip install py-spy py-spy dump --pid $PID # 各线程当前栈与状态 (找 gil:waiting / lock) py-spy top --pid $PID # 实时热点函数, 持续 30s 观察谁是 CPU 大头 py-spy record -d 60 -o prof.svg --pid $PID # 火焰图归档对比
ProcessPool 按任务收序列化费, 任务太碎通信开销反超收益 — 提交端先聚合到每片 ≥50ms:
rows = load_10m_rows() chunks = [rows[i:i+50_000] for i in range(0, len(rows), 50_000)] # 聚合!别逐行 with ProcessPoolExecutor() as ex: results = list(ex.map(score_chunk, chunks)) # 20 任务 >> 10_000 任务的 IPC 次数
图像/加解密类循环重的负载: 计算块内放 GIL, C 层多线程真并行, Python 只做编排:
# stats.pyx — Cython def batch_entropy(double[:] data, int nthreads): cdef double total = 0 with nogil, parallel(num_threads=nthreads): # GIL 已放: OpenMP 并行 for i in prange(data.shape[0]): total += _entropy_kernel(&data[i]) return total
夜间报表: 按日期分片天然幂等, 单片失败只重跑该片:
def nightly_report(dates: list[str]): with ProcessPoolExecutor(max_workers=8) as ex: fut = {d: ex.submit(agg_one_day, d) for d in dates} for d, f in fut.items(): try: save(d, f.result()) except Exception: log.exception("shard fail, rerun single day: %s", d) mark_retry(d) # 分片粒度重试, 不整批重跑
asyncio 服务同样受单进程容量上限(单事件循环 + 一把 GIL), 容器里 workers 取 CPU limit:
# K8s CPU limit=2 的容器内 (Dockerfile CMD): CMD ["uvicorn", "app:app", "--workers", "2", "--host", "0.0.0.0", "--port", "8000"] # 每个 worker = 独立进程 + 独立事件循环 + 独立 GIL; 前面网关按连接分发
# 错: CPU 密集开线程 — 比串行更慢(切换 + GIL 争用) ts = [Thread(target=transform, args=(c,)) for c in chunks] # 对: 进程池真并行, 各进程独立 GIL with ProcessPoolExecutor() as ex: results = list(ex.map(transform, chunks))
sys.setswitchinterval(0.001) 缓解。
# 错: 100ms 长计算线程反复插队, 1ms 短 IO 线程 P99 毛刺 (convoy effect) # 对: 计算拆进程根治; 临时用更小切换窗口缓解 sys.setswitchinterval(0.001) # 5ms → 1ms: 短任务更快拿到 GIL
x = x + 1 是多条字节码, 多线程下会丢更新; list.append 恰好原子也不代表组合操作安全。正解: threading.Lock 保护完整业务不变量, 或用 queue.Queue 传递代替共享。
counter = counter + 1 # 错: 多条字节码, 并发丢更新 → 总和 < 期望 with lock: counter += 1 # 对: 锁保护完整业务不变量 → 恰好正确 lst.append(x); len(lst) # append 原子 ≠ 组合操作原子, 边界仍要锁
multiprocessing.shared_memory; 让 worker 自己加载数据分片。
ex.submit(work, huge_df) # 错: GB 级 pickle 往返, 序列化吞掉并行收益 ex.submit(work, "/data/part-01.parquet") # 对: 传路径, worker 自己加载分片 SharedMemory(create=True, size=n) # 或: 共享内存零拷贝
# 错: 生产直接切 3.13 no-GIL 构建 — 老 Cython 扩展可能直接崩 # 对: CI 全量测试 + 基准对比, 扩展声明支持再上 pytest -q; python -m timeit "workload()" # 两套构建各跑一遍, 数据说话
with lock_a, lock_b: 一次性原子获取两把。
with lock1: with lock2: ... # 错: A 拿1等2, B 反向 → 死锁 with lock_a, lock_b: transfer(a, b) # 对: 全局统一加锁顺序, 一次性获取
queue.Queue 传消息(自带锁与等待语义), "不共享"比"记得加锁"可靠一个数量级。
while not jobs: time.sleep(0.01) # 错: 共享字段 + 轮询, 边界条件漏保护 job = queue.Queue().get() # 对: 自带锁与阻塞等待, "不共享"更可靠
ThreadPoolExecutor 跑 CPU 密集毫无加速甚至倒退。判断口诀: 等 IO 用线程池, 算数用进程池; 拿不准就先小样本 benchmark。
# 错: CPU 密集用线程池 → 毫无加速甚至倒退 with ThreadPoolExecutor() as ex: list(ex.map(sha256_file, files)) # 对: 等 IO 用线程池, 算数用进程池 with ProcessPoolExecutor() as ex: list(ex.map(sha256_file, files))
multiprocessing.set_start_method('spawn')(macOS/Windows 默认), Linux 下显式声明更稳。
set_start_method('fork') # 错: 多线程下 fork, 子进程继承"持有者已不存在"的锁 → 死锁 set_start_method('spawn') # 对: 干净子进程; Linux 默认 fork, 显式声明更稳
# 错: 以为协程切换会放 GIL — yield/await 间仍持锁, CPU 依旧单核排队 async def cpu(): sum(i*i for i in range(10**8)) # 对: 真正放锁只在阻塞 IO / time.sleep; CPU 活下沉进程池 await loop.run_in_executor(ProcessPoolExecutor(), heavy)
Py_BEGIN_ALLOW_THREADS, 纯计算也全程持锁, 整个进程被它串行化。选型时查扩展文档的 GIL 策略(图像/压缩库重灾区)。
out = legacy_lib.process(big) # 错: 不放锁的扩展全程持 GIL, 整进程被串行化 out = cv2.GaussianBlur(img, (5, 5), 0) # 对: 选放锁实现(numpy/BLAS 重计算段已放) # 选型时查扩展文档的 GIL 策略(图像/压缩库重灾区)
join(timeout) 优雅收尾, daemon 只用于"丢了无所谓"的心跳类线程。
Thread(target=flush_loop, daemon=True).start() # 错: 退出即杀, 文件半写/事务悬空 stop.set(); t.join(timeout=5) # 对: 停止事件 + join 优雅收尾
Thread(target=ms_control_loop).start() # 错: GIL 轮转唤醒延迟不可控 # 对: 关键路径独立进程, 或 asyncio 显式编排 await asyncio.wait_for(poll(), timeout=0.001) # 超时语义显式可控
signal.signal(SIGTERM, lambda *_: workers.stop()) # 错: handler 只在主线程跑, 直接碰工作线程状态 signal.signal(SIGTERM, lambda *_: q.put('stop')) # 对: 只丢事件, 专门线程消费转发
lst = Manager().list(); lst[i] += 1 # 错: 每次下标访问都是一次 IPC 往返, 慢到怀疑人生 results = ex.map(worker, chunks) # 对: 子进程算完一次性返回, 不细粒度共享
if __name__ == '__main__': 与函数内。
client = connect_db() # 错: 顶层副作用, notebook spawn 重复执行 if __name__ == '__main__': main() # 对: 副作用收进 main 守卫与函数内
ex.map(score, [big_matrix] * 8) # 错: GB 级数据来回 pickle, 并行收益被吞 Thread(target=score, args=(big_matrix,)).start() # 对: 线程共享只读大对象零拷贝 SharedMemory(create=True, size=mb) # 或共享内存, 跨进程免序列化
# 错: 不画像就断言 "瓶颈肯定是 GIL", 为不存在的瓶颈重构 py-spy top --pid $PID # 对: 先看热点 — 集中在 recv/read 就是 IO 瓶颈 py-spy dump --pid $PID # 大量 gil:waiting 才说明真是 GIL 争用
# 错: "no-GIL 一定更快" — 细粒度锁有开销, 单线程可能慢 5-10% # 对: 两套构建各跑基准, 数据说话再迁移 python -m timeit -s "x = list(range(10**6))" "sum(x)"
sys.setswitchinterval(0.0001) # 错: 拍脑袋调小百倍 — 切换开销暴涨, 吞吐反降 sys.setswitchinterval(0.001) # 对: 压测吞吐与 P99 双指标后定, 注释写明依据