Python GIL 深度解析:多线程为何跑不满多核?何时该用多进程?
“4 个线程没跑满 4 个核”这大概是 Python 并发初学者最容易碰上的一记闷棍。明明机器是 4 核 8 线程明明代码里洋洋洒洒开了 4 个threading.Thread运行起来 CPU 占用率却只在一个核上打转剩下几个核在旁边看戏。换成 Java 或者 C这种写法通常早就把核吃满了。于是很多人会得出一个结论Python 多线程没用。这个结论对了一半。另一半藏在 GIL 这个名字里。GIL 全称 Global Interpreter Lock中文一般叫全局解释器锁。它是 CPython 解释器实现里的一个老设计也是 Python 新手到中级开发者之间最著名的“知识分水岭”。这篇文章不是要背概念而是想和你一起用最小实验把它看透GIL 到底锁了什么、放过了什么、CPU 密集场景为什么慢、什么时候应该果断换多进程。读完你会得到一个可以直接落地的选型判断而不是笼统的“多线程没用”或者“无脑上多进程”。1. GIL 是什么一句话解释 关键误区先给一个比较准确但不拗口的定义GIL 是 CPython 解释器内部的一把全局互斥锁它保证同一时刻只有一个线程能执行 Python 字节码。注意几个关键词CPython、解释器内部、字节码。CPython 是 Python 语言最主流的官方实现你从 python.org 下载的、在绝大多数 Linux 发行版里默认安装的基本都是 CPython。Python 还有其他实现比如 Jython跑在 JVM 上、IronPython跑在 .NET 上它们并不一定有 GIL。但日常开发、面试、部署遇到的绝大多数是 CPython所以讨论 GIL 就是讨论我们最常用的这个 Python。“只有一个线程能执行 Python 字节码”这句话是理解一切的关键。它意味着多线程并不能利用多核 CPU 同时执行 Python 代码。线程之间的切换不是“并行”而是“并发”——快速交替执行。CPU 密集任务用多线程往往不仅不加速还可能因为线程切换开销变得更慢。但这里有一个经典误区需要立刻说清楚。很多人把 GIL 理解为“Python 多线程完全没用”这是不准确的。GIL 锁的是 Python 字节码的执行权但锁不住所有事情。一个线程在等待网络响应、磁盘读写、数据库返回时这个线程并不需要持有 GIL锁会被释放掉让其他线程去跑。这就是 I/O 密集场景下多线程仍然有效的原因。你可以这样理解GIL 卡的是“计算”不卡“等待”。那么为什么 GIL 会存在最核心的一个原因是 CPython 的内存管理依赖引用计数。每个 Python 对象都有一个引用计数当计数归零时对象会被立即回收。如果允许两个线程同时操作同一个对象引用计数就可能在极短的时间窗口内出现不一致导致内存被错误回收或者泄漏。给解释器加一把大锁是当时最直接的方案。从工程历史上看这个设计让 CPython 在多线程环境下更容易保证内存安全付出的代价就是牺牲了并行执行能力。所以你会看到很多老 Python 工程师说不是 Python 不想并行是解释器实现路径下的历史包袱太重平滑改掉 GIL 极其困难。2. 多线程、多进程、协程并发与并行的边界要判断什么时候该用多线程、什么时候该换多进程先要理清两组概念并发与并行CPU 密集与 I/O 密集。并发Concurrency是指任务在时间片层面交替执行。从宏观上看多个任务都在推进从微观上看某一时刻可能只有一个任务在真正运行。并行Parallelism是指多个任务在物理上同时执行。这要求硬件支持多核或单核多线程并且语言/运行时能真正把任务分配到不同的执行单元上。在 CPython 里多线程只能提供并发不能提供并行多进程则既能并发也能并行因为每个进程有自己独立的解释器实例和内存空间GIL 互不干扰。对比维度多线程threading多进程multiprocessing协程asyncio能否利用多核不能受 GIL 限制能不能单线程事件循环内存共享天然共享同一进程内存但要注意锁独立地址空间需 IPC 传递数据天然共享本质是单线程适用场景I/O 密集任务CPU 密集任务高并发 I/O 密集任务启动开销小大很小调试难度中等有竞态风险较高进程间通信复杂低但要求代码全部异步化很多教程会把“多线程做 CPU 密集任务没用”和“多线程没用”画等号这是典型的以偏概全。另一个常见误区是把协程和多线程对立起来。实际上协程解决的是“高并发 I/O 等待”问题它用更小的开销做调度但底层依然跑在单线程里。如果任务是纯 CPU 计算协程同样无法加速。落到日常开发上选型的核心判断就是任务到底是“在计算中”还是“在等待中”。计算密集型的任务比如数值计算、图像处理、视频编解码、大量数据排序选择多进程或者直接调用已经释放 GIL 的 C 扩展库。等待密集型的任务比如网络爬虫、Web 接口调用、读写文件、操作数据库选择多线程或协程它们的开销远小于多进程。3. 实验环境与准备工作概念说完了下面用最小可运行实验来验证。整个实验只需要 Python 标准库不需要安装任何第三方依赖非常适合新手跟着敲一遍。操作系统方面Windows、macOS、Linux 都可以运行Python 版本建议 3.8 以上因为下文涉及的部分 API 在旧版本上有差异。你可以先用下面命令确认版本python --version如果控制台输出类似于Python 3.10.12或更高就没有问题。实验会用到四个模块threading创建多线程。multiprocessing创建多进程。time统计耗时。concurrent.futures在工程推荐写法中使用它封装了线程池和进程池。建议新建一个目录比如C:\code\gil-demo或~/code/gil-demo把后面每个实验分别存成独立的.py文件。这样调试时排错更清晰。还要提醒一句很多编辑器或者交互式环境比如 Jupyter Notebook对多进程实验支持不太好尤其是 Windows 平台容易出现递归启动报错。所以多进程相关代码最好用命令行方式运行python cpu_thread_vs_process.py而不是在 Notebook 里直接执行。4. 第一个实验CPU 密集场景下多线程为什么跑不快先写一个典型的 CPU 密集任务。这里用一个两层循环做整数累加计算量足够大而且不涉及任何 I/O能纯粹反映 CPU 的“算力”。完整代码如下# 文件路径cpu_thread_vs_process.py import time import threading from multiprocessing import Pool def compute(n400): cnt 0 for i in range(n): for j in range(n): cnt i * j return cnt def single_thread(): start time.perf_counter() for _ in range(4): compute() print(f单线程耗时: {time.perf_counter() - start:.3f}s) def multi_thread(): start time.perf_counter() threads [threading.Thread(targetcompute) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f多线程耗时: {time.perf_counter() - start:.3f}s) def multi_process(): start time.perf_counter() with Pool(4) as pool: pool.map(compute, [400, 400, 400, 400]) print(f多进程耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: single_thread() multi_thread() multi_process()运行命令python cpu_thread_vs_process.py在常见的 4 核 8 线程开发机上大概率会看到类似下面的输出单线程耗时: 1.412s 多线程耗时: 1.435s 多进程耗时: 0.372s单线程和多线程几乎一样甚至多线程偶尔还慢一点点原因是线程创建和切换有额外开销。多进程则接近线性加速4 个 CPU 密集任务跑出了接近单线程四分之一的耗时。这个实验直观地回答了标题里的问题4 个线程跑不满 4 个核就是因为 GIL 只允许一个线程执行 Python 字节码。这里要特别说明一下不同 CPU 型号、不同 Python 小版本、不同系统调度策略下具体数值会有浮动但趋势非常稳定纯计算任务里多线程无法获得多核并行能力。如果你跑出来的百分比和上面不完全一致不需要担心关键看三类方式的相对差距。5. 第二个实验I/O 密集场景下多线程仍然有效接着看另一种任务大量时间消耗在等待上。这里用time.sleep模拟网络请求或者数据库查询的等待过程。# 文件路径io_thread_vs_process.py import time import threading from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def io_task(delay1): time.sleep(delay) return delay def run_thread_pool(): start time.perf_counter() with ThreadPoolExecutor(max_workers4) as executor: list(executor.map(io_task, [1, 1, 1, 1])) print(f线程池耗时: {time.perf_counter() - start:.3f}s) def run_process_pool(): start time.perf_counter() with ProcessPoolExecutor(max_workers4) as executor: list(executor.map(io_task, [1, 1, 1, 1])) print(f进程池耗时: {time.perf_counter() - start:.3f}s) if __name__ __main__: run_thread_pool() run_process_pool()运行python io_thread_vs_process.py预期输出线程池耗时: 1.023s 进程池耗时: 1.043s4 个任务各自 sleep 1 秒线程池和进程池都能做到约 1 秒完成。线程池创建的额外开销更小所以整体耗时可能还略微占优。这就验证了前面的结论I/O 等待期间线程不需要持有 GIL锁会被释放其他线程可以继续推进。实际开发中网络 I/O 比time.sleep更复杂但原理完全一致。比如爬虫请求一个网页大部分时间是在等服务器响应真正占用 CPU 的解析逻辑占比不高。此时用多线程就可以同时发几十个请求吞吐量成倍提升。这也是为什么写 Python 爬虫的人很少用多进程多用多线程或协程。6. 深入GIL 在字节码层面如何切换理解了宏观现象再看 GIL 的微观机制会对后续排查更有帮助。Python 代码在执行前会被编译成字节码CPython 解释器通过一个执行循环逐条执行这些字节码。GIL 的设计就是在这个执行循环外面加一把锁线程必须先抢到 GIL 才能进入循环执行字节码。那线程什么时候会释放 GIL主要有几种情况执行一定数量的字节码指令后达到sys.getswitchinterval()设置的切换间隔默认一般是 0.005 秒即 5 毫秒。遇到 I/O 阻塞操作时比如time.sleep、socket.recv、requests.get。调用某些会主动释放 GIL 的 C 扩展函数时比如numpy的很多底层计算函数。第一种情况解释了为什么 CPU 密集的多线程几乎不加速每个线程都只跑 5 毫秒就要让出锁其他线程抢到锁再跑 5 毫秒线程之间频繁切换反而增加了额外的调度开销。从宏观上看4 个线程把 1 个核的时间分成了若干片但没有任何一秒是多核同时在工作。你可以尝试调整切换间隔再跑一次 CPU 密集实验观察对耗时的影响import sys sys.setswitchinterval(0.0001) # 切换间隔设置为 0.1 毫秒切换间隔越短上下文切换越频繁CPU 密集多线程的耗时通常会越长。第二个需要特别注意的问题是GIL 并不保证 Python 代码片段都是原子操作。一个很经典的例子是count 1。看起来是一条语句但在字节码层面实际包含多条指令比如读取变量、执行加法、写回变量。两个线程同时执行这段代码时可能在中间被打断最终导致结果小于预期。# 文件路径counter_race.py import threading counter 0 def add_one(): global counter for _ in range(1000000): counter 1 if __name__ __main__: threads [threading.Thread(targetadd_one) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(fcounter 最终值: {counter}, 期望值: 4000000)在一台常规机器上多次运行你大概率会看到最终值小于 4000000不同运行次数结果还会波动。这再次说明GIL 只是让“同一时刻只有一个线程执行字节码”并不等于“共享变量的所有操作都是安全的”。所以在写多线程共享可变状态时仍然必须引入threading.Lock或其他同步机制。最后C 扩展释放 GIL 是一个容易被忽略但非常重要的场景。numpy这类库的大规模矩阵计算底层是用 C 实现的并且会在计算过程中主动释放 GIL。这意味着如果你的 CPU 密集任务已经交给了numpy处理那么多线程反而可能获得真正的并行加速。这也是为什么有些数值计算项目用多线程也能跑得快而如果只写纯 Python 循环多线程就寸步难行。这个细节的工程含义是选型之前先判断计算到底发生在 Python 层还是已经下沉到 C 层。7. 什么时候该换多进程判定方法从上文已经能看出选型的基本逻辑这里给出更明确的判定路径。任务类型场景举例推荐方案原因CPU 密集纯 Python 循环、解析大日志、加解密、数值计算多进程 / ProcessPoolExecutor每个进程独立解释器能并行利用多核I/O 密集网络请求、读写数据库、下载文件多线程 / 线程池 / asyncioGIL 在等待期间释放线程切换成本低混合型Web 服务、数据处理流水线多线程 多进程结合等待与计算并存分层处理已下沉到 C 扩展numpy 矩阵运算、部分 Python 三方库多线程可尝试C 扩展释放 GIL 后有可能并行用多进程时multiprocessing的标准使用方式是进程池避免频繁创建销毁进程。Pool提供map、apply、starmap等接口下面是一个更贴近实际项目的示例模拟对一批 URL 做 CPU 密集的文本分析# 文件路径process_pool_demo.py from concurrent.futures import ProcessPoolExecutor def analyze_url_content(url): # 这里假设是纯 Python 计算密集型分析 total 0 for i in range(500): for j in range(500): total i * j return url, total def main(): urls [ https://example.com/a, https://example.com/b, https://example.com/c, https://example.com/d, ] with ProcessPoolExecutor(max_workers4) as executor: results executor.map(analyze_url_content, urls) for url, score in results: print(f{url}: {score}) if __name__ __main__: main()运行python process_pool_demo.pyProcessPoolExecutor的 API 比multiprocessing.Pool更现代也更贴近标准库asyncio和ThreadPoolExecutor的使用习惯。在工程代码里我更推荐统一使用concurrent.futures下的三种 ExecutorThreadPoolExecutor、ProcessPoolExecutor和asyncio的异步任务。关于多进程还要强调几个工程注意事项。第一Windows 上多进程代码必须放在if __name__ __main__:保护块里否则子进程在导入模块时会递归执行外部代码报出各种神秘错误。这也是为什么上面所有多进程示例都保留了这个写法。第二进程之间不共享内存。要让子进程把计算结果传回主进程通常通过进程池的返回值、multiprocessing.Queue、Pipe等方式。此时如果传递的数据量很大序列化和反序列化开销会成为新的瓶颈。第三进程创建成本远高于线程。在 Linux 上虽然使用了 fork 机制创建速度很快但在 Windows 上每次创建进程都相当于启动一个全新解释器开销明显。所以多进程适合粒度较大、运行时间较长的任务不适合把大量小任务切来切去。8. 常见问题与排查方法在学习和实战中下面几个问题出现频率非常高这里统一列出排查思路。问题现象可能原因排查方式解决方案多线程 CPU 密集反而更慢GIL 抢占导致上下文切换开销对比单线程和多线程耗时用 perf_counter 统计CPU 密集改用多进程或 C 扩展进程池代码在 Windows 上无限报错缺少if __name__ __main__:保护查看导入路径是否有递归执行把启动代码放入 main 保护块多个线程读写同一个变量结果不正常字节码并发执行产生竞态打印中间值或使用锁保护用threading.Lock或改用multiprocessing.Manager多进程耗时比预期长很多子进程启动开销大或频繁传递大对象统计单任务耗时和传输耗时减少任务数量改用持久进程池压缩数据进程池数量太多把机器资源打满没有根据 CPU 核心数限制进程数观察 CPU 和内存监控用os.cpu_count()动态设置预留系统资源I/O 密集场景多线程提升不明显实际瓶颈是任务本身串行或被锁阻塞用 profile 工具定位耗时点分解阻塞点考虑协程进一步提升并发量锁定共享变量后代码变得很慢锁粒度太大抢锁等待时间过长分析临界区代码长度缩小锁范围或使用无锁数据结构和原子操作这些问题的排查路径有一个共同原则不要凭感觉判断先用监控工具确认 CPU、内存、I/O 的占用情况再决定优化方向。有时候瓶颈根本不在并发模型而在数据库慢查询或者远程接口响应过慢。9. 最佳实践与工程建议前面实验和原理讲完了这一节给出可以直接落到项目里的建议。第一任务分类先行。动手写并发代码之前先问自己这段计算是 CPU 密集还是 I/O 密集如果是 I/O 密集多线程和协程都能胜任如果是 CPU 密集且集中在纯 Python 循环里优先多进程如果计算已经由 numpy、pandas、OpenCV 这类 C 扩展库承担先确认它们是否释放 GIL再决定是否值得用多线程。第二优先使用高层次的 Executor API。concurrent.futures.ThreadPoolExecutor和concurrent.futures.ProcessPoolExecutor的接口一致从线程池切换到进程池只需要改一个类名这对快速做 A/B 对比非常有用。不要一上来就手写线程管理和进程管理。第三进程池大小按需设置。很多人看到 8 核机器就直接开 8 个进程这里有两个隐患一是机器上还跑着其他服务抢资源会影响线上稳定二是任务本身如果包含 I/O进程数可以适当大于核心数因为等待时 CPU 是空闲的。更稳的做法是通过环境变量或配置文件控制进程数而不是写死。第四避免跨进程共享大对象。多进程之间的数据传输要做序列化大对象会显著拖慢性能。如果子进程确实需要同一份只读数据在 Linux 上可以利用 fork 时写时复制的特性在创建进程前先把数据准备好在 Windows 上就要谨慎评估传输成本。第五能用协程解决的不要急于上多进程。Web 后端、爬虫这类高并发 I/O 场景asyncio 可以做到单进程海量并发资源占用远低于多进程。但异步代码对现有代码侵入性强如果团队不熟悉从多线程起步也是合理选择。第六多线程共享资源时锁的粒度越小越好。不要为了“安全”把整个函数包在一个大锁里那样基本等于把所有线程串行化。应尽量只保护真正会并发修改的临界区。第七性能优化需要量化。不要凭直觉判断“多线程快”或“多进程快”用统一的计时器和真实数据说话。建议在生产代码里加入简单的耗时统计方便灰度对比。第八明确 GIL 在版本演进中的变化。Python 社区一直在探索移除或限制 GIL 的方案比较知名的如 Perl 语言的ithreads模式、Python 社区提出的nogil分支以及通过threading模块改善调度等方式。但截至当前主流的 CPython 3.x 版本GIL 依然存在。这意味着本文的基础结论在今天依然有效但如果你看到未来的 Python 大版本发布新特性最好重新验证一遍旧结论。10. 总结与后续学习方向这篇文章用三个最小实验和一组源码把 GIL 的核心问题拆开了4 个线程没有跑满 4 个核是因为 CPython 解释器里的全局锁只允许一个线程执行字节码CPU 密集场景多线程不仅不加速还会因为切换开销变慢而 I/O 密集场景下线程在等待时释放 GIL所以多线程依然有明显效果需要真正利用多核时多进程才是确定性的方案。核心判断可以浓缩成一句话多线程解决 Python 的“等待问题”多进程解决 Python 的“计算问题”。如果你还想继续深入建议按这个顺序扩展学习阅读threading和multiprocessing官方文档熟悉Lock、Semaphore、Queue等同步原语。实践concurrent.futures的线程池和进程池对比不同任务类型下的表现。学习 asyncio理解协程在 I/O 密集高并发场景的优势。研究 GIL 相关的 PEP 提案和 CPython 源码中ceval.c的部分能建立更底层的认知。实际项目中遇到并发性能问题不要急着改架构先按本文的实验方法测量一遍。并发模型的选型往往是性能、复杂度和维护成本的权衡适合的才是最好的。建议把上面几个实验代码保存下来遇到拿不准的场景时跑一遍用数据说话比搜经验贴可靠得多。