朕喜技术(ZhenxiJS)
2026.09.03
1 分钟阅读

Python 多线程跑 CPU 密集任务被 GIL 限制:8 线程却没跑满多核(用 ProcessPoolExecutor 解决)

问题背景

维护的图片加签/内容指纹服务(Python)里有个 CPU 密集型函数 hash_content(大量 sha256 迭代哈希),被 Web 接口反复调用。为提升并发吞吐,用 ThreadPoolExecutor(max_workers=8) 开 8 个线程去跑,期望 8 核各干各的、吞吐翻几倍。

结果没提速:8 个线程都在忙,接口依旧慢;top 里进程 CPU 只有约 ~100%(单核),8 个核根本没跑起来。

关键词:Python GIL、全局解释器锁、多线程不加速、线程池跑不满 CPU、ThreadPoolExecutor、ProcessPoolExecutor、multiprocessing、CPU 密集型任务、sha256 哈希、单核瓶颈、多核并行、PicklingError lambda

复现步骤

环境:Linux + Python 3(8 核)。

# 进入问题目录并启动 Docker 复现环境
cd scenario/2026-09-03-1600
docker compose up -d --build
docker compose exec app bash
cd /workspace/scenario/2026-09-03-1600

# 1) 串行基线
python3 serial_app.py        # [serial] 耗时 1.315s

# 2) “多线程加速”版本(问题所在)
python3 thread_app.py        # [threads-8] 耗时 1.122s —— 与串行几乎相同!

# 3) 另开终端看 CPU
#    top -d 1   → 只有一个 python3 进程 CPU≈100%(106%),跑不满多核

关键佐证(time 命令的 user/real 比):

  • 线程版:user 8.5s ≈ real 8.5s → 始终只有 1 个核在工作。
  • 进程版:user 13.1s >> real 1.94s → 8 个核并行,real 远小于 user。

根本原因

CPython 解释器带 GIL(Global Interpreter Lock,全局解释器锁):任意时刻只有一个线程能执行 Python 字节码。threading / ThreadPoolExecutor 开的线程再多,对 CPU 密集型(纯计算、不释放 GIL)任务,同一时刻也只能有一个线程跑字节码,其余都在抢同一把锁 → CPU 只有 ~100%(单核),耗时不降反可能因切换略升。

threading 只适合 I/O 密集(网络/磁盘等待时线程让出 GIL)或调用会主动释放 GIL 的 C 扩展(部分 numpy 运算)。

解决办法

把 CPU 密集任务交给多进程,每个 worker 是独立进程、各自有独立 GIL,才能真正并行吃满多核。改动最小的是 concurrent.futures.ProcessPoolExecutor(API 与线程池一致):

# process_app.py
import hashlib
from concurrent.futures import ProcessPoolExecutor

def hash_content(seed, rounds=WORK):   # 顶层函数,必须可 pickle
    digest = f"seed-{seed}".encode()
    for _ in range(rounds):
        digest = hashlib.sha256(digest).digest()
    return int.from_bytes(digest[:4], "big")

if __name__ == "__main__":             # 进程入口必须放这
    with ProcessPoolExecutor(max_workers=8) as ex:
        results = list(ex.map(hash_content, range(TASKS)))

验证

python3 process_app.py   # [process-8] 耗时 0.282s(串行 1.315s / 线程 1.122s)

容器内 8 核实测对比:

方案耗时CPU 占用
串行 serial1.315s~1 核
多线程 threads-81.122s~1 核(106%,单进程)
多进程 process-80.282s8 个进程各 ~100%

top 采样验证:

  • 线程版:只有 1 个 python3 进程,CPU≈106%(单核)。
  • 进程版:top 显示 8 个 python3 进程,每个 ~100%,8 核同时吃满。
  • 三者结果校验和一致(15856484358),证明逻辑正确、只有运行方式改变。

经验与避坑

  1. 纯 Python CPU 密集任务别用 threading:GIL 决定它跑不满多核。要加速用 multiprocessing / ProcessPoolExecutor,或迁移到能释放 GIL 的 C 扩展 / 别的语言。
  2. 多进程入口必须写 if __name__ == '__main__'::否则子进程递归导入模块会反复 fork / 报错。
  3. worker 函数与参数必须可 pickle:进程池靠序列化传参,不能传 lambda 或局部闭包,否则报 _pickle.PicklingError: Can't pickle <function <lambda> at ...>。worker 写成模块级顶层函数即可。
  4. 线程 vs 进程的选择:I/O 密集用线程(开销小省内存);CPU 密集用进程;能释放 GIL 的 C 扩展(部分 numpy)用线程也行。
  5. 判断是否吃满多核的快捷法:time 命令里 user 明显大于 real → 并行生效;user≈real → 只有单核在忙。
  6. 判断要不要多进程:若任务很小且高频,进程间序列化 IPC 开销可能吞掉并行收益,可调 chunksize 合并。
  7. Docker 复现环境两个坑:Compose 里 dockerfile 路径是按 context 解析的,别写错层级;bookworm-slim 的 Python 3.11 受 PEP 668 保护,装 pip 包要加 --break-system-packages。

  • 技术栈:Python 3.11、threading、ThreadPoolExecutor、ProcessPoolExecutor、multiprocessing、GIL、hashlib/sha256、docker compose、Linux 多核。
  • 可复现命令:见上文「复现步骤」。
  • 报错信息关键词:Can't pickle <function <lambda>、lstat /.devcontainer: no such file or directory、externally-managed-environment、PEP 668。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-03-1600-gil-multithreading-multiprocess

分享这篇文章