拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Python GIL 全面解析:多线程为何跑不满CPU,何时该换多进程

这次来聊一个很多 Python 开发者都遇到过的现象机器明明是 4 核程序也开了 4 个线程结果 CPU 占用率一直上不去4 个核没有一个跑满。第一反应通常是线程写得不对或者操作系统调度有问题。但如果你的代码是纯 Python 的 CPU 密集计算那么问题大概率出在 CPython 解释器内置的 GIL 上。GIL 的全称是 Global Interpreter Lock中文一般叫全局解释器锁。网上讲 GIL 的文章很多但多数停留在概念解释。这篇博客会从“现象”出发用两个可以直接运行的 Python 实验把结论验证出来CPU 密集场景下多线程在 CPython 里基本不能利用多核想榨干多核要换 multiprocessing 多进程。同时也会说明经常被忽略的另一面如果是 I/O 密集场景比如爬虫、文件读写、网络请求多线程依然非常有用。这篇文章适合正在学 Python 并发的同学也适合已经写了几年 Python 但一直被“多线程和 GIL 说不清”困扰的开发者。读完你会得到一份可以收藏备用的排查清单先看任务类型再看解释器最后决定用线程池、进程池还是异步方案。1. GIL 到底锁了什么核心知识点速览先把结论放出来GIL 锁的不是某个变量也不是某段业务代码而是 CPython 解释器里 Python 字节码的执行权限。也就是说一个进程里同时只能有一个线程真正在执行 Python 字节码其他线程就算已经创建成功也只能等待 GIL 释放。项目说明GIL 全称Global Interpreter Lock全局解释器锁锁的粒度进程级别一个 Python 进程只有一个 GIL锁住的是什么Python 字节码的解释执行权限受影响最大的场景纯 Python 写的 CPU 密集计算不受影响的场景I/O 等待、网络请求、文件读写、部分会释放 GIL 的 C 扩展典型的替代方案multiprocessing、concurrent.futures.ProcessPoolExecutor、任务队列多线程适合的场景I/O 密集任务爬虫、批量请求、文件处理多进程适合的场景CPU 密集任务大量计算、图像算法、数据清洗判断方法运行任务时打开任务管理器或 htop 看各核占用率这里有个很容易混淆的点GIL 是 CPython 的实现细节不是 Python 语言规范的一部分。Java 没有 GILJython 或 IronPython 也没有 CPython 这种 GIL。但绝大多数开发者在命令行敲 python 启动的解释器就是 CPython所以讨论 GIL 对日常开发是有实际意义的。还要强调一下GIL 不等于线程安全。很多新手以为有了 GIL多线程操作共享变量就不会出问题。实际上 GIL 只保证单个字节码指令级别的安全并不保证一段多步操作的完整性。业务代码里的共享数据该用 Lock 还是得用 Lock。2. 什么时候用多线程什么时候换多进程场景边界判断该用多线程还是多进程核心是看任务是 CPU 密集还是 I/O 密集。这个判断比“哪个 API 更快”更重要。选错模型代码写得再漂亮性能也上不去。任务特点优先方案原因CPU 密集纯 Python 计算multiprocessingProcessPoolExecutor每个进程有独立 GIL能真正使用多核I/O 密集大量等待threadingThreadPoolExecutor等待 I/O 时线程会让出 GIL其他线程可以运行混合型任务计算量大且有外部调用多进程做粗粒度并行进程内再用多线程处理 I/O既利用多核又能高并发处理外部请求高并发 Web 服务、长连接、异步事件asyncio 事件循环单线程配合异步 I/O资源占用更小用真实场景来解释更直观。如果你在写一个批量爬虫大部分时间花在等待对方服务器响应这时候用多线程是对的。线程在 socket 等待期间会释放 GIL让其他线程去发请求整体并发量能大幅提升。反过来如果任务是遍历一个很大的列表做数学计算比如统计质数、处理图像像素计算本身不涉及任何外部等待那么多线程就帮不上忙。因为每个线程抢到 GIL 后只执行一小段字节码又要让给下一个线程表面上开了 4 个线程实际是在一个核上轮流干活还要搭上切换开销。有一个特殊情况也要知道如果计算发生在 numpy、PyTorch 这类 C 扩展内部很多底库在执行计算时会主动释放 GIL这时候多线程可能也能看到一定并行效果。但这种情况并不稳定依赖具体库的实现不能把“多线程对 CPU 密集计算有效”当成通用结论。最稳妥的原则是纯 Python 循环级别的 CPU 密集任务优先考虑多进程。3. 为什么 4 个线程跑不满 4 个核回到标题里“4 个线程没跑满 4 个核”的现象。要理解这个问题得先知道 GIL 在解释器内部是怎么工作的。每个 CPython 进程启动时都会创建一把 GIL。线程要执行 Python 字节码第一件事就是尝试获取这把锁。拿到锁的线程可以连续执行一定数量的字节码指令到了切换点或者被操作系统强制抢占时释放 GIL然后其他线程才有机会去抢。问题在于纯 CPU 计算任务里没有任何阻塞等待点线程不会主动让出 GIL只能靠解释器的指令计数切换和操作系统的线程调度。结果就是4 个线程反复竞争同一把锁但同一时刻只有一个线程在真正计算。用系统监视器看 CPU 曲线通常是一个核接近满载其他核大部分时间低占用偶尔因为锁切换和调度露出一些“小尖峰”。四核机器上多线程 CPU 密集任务的执行时间不仅不会缩短甚至可能比单线程略慢。因为线程从创建、切换到销毁都有成本GIL 竞争还会让线程频繁阻塞和唤醒。很多人觉得“Python 多线程没用”其实说的是这一类场景。如果你遇到了“线程数等于核数、CPU 却没打满”的情况排查顺序应该是先确认任务是不是真的纯 CPU 计算里面有没有隐藏的 I/O 等待比如打印日志、读写文件、请求数据库。再确认运行的 Python 是不是标准 CPython 构建。如果你用的是 Python 3.13 的实验性 Free-Threaded 版本行为会不一样但那不是大多数环境的默认情况。最后才看自己的并发代码本身是不是线程之间加了不必要的锁或者在循环里频繁打印导致性能退化。只有先排除掉这些因素才能比较确定地说是 GIL 限制了并行。4. CPU 密集场景验证多线程 vs 多进程理论不能只靠背下面直接跑实验验证。实验代码只用了 Python 标准库不需要安装第三方依赖。4.1 环境准备建议使用 Python 3.8 以上的版本测试机器有 4 核或更多。操作系统不限但下面看 CPU 占用时Windows 用任务管理器macOS 用活动监视器Linux 用 top 或 htop。可以先用一段命令确认环境信息python --version python -c import os; print(os.cpu_count())4.2 实验代码复制下面的代码保存为 gil_demo.py。逻辑很简单找出 150000 以内的质数数量分别用单线程、多线程、多进程跑相同量级的工作比较耗时。import os import time from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor def is_prime(n: int) - bool: if n 2: return False if n in (2, 3): return True if n % 2 0 or n % 3 0: return False i 5 while i * i n: if n % i 0 or n % (i 2) 0: return False i 6 return True def count_primes(limit: int) - int: counts 0 for num in range(2, limit): if is_prime(num): counts 1 return counts def run_single(limit: int) - float: start time.perf_counter() count_primes(limit) return time.perf_counter() - start def run_threads(limit: int, workers: int) - float: start time.perf_counter() with ThreadPoolExecutor(max_workersworkers) as pool: list(pool.map(count_primes, [limit] * workers)) return time.perf_counter() - start def run_processes(limit: int, workers: int) - float: start time.perf_counter() with ProcessPoolExecutor(max_workersworkers) as pool: list(pool.map(count_primes, [limit] * workers)) return time.perf_counter() - start if __name__ __main__: LIMIT 150_000 WORKERS os.cpu_count() or 4 print(f逻辑核数: {WORKERS}, 计算范围: {LIMIT}) single run_single(LIMIT) thread run_threads(LIMIT, WORKERS) process run_processes(LIMIT, WORKERS) print(f单线程基线: {single:.2f} s) print(f{WORKERS} 个线程: {thread:.2f} s) print(f{WORKERS} 个进程: {process:.2f} s)运行命令python gil_demo.py4.3 运行效果与判断标准不同机器跑出来的绝对时间不一样但趋势基本一致执行模式预期趋势单线程作为基线假设耗时为 T多线程大约接近 N 倍 T甚至比 N 倍 T 还高一点几乎没有加速多进程接近 T / N受核数、调度、序列化开销影响判断实验是否成功的标准有三个多线程耗时没有明显低于单线程多进程耗时明显下降运行多进程期间系统监视器能看到多个核被同时拉高。这就是“CPU 密集任务该换多进程”的直接证据。如果你把 LIMIT 调大比如到 500000多线程和多进程的差距会更明显。但第一次测试建议先用较小的值跑通避免本机计算时间过长。这里有一个注意事项ProcessPoolExecutor 在 Windows 上依赖 spawn 方式创建子进程要求主模块可以安全导入。所以进程池的启动代码必须放在 ifname main: 里面不要直接在模块顶层执行。这也是很多 Windows 用户跑多进程代码时反复崩溃的原因。5. I/O 密集场景验证多线程的优势看到多线程在 CPU 密集任务中没有加速很容易产生“Python 多线程没用”的想法。但这是错误的。下面验证 I/O 密集场景也就是真正适合多线程的领域。5.1 用 sleep 模拟 I/O 等待真实网络请求需要外部服务配合环境不稳定。为了便于复现先用 time.sleep 模拟阻塞等待。sleep 会让线程进入等待状态并释放 GIL行为和网络请求、文件读取的基本模式一致都可以反映线程并发的收益。import time from concurrent.futures import ThreadPoolExecutor def fetch_one(url: str) - str: # 真实场景替换为 requests.get(url) 或 urllib.request.urlopen(url) time.sleep(1) # 模拟网络等待 return url def run_single(urls): start time.perf_counter() for url in urls: fetch_one(url) return time.perf_counter() - start def run_thread(urls): start time.perf_counter() with ThreadPoolExecutor(max_workers8) as pool: list(pool.map(fetch_one, urls)) return time.perf_counter() - start if __name__ __main__: urls [fhttps://example.com/news/{i} for i in range(8)] single_used run_single(urls) thread_used run_thread(urls) print(f串行耗时: {single_used:.2f} s) print(f线程池耗时: {thread_used:.2f} s)运行结果会显示8 个任务每个等待 1 秒串行大约是 8 秒左右线程池只需要 1 秒多。这就是 I/O 密集场景下多线程的核心价值线程在等待期间释放 GIL其他线程可以继续执行。5.2 真实爬虫场景扩展把 sleep 换掉就是典型的并发爬虫雏形。可以用 urllib 标准库做真实请求测试但要遵守目标站点的访问频率和授权要求这里不指向任何具体站点示例仅仅演示写法import urllib.request def fetch_real(url: str) - int: with urllib.request.urlopen(url, timeout10) as resp: body resp.read() return len(body)然后用 ThreadPoolExecutor.map 批量请求逻辑和上面的 sleep 示例一样。真实场景里还会涉及连接复用、超时控制、重试策略、限速等这些属于并发工程的延伸问题。核心结论不变只要任务大部分时间在等待外部资源多线程就值得用。6. 换多进程后要小心的四个细节多进程虽然能绕开 GIL但它不是免费的补丁。项目里把多线程直接改成多进程往往会遇到新问题。6.1 进程创建与入口保护multiprocessing 和 ProcessPoolExecutor 在 Windows 上会以 spawn 方式启动新进程子进程要重新导入主模块。如果主模块顶层直接执行了创建进程池的代码就会递归创建进程最后报错或卡死。解决方法是把启动逻辑放进 ifname main: 保护块。即使你的代码只跑在 Linux也建议养成这个习惯因为框架、打包工具和不同平台的部署方式会让问题隐性出现。6.2 传参数必须可以被 pickle多进程传参时任务函数和参数都需要被序列化。模块级函数、基本类型、普通自定义类通常没问题但 lambda、局部函数、锁对象、文件句柄这类东西不能直接传给子进程。遇到 EOFError 或者 pickle 相关报错时优先检查传进去的对象类型。如果业务上必须传大对象比如一个很大的 DataFrame不要直接传可以先保存成文件子进程读取文件路径这样可以避免巨额序列化开销。6.3 进程间通信要换思路多线程共享同一个进程内存多线程操作同一个 list、dict 很方便。多进程里每个进程有独立内存空间不能直接共享复杂数据结构。基础做法有三种multiprocessing.Queue 用于传递任务结果Pipe 适合两个进程之间通信Value 和 Array 适合简单的共享数值。再复杂的数据共享可以使用 multiprocessing.Manager 或 shared_memory但都要付出同步或序列化成本。设计多进程任务时尽量把数据流设计成“输入参数进输出结果回”避免频繁双向通信。6.4 进程数量不等于越多越好逻辑核数来自 os.cpu_count()但超线程会让这个数字翻倍。进程数开太多会被 CPU 调度和内存带宽限制收益不再线性增长反而可能出现性能回退。通常的做法是先按物理核数或逻辑核数的一半设置初始值再根据实际 CPU 占用折线图调整。另外每个子进程都有独立的 Python 解释器和内存空间进程数量开大会让内存占用成倍增长内存不够时很容易触发交换分区性能反而更差。7. 资源占用与性能观察方法很多时候不需要复杂的 profiler只看系统自带的资源监视器就能判断是不是 GIL 问题。Windows 用户打开任务管理器切到性能标签页把 CPU 视图改成“逻辑处理器”运行任务时能直接看到每个核的占用曲线。如果只是个别核跑高总占用很低说明并行没有真正发生。macOS 用户用活动监视器的 CPU 历史窗口也可以看到每个逻辑核的活动。Linux 用户最方便的方式是 htop按 1 键展开每个核的占用条。如果想在代码里记录 CPU 占用可以安装 psutilpip install psutil然后采样当前每个逻辑核的占用率import time import psutil while True: per_cpu psutil.cpu_percent(interval1, percpuTrue) print(per_cpu) time.sleep(1)跑这个采样脚本再并行执行前面的 gil_demo.py对比现象更直观。多进程实验运行时你应该能看到大部分核的占用被拉高。多线程实验运行时占用率曲线会集中在少数核上而且整体曲线波动明显。需要留意的还有一个指标内存。多进程实验如果开 8 个进程每个进程都要加载解释器和任务依赖内存占用可能比多线程高好几倍。做批量任务时要同时观察内存和 CPU不要只看其中一个。用 psutil 可以统计当前进程树的总内存import os import psutil def current_memory_mb() - float: proc psutil.Process(os.getpid()) return proc.memory_info().rss / 1024 / 10248. 常见问题与排查方法下面这份表格基本覆盖了从“线程没跑满”到“进程池报错”的高频问题。遇到问题先对号入座能省不少排查时间。问题现象可能原因排查方式解决方案线程数量和核数相同CPU 占用仍不是 N 倍纯 Python CPU 密集任务受 GIL 限制打开任务管理器或 htop 看各核占用CPU 密集改用多进程多进程代码运行报 EOFError 或窗口反复启动Windows 下缺少入口保护检查子进程是否重复导入主模块把进程池代码放进 ifname mainProcessPoolExecutor 报无法 pickle传给子进程的是 lambda、局部函数或不可序列化对象定位传参对象类型改用模块级函数只传可序列化参数多进程明显变慢每个任务传入数据量太大序列化开销过高对比传大对象和传文件路径的耗时使用文件路径、数据库 ID 或共享内存子进程打印信息混乱多进程共享标准输出的缓冲行为不同观察打印顺序和内容加 flushTrue或改为写日志文件多线程在 I/O 密集任务中也没有提升线程数过高导致上下文切换或存在资源争用尝试不同线程数做压测适当限制并发使用连接池复用资源共享变量出现脏数据错误地认为 GIL 可以保护业务代码检查变量更新是否为多步操作使用 threading.Lock 或改用不可变数据内存快速增长后自动退出子进程数量过多内存翻倍观察任务峰值内存降低进程数分批处理任务最后一个值得单独强调的问题不要因为 Python 有 GIL就把“原子性”理解成“事务性”。GIL 保证的只是解释器每次执行一个字节码期间内存管理安全不等于你的两条 Python 语句之间不会被其他线程插入。比如一个计数器自增操作在字节码层面不是单条指令多线程同时执行时不加锁依然会丢计数。GIL 不能替代业务锁。9. 最佳实践与使用建议到这里GIL 对多线程的限制已经清楚了。最后给出一套可以直接落到代码里的工程建议。第一先跑最小基线。在任何并发优化之前先用单线程版本跑一遍任务记录耗时间和 CPU 占用。没有基线后面很难判断多线程、多进程到底有没有收益。第二按任务类型选并发模型。任务大部分时间在等待网络、磁盘、数据库用 ThreadPoolExecutor 或 asyncio任务大部分时间在做纯 Python 计算用 ProcessPoolExecutor 或 multiprocessing任务两者都有先按粗粒度切分成多进程进程内部再用多线程做 I/O。不要一上来就上进程池也不要因为 GIL 存在就不用线程。第三并发代码要加日志和错误隔离。批量任务尤其重要。把每个子任务的输入、输出、异常都记录下来任务失败时先重试再进入死信队列。很多线上问题不是并发模型错了而是没有日志失败任务把整个队列堵住。第四接口服务和 UI 类项目要特别关注资源限制。多进程会成倍占用内存如果是带界面的 Python 应用或者 Web 服务里直接开大量进程要设计好进程池上限。如果你在 ComfyUI 这类工具里做节点批量处理看到的“任务并行”往往更多是调度和 I/O 等待带来的效果不是 Python 线程在做多核计算两者不要搞混。第五数据请求要合法合规。用多线程写爬虫或者批量调用第三方接口时确认目标服务是否允许这种频率的访问遵守对方的使用条款和 robots 规范。涉及用户数据、版权内容务必获得明确授权。讲完这些回到标题的问题4 个线程跑不满 4 个核是 GIL 限制了纯 Python 字节码在同一进程内的并行执行。想利用多核CPU 密集任务就该换多进程但换之前先确认任务真的是 CPU 密集而不是被隐藏的 I/O 拖慢了。建议收藏备用。把 gil_demo.py 跑一遍记录本机的基线和 CPU 占用曲线后面做并发选型时这就是你的第一手判断依据。下一步可以继续看两个方向如果你是网络请求和爬虫为主往 asyncio 和任务队列方向深入如果你是计算和数据处理为主研究 multiprocessing 的数据分发策略和共享内存。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门