
很多从Go或C转到Python的朋友第一次用 threading 模块的时候都会陷入同一个困惑明明开了4个线程CPU总占用却只有100%左右也就是只有一个核在工作。更让人费解的是有时候线程开得越多程序反而越慢。这个现象背后的元凶就是GILGlobal Interpreter Lock全局解释器锁。这是CPython解释器的一个内置机制也是Python多线程性能优化绕不开的核心话题。这篇文章不打算从官方文档抄概念给你而是从GIL为什么存在讲起用一组可以自己复现的基准测试把多线程、多进程、协程在不同任务类型下的真实成绩摊开再完整走一遍图像批量处理的并发改造过程最后落到工程选型和Python 3.13无GIL实验特性的判断上。适合刚入门并发编程的新手也适合写了好几年Python业务代码但一直没搞明白并发问题的人。1. GIL到底卡在哪从设计初衷到它对两类任务的差异化影响1.1 为什么CPython需要一个“全局锁”GIL是CPython解释器在内存管理和线程安全之间做的一个非常朴素的折中。CPython的内存管理依赖引用计数每个Python对象都带一个计数器对象被引用时加1引用释放时减1归零就回收内存。问题在于这个计数器本身不是原子操作。如果两个线程同时修改同一个对象的引用计数理想情况下应当加两次之后数值加2实际却可能因为竞争只加了一次最后导致内存被提前回收或者泄漏。当时的解法思路基本是两条一是给每个对象配一把独立的细粒度锁二是干脆给整个解释器加一把统一的全局锁。细粒度锁在理论上更灵活但会带来大量锁获取和释放的开销也更容易出死锁问题维护成本极高。CPython最终选择了全局锁也就是GIL。执行任何一段Python字节码之前线程必须先拿到这把全局锁同一时刻只允许一个线程在解释器里执行代码。这个设计在单核处理器时代几乎不碍事毕竟CPU本来也只有一颗核。这个历史背景很重要。很多人一上来就骂GIL设计得愚蠢但它是在单核CPU时代背景下的合理选择换来的好处是所有字典、列表、集合的基本操作默认线程安全写起来极其省心。真正的问题是时代变了CPU从单核变成了几十核这个老设计开始严重制约并行计算。1.2 GIL什么时候释放这是决定多线程快慢的关键GIL不是永远锁死不解锁的。CPython内部有一套切换机制大概可以分成两种情况。主动释放发生在所有可能阻塞的操作前读写文件、网络IO、time.sleep、数据库连接等待线程在发起系统调用之前会主动释放GIL等结果返回后再重新抢锁。被动切换则发生在纯CPU密集任务中线程如果一直不碰IO在执行的字节码指令数量到达阈值之后调度器会强制让它让出锁给其他线程执行机会。Python 3.2之后这个切换间隔默认是5毫秒可以通过sys.setswitchinterval()调整。理解这个机制之后GIL对性能的影响就很好推测了。IO密集型任务中线程绝大多数时间都在等待外部资源等待期间GIL是释放状态其他线程可以正常推进多线程基本能拿到接近线性的加速。而CPU密集型任务中每个线程都在拼命计算谁都不肯放手GIL成了必须排队用的独木桥多线程不但无法并行使用多核CPU还要额外付出线程切换和锁竞争的开销所以现实往往是线程开得越多速度越慢。这个特性也解释了为什么ThreadPoolExecutor在网络爬虫、文件批量上传这类场景中非常好用而在纯计算、数据处理、循环密集型任务中沦为灾难。1.3 一个容易被混淆的概念并发和并行讨论GIL的时候很多人会把并发和并行搅在一起。并发指的是多个任务在一个时间段内交替推进看起来是同时在跑实际可能是轮流占用同一个资源并行则是多个任务在多个核上真真正正同时运行。单线程协程是一种并发多线程在GIL限制下对CPU密集任务通常也是并发但不并行只有多进程以及不受GIL束缚的原生线程比如在C扩展里释放GIL之后才能真正实现并行。理解了这两个词的区别后面选型的时候思路会清晰很多。2. 基准测试复盘多线程、多进程、异步在两类任务下的真实成绩2.1 CPU密集任务线程不仅没加速反而拖后腿我写一个很典型的CPU密集函数连续计算前N个整数的平方和。这个函数几乎没有IO纯粹给CPU上强度。同样一份任务量拆成4份分别用单线程顺序执行、ThreadPoolExecutor四个线程并行、ProcessPoolExecutor四个进程并行。为了避免首启缓存的影响每个方案重复跑5次取中位数。import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def cpu_bound(n): total 0 for i in range(n): total i * i return total def run_serial(task_chunks): return [cpu_bound(n) for n in task_chunks] def run_thread(task_chunks): with ThreadPoolExecutor(max_workerslen(task_chunks)) as ex: return list(ex.map(cpu_bound, task_chunks)) def run_process(task_chunks): with ProcessPoolExecutor(max_workerslen(task_chunks)) as ex: return list(ex.map(cpu_bound, task_chunks)) if __name__ __main__: total 40000000 chunks [total // 4] * 4 for name, fn in [(单线程, run_serial), (四线程, run_thread), (四进程, run_process)]: start time.perf_counter() fn(chunks) print(f{name}耗时: {time.perf_counter() - start:.3f}s)我机器上跑出来的成绩大致是单线程约3.1秒四线程约3.3秒四进程约0.85秒。这个结果非常有代表性四线程不仅没有比单线程快反而慢了0.2秒左右四进程则几乎实现了4倍加速。多线程那0.2秒的额外开销来自线程创建、销毁以及线程之间在GIL上的反复争抢。如果你在自己的机器上测出来线程比单线程还快一点点也不用奇怪可能是任务恰好被某层C逻辑切出去了或者调度上有偶然优势。但从趋势上看CPU密集任务在纯Python层几乎没有用多线程优化的余地。2.2 IO密集任务多线程终于找回了存在感再来看IO密集。我用模拟网络请求的测试用time.sleep模拟等待这很接近真实数据库查询、HTTP请求的阻塞模型。同样的任务量跑4次对比三种方式。import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor def io_bound(t): time.sleep(t) # 模拟IO等待 return t if __name__ __main__: sleep_time 1.0 tasks [sleep_time] * 4 start time.perf_counter() [io_bound(t) for t in tasks] print(f串行耗时: {time.perf_counter() - start:.3f}s) start time.perf_counter() with ThreadPoolExecutor(max_workers4) as ex: list(ex.map(io_bound, tasks)) print(f四线程耗时: {time.perf_counter() - start:.3f}s) start time.perf_counter() with ProcessPoolExecutor(max_workers4) as ex: list(ex.map(io_bound, tasks)) print(f四进程耗时: {time.perf_counter() - start:.3f}s)结果基本是串行约4.0秒四线程约1.0秒四进程约1.0秒。串行的4倍任务被压缩到四分之一时间因为sleep期间线程主动释放了GIL其他线程可以继续跑。多线程和多进程效果接近但多线程的创建开销小、内存占用低在这个场景更优。2.3 测试方法细节别让print和随机波动骗了你简单的性能对比测试如果方法不对结论很容易失真。根据我的经验至少要注意这么几点。用time.perf_counter而不是time.time。time.time精度不高还会受系统时间调整影响perf_counter专门用来测量短时间间隔。重复多次取中位数单次运行会受到操作系统调度、后台进程干扰取中位数比平均值更能反映稳定状态最好先跑一轮热身。print尽量不要塞进被测量的代码块终端输出带来的IO开销在某些方案里会意外放大或掩盖性能差异测试函数里只做核心计算最后统一打印。还要控制任务规模。如果任务只有几毫秒创建线程和进程的开销反而会喧宾夺主。先放大任务粒度把循环次数提到千万级再开始对比。提示生产环境的性能观测不要只跑一次。建议把这段测试写成bench脚本以后换机器、换Python版本时重跑用同一组数据来判断并发方案的收益变化。3. 三条绕开GIL的成熟路线及选型逻辑3.1 多进程方案用独立解释器换真并行最直接的绕开GIL的方式就是把程序拆成多个独立进程。每个进程都有自己独立的Python解释器和独立的内存空间也就有各自的GIL副本互相之间不再争抢同一把锁CPU密集型任务因此可以真正并行使用多核。Python提供的进程抽象做得比较完善。传统写法用multiprocessing模块的Process、Pool新版代码更推荐concurrent.futures.ProcessPoolExecutor接口风格跟线程池一致迁移成本低。from concurrent.futures import ProcessPoolExecutor def square(x): return x ** 2 if __name__ __main__: with ProcessPoolExecutor(max_workers4) as ex: results list(ex.map(square, range(100)))代码看起来和线程池几乎一样但背后的代价需要清醒认识。进程启动开销大每个进程都要重新初始化解释器、加载依赖模块在Windows上还得重新导入主模块单次任务太短时启动成本会吞掉并行收益。跨进程数据要序列化任务参数和返回值会通过pickle传输大对象如果频繁传递序列化和内存复制会成为新的瓶颈。全局变量不共享在主进程里改了一个全局变量子进程里完全看不到要想跨进程共享得用Queue、Pipe、共享内存或者Manager这是很多人从多线程转向多进程后最容易懵的地方。多进程适合的任务是“单个任务粒度足够大”“数据量可控”“可以拆成相互独立的子任务”。第4章的图像批处理就是非常典型的场景。3.2 C扩展层释放GIL让锁在计算核心代码临时消失换个思路GIL限制的是Python字节码的执行如果核心计算放在C/C扩展里扩展代码在运行期间可以主动释放GIL那么一个进程内的多个原生线程就能在C层真正并行。这也是很多科学计算库能在多线程下提速的根本原因。在Python C API里释放GIL的宏长这样Py_BEGIN_ALLOW_THREADS // 这里执行真正耗时的C代码不持有GIL Py_END_ALLOW_THREADSCython里也有对应的nogil选项。对一般应用开发者来说自己写C扩展的场景不多但理解这个机制很有用你会发现某些第三方库虽然底层是C实现多线程也能加速某些库却完全没有释放GIL多线程仍然被锁死。判断方法很简单看它们的文档或者源码里是否出现了Py_BEGIN_ALLOW_THREADS、nogil、releaseGIL之类的字样。如果不想纯手写C可以借助Numba这类工具给计算函数加编译装饰器并允许它在编译后的机器码执行期间释放GILfrom numba import njit njit(nogilTrue) def heavy_math(n): s 0 for i in range(n): s i * i return s这样你就获得了“多线程 释放GIL”的组合效果外部用ThreadPoolExecutor管理线程真正吃CPU的循环跑在无GIL约束的机器码上。不过Numba也不是万能的部分Python对象类型和动态特性不支持需要提前确认算法能不能被它编译。3.3 asyncio协程用单线程承接海量IO请求绕开GIL的第三条路线更取巧既然GIL不允许线程并行干脆也不用多线程了单线程里用事件循环按需切换协程照样可以高效处理大量IO请求。这就是asyncio的思路。与多线程相比asyncio的并发开销极低。每个协程只是一个状态机加少量内存一个进程轻轻松松挂几万个协程而每个线程要占MB级别的栈空间几千个线程已经能把内存打满。在高并发网络服务场景比如网关、爬虫、消息推送服务asyncio几乎是首选。import asyncio async def fetch_order(order_id): await asyncio.sleep(0.1) # 模拟异步查询 return order_id async def main(): tasks [fetch_order(i) for i in range(1000)] results await asyncio.gather(*tasks) return results if __name__ __main__: asyncio.run(main())选用asyncio时要注意它只解决IO并发不解决CPU密集问题。如果某个协程里有一段未经await的纯Python计算循环它会一直占住事件循环所有其他协程都必须排在它后面反而比线程还糟。遇到这种场景通常把计算量大的部分丢给ProcessPoolExecutor在asyncio里用loop.run_in_executor调用它两者组合使用。4. 实战改造图像批量滤波从串行到并行的完整过程4.1 任务特性拆解与方案选择光讲理论没意思我拿一个实际项目来演示。假设接到一个批量图像处理任务把目录下的1000张JPG图片统一做高斯模糊处理输出到另一个目录。单张图片的处理时间大约150毫秒1000张串行大概要150秒太慢了必须上并发。先拆解任务特性。每张图片的处理流程完全独立不存在相互依赖天然适合并行。单张图片处理时间在百毫秒级远大于进程启动的边际成本所以多进程的创建开销可以摊薄。处理过程由Pillow的C扩展完成底层像素运算但外层还有Python的Image.open、save、循环调度等字节码操作纯粹的ThreadPoolExecutor大概率被GIL拖累提升有限。图片数据要从磁盘读入再写入磁盘IO时间占比不低这对多线程是有利的但在CPU核心充足时多进程仍然综合最佳。根据这些特征我选择了ProcessPoolExecutor多进程方案。4.2 代码改造串行版、多进程版、参数调优先看串行基线版本import os from PIL import Image, ImageFilter SRC_DIR input_images DST_DIR output_images def process_one(filename): src_path os.path.join(SRC_DIR, filename) dst_path os.path.join(DST_DIR, filename) with Image.open(src_path) as img: blurred img.filter(ImageFilter.GaussianBlur(radius2)) blurred.save(dst_path, quality90) return filename def run_serial(files): return [process_one(f) for f in files] if __name__ __main__: files os.listdir(SRC_DIR) result run_serial(files) print(f处理完成: {len(result)} 张)改成多进程版本from concurrent.futures import ProcessPoolExecutor def run_parallel(files): with ProcessPoolExecutor(max_workers4) as ex: results list(ex.map(process_one, files)) return results如果一次性提交1000个任务担心结果排队占用内存可以给ex.map加chunksize参数比如chunksize50让进程池分批把任务发给子进程降低通信频率、提升吞吐量results list(ex.map(process_one, files, chunksize50))4.3 实测成绩与性能拐点分析我在一台8核机器上跑出来的数据大概是这样的方案耗时备注串行152秒基线4进程41秒接近3.7倍加速8进程23秒接近6.6倍加速16进程22秒没有进一步加速10分钟左右的串行任务8进程压缩到20多秒效果还是很明显的。但注意看16进程并没有比8进程更快这就是性能拐点。瓶颈不再是CPU而是磁盘读取速度、图片解码编码之间的竞争以及进程调度开销。这个拐点每个硬件环境都不同要自己在目标机器上边调参数边观察不要一味堆worker数量。提示一般的经验是把ProcessPoolExecutor的max_workers设成os.cpu_count()或者略微超过一点。但如果你同时开了多个类似服务应当主动留一些核心给系统和其他进程否则反而互相抢资源。另外一个容易被忽略的优化点多进程模式下每个子进程都要重新导入模块、初始化全局状态。如果你的脚本顶部有一段耗时的资源加载逻辑每个子进程会各加载一份内存直接翻好几倍。解决办法是把资源加载放到任务函数内部复用或者使用共享内存。5. 落到工程上选型决策、数据通信与无GIL新趋势5.1 一张决策表看穿并发方案选择每到一个新项目先判断任务性质再选方案比稀里糊涂把threading和asyncio混在一起有用得多。我常用的判断逻辑很简单可以直接照着做任务类型首选方案原因备选CPU密集、可拆分为孤立子任务ProcessPoolExecutor / multiprocessing多进程绕过GIL真并行将核心计算下沉到C扩展保留多线程CPU密集、依赖大量共享状态C扩展 多线程 / 状态分片后用多进程多进程共享成本太高使用成熟的高性能计算库IO密集、并发量中等ThreadPoolExecutor线程在等待IO时释放GIL改动成本低asyncioIO密集、单机需要数万连接asyncio协程开销远小于线程多进程 asyncio 组合混合型既有IO又有算力分层处理asyncio做IO编排ProcessPoolExecutor做计算各取所长引入任务队列中间件解耦这套表格解决的是选哪个的问题。实际落地时还要想清楚线程和进程里跑的代码是否线程安全。比如并发保存到同一个文件路径就要保证目标文件名不冲突否则后写完的会把前面的覆盖掉。5.2 进程间共享数据的正确姿势与常见坑多进程最让人措手不及的问题是数据共享。不同进程拥有完全隔离的内存空间所以“改一个全局变量让所有进程看到”这个多线程时代的习惯必须抛弃。跨进程数据传递通常有这几类方案。Queue / Pipe基于消息传递适合任务分发和结果回传Queue线程安全multiprocessing.Pool自带背后的队列机制。共享内存Value / Array / mmap适合大块数值数据在Unix系操作系统上很高效缺点是你必须自己设计读写同步。Manager则是在独立服务进程中维护共享对象比如Manager().list()、dict()使用方便但因为有代理进程和序列化开销速度慢不适合高频率访问。我在项目里常用的组合是主进程生成一批元数据比如文件路径、参数元组通过Queue分批派给子进程子进程把计算结果用pickle打包后put回结果队列主进程统一收结果。这个模式下消息体积尽量小避免每次传输整个大对象。如果必须跨进程传大数组优先用共享内存而不是序列化后走Queue序列化一次几GB数据的耗时和内存占用都承受不起。多进程还容易踩的坑是把主进程里已经打开的数据库连接、网络连接直接丢给子进程用。文件句柄和连接对象在fork或spawn之后往往处于不稳定状态最稳的做法是子进程里自己重新连接或重新打开文件不要试图继承父进程的资源。5.3 Python 3.13的Free-Threaded实验模式要不要追近年Python社区最值得关注的一件事是PEP 703提出的无GIL构建模式并作为实验特性进入较新的Python版本。简单说通过特定编译参数构建的Python可以不启用GIL让普通多线程真正利用多核CPU。CPython默认发行版目前仍然带有GIL无GIL模式是实验性可选编译方式离生产环境大规模普及还有距离。这里面最大的阻力其实是C扩展生态。很多用C写的扩展模块设计前提是有GIL保护所以不必自己做线程安全。没有GIL之后这些扩展会因为共享状态的竞争出现不稳定、崩溃需要逐个修复和验证。目前一些核心科学计算库已经在做无GIL适配但覆盖面还远远不够。我的建议是现阶段不要为了追新把项目切到无GIL构建更不要指望切换后代码零改动。更实际的做法是关注你依赖的核心库对Free-Threaded模式的兼容状态拿自己的项目在无GIL解释器下跑一遍单元测试和压测提前摸清差距。如果项目里大量使用纯Python的CPU密集逻辑而且不敢引入多进程那未来无GIL模式可能才是治本方案到时候迁移收益会很大。最后说一点我自己的体会。刚工作那会儿我也迷信并发觉得线程开得多就代表技术上很厉害后来被线上事故教训过几次才明白并发编程的第一原则是搞清楚瓶颈到底在哪。做完项目里的性能优化我养成了一个习惯任何并发方案在正式改造前先写一个不修改业务代码的压测微脚本把任务类型、耗时分布、worker数量摆到桌面上再决定用ThreadPoolExecutor还是ProcessPoolExecutor还是asyncio。GIL确实是一个绕不开的路障但只要你能准确判断自己的任务属于哪种类型绕开它的手段其实很多不必神话它也不必惧怕它。