资讯中心

Python GIL详解:多线程为何跑不满多核?

📅 2026/9/27 21:41:35
Python GIL详解:多线程为何跑不满多核?
一台 4 核的 Linux 服务器一个看起来完全正常的 Python 并发脚本4 个线程分别处理 1/4 的数据理论上应该把机器“吃满”。运行之后用top一看CPU 总占用率却只有 100% 出头——准确说是 4 个核里只有一个核在忙另外三个核全程闲着。再把耗时打出来更让人意外4 个线程的总执行时间不仅没有变成串行的 1/4反而比串行还要慢了一点。这不是配置写错也不是服务器资源不够。这是 CPython 解释器里那把著名的 GILGlobal Interpreter Lock全局解释器锁在起作用。很多人第一次遇到这个问题第一反应是去查虚拟化、容器配额、CPU 亲和性甚至怀疑是不是 Python 安装有问题。其实问题不在环境而在你对“多线程”这件事的预期。这篇文章会从一个可以自己复现的实测案例讲起拆清楚 GIL 到底限制了什么、多线程为什么在 CPU 密集场景反而更慢、什么时候该换多进程以及我在真实项目里最常用的选型和排查流程。1. 先复现一次“4 线程跑不满 4 核”的现场1.1 先写一个纯粹的计算任务为了把 GIL 的影响测出来第一个任务必须是一个纯 CPU 计算任务。注意这里不能有sleep、网络请求、文件读写这些 I/O 操作否则线程会在等待时释放 GIL干扰判断。简单起见用一个足够消耗时间的循环import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def heavy(n): total 0 for i in range(n): total i return total这个函数只做整数累加没有任何系统调用。它完全在解释器字节码层面执行是观察 GIL 影响的最佳样本。1.2 单线程、多线程、多进程三个版本跑一遍接着写三个执行方式分别对应串行、线程池、进程池def run_single(): start time.perf_counter() for _ in range(4): heavy(50_000_000) return time.perf_counter() - start def run_threads(): start time.perf_counter() with ThreadPoolExecutor(max_workers4) as pool: pool.map(heavy, [50_000_000] * 4) return time.perf_counter() - start def run_processes(): start time.perf_counter() with ProcessPoolExecutor(max_workers4) as pool: pool.map(heavy, [50_000_000] * 4) return time.perf_counter() - start if __name__ __main__: print(fsingle : {run_single():.4f} s) print(fthreads : {run_threads():.4f} s) print(fprocesses: {run_processes():.4f} s)在我本地一台 4 核机器上常见结果大概是这样的具体数值会随机器和 Python 版本变化但趋势稳定单线程约 5.2 秒4 线程约 5.4 秒有时还会略高4 进程约 1.4 秒用top观察时会看到单线程一个核接近 100%其余核心空闲4 线程整体 CPU 仍然只有 100% 左右本质还是只有一个核在执行业务逻辑4 进程整体 CPU 接近 400%4 个核全部在跑。1.3 第一反应不该是“调并发参数”而是确认任务类型很多人会把问题定位成“并发参数不对”“机器没有开满”“需要绑定 CPU”。这些当然值得排查但在这之前更应该先做一个判断你的任务是 CPU 密集还是 I/O 密集如果线程里的任务大部分时间是纯计算那么多线程在 CPython 下几乎不会带来并行加速如果线程里的任务大量时间花在等待网络、磁盘、数据库响应上那么多线程确实有真实收益。这个判断会决定后面所有的方案选择。所以先别急着改代码我们先把 GIL 本身讲清楚。2. GIL 是什么它锁住了什么、放过了什么2.1 一句话版本同一时刻只能有一个线程执行 Python 字节码GIL 是 CPython 解释器内部的一个互斥锁。它保证同一时刻一个 Python 进程里只能有一个线程执行 Python 字节码。注意几个关键点它锁的是“执行字节码”这件事不是“线程不能运行”线程依然是操作系统线程会被系统调度也可以进入等待状态当线程做 I/O、调用会释放 GIL 的 C 扩展、或者主动等待时其他线程可以抢到 GIL 继续执行所以 GIL 不是“一锁全锁”而是“同一时间只有一个线程在做 Python 层的计算”。2.2 CPython 为什么需要 GIL核心原因在内存管理。CPython 的对象通过引用计数来管理生命周期对象被引用时引用计数 1引用被释放时引用计数 -1引用计数变成 0 时对象被回收。如果两个线程同时修改同一个对象的引用计数就会出现一边 1 一边 -1 的竞争轻则内存泄漏重则对象提前释放。解决这个问题有几种常见思路给每个对象加细粒度锁使用更复杂的内存管理策略用一个全局锁保住解释器核心数据简单粗暴。CPython 选择了全局锁方案也就是 GIL。它让解释器实现变得简单、可靠单线程性能也不至于因为大量细粒度锁而明显下降。这里需要给一个判断GIL 不是什么历史遗留 bug而是 CPython 在工程上的一种取舍。它牺牲了多线程并行执行 Python 计算的可能性换来了解释器内存安全与实现简单。理解这一点才不会被网上各种“Python 多线程就是个废物”的说法带偏。2.3 GIL 管得到哪里管不到哪里用一张表来画清边界场景是否会受 GIL 限制原因纯 Python 整数、字符串、循环计算会每个字节码都需要持有 GIL文件读写、网络请求、数据库查询通常不会持续占用等待 I/O 时会让出 GILtime.sleep会释放 GIL进入系统调用等待大量 NumPy 数值计算部分操作会释放 GILC 扩展可以主动释放 GIL多进程不受 GIL 影响每个进程有独立解释器和 GIL加锁、队列等同步操作会阻塞需要等待对应资源这张表里最值得记住的是倒数第二行多进程不受 GIL 影响因为它根本不是“同一个解释器里的多线程”而是多个解释器进程并行。这也是 CPU 密集场景通常推荐多进程的根本原因。2.4 几个需要纠正的常见误解误解一Python 多线程完全不能并发。不对。多线程可以“同时等待很多 I/O”只是不能“同时做 Python 字节码计算”。一个线程等待网络响应时另一个线程可以抢到 GIL 继续算。误解二去掉 GIL 就万事大吉。去掉 GIL 后解释器内存管理、C 扩展兼容、多线程竞争都要重新设计。Python 3.13 开始出现 free-threading 方向但目前更多是实验性支持生产环境的主流仍然是带 GIL 的 CPython。误解三所有 CPU 密集任务都被 GIL 卡死。不一定。如果计算发生在 C 扩展内部并且扩展在计算期间释放了 GIL那么多线程是可以并行加速的。判断标准不是“任务吃不吃 CPU”而是“计算是发生在 Python 字节码上还是发生在会释放 GIL 的 C 层”。3. CPU 密集场景多线程为什么反而更慢3.1 四把钥匙却只有一把锁可以这样理解4 个线程就像 4 个人但工作间里只有一台工作台。Python 字节码执行就是这台工作台GIL 就是工作间的锁。4 个人不能同时使用工作台操作系统每隔一小段时间就把一个人换进去干活。换人本身有成本所以 4 个人轮流干同一件纯计算的事往往比 1 个人从头干到尾更慢。在纯 Python 计算任务里这个现象非常明显单线程不需要抢锁一直执行上下文干净4 线程线程 1 执行一小段时间时间片到了释放 GIL线程 2 抢到锁继续执行线程之间反复切换、排队、竞争。如果任务之间还要共享数据、加锁、同步结果竞争成本还会进一步放大。3.2 GIL 切换间隔带来的额外开销CPython 在 Python 3.2 之后优化了 GIL 的行为默认大约每 5 毫秒切换一次也可以通过sys.setswitchinterval()调整。import sys print(sys.getswitchinterval()) # 查看当前切换间隔通常是 0.005 秒切换间隔不是越小越好。间隔太短上下文切换开销大间隔太长交互型任务的响应会变差。默认值是权衡结果普通程序通常不需要去改。在纯 CPU 计算场景里多线程版本经常比单线程版本慢 5% 到 20%。慢的部分主要来自线程切换和 GIL 竞争CPU 缓存亲和性变差线程状态维护和系统调度开销。3.3 用perf_counter做一次可信的基准用time.perf_counter()是最合适的计时方式因为它精度高、受系统时间跳变影响小。但更重要的是一套可信的基准方法预热先执行一次小规模任务让解释器缓存和内存分配进入稳定状态多次运行至少跑 3 次取中位数不要只看最好的一次环境隔离关掉浏览器、其他编译任务避免 CPU 波动记录 CPU 利用率不要只看时间还要看是不是真的吃满了多核。注意不要在正在提供服务的线上环境直接跑全量压测。先在预发环境或本机用小样本确认逻辑再逐步加大数据量。3.4 真正需要注意的例外C 扩展会释放 GILNumPy、SciPy、很多加密和编解码库的 C 实现会在核心计算阶段主动释放 GIL。如果你在一个线程等待某个 C 函数计算的同时另一个线程能拿到 GIL 继续做 Python 层工作那么多线程也可能有收益。所以更准确的选型方法不是“CPU 密集就一定要换多进程”而是Python 层循环、字符串处理、自定义运算GIL 影响很大多进程更合适C 层数组运算、第三方库核心计算先实测多线程收益再决定是否升级到多进程。这一条在实战里非常重要因为不少人把“所有 CPU 密集任务都应该换多进程”当成铁律结果在 NumPy 计算任务上反而白白丢掉多线程的轻量优势。4. I/O 密集场景多线程依然是轻量好用的方案4.1 I/O 密集的本质是“等”而不是“算”写爬虫、批量请求接口、做文件扫描时任务的大部分时间花在等待上等网络响应、等磁盘读写、等数据库结果。真正的 CPU 计算比例很小所以 GIL 的占用率不高线程之间的竞争也小。用一个简单的sleep来模拟这种等待不是真实网络 I/O但表现上很接近import time from concurrent.futures import ThreadPoolExecutor def io_task(seconds): time.sleep(seconds) return seconds start time.perf_counter() with ThreadPoolExecutor(max_workers4) as pool: pool.map(io_task, [1, 1, 1, 1]) print(time.perf_counter() - start)结果大约是 1 秒而不是 4 秒。原因就是sleep进入系统等待后会让出 GIL让另外三个线程也能同时进入等待四路等待重叠了。4.2 爬虫、接口批处理为什么更适合线程池假设你有 100 个 HTTP 请求每个请求需要 200ms串行执行总耗时 20 秒多线程执行10 个并发总耗时接近 2 秒多进程执行也能达到类似效果但每个请求要在进程之间序列化参数、复制内存、维护进程池成本反而更高。工程上常见的 I/O 密集场景大多可以用concurrent.futures.ThreadPoolExecutor或asyncio处理。线程池的优点在于写起来接近同步代码调试方便不需要把整个代码改成async/await风格。缺点是任务量特别大时线程管理和上下文切换成本会上升。4.3 多线程、多进程、asyncio 的选型对照维度多线程 ThreadPoolExecutor多进程 ProcessPoolExecutorasyncio适合类型I/O 密集CPU 密集大量 I/O 并发代码难度低基本是同步代码中需要数据序列化高需要 async/await数据共享通过锁和队列通过 Queue/Pipe/共享内存单线程内共享无锁但注意任务协作典型场景爬虫、请求批处理、日志处理数值计算、图像处理、数据清洗高并发网关、WebSocket、长连接是否受 GIL 影响I/O 等待时影响小不受影响单线程事件循环本质上在复用等待时间我的通常建议是先想清楚哪种代码最好维护。如果任务以等待为主、并发规模在几十到几百优先线程池如果大量纯 Python 计算且计算是主要瓶颈再考虑进程池如果要做上万连接级别的长连接服务asyncio 的价值才会真正体现出来。4.4 一个容易误判的点用 sleep 测 CPU 密集有人会用time.sleep(5)去测“CPU 密集型多线程”然后看到加速效果得出“GIL 不影响”的错误结论也有人把大量字符串拼接当 I/O其实那是 CPU 密集。判断标准很简单任务主要时间花在等待外部资源I/O 密集任务主要时间花在 CPU 计算CPU 密集不确定时用top或psutil看 CPU 利用率CPU 利用率接近 100% 且长时间不降CPU 密集CPU 利用率不高但程序耗时很长大概率在等待。5. 什么时候该换多进程换之前想清楚这 5 件事5.1 判断标准纯 Python 计算且计算量大到值得付出开销当任务的瓶颈是 Python 层 CPU 计算并且计算量已经大到值得付出进程启动、数据序列化和通信成本时才建议换多进程。典型的候选场景大量日志清洗、文本规则解析图像处理里的逐像素 Python 循环数据管道里每一行都要跑一段自定义 Python 逻辑批量计算复杂指标、模拟实验。如果任务本身很小比如一次计算只有 50ms那么多进程的启动和通信开销可能比任务本身还大反而不划算。5.2 ProcessPoolExecutor 的最小可运行写法沿用第 1 章的heavy函数from concurrent.futures import ProcessPoolExecutor if __name__ __main__: with ProcessPoolExecutor(max_workers4) as pool: results list(pool.map(heavy, [50_000_000] * 4)) print(results)这段代码里藏着一个很重要的工程细节if __name__ __main__保护。在 Windows 上进程池使用spawn方式启动子进程它会重新导入当前脚本文件。如果没有这段保护子进程在导入时会再次创建进程池形成递归直接报错。在 Linux 和 macOS 上默认启动方式可能不同但统一写成带保护的版本是最稳妥的。另外注意pool.map返回结果的顺序与输入顺序一致但任务本身是并行执行的。ProcessPoolExecutor比手动维护multiprocessing.Process省心很多适合大多数“任务独立、结果需要按顺序收集”的场景。5.3 数据传递是隐藏成本进程之间不共享内存。传给进程池的每个参数都会先序列化再复制到子进程。比如把一个很大的 DataFrame 切成 4 块丢给进程池序列化开销可能远超计算收益。几点建议能传文件路径、小参数就优先传小参数大对象尽量放到文件或共享内存里子进程按路径读取不要在任务执行过程中反复向进程池传同一个大对象如果必须共享大数组可以使用multiprocessing.shared_memory但要设计好同步方式。5.4 进程间通信不是免费的需要多个进程协作时常见方式有Queue适合生产者/消费者模式Pipe适合两个进程点对点通信Manager可以共享列表、字典等对象但操作较慢shared_memory适合大数据块共享但要小心同步问题。一个常见的工程模式是主进程把任务拆成多个任务项子进程从队列里取任务处理完把结果写入另一个队列主进程最后统一聚合。这种模式清晰也方便控制流量和重试。5.5 进程数不是开得越多越好ProcessPoolExecutor(max_workers4)在 4 核机器上通常能吃满 CPU。但如果任务里有磁盘访问、频繁加锁、大量进程间消息可能 2 到 3 个进程才是吞吐量最优值。建议先用 2、4、8 三个档位分别做小规模压测而不是直接写最大核数。还要注意容器环境os.cpu_count()有时返回的是宿主机核数而不是容器配额。如果机器是 64 核但任务内存占用大进程数开太多会触发内存交换反而变慢。注意换多进程不是“把 Thread 改成 Process”就完事还要同时考虑启动方式、数据序列化、进程间通信和资源上限。先在 2 个小任务上验证正确性再逐步扩大并发。6. 一套可复用的选型框架和排查链路6.1 先判断任务类型而不是先改并发方案给所有并发改造之前加一道分类热点逻辑是纯 Python 循环或函数→ CPU 密集候选热点逻辑在等待网络、磁盘、数据库→ I/O 密集候选热点逻辑在 C 扩展且扩展会释放 GIL→ 先试多线程再用 CPU 利用率判断。判断工具可以是top、pidstat、psutil。辅助判断也很简单如果程序在等待接口时 CPU 很低、耗时很长那基本就是 I/O 密集。6.2 跑一个 5 分钟就能完成的基准测试推荐的测试脚本结构如下其中task需要替换成你自己的任务函数import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def task(n): total 0 for i in range(n): total i return total def bench_single(n50_000_000, repeat3): start time.perf_counter() for _ in range(repeat): task(n) return time.perf_counter() - start def bench_threads(n50_000_000, workers4, repeat3): start time.perf_counter() for _ in range(repeat): with ThreadPoolExecutor(max_workersworkers) as pool: pool.map(task, [n] * workers) return time.perf_counter() - start def bench_processes(n50_000_000, workers4, repeat3): start time.perf_counter() for _ in range(repeat): with ProcessPoolExecutor(max_workersworkers) as pool: pool.map(task, [n] * workers) return time.perf_counter() - start if __name__ __main__: print(single :, bench_single()) print(threads :, bench_threads()) print(processes:, bench_processes())测试要点先预热一次每个方案跑 3 次取中位数记录峰值内存和 CPU 利用率小样本先确认逻辑正确再扩大数据量。6.3 选型参考表条件推荐方案原因I/O 密集并发几十到几百代码可读性优先多线程简单、调试成本低I/O 密集上万连接愿意改异步asyncio单线程事件循环适合大量长连接纯 Python CPU 计算计算量大多进程每个进程有独立 GIL可真正并行NumPy/SciPy 大量计算先测多线程C 层可能释放 GIL多线程也许够用需要高吞吐且数据分块明显多进程 队列进程独立队列负责流量控制任务量小、进程启动成本高保持串行或多线程多进程开销不划算6.4 “开了并发还是慢”的排查顺序如果遇到“我已经用了多线程/多进程但还是很慢”不要急着改参数。按下面的顺序排查看现象CPU 总利用率只有 100%大概率受 GIL 限制或代码实际是单线程CPU 总利用率到了 400% 但耗时依然高问题可能出在数据拆分、进程间通信或锁竞争CPU 很低、耗时很高大概率在等待 I/O 或锁先看等待发生在哪里。看任务任务里有sleep、网络、文件操作吗有 → 线程可能确实在并行等待任务里有纯 Python 大循环吗有 → GIL 很可能是瓶颈。看数据输入数据能否分片每个子任务要持有多少数据有没有把超大对象反复传给进程池子任务之间是否存在强依赖如果依赖顺序强并发收益会很小。看环境是 Linux 还是 Windowsspawn和fork的行为不同Python 版本是多少第三方库是否兼容容器对 CPU 有限制吗os.cpu_count()可能并不能反映真实配额。看日志和指标给任务加耗时埋点分别统计计算、等待、数据传递的时间如果环境允许可以用py-spy dump --pid pid查看线程栈确认线程到底阻塞在哪里。6.5 什么时候该关心 GIL而不是关心换语言最后说一个容易被忽略的判断不是所有性能问题都出在并发上。如果一个任务单线程本身就很慢应该先优化算法和数据结构再考虑多进程如果单线程已经很快只是 CPU 资源没有用满才轮到并发方案。换个角度理解GIL 只是让你不能“用线程白嫖多核做纯 Python 计算”。它没有阻止你优化算法也没有阻止你用多进程处理真正的 CPU 密集任务更没有阻止你用线程做 I/O 并发。它真正提醒我们的是并发工具的选择要先看任务是“在算”还是在“等”。回到文章开头那台 4 核服务器。那次排查的最后我把脚本从线程池换成进程池4 个核终于都跑到了接近 90%总耗时降到原来的 1/4 左右。整个过程没有改业务逻辑只是把并发工具从ThreadPoolExecutor换成了ProcessPoolExecutor。但这里的“换”建立在三个确认之上确认任务类型、确认数据传递成本、确认压测结果。如果你下次也遇到“4 个线程没跑满 4 个核”可以先按这个顺序问自己三个问题任务是在算还是在等计算发生在 Python 层还是 C 层数据复制和多进程启动成本能不能接受答案清楚了方案自然会清晰。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案