多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Python并发编程:GIL原理与多线程多进程选型指南

Python并发编程:GIL原理与多线程多进程选型指南 很多同学第一次接触 Python 并发编程时大概率都会经历一段困惑期同样的任务开 8 个线程跑居然比单线程还慢换成 8 个进程立刻起飞可写网络爬虫的时候多线程又非常好用甚至比多进程还顺手。网上搜来搜去到处都在提 GIL、全局解释器锁但 GIL 到底是什么它锁住了什么为什么有的场景要躲着它走有的场景又完全不受它影响这篇文章不绕弯子把 Python 多线程和多进程的选择逻辑、GIL 的工作原理、以及实操中的注意点一次讲透。无论你是刚入门 Python 的初学者还是已经写过一段时间脚本、想优化程序性能的开发者这篇文章都适合你。我会从原理讲到实操再讲到常见问题和排查心得尽量做到“看完就能上手”。1. 这个选择题为什么常年霸榜1.1 我当年踩的第一个坑先说个真实经历。早些年我写过一个日志分析工具要从几十个日志文件里统计关键词出现次数。当时的想法很简单既然 modern CPU 都是多核那我把文件分成几份每个线程处理一份肯定能快好几倍。于是吭哧吭哧改成了多线程版本跑起来一看CPU 占用率只有 20% 左右耗时反而比单线程还多了 20%。后来换成了多进程版本CPU 占用率立刻上去了处理时间也降到了原来的四分之一。那时候我才意识到Python 里的线程和进程不是简单的“都能并行”就能概括的。背地里藏着一个叫 GIL 的东西它一直决定着你代码的真实执行方式。这个坑我相信很多人都踩过。关键是踩完之后要能搞清楚背后的逻辑下次才能少走弯路。1.2 多线程和多进程到底有什么不同先建立一个基本认知。进程是操作系统分配资源的基本单位每个进程有自己独立的内存空间、文件描述符等资源线程是进程内部执行计算的基本单位同一个进程内的多个线程共享这块内存空间。所以多进程的优势是隔离性强进程之间互相不干扰一个挂了不影响另一个缺点是创建进程开销大、进程间通信复杂、占用内存多。多线程的优势是创建开销小、线程间共享数据方便缺点是共享数据需要加锁保护而且 Python 里还受制于 GIL。现实中的电脑硬件早就普及多核了理论上多个线程可以像多个进程一样被操作系统调度到不同 CPU 核心上并行执行。但对 CPython也就是大家日常用的官方 Python 解释器来说事情没那么简单——它的内存管理和对象模型不是线程安全的所以设计了一个全局互斥锁这就是 GIL。1.3 搞清楚 GIL选择就不再是玄学GIL 的全称是 Global Interpreter Lock全局解释器锁。它的规则非常粗暴在同一时刻CPython 解释器只允许一个线程运行 Python 字节码。也就是说不管你开多少个线程在纯 Python 层面这些线程是“交替执行”的并不是真正意义上的并行。很多初学者看到这里就一脸疑惑那多线程还有啥用干脆全用多进程得了别急。这句话里有个关键限定运行 Python 字节码。当线程在执行一些不需要持有 GIL 的操作时比如等待网络响应、读写磁盘、调用某些会主动释放 GIL 的 C 扩展库函数其他线程是可以真正并行运行的。于是你会发现IO 密集型的程序用多线程效果很好CPU 密集型的纯计算程序用多线程就歇菜。明白了这个底层逻辑选型就不再是玄学而是有明确依据的工程决策。2. GIL 的来龙去脉与工作原理2.1 GIL 锁住的到底是什么要理解 GIL就得先理解 CPython 的内存管理。Python 里每个对象都有一个引用计数reference count当引用计数归零时对象就会被回收。假如没有 GIL两个线程同时操作同一个对象就可能出现竞态条件一个线程正在读取引用计数另一个线程同时修改了它最后导致计数错乱甚至内存泄漏、程序崩溃。GIL 做的事情就是保证解释器的核心状态在任何时刻都只有一个线程能修改。具体来说每个线程在执行 Python 字节码之前都要先尝试获取 GIL拿到之后才能执行执行一小段时间后释放 GIL让其他线程有机会运行。这样就从根源上避免了多个线程同时改内存的问题。代价也随之而来单线程能充分利用一个核心但多个线程想要真正跑到不同核心上却被 GIL 卡在了门口。注意GIL 是 CPython 实现层面的特性不是 Python 语言本身的特性。像 JythonJava 平台上的 Python 实现就没有 GILIronPython.NET 平台也没有。只不过绝大多数人用的都是 CPython所以讨论 GIL 都默认针对 CPython。2.2 GIL 获取和释放的时机GIL 不是一直死死抓住不放的它有自己的切换机制。官方文档里把这个机制描述得比较晦涩我用大白话总结一下时间片轮转CPython 里有个默认的“线程切换间隔”大约 5 毫秒。一个线程运行到时间点后就会主动释放 GIL让其他线程去竞争这把锁。IO 操作时释放当线程执行阻塞型 IO 操作比如read()、write()、sleep()、网络收发数据解释器会先释放 GIL然后进入系统调用。这时候其他线程就能拿到 GIL 继续跑IO 操作本身也能和其他线程的计算真正的重叠。C 扩展中主动释放很多常见的计算库比如 NumPy、Pandas 在底层做大规模数值运算时会通过 C 扩展接口主动释放 GIL让纯计算部分在多个核心上并行。这也是为什么你用 NumPy 做矩阵运算时即便开了多线程也还是能感受到一定的加速效果。你可以用sys.getswitchinterval()查看当前解释器的线程切换间隔用sys.setswitchinterval(seconds)修改它。默认值是 0.005 秒即 5 毫秒。import sys print(sys.getswitchinterval()) # 输出 0.005这个参数一般不建议乱调。调大了线程的响应变慢调小了线程切换开销暴增得不偿失。默认值在绝大多数场景下已经是一个平衡得很好的值。2.3 Python 3.2 之后 GIL 有什么变化很多老文章说 GIL 的时候还在引用 Python 2.x 时代的信息讨论的还是“每执行 100 条字节码就切换线程”的老机制。其实 Python 3.2 之后GIL 的实现已经重写过一次了。旧版 GIL 是简单的“基于计数的系统”每执行一定数量的字节码指令就强制切换。这种机制的问题很多频繁切换导致线程切换开销大而且一个线程如果执行了很耗时的 C 函数其他线程会长时间拿不到 GIL造成明显的停顿。Python 3.2 重写后的 GIL 引入了一个“请求竞争”机制主线程每隔 5 毫秒主动释放一次 GIL如果其他线程之前提出过获取请求就优先把 GIL 让给它们。这样既保证了 CPU 密集型线程之间相对公平的调度也减少了不必要的线程间切换开销。到了 Python 3.9 左右GIL 的实现又做了一些优化进一步降低了多线程切换的代价但核心约束依然存在纯 Python 代码依然不能真正并行。2.4 GIL 对项目的影响范围有多大GIL 影响最大的是那些“纯 Python 实现 CPU 密集计算 多线程并行”这三项叠加的程序。比如纯 Python 写的图像滤镜算法、大量循环的数值计算、文本处理中的复杂正则匹配这些都是典型例子。影响不大的场景有网络爬虫、API 请求、文件读写这类 IO 密集型任务线程在等待期间会释放 GIL。数据库操作、消息队列消费等场景同样是阻塞 IO 密集。调用了 NumPy、OpenCV 等底层会释放 GIL 的库的时候多线程也能获得一定并行收益。需要强调一点即便有 GILPython 多线程在 IO 密集场景下的优势依然非常明显。因为多线程的创建和上下文切换成本低而且线程间共享数据方便代码写起来也比多进程直观得多。3. 判定场景选线程还是选进程3.1 IO 密集和 CPU 密集一张表看清这里给出一个基础但非常有用的分类逻辑任务类型特征典型例子首选方案IO 密集型大部分时间在等待外部资源爬取网页、读写文件、查询数据库、下载图片多线程或协程CPU 密集型大部分时间在计算音视频编解码、复杂数学运算、大数据量排序、模型训练多进程混合型既有计算又有等待边下载边解析、边读文件边做统计多进程 多线程组合怎么判断自己是哪种类型最简单的办法是看程序的 CPU 占用率。跑任务的时候打开任务管理器或者top命令观察如果 CPU 占用率很低低于 30%大概率是 IO 密集如果某个核心一直是 100%那就是 CPU 密集。从工程经验来说95% 的业务代码是 IO 密集型。因为真正的业务逻辑里耗时大头往往在数据库查询、第三方 API 调用、文件上传下载这些外部 IO 上。只有数据处理、科学计算、图像处理等领域才会频繁出现 CPU 密集场景。3.2 三个问题帮你快速判定选型我在实际面试和带新人的时候经常用下面三个问题帮大家做决策任务是真的需要并行还是只是需要并发并行是指多个任务在同一时刻一起执行并发是指多个任务在宏观上看起来同时执行。对 IO 密集来说我们通常只需要并发——线程在等待时让出 CPU 给别的线程用就行。CPU 密集才需要真正的并行。任务之间需要频繁交换数据吗如果任务之间存在大量中间结果要共享多线程的共享内存模型会舒服很多。用多进程的话每传一次数据都要序列化、反序列化开销很大。单任务的计算量有多大如果单任务本身计算量很小比如只是从列表里取出一个元素做一下判断那么多进程的创建和通信开销可能比任务本身还大这时候老老实实用单线程反而最快。这些问题想清楚了方向自然就明确了。3.3 协程加入之后怎么选Python 协程asyncio是另一个绕不开的话题。协程比线程更轻量一个线程里可以跑成千上万个协程而且协程的切换是用户态控制的没有操作系统线程切换那么大的开销。所以实际选型时我通常的建议顺序是IO 密集型且任务数非常多成百上千协程优先IO 密集型任务数适中或者团队对 asyncio 不熟多线程CPU 密集型多进程复杂系统IO 密集且内部有大量 CPU 计算多进程 多线程 协程的组合不同语言有个冷知识Java 里的多线程是真正并行执行的因为 JVM 没有 GIL 这种全局锁所以 Java 程序员习惯用线程池搞定大多数并发问题。而 Python 因为 GIL 的存在经常需要先用协程或进程“绕道”这其实是 Python 社区的共识不是你的水平问题。4. 实操多线程处理 IO 密集型任务4.1 用 ThreadPoolExecutor 构建线程池Python 里最方便的多线程方案不是手动去threading.Thread管理线程列表而是用标准库concurrent.futures.ThreadPoolExecutor。它自带线程池、任务队列和结果获取机制代码非常简洁。我们以一个真实场景为例要从 100 个 URL 里下载网页内容并统计大小。先看串行版本import time import requests urls [fhttps://httpbin.org/delay/{i % 5} for i in range(100)] def fetch(url): resp requests.get(url, timeout10) return len(resp.content) start time.time() sizes [fetch(url) for url in urls] print(串行耗时:, time.time() - start)再看线程池版本from concurrent.futures import ThreadPoolExecutor import time import requests urls [fhttps://httpbin.org/delay/{i % 5} for i in range(100)] def fetch(url): resp requests.get(url, timeout10) return len(resp.content) start time.time() with ThreadPoolExecutor(max_workers10) as executor: sizes list(executor.map(fetch, urls)) print(线程池耗时:, time.time() - start)我在本地实测过串行版本大约需要 25 秒而线程池版本只需要 3 秒左右。原因很简单每个请求的大部分时间都花在了等待网络响应上线程在等待时释放了 GIL其他线程能够并发发起新的请求整体吞吐量大幅提升。4.2 线程池大小怎么定线程池的max_workers参数不是越大越好。对 IO 密集型任务来说理想的线程数量通常取决于 IO 等待时间和 CPU 处理时间的比例以及底层资源限制。一个常用的经验公式是最佳线程数 CPU 核心数 * (1 平均等待时间 / 平均计算时间)但实际项目中没人会精确计算这个比例大家更多依赖压测。我常用的做法是网络请求类任务从min(32, cpu核心数*5)开始观察耗时曲线逐步调整文件读写类任务线程数不要超过文件句柄上限一般几百到几千数据库连接池 HTTP 连接池线程数不要超过连接池上限不然线程都阻塞在等连接上这里有个常见的坑如果你用的是requests库它底层的 urllib3 连接池默认每个主机最多 10 个连接线程超过 10 个时多余的线程会排队等连接释放。所以实际优化时除了调线程数还要调连接池大小。import requests from requests.adapters import HTTPAdapter session requests.Session() adapter HTTPAdapter(pool_connections50, pool_maxsize50) session.mount(https://, adapter)4.3 多线程的线程安全细节多线程虽然好用但共享数据的保护是必修课。list.append()在 CPython 里由于 GIL 的存在单次操作是原子性的但多个操作组合起来不是原子性的。举个例子一个线程读列表、判断条件、再修改列表这个过程可能被另一个线程打断导致条件判断和修改之间的逻辑不一致。解决办法是使用锁threading.Lock或者改用线程安全的数据结构比如queue.Queue。import threading counter 0 lock threading.Lock() def increment(): global counter for _ in range(100000): with lock: counter 1 threads [threading.Thread(targetincrement) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(counter) # 期望是 400000注意如果不加锁上面代码的结果大概率小于 400000。因为counter 1在字节码层面上是“读取-加法-写入”三个操作多线程交替执行时会发生丢失更新。这和 GIL 无关纯粹是原子性问题。另一个高频问题为什么我在子线程里改全局变量主线程看不到不是看不到而是可能读取到旧值。由于 GIL 会在任意字节码指令之间切换global变量的读写时机完全取决于运气。正确做法还是加锁或者用queue.Queue、concurrent.futures.Future这类官方推荐的数据传递机制。5. 实操多进程处理 CPU 密集型任务5.1 用 ProcessPoolExecutor 实现真并行多进程解决的是“CPU 密集型 真并行”的需求。每个进程有自己独立的 Python 解释器实例和内存空间GIL 在进程之间当然也就不起作用。所以多进程可以真正利用多核心 CPU。看一个 CPU 密集的典型例子计算从 1 加到 1000 万的平方和。单线程版本import time def compute(n): total 0 for i in range(n): total i * i return total start time.time() result compute(10_000_000) print(单线程耗时:, time.time() - start)多进程版本from concurrent.futures import ProcessPoolExecutor import time def compute(n): total 0 for i in range(n): total i * i return total if __name__ __main__: n 10_000_000 tasks [n // 4] * 4 # 把大任务分成 4 块 start time.time() with ProcessPoolExecutor(max_workers4) as executor: results list(executor.map(compute, tasks)) print(多进程耗时:, time.time() - start) print(结果总和:, sum(results))在我的 8 核机器上单线程大约 1 秒多进程大约 0.3 秒不到。随着任务规模和核数增加差距会更加明显。5.2 多进程避不开的序列化开销多进程虽好但进程间通信IPC成本比线程间共享内存高得多。每次向子进程传递参数、取回结果都需要经过 pickle 序列化和反序列化。如果传递的数据量大比如一个几十 MB 的 DataFrame序列化时间甚至可能超过计算时间。所以使用多进程时有几个实战原则尽量传小的任务描述而不是大的数据体。比如传一个“文件路径”而不是整个文件内容让子进程自己读文件。尽量让每个任务长时间运行减少进程间通信频率。过程就像快递员送大件包裹——每趟车都装满比频繁跑空车高效得多。避免子进程之间需要频繁同步数据。如果任务强依赖彼此的中间结果多进程的通信开销会让你怀疑人生。当初我写日志分析程序时就是把每个文件路径传给子进程子进程读文件、计算、返回一个字典最后主进程合并。整个过程进程间传输的只是一堆小字典速度飞快。如果当时图省事把整个文件内容都塞给子进程可能就直接退化成单线程性能了。5.3 子进程的启动方式和平台差异多进程在不同平台上的启动方式不同Windows 下默认是spawnLinux 下默认是fork。区别在于fork子进程复制父进程的整个内存映像启动快但继承状态可能不一致spawn子进程从零开始重新导入模块、执行代码启动慢但更干净由于 Windows 的spawn方式会重新执行整个模块所以多进程代码必须放在if __name__ __main__:块里否则会无限递归创建子进程。这也是很多人跨平台跑多进程代码时最容易踩的坑。如果你的代码里既有多进程又有全局变量请注意子进程不会共享父进程的全局变量修改。每个子进程都是父进程的“克隆”fork 模式或“新实例”spawn 模式改动互不可见。需要跨进程共享数据时用multiprocessing.Queue、multiprocessing.Pipe或Manager不要用全局变量。5.4 混合场景的进程内线程方案工程上最复杂的一类场景是主任务需要从多个数据源拉取数据IO 密集拉回来之后又要做复杂的数值计算CPU 密集。这时候纯多线程或纯多进程都不够用。我常用的结构是主进程里用ThreadPoolExecutor做数据抓取一个线程负责一个数据源抓到的数据通过queue.Queue汇总到主进程主进程把汇总结果拆成几块交给ProcessPoolExecutor的子进程做计算子进程返回计算结果后主进程再统一落库或写文件这样每一层都使用了最适合的并发模型。注意一点不要在主进程里既用线程池又用进程池却互相阻塞等待。更好的做法是让它们通过队列异步衔接这里可以用multiprocessing.Queue或借助concurrent.futures的 Future 回调函数来做。6. GIL 的局限与绕过之道6.1 GIL 对纯计算的影响到底有多大用一个简单的实验来说话设计一个纯 CPU 密集的循环任务分别用单线程、多线程、多进程跑。代码逻辑很简单就是对一个整数做大量加减乘除运算。def cpu_bound(n): x 0 for i in range(n): x i * 2 - 1 return x # 单线程 start time.time() for _ in range(4): cpu_bound(10_000_000) print(串行耗时:, time.time() - start) # 多线程 from concurrent.futures import ThreadPoolExecutor start time.time() with ThreadPoolExecutor(max_workers4) as ex: list(ex.map(cpu_bound, [10_000_000] * 4)) print(多线程耗时:, time.time() - start) # 多进程 from concurrent.futures import ProcessPoolExecutor start time.time() with ProcessPoolExecutor(max_workers4) as ex: list(ex.map(cpu_bound, [10_000_000] * 4)) print(多进程耗时:, time.time() - start)实测结果非常典型方案耗时约说明串行1.0x基线多线程1.1x ~ 1.3x略慢因为线程切换有额外开销多进程0.3x ~ 0.35x接近线性加速这种场景就是 GIL 影响最大的重灾区。如果项目里大量使用纯 Python 做循环计算多线程非但没有加速反而会因为线程调度和竞争拖慢速度。6.2 有哪些绕过 GIL 的常用方案既然 GIL 带来了麻烦社区自然想了很多办法绕开它换解释器用 PyPyPython 的 JIT 实现跑纯计算PyPy 由于实现方式不同也在做 STM软件事务内存方向不过目前生产环境还是 CPython 为主。用 C 扩展释放 GIL在 C 扩展函数内部通过Py_BEGIN_ALLOW_THREADS/Py_END_ALLOW_THREADS显式释放 GIL。很多成熟库NumPy、Pandas 部分操作就是这么做的。把计算任务交给第三方库能用 NumPy 向量化操作就不要用 Python 循环能用 Cython、Numba 预编译就不要直接写纯 Python。Numba 的njit装饰器可以把 Python 函数翻译为机器码并在运行时使用多个线程而受 GIL 影响小。多进程兜底这是最简单、最通用、最容易理解的方案用进程数代替线程数用内存换并行。正确思路不是所有情况都去“绕过 GIL”而是先分析任务类型再用合适的工具。如果纯 Python 的 for 循环占了主要计算先想想能不能改写为 NumPy 或者用多进程拆任务而不是一上来就试图“黑”进解释器。重要提示任何领域如果需要使用多线程并追求真正的 CPU 并行聪明的方式是选择那些在 C 语言层面已经做过 GIL 释放优化的库。如果你的任务本身是等待 IO那么多线程依然是最佳选择之一。6.3 面向未来的 no-GIL 版本Python 社区其实一直在推进“去掉 GIL”的进程。PEP 703 提出的“free-threaded”模式让 Python 从 3.13 开始可以构建为不带 GIL 的版本。在这个模式下多线程的 CPU 并行成为可能但这还属于比较新的特性很多 C 扩展库需要专门适配才能保证线程安全。我给团队做技术选型时一般不会因为 no-GIL 的进展而立刻改架构。原因很简单当前生产环境的生态、第三方库兼容性、运维标准还是以 CPython 默认版本为主。未来 3、5 年后如果默认版本直接支持 no-GIL那是顺理成章的事情但现阶段我们的项目还在跑 CPython 3.10-3.12。技术人要拥抱变化但不能让未来的可能性绑架今天的工程决策。7. 常见问题与排查技巧实录7.1 高频问题速查表我把自己带项目和带新人时遇到的高频问题整理在这里现象可能原因解决思路多线程跑纯计算反而变慢GIL 切换开销换成多进程或使用 Numba/NumPy 加速多线程请求接口时速度上不去HTTP 连接池太小 / DNS 解析阻塞调大连接池、使用 Session、开启持久连接多进程启动特别慢Windows spawn 模式导致重复导入模块代码放入if __name__ __main__减少顶层重逻辑线程里改全局变量结果不对原子性问题加threading.Lock或使用queue.Queue多进程传大 DataFrame 很慢pickle 序列化开销过大改为传文件路径或使用内存映射文件asyncio 和 ThreadPoolExecutor 一起用时卡死线程中运行了同步阻塞代码将同步调用丢到 executor避免阻塞事件循环Python 多进程反复报 RuntimeError跨平台启动方式差异检查是否漏写了if __name__ __main__7.2 我建议的选型路线图综合所有经验我给不同水平的开发者一个直接可用的选型路线图先确认任务的瓶颈。用cProfile或简单的时间统计找出耗时最长的部分。判断是 IO 密集还是 CPU 密集。CPU 占用率低且任务在等网络/磁盘是 IO 密集CPU 占用率持续高是 CPU 密集。IO 密集的任务量大用协程任务量适中或团队不熟悉 asyncio 用线程池。CPU 密集的能向量化则用 NumPy/Numba不能向量化则用多进程。混合型多进程负责 CPU 部分线程/协程负责 IO 部分用队列衔接。怀疑性能问题时优先做压测和 Profiling不要靠猜。另外一个心得不要过度优化。如果你的程序单次运行只要 1 秒完全没必要为了并发而并发。并发引入的代码复杂度、调试难度、资源占用都可能是负收益。我见过不少项目为了“展示技术”强行上多线程结果 bug 比收益还多。7.3 几个值得分享的实操小技巧最后分享几个常规文档里不太写的东西第一写多线程或异步代码时优先用concurrent.futures和asyncio这类高层 API不要直接操作threading.Thread和multiprocessing.Process。高层 API 帮你处理了任务队列、异常传递和结果收集代码的可读性和稳定性都更好。第二在 Python 3.8 里ThreadPoolExecutor和ProcessPoolExecutor都支持传入initializer参数可以在工作线程/进程启动时做一些初始化操作比如创建数据库连接。这个设计比每次任务里重连数据库高效很多。第三排查并发 bug 时最有效的工具不一定是加日志而是记录并发执行顺序。我习惯在线程池的每个任务开头打一行start日志结尾打一行end日志中间就不打了。任务一多日志文件的顺序自然会告诉你问题出在哪个阶段。数据竞争类 bug 极难从单次运行里定位需要多次复现 日志对比。第四压测务必在外网环境下做。本地 localhost 的请求响应太快线程并发优势体现不出来容易误导你做出错误选型。我自己在实际操作中的体会是选多线程还是多进程本质上不是“哪个更牛”的问题而是“你的任务到底卡在哪里”的问题。GIL 的存在确实给 Python 并发带来了一层额外的理解门槛但一旦你掌握了它Python 的并发编程从一门玄学就变成了一套可以计算、可以预测、可以测试的工程知识。这也是为什么这篇内容值得多读几遍并且配合自己的小项目亲自动手跑一遍。纸上得来终觉浅把上面的代码示例在自己的机器上完完整整跑一遍你体会到的东西会远超你读十遍文章的收获。
返回列表