← 返回模块
3.3.1.2beta 可读 · 未来付费内容校验中内容版本 2026-05-27

GIL、多线程与多进程

3.3.1 · 并发与性能 · 编程

Hook

周一下午四点收盘后,私募研究服务器上挂着两份待跑的任务:先把 100 只沪深300 成分股最近 30 个交易日的日线从米筐风格接口同步下来,再用 100 万条蒙特卡洛路径给一张 510300.SH 看涨期权定价。一颗八核 CPU 跑了二十多分钟,T+1 风控报表迟迟出不来。问题不在算法,在于代码全程单线程。这两份任务该交给的并发原语其实不同:线程池处理外部 I/O,进程池绕开 GIL 吃满其余物理核——下一课的 asyncio 再把 I/O 路径推到极致。

GIL:一进程一把锁

CPython 的全局解释器锁(global interpreter lock, GIL)是一把进程级互斥量。它在解释器执行 Python 字节码时被持有,并在两类场景里被释放:(1) 阻塞式系统调用(socket 读写、文件 I/O)期间;(2) 写得规矩的 C 扩展主动释放,例如 NumPy 的 BLAS 归并、requests 的 socket 读取。

由此两条经验:纯 Python 的 CPU 密集循环开 8 条线程不会更快,常因为锁竞争(lock contention)反而更慢;同一段循环若主体是 np.dot,则随线程数近线性扩展,因为 BLAS 内部不持锁。下面挑工具就靠这条界线。

ThreadPoolExecutor:I/O 密集型扇出

第一类典型场景:给行情接口发 100 个 HTTPS 请求拉日线。requests.get 是阻塞调用,但在等待网络返回时会释放 GIL,于是 16 条线程能真正同时等待,把总耗时从 N 倍单次延迟压回接近单次。

from concurrent.futures import ThreadPoolExecutor

def fetch(ticker):
    return requests.get(f'https://api.example.com/bars/{ticker}').json()

tickers = ['600519.SH', '000001.SZ', '600036.SH', '300750.SZ', '601318.SH']
with ThreadPoolExecutor(max_workers=16) as ex:
    results = list(ex.map(fetch, tickers))

ex.map 按提交顺序产出结果,便于和 tickers 一一对齐;若你只关心「先到先用」,把它换成 as_completed:每个 future 在自己完成的瞬间产出,慢响应的等待便能和快响应的处理重叠。

max_workers=16 是稳妥起点;写成 10_000 会撑爆进程的文件描述符表,也会立刻打穿对端 API 的连接池。常见拍法:从 min(32, 4 × CPU 核数) 起步,盯着实测吞吐与接口 429 错误率两条线再微调。

到 2026 年线程池依旧是「目标库没有 async 实现」时的默认答案——某些券商 SDK 只暴露同步接口,下一课在和 asyncio 对比时会再点一次。

ProcessPoolExecutor:用进程绕过 GIL

第二类典型场景是 CPU 密集循环:蒙特卡洛定价、NumPy 覆盖不到的纯 Python 数值循环、大量 Series.apply。线程在这里彻底没用,正解是开多个进程:每个进程有自己的解释器、自己的 GIL,4 个 worker 真的能占满 4 颗物理核。

启动方式(start method)有两种:fork 仅在 Linux 上默认,子进程从父进程内存复制出来,启动便宜但和 OpenMP、CUDA、urllib3 连接池有兼容隐患;spawn 是 macOS / Windows 默认,也是新代码推荐,子进程从零导入模块,干净但启动慢一点;Python 3.14+ 已计划把 spawn 设为所有平台默认。

spawn 之下子进程会重新导入主模块,​​所有触发 Pool 创建的代码都必须放在 if __name__ == '__main__': 守卫之下​​,否则就是递归 fork 直至崩溃。

进程边界的代价是序列化开销(pickling overhead):每个入参与返回值都要跨进程序列化。任务粒度(task granularity)必须够粗——少而大的 chunk,而不是多而细的 call——否则常见「开 8 个进程反而比串行慢」的反直觉结果。

下面这段把一张 510300.SH 一个月期欧式看涨期权的蒙特卡洛定价分到 4 个进程,每个 worker 跑 25 万条几何布朗运动路径:

from concurrent.futures import ProcessPoolExecutor
import numpy as np

def price_chunk(args):
    seed, n_paths, S0, K, r, sigma, T = args
    rng = np.random.default_rng(seed)
    Z = rng.standard_normal(n_paths)
    ST = S0 * np.exp((r - 0.5 * sigma ** 2) * T + sigma * np.sqrt(T) * Z)
    return float(np.exp(-r * T) * np.maximum(ST - K, 0).mean())

if __name__ == '__main__':
    S0, K, r, sigma, T = 4.20, 4.30, 0.025, 0.22, 30 / 252
    n_paths_per_worker = 250_000
    chunks = [(i, n_paths_per_worker, S0, K, r, sigma, T) for i in range(4)]
    with ProcessPoolExecutor(max_workers=4) as ex:
        prices = list(ex.map(price_chunk, chunks))
    print(sum(prices) / len(prices))

np.random.default_rng(seed=worker_id) 给每个 worker 一颗互不相交的确定种子,既保证回归可复现、又避免重复采样。把同一段代码在 1、2、4、8 个 worker 下各跑一次,用 L1 介绍的 time.perf_counter()cProfile 量墙钟时间:

n_workerswall-clock secondsspeed-up vs 1 worker
11.4201.00×
20.7601.87×
40.4303.30×
80.3953.59×

加速比(speed-up)从 4 worker 之后明显走平:物理核就 4 颗,再加 worker 只是徒增 pickling 与结果聚合开销。这就是「向核数靠近、不要超过」的标准曲线。

SharedMemory:把大 NumPy 数组零拷贝交给 worker

当你要在多个进程之间传一块几百 MB 的特征矩阵时,pickling 会把它在每个 worker 里各复制一份,慢且吃内存。multiprocessing.shared_memory 提供一段所有进程都能看见的物理内存:

from multiprocessing.shared_memory import SharedMemory
import numpy as np

arr = np.arange(1_000_000, dtype=np.float64)
shm = SharedMemory(create=True, size=arr.nbytes)
buf = np.ndarray(arr.shape, dtype=arr.dtype, buffer=shm.buf)
buf[:] = arr[:]
# pass shm.name to a worker; worker re-attaches with SharedMemory(name=shm.name)
shm.close(); shm.unlink()

每个 worker 用 SharedMemory(name=shm.name) 挂载同一块物理内存,再以同样的 np.ndarray(..., buffer=shm.buf) 重建视图,读取无拷贝。生命周期由调用方负责:每个 worker 用完调用 shm.close() 释放本地句柄,最后由父进程 shm.unlink() 回收 OS 资源。

必须记住的警告:共享内存​​不提供锁​​。两个 worker 同时往重叠切片写入就是数据竞争,结果未定义;并发写需要外部同步原语,那是 3.3.2 主题的话题。

PEP 703:去 GIL 的远景

CPython 3.13 已实验性地引入了自由线程(free-threaded)解释器(PEP 703),编译标志 --disable-gil,目标是让线程池在 CPU 密集场景也能扩展。但到 2026 年中,主流第三方扩展(NumPy、pandas、PyTorch)的兼容尚未稳定,性能也未必胜过 GIL 版本。日常建议依旧是:线程做 I/O,进程吃满核。多机分布式(Dask、Ray)与 GPU 路径不在本课范围,L4 末尾会一句带过。

衔接下一课

到这里你已经能用线程池把 100 个同步 requests.get 压到接近一次延迟,也能用进程池把蒙特卡洛定价分到 4 颗核。下一课把同样的 100 个行情请求重写成 asyncio + aiohttp:当连接数继续涨到 1000 时,你会看到协程相比线程在内存和上下文切换开销上的量级差距,也会理解 asyncio.TaskGroupSemaphore(10) 这类原语为什么是新代码的默认起点。

练习

Exercise

给定函数 def cpu_bound(n): total = 0\n for i in range(n):\n total += i * i\n return total,完成以下三种执行方式的对比:

(a) 用列表推导式对 inputs = [10_000_000] * 4 顺序调用 cpu_bound,并用 time.perf_counter() 计时。 (b) 把同一份 inputs 喂给 ThreadPoolExecutor(max_workers=4).map(cpu_bound, inputs) 并计时。 (c) 把同一份 inputs 喂给 ProcessPoolExecutor(max_workers=4).map(cpu_bound, inputs) 并计时。 (d) 报告三组墙钟时间,指出哪种执行器胜出、相对于顺序基线的加速倍数是多少,并解释为什么线程在这个工作负载上完全没有提速。

提示
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor 放在文件顶部,三段计时都套 t0 = perf_counter(); ...; dt = perf_counter() - t0 的同一句式,让三个数字直接可比。
提示
cpu_bound 是纯 Python 整数循环,全程持有 GIL:线程池里只能让一条线程在跑、其余在排队;进程池每个 worker 是独立解释器,能真正吃满 4 颗物理核。

延伸阅读:《Fluent Python》第 2 版中译第 19–20 章(并发执行器、进程与线程);CPython 官方 concurrent.futuresmultiprocessing 中文文档;PEP 703 的中文导读(社区翻译);David Beazley 的 Understanding the Python GIL 中文字幕版;multiprocessing.shared_memory 的 3.13 文档页(注意 Python 3.12 之前 resource_tracker 警告的已知 issue)。