
“Python 多线程到底行不行”每次讨论这个问题评论区都会为 GIL 吵起来。哪怕你只写了半年 Python也一定听过那句口头禅“Python 多线程是假的因为有个全局解释器锁GIL。”这句话不能说错但它太粗糙了。实际工作中我用多线程处理过批量下载、日志采集、Web接口并发请求一分钟能跑完串行半小时的量我也用多线程跑过纯数值计算八核机器上反而比单线程慢。真正有价值的结论不是“行或者不行”而是弄清楚 GIL 到底卡在哪一类任务、多线程和多进程各自适合什么场景、以及你怎么把两者放进同一套代码里各司其职。这篇文章就从 GIL 的底层机制说起把多线程、多进程的选型逻辑、实测表现和常见坑一次性讲透。1. 先搞清楚你要解决什么问题1.1 并发与并行一字之差结果完全不同很多新手把“并发”和“并行”当成一个东西实际上这是两件完全不同的事。并发concurrency指的是程序能同时应对多个任务比如你在等一个网络请求返回的时候顺手去处理另一个请求并行parallelism指的是程序真的在同一时刻用多个 CPU 核心执行多个任务。举个生活里的例子。你在厨房做饭一边烧水一边切菜一个人来回切换、交替推进这是并发。叫来两个朋友一个炒菜一个煲汤两个人同时干活这是并行。Python 的多线程受 GIL 限制擅长的是“来回切换、交替推进”这种并发多进程因为有独立的解释器实例才真正做到了并行。先把这两个概念分开后面很多问题就自然清楚了。1.2 任务类型的判断IO密集还是CPU密集选多线程还是多进程第一件事不是查文档而是判断你的任务是 IO 密集还是 CPU 密集。IO 密集任务的特点是大量时间花在“等待”上等待网络响应、等待硬盘读写、等待数据库返回结果。这类任务把 CPU 闲置了所以多线程的价值是把等待时间重叠起来。CPU 密集任务则相反每一步都需要 CPU 实实在在算完数据清洗、加密解密、图像处理、矩阵运算。这类任务真正考验的是 CPU 计算能力。判断方法也很简单任务逻辑里大部分是函数调用、算术运算就偏 CPU 密集只要出现请求、读文件、sleep、查数据库就要进一步分析耗时占比。我见过不少人把数据库查询封装成函数以为它是 IO 任务实际跑起来才发现 80% 的时间都耗在结果集的循环计算上那其实是 CPU 密集。1.3 GIL 并不是多线程唯一的敌人但它是最大的变量就算没有 GILPython 多线程也逃不开线程切换的开销、线程安全带来的锁竞争、以及多线程本身不容易调试这些问题。GIL 是压在所有 Python 开发者头顶的一个特殊变量它决定了一个进程里的多个线程同一时刻只能有一个线程在解释器里执行 Python 字节码。网络热词里出现“python中的多线程”“python多进程”搜索量很高说明这是很多人实际开发中的痛点。之所以大家反复纠结就是因为同样一段代码放在网络请求场景下多线程提升明显放在计算场景下多线程毫无收益甚至倒退。接下来我会先拆 GIL 的原理再用实测数据把两类场景的差异摆清楚。2. GIL 到底是什么一个专属于CPython的设计妥协2.1 引用计数为什么需要一把全局锁先明确一点GIL 不是 Python 语言本身的特性而是官方 CPython 解释器的实现细节。PyPy、Jython、IronPython 等实现并不都受 GIL 限制只是绝大多数人日常用的 python.exe 或 Linux 下的 python都是 CPython。CPython 的内存管理依赖引用计数。每一个 Python 对象内部有一个名为ob_refcnt的计数器记录这个对象被多少个变量引用。当某个变量不再指向这个对象时解释器就把计数减一计数归零就立刻释放内存。问题来了如果两个线程同时操作同一个对象比如同时给同一个字典赋值两个线程同时读写ob_refcnt就可能导致计数错乱——一个对象明明还被引用着却被提前释放程序轻则崩溃重则产生难以复现的内存错误。为了避免这种竞争CPython 选择了一个最简单粗暴的策略整个解释器同一时刻只允许一个线程执行 Python 字节码。持有这把全局锁的线程才能操作 Python 对象其他线程想干活得先拿到锁。这就是全局解释器锁GIL的由来。2.2 GIL 的切换机制与时间片GIL 不是无限期占有的。CPython 内部有一个切换间隔switch interval默认是 5 毫秒也就是 0.005 秒。你可以用以下代码看到当前值import sys print(sys.getswitchinterval()) # 默认输出 0.005旧版本 Python3.2 之前不是按时间而是按字节码指令数切换每执行 100 条字节码就让出一次 GIL。后来改成时间片是为了在不同线程之间更公平地分配执行时间。当你启动多个线程做 CPU 密集计算时每个线程大概跑 5 毫秒就会遇到一个检查点被迫让出 GIL让另一个线程拿锁执行。这个让出和重抢的过程就是 GIL 竞争的核心开销。这里要注意GIL 切换和操作系统线程调度的切换是叠加的。Python 线程让出 GIL 后操作系统还可能再做一次线程上下文切换。两层切换叠加CPU 密集场景下 8 个线程反复争抢 GIL 的开销常常比任务本身的计算量还大。2.3 GIL 什么时候主动释放什么时候被迫让出搞清楚 GIL 的释放条件很多困惑就迎刃而解。GIL 的释放分两种情况主动释放和被动让出。主动释放发生在线程执行阻塞操作的时候。比如time.sleep()、socket.recv()、requests.get()等待响应、threading.Lock.acquire()等待锁、读取大文件时等待磁盘 IO这些操作会让线程进入阻塞状态。既然线程已经等着了继续握着 GIL 也没什么用CPython 会在进入阻塞前主动释放 GIL等其他线程拿到锁继续干活。这就是为什么多线程处理网络请求有显著加速效果线程 A 在等服务器响应线程 B 就能利用这块时间发送另一个请求。被动让出发生在线程持续执行 CPU 密集型字节码的场景。每过sys.getswitchinterval()的时间解释器的 eval loop 会检查信号、异步任务等这时候如果发现有其他线程在等待 GIL当前线程就会被要求让出。所以 CPU 密集任务并不是不让出 GIL而是让出得太频繁锁竞争成本太高。用一句话概括阻塞等待时多线程能并行等待计算期间多线程只能串行执行。这就是多线程适合 IO 密集、不适合 CPU 密集的根本原因。2.4 自由线程与 Python 3.13 之后的变化Python 3.13 引入了一个实验性的“自由线程”Free-threaded构建也就是传说中的 no-GIL 版本。这个构建方式禁用了 GIL用更细粒度的锁来保护对象操作目标是让多线程真正利用多核。但目前这个方案还在实验期默认安装的 Python 依然是带 GIL 的。自由线程版本在 3.13 里需要特殊构建参数启用很多第三方 C 扩展库还没有为它适配。我个人的建议是可以关注可以用测试环境跑实验但生产项目暂时不要赌它。真实世界里绝大多数 Python 服务仍然运行在带 GIL 的 CPython 上我们讨论选型策略也以此为准。3. 多线程与多进程的实际表现对比3.1 线程池处理IO密集任务的真实收益空谈理论没用我说一个自己踩过的实际案例。有段时间我需要批量下载几十个城市的天气数据每个请求大约耗时 1 秒。串行跑 50 个请求稳稳的 50 秒。后来改成线程池max_workers设为 16同样的 50 个请求跑下来大约 4 到 5 秒耗时缩短到原来的十分之一。核心代码如下from concurrent.futures import ThreadPoolExecutor import requests def fetch(city_id): url fhttps://api.example.com/weather/{city_id} resp requests.get(url, timeout10) return city_id, len(resp.content) city_ids list(range(1, 51)) with ThreadPoolExecutor(max_workers16) as executor: results list(executor.map(fetch, city_ids))为什么快这么多因为每个请求发出后线程都在等待网络响应。等待期间 GIL 是释放的其他线程可以发出新的请求。16 个线程相当于同时有 16 个请求在网络中飞行等待时间被大量重叠。这种任务你换成单线程再快也快不起来因为瓶颈本来就不在 CPU而在网络延迟。3.2 进程池处理CPU密集任务的表现同样是这批数据如果我要对每一份 JSON 做复杂的特征计算比如逐字段解析、重采样、统计聚合那就是 CPU 密集任务。我用多线程跑过一次8 核机器上 8 个线程的耗时几乎等于单线程有时候还会因为 GIL 竞争稍微慢一点。换成进程池之后效果立刻不一样。每个进程有独立的 Python 解释器和独立的 GIL真正的并行执行。代码大致是from concurrent.futures import ProcessPoolExecutor import os def heavy_compute(city_id): total 0 for i in range(2_000_000): total (i * city_id) % 10007 return city_id, total city_ids list(range(1, 21)) if __name__ __main__: with ProcessPoolExecutor(max_workersos.cpu_count() - 1) as executor: results list(executor.map(heavy_compute, city_ids))这里有个细节我特别提醒进程池代码一定要放在if __name__ __main__:里面。在 Windows 上multiprocessing 模块使用 spawn 方式启动子进程子进程会重新导入主模块如果不加保护子进程会递归创建进程直接报错或者程序卡死。这个坑几乎每个用进程池的人都会踩一次。3.3 关键数据对照表为了更直观我把同一台机器上的实测表现整理成表格。这里的时间不是绝对值不同机器差异很大看的是相对趋势。场景方案8核机器实测趋势原因网络请求50次单线程基准耗时每个请求都在等网络网络请求50次16线程约1/8耗时至1/10耗时等待期重叠GIL释放纯计算任务8个8线程约等于单线程或多于单线程线程争抢GIL纯计算任务8个8进程接近单线程的6-7倍耗时缩减并行执行无GIL争抢混合型任务线程进程视比例而定每一段各自获益表里最后一行“混合型任务”值得展开说。真实业务很少有纯 IO 或纯 CPU 的任务。比如爬虫加解析爬取是 IO解析是 CPU数据入库读取是 IO索引构建是 CPU。这时候就有了分层设计的需求下面第 4.4 节具体讲怎么做。3.4 混合方案线程负责IO、进程负责计算一个比较成熟的实践方式是用线程池做 IO 阶段把结果汇总到一个队列再交给进程池做 CPU 密集的计算。这样做并不是为了炫技而是让每一类任务都在适合自己的执行模型下运行。我自己维护过一个数据管道项目线程池负责从消息队列拉取数据并写入本地文件进程池负责对文件做清洗和特征工程。两个池子之间通过queue.Queue传递文件路径字符串而不是传递 Python 对象。这样做的好处是跨进程传递的是一个轻量字符串避免了大量序列化开销如果直接传递巨大的 DataFrame 或复杂对象光 pickle 序列化就能让性能崩掉。4. 如何选择决策思路与落地代码4.1 一张决策树解决90%的选择问题我总结了几个判断规则按顺序走下来基本不会选错任务是 CPU 密集还是 IO 密集不确定就先写个计时脚本统计耗时构成。有时序分析用cProfile或者简单time.perf_counter()打点。CPU 密集任务优先用multiprocessing或ProcessPoolExecutor。进程数建议参考os.cpu_count() - 1多留一个核给系统和主进程不然你会在开着 IDE、浏览器的情况下把机器卡到鼠标都动不了。IO 密集任务优先用threading.ThreadPoolExecutor或者直接考虑asyncio。线程池适合简单快速改造的同步代码如果项目本身是 async 环境协程通常比线程更轻量。任务规模不大、逻辑简单用concurrent.futures就够了别把multiprocessing.Process和Pool用出花来。任务需要共享大量可变状态多线程加锁会很痛苦多进程只能通过 IPC 传递消息。如果两者都难写重新审视一下架构把共享状态设计成外部存储数据库、Redis、文件往往更省事。4.2 多线程落地ThreadPoolExecutor与Queue多线程共享进程内存天然方便但“方便”也意味着“容易出错”。多线程里最忌讳直接让多个线程去改同一个全局字典不加锁的话你可能会遇到数据丢失或者逻辑错乱。如果只是简单任务用executor.map就够了。如果任务之间有明确的流程衔接建议用queue.Queue做任务分发。比如从网页抓标题的场景import threading import queue import requests from bs4 import BeautifulSoup q_in queue.Queue() q_out queue.Queue() def worker(): while True: url q_in.get() if url is None: break text requests.get(url, timeout10).text title BeautifulSoup(text, html.parser).title.string q_out.put((url, title)) urls [https://example.com/a, https://example.com/b] threads [] for _ in range(4): t threading.Thread(targetworker) t.start() threads.append(t) for url in urls: q_in.put(url) for _ in threads: q_in.put(None) # 哨兵值让线程结束 for t in threads: t.join() while not q_out.empty(): print(q_out.get())这种“生产者-消费者”模式比裸开线程再 join 要清晰得多。把None作为结束信号是个常见技巧它能让线程优雅退出而不是强制杀线程。4.3 多进程落地ProcessPoolExecutor与Pipe/Queue多进程的开销远大于线程。每创建一个进程Python 都要复制一份解释器环境在 spawn 模式下还会重新导入主模块所以启动进程池本身就有一个固定成本。如果任务是重 CPU 计算这个启动成本会被任务执行时间摊薄无所谓。但如果任务是那种只算几十毫秒的小计算进程池的启动开销反而比计算本身还大这时候老老实实单线程可能更快。进程间通信也是重点能用Pool.map传递基本参数和结果就尽量不要手动创建Pipe。手动管理Pipe很容易写出“双方都在 etc.” 的逻辑错误进程间互相等待直接死锁。跨进程传数据还受限于可序列化。你传一个 lambda 函数给进程池会直接报错提示无法 pickle因为 lambda 没有名字序列化机制不认。实践中尽量传基础类型自建类也别写得太复杂。4.4 进程池里共享数据Value、Array与Manager多线程里多个线程可以直接读写同一个全局变量多进程里不行每个进程的内存空间是隔离的。如果你确实需要多进程共享一个计数器或标志位可以看这几个工具multiprocessing.Value共享一个 C 类型的数值变量适合计数器、标志位。multiprocessing.Array共享一个数组适合存储固定类型的数据。multiprocessing.Manager共享字典、列表等 Python 容器使用方便但性能很一般频繁读写时会成为瓶颈。我自己统计多进程任务进度时用过Value。注意一定要配套加锁否则多个进程同时counter.value 1读改写三步之间有间隙计数会丢。别问怎么知道的这种坑总要自己踩一次才长记性。5. 常见坑与排查经验5.1 常见问题速查表现象可能原因解决思路多线程跑 CPU 计算没有加速GIL 被线程争抢换多进程或接受串行现实多进程程序在 Windows 上反复启动没有加if __name__ __main__保护按规范包裹入口代码进程池传 lambda 报 pickle 错误lambda 无法序列化改成模块级函数或使用functools.partial多进程任务完成后子进程不退出有未关闭的非 daemon 子进程检查Process是否正确 join 或 terminate共享计数不准、结果随机丢多线程/多进程共享数据未加锁使用threading.Lock或multiprocessing.Lock线程池里某个任务抛异常主程序没反应executor.map的异常要迭代结果时才抛出用as_completed或add_done_callback捕获异常进程池每跑一次都要很久进程启动/序列化开销过大增大单次任务量或换线程/协程5.2 为什么线程多了反而变慢这个问题我回答过很多次。线程数从 2 加到 16IO 密集任务的吞吐量一路上升一旦超过某个临界点反而开始下降。原因有几个线程太多导致频繁的线程上下文切换每个线程都在竞争 GIL切换间隔 5 毫秒是高并发下线程切换成本的主要来源还有大量线程同时持有 socket 连接文件描述符也容易被耗光。我处理过一个日志采集脚本开了 64 个线程去拉数据4 核机器直接卡住。后来把max_workers降到 8吞吐量反而翻倍。对于 Python 多线程不是越激进越好线程数大约是实际并发上限的 2 到 4 倍比较合理而且一定要实测调参。另外可以尝试缩小线程切换间隔import sys sys.setswitchinterval(0.001)这个操作把 GIL 切换间隔从 5 毫秒调到 1 毫秒对本来看似“线程不够快”的场景往往没有帮助因为切换更频繁了但在某些锁竞争场景下反而能让等待线程更快拿到锁提升响应速度。想试可以但要用压测数据说话不要凭感觉调。5.3 为什么 print 顺序乱套多线程程序里 print 出的日志顺序不对是另一个高频困惑。print 本身是“解释器调用操作系统写文件描述符”虽然 GIL 保护了字节码但print内部至少包含准备字符串、写入缓冲区、刷新输出三个步骤中间 GIL 可能被让出不同线程的输出就会交错。解决办法很简单让输出只发生在单个线程里其他线程把日志内容丢进queue.Queue由一个专门的日志线程统一输出。还有一个小技巧给 print 传flushTrue也不能解决顺序问题因为多线程下顺序本来就取决于调度不是输出缓冲的问题。5.4 什么时候 GIL 会释放一个经验性的答案有人会问我写 C 扩展或者做数值计算的时候GIL 会不会释放这取决于第三方扩展的实现是否主动释放 GIL。经典的numpy在做大规模矩阵运算时就会释放 GIL让其他 Python 线程有机会运行。所以用 numpy 做计算时开线程可能反而有点效果。而纯 Python 写的循环比如for i in range(10000000): total i全程持有 GIL多个线程只会互相拖后腿。Python 3.13 的自由线程构建在逐步改变这个局面但目前默认解释器还是老规矩。另外一个经验是别把 GIL 当作所有性能问题的挡箭牌。我见过一个项目明明瓶颈是大量日志 fmt 字符串的 CPU 密集格式化产品经理坚持要改成多进程结果因为 IPC 开销过大反而更慢。先测清楚瓶颈在哪一层再谈选型。5.5 从 cProfile 到真实压测别让感觉代替数据最后给一个排查性能问题的标准流程。先写一个小型压测脚本把任务的耗时打出来用cProfile看函数级耗时分布确认瓶颈是 IO 等待还是 CPU 计算。如果耗时主要花在socket、read、sleep这类系统调用上那是 IO 密集如果耗时集中在纯 Python 的热点函数里那是 CPU 密集。判断清楚之后再套用前文的决策树。命令行里可以顺手跑一下python -X importtime your_script.py看看启动时模块导入开销这个对多进程场景特别有用因为每个子进程都会重新导入依赖如果第三方库导入很重进程池的收益会被大幅稀释。写在最后的体会最后分享一点我自己踩过多次坑之后的感受。接手过几个并发项目最典型的错误是一开始就上重型方案有人把所有数据处理逻辑都丢进程池结果每秒钟要在父子进程之间传几百个复杂对象光 pickle 序列化就把耗时吃回去了也有人迷信多线程线程开了一百个结果大量时间花在来回抢 GIL。从此我给自己定了一条规矩先量化再选型。任务类型没搞清楚之前多线程和多进程的选择就像闭眼买车——选项本身没有对错错的是场景。你如果也遇到“多线程没提升”的情况建议先跑一个计时脚本数一数耗时到底花在等待还是计算。想通了 GIL你就不会被它吓住也不会再迷信任何一刀切的结论。Python 的并发工具箱里线程和进程只是其中两把扳手关键永远是你要修的机器是哪种型号。