这次来聊一个很多 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 没有 GIL,Jython 或 IronPython 也没有 CPython 这种 GIL。但绝大多数开发者在命令行敲 python 启动的解释器就是 CPython,所以讨论 GIL 对日常开发是有实际意义的。
还要强调一下:GIL 不等于线程安全。很多新手以为有了 GIL,多线程操作共享变量就不会出问题。实际上 GIL 只保证单个字节码指令级别的安全,并不保证一段多步操作的完整性。业务代码里的共享数据,该用 Lock 还是得用 Lock。
2. 什么时候用多线程,什么时候换多进程:场景边界
判断该用多线程还是多进程,核心是看任务是 CPU 密集还是 I/O 密集。这个判断比“哪个 API 更快”更重要。选错模型,代码写得再漂亮,性能也上不去。
| 任务特点 | 优先方案 | 原因 |
|---|---|---|
| CPU 密集,纯 Python 计算 | multiprocessing,ProcessPoolExecutor | 每个进程有独立 GIL,能真正使用多核 |
| I/O 密集,大量等待 | threading,ThreadPoolExecutor | 等待 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_workers=workers) 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_workers=workers) 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_workers=8) as pool: list(pool.map(fetch_one, urls)) return time.perf_counter() - start if __name__ == "__main__": urls = [f"https://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, timeout=10) 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 占用,可以安装 psutil:
pip install psutil然后采样当前每个逻辑核的占用率:
import time import psutil while True: per_cpu = psutil.cpu_percent(interval=1, percpu=True) 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== "main" |
| ProcessPoolExecutor 报无法 pickle | 传给子进程的是 lambda、局部函数或不可序列化对象 | 定位传参对象类型 | 改用模块级函数,只传可序列化参数 |
| 多进程明显变慢 | 每个任务传入数据量太大,序列化开销过高 | 对比传大对象和传文件路径的耗时 | 使用文件路径、数据库 ID 或共享内存 |
| 子进程打印信息混乱 | 多进程共享标准输出的缓冲行为不同 | 观察打印顺序和内容 | 加 flush=True,或改为写日志文件 |
| 多线程在 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 的数据分发策略和共享内存。