Python多线程与多进程怎么选?一文讲透GIL、CPU密集和IO密集的并发选型 如果你用Python写过多线程或多进程代码应该会有这种感觉查了一堆资料说得头头是道可真到自己的项目里选型时还是拿不准到底该用线程还是进程。尤其是当搜进程 线程 区别之类的关键词时搜出来的全是概念定义进程是资源分配的最小单位线程是CPU调度的最小单位。话说得没错但看完依然不知道怎么写代码。我最早接触Python并发时也卡在这。让我真正把两者弄明白的是一个反复调优的爬虫项目和一个吃满CPU的日志分析任务。从那以后我形成了一套自己的判断方法先看任务是吃CPU还是等IO再看数据要不要共享最后看进程和线程各自的开销能不能接受。这套思路实际跑下来比单纯背概念管用得多。这篇文章就把我这些年积累的判断逻辑、踩坑教训和实操套路完整梳理一遍。文章讲的东西适合这几类读者刚学Python并发、只会用threading但不知道什么时候换multiprocessing的项目里遇到性能瓶颈不知道怎么拆分的以及看标题搜进来想彻底搞明白进程和线程底层差异的。不论你是哪种情况这篇文章的核心目标只有一个让你看完之后不再纠结选型能直接照着写代码。1. Python的GIL到底挡了谁的路先把最大的拦路虎说清楚聊Python的进程和线程绕不开GIL。但网上关于GIL的解释两极分化严重一边说GIL让多线程没用另一边说GIL不影响IO密集任务。两个说法都对但都只说了一半。很多文章一上来就搬出CPython内存管理、引用计数、字节码这些概念看得人头大。这里我用自己项目里真实遇到的问题来说更好懂。我之前写过一个脚本作用是批量处理一批图片每一张要做像素级运算比如去噪、颜色调整。用threading写了多线程版本丢到四核机器上跑发现CPU占用只有100%左右一个核心在忙其他核心在围观总耗时和单线程几乎一样。问题就出在GIL上——CPython解释器里有个全局锁同一时刻只允许一个线程执行Python字节码。所以所谓多线程并行在CPython里实际上是多线程轮流执行一个线程拿到锁就跑一会儿时间片到了就换下一个。线程切换得快看起来像并行但对CPU密集型任务而言锁竞争反而带来了额外开销速度不仅没提升甚至可能比单线程还慢。那为什么IO密集任务不受影响因为线程在等待网络响应、磁盘读取这类操作时根本不需要持有GIL。锁会被释放掉让别的线程去执行代码。所以一个线程在等请求另一个线程在计算CPU照样能跑起来多线程在IO密集场景下是实实在在有加速效果的。有人会问既然GIL这么碍事为什么CPython不把它删了因为删除GIL极难。CPython的内存管理依赖引用计数引用计数的增减必须保证原子性。没有了GIL就得给每个对象加锁或者改用其他内存管理方案代价是单线程程序性能大幅下降而且CPython内部有大量C扩展代码都依赖GIL的保护全改一遍工程量巨大。好消息是Python 3.13已经开始尝试free-threading自由线程模式可以在编译时选择不带GIL的版本让真正的多线程并行成为可能但那是另一个话题了后面我会专门讲。所以现在可以明确表态了在Python标准CPython里多线程适合IO密集任务多进程才是CPU密集型任务的正解。这算是我总结出的第一原则。2. 进程与线程的底层差异从资源分配到切换成本逐个拆解理解了GIL只是理解了Python层面的限制。真要彻底搞明白进程和线程的区别还得回到操作系统层面看它们的本质。这里我用一个餐厅的比喻来解释我觉得比死记定义直观多了。2.1 进程是一家餐厅线程是餐厅里的员工进程在操作系统里是资源分配的基本单位。开一个进程操作系统要给它分配独立的地址空间、文件描述符表、环境变量、信号处理机制。你可以把进程想象成一家餐厅餐厅有自己的厨房、座位、营业执照资源独立对外营业。进程之间互不干扰一家餐厅菜品出了问题隔壁餐厅完全不受影响。线程是CPU调度的基本单位同一个进程里的多个线程共享进程的地址空间和资源线程自己只保留栈和少量寄存器上下文。还拿餐厅比喻线程就是餐厅里的厨师和服务员大家在同一家店里工作共用厨房、共用食材储备、共用菜谱。一个线程改了全局变量其他线程立刻就能看到因为大家本来就在同一个内存空间里。这个比喻能直接解释为什么线程轻、进程重开一家新餐厅要选址装修办证备货创建进程开销大招一个服务员只需要培训一下就能上岗创建线程开销小。2.2 上下文切换的成本到底差在哪很多人知道进程切换比线程切换慢但不清楚为什么慢也不知道慢多少。我来拆解一下。线程切换时操作系统只需要保存和恢复线程的寄存器状态、程序计数器、栈指针然后继续在同一个进程的地址空间里跑不需要动页表。进程切换就麻烦多了光保存寄存器还不够得切换整个地址空间——页表要切换TLB快表要刷新各种缓存要重填。地址空间切换和TLB刷新这两个操作在现在的CPU上成本很高如果是Linux上典型的fork出来的进程COW写时复制机制还会让首次写入缺页中断频繁发生。直观地说一次进程上下文切换的成本可能比线程切换高出几十到上百倍尤其在进程数远大于CPU核心数时频繁切换会浪费大量CPU时间在切换本身而不是干活上。这也是为什么多进程方案不能无脑开进程数一旦超过CPU核心数好几倍性能反而会变差。2.3 隔离性与共享越安全越贵越自由越危险进程之间地址空间相互隔离一个进程崩溃了不会拖垮其他进程。我在生产环境部署过用多进程跑不同业务模块的服务某个子进程因为数据异常段错误挂了主进程检测到后直接重新拉起子进程其他模块完全不受影响这是进程架构的核心优势。代价就是进程间数据共享非常麻烦必须走进程通信IPC常见的手段有管道、队列、共享内存等全是额外的设计和开发成本。线程就不一样了同进程内的线程天然共享全局变量、堆内存、静态数据通信成本几乎为零直接读写变量就行。但共享带来的代价是竞争条件race condition——两个线程同时改写同一个变量结果可能出乎意料。为了保证正确性你又得引入锁、条件变量、原子操作这些机制。线程之所以容易写出难以排查的bug根源就在共享这两个字上。把这个逻辑翻译成选型语言涉及敏感数据、需要故障隔离和大规模计算的任务偏向多进程需要大量交互、频繁共享状态的任务偏向多线程但要接受锁带来的复杂度和性能损耗。这个判断在后面第4部分还会展开。3. 实操选型CPU密集、IO密集和高并发连接分别该上什么概念说完了进入最关键的实操问题写代码时到底怎么选。我先说结论再说为什么。3.1 四种典型场景的结论做一个速查表任务类型特点推荐方案理由CPU密集型大量计算CPU吃满multiprocessing / ProcessPoolExecutor绕开GIL真正多核并行IO密集型网络、磁盘、数据库等待居多threading / ThreadPoolExecutor / asyncioGIL在等待时释放多线程效果明显高并发连接大量长连接但同时活跃度不高asyncio或线程池协程更省资源线程池好上手混合型既有计算又有IO进程池线程池分层各有分工计算走进程、等待走线程拿我实际经历来举例子这样更直观。之前写过一个日志分析工具要处理几十GB的服务器日志每行日志要做正则提取、字段拼接、聚合统计。这类任务基本全程在CPU上跑我用ProcessPoolExecutor拆成8份机器是8核8个进程同时跑耗时从原来的20分钟降到了3分钟出头。这种提升是线程方案永远给不了的。3.2 CPU密集型任务用进程池不要傻乎乎手动生进程用multiprocessing.Process直接写当然也行但管理进程池、处理任务分发和结果回收都有现成封装没必要重复造轮子。concurrent.futures.ProcessPoolExecutor是我最常用的代码非常简洁import concurrent.futures import math def heavy_calc(n): # 模拟CPU密集型计算比如大数据量开方、矩阵运算 total 0 for i in range(1000): total math.sqrt(i * n) return total if __name__ __main__: tasks list(range(100)) with concurrent.futures.ProcessPoolExecutor(max_workers8) as executor: results list(executor.map(heavy_calc, tasks)) print(results[:5])这里的max_workers8是配合CPU核心数定的。有一个容易踩的坑我提醒一下进程池传进去的任务函数必须可以被pickle序列化所以不能用lambda、不能用局部函数也不能传类的实例方法除非这个类可以正常序列化。很多新手在进程池里莫名其妙报错八成是栽在pickle上。3.3 IO密集型任务线程池比协程更适合大多数人asyncio虽然很热但它有一个较高的门槛要么全部写异步要么用run_in_executor传统同步代码打个补丁写起来像在写两种语言。大多数项目里同步的IO密集任务用ThreadPoolExecutor就够了代码改动量最小心智负担最低。来看一个经典的爬虫示例import concurrent.futures import requests urls [https://example.com] * 50 def fetch(url): resp requests.get(url, timeout10) return len(resp.content) if __name__ __main__: with concurrent.futures.ThreadPoolExecutor(max_workers10) as executor: lengths list(executor.map(fetch, urls)) print(lengths[:5])10个线程同时发请求网络请求在等待时GIL自动释放效果非常明显。50个请求串行可能要15秒10线程跑一般3秒内就完事。3.4 一个现实中的组合场景进程池里放线程池工作里我还遇到过这种场景任务既有大量网络请求IO密集又要对拿回来的数据做复杂计算CPU密集。这种混合任务我一般是两层架构外层用进程池分到多核内层每个进程里再开一个小线程池让网络请求在进程内并发拿到数据后直接在当前进程算。这样进程负责撑满CPU线程负责在等待时继续发请求两层各司其职。代码结构大致长这样import concurrent.futures import requests def download_batch(urls): # 进程内用线程池并发下载 with concurrent.futures.ThreadPoolExecutor(max_workers4) as t_exec: raw_data list(t_exec.map(download_one, urls)) return [process_data(d) for d in raw_data] def download_one(url): resp requests.get(url, timeout10) return resp.content def process_data(data): # 模拟CPU密集计算 return hash(data) % 1000 if __name__ __main__: with concurrent.futures.ProcessPoolExecutor(max_workers4) as p_exec: results list(p_exec.map(download_batch, [url_group_1, url_group_2]))这种写法在批量采集、批量分析的真实业务里很常见。我见过不少人在这种场景里纠结到底用进程还是线程其实就是没意识到可以两者一起上。4. 并发编程里的经典坑从死锁到线程池参数每一个都让我吃过亏并发代码写得出来不难写得不出问题才难。我把自己真正踩过、也看别人反反复复踩的坑集中整理一下每个坑都附上问题分析和解决方案能帮读者少走半年弯路。4.1 线程死锁你以为加了锁就安全了先看一个最典型的死锁例子import threading lock_a threading.Lock() lock_b threading.Lock() def worker1(): with lock_a: print(worker1 acquired lock_a) with lock_b: print(worker1 acquired lock_b) def worker2(): with lock_b: print(worker2 acquired lock_b) with lock_a: print(worker2 acquired lock_a) t1 threading.Thread(targetworker1) t2 threading.Thread(targetworker2) t1.start() t2.start() t1.join() t2.join()如果worker1先拿到lock_a、worker2同时拿到lock_b接着worker1想拿lock_b、worker2想拿lock_a两边都卡在等待对方释放锁程序就彻底卡死了。死锁是我第一次在生产环境遇到并发bug时最头疼的问题——程序不报错也不退出就是卡着不动排查起来非常痛苦。解决死锁的常用手段有几个。第一是保证所有线程获取多个锁的顺序一致比如统一先获取lock_a再获取lock_b就不会有循环等待。第二是给获取锁加超时用lock.acquire(timeout5)拿不到锁就放弃并重试。第三是能用threading.RLock的地方尽量用RLock——RLock是可重入锁同一个线程可以重复获取同一个锁这在递归调用里特别省心。注意Lock如果在一个线程里重复acquire会把自己锁死RLock不会。这个细节很多人不知道。4.2 线程池的阻塞队列选择默认无界队列的隐患很多人用ThreadPoolExecutor时只关心max_workers从不关心它的任务队列。我要提醒一句ThreadPoolExecutor内部的任务队列默认是无界的。什么意思你疯狂往线程池里提交任务比如一次性提交10万个下载任务任务会全部堆在队列里内存就被快速消耗最终可能OOM。如果你的任务列表本身就是巨大的比如上百万个URL不要一次性全部提交要用executor.submit分批提交或者控制任务提交速率。代码层面可以这样做import concurrent.futures import time BATCH_SIZE 1000 def process(item): return item * 2 with concurrent.futures.ThreadPoolExecutor(max_workers10) as executor: futures [] for i in range(100000): futures.append(executor.submit(process, i)) if len(futures) BATCH_SIZE: # 等这一批全部完成再提交下一批避免积压过多 for f in futures: f.result() futures.clear() # 处理剩余任务 for f in futures: f.result()还有一个细节是ThreadPoolExecutor里阻塞队列的选择。在标准库里默认用的queue.SimpleQueue这类无界队列如果要限流就得自己在提交层做节流。如果你做的是生产者消费者模式记得用queue.Queue(maxsizeN)配合put的阻塞特性来控制积压别让它无限膨胀。4.3 进程池的隐藏坑全局变量、pickle序列化和内存放大进程池的坑主要集中在三个方面我一个个说。首先进程池里的全局变量不共享。在multiprocessing里每个子进程都有自己独立的内存空间你在主进程里设置的全局变量、修改的字典子进程里看不到更别提共享修改了。要想跨进程共享数据得用multiprocessing.Manager、Array、Value这些专门机制。但共享机制本身有性能开销能不用就不用优先靠任务入参和返回值传递数据。其次pickle序列化的限制。进程池传递任务和返回结果时都是通过pickle把对象序列化进管道或队列。因此任务函数必须是模块内定义的顶层函数lambda不行局部函数也不行类实例方法通常也不行。我在写多进程代码时经常提醒自己所有给进程池用的函数一律定义在模块顶层。最后内存放大问题。进程虽然各用各的内存空间但fork出来的子进程在Linux下会继承父进程的内存映像写的时候才真正复制COW机制。如果你的父进程已经加载了大量数据比如几十GB的DataFramefork出的每个子进程的虚拟内存都会先指向同一块物理内存看似不占额外空间一旦子进程对数据做了修改就会触发复制内存占用倍数增长。应对做法是在提交任务之前尽量压缩内存占用或者把大数据的加载延迟到子进程内进行而不是提前加载到父进程再fork。4.4 守护线程不等于安全退出热搜词里有一条守护进程与会话这个在Python环境里对应的是daemon线程和进程。很多人在主线程结束时发现子线程还在跑或者子线程被强行终止就是因为没搞清楚daemon的语义。threading.Thread的daemon属性如果设为True表示这个线程是守护线程主进程退出时不等待它直接终止。如果不设daemon默认是False主线程退出时会等待所有非daemon线程执行完毕。这里有个坑如果你开发的是脚本或服务主线程任务完成后想退出但忘了把子线程设为daemon或者没手动退出子线程程序就会一直挂着结束不了。反过来如果设了daemon子线程里有什么需要清理的资源比如数据库连接、临时文件就可能来不及释放。我的建议是需要长期运行的后台线程如心跳检测、日志轮转应该设为daemon如果有明确的关闭逻辑就定义一个关闭函数用Event或Queue通知线程退出而不是简单粗暴地设daemon让它随遇而安。这样的程序退出时才不会留下半截状态。5. 进程间通讯IPC数据怎么跨进程传递才不踩雷多进程最大的麻烦就是数据共享。好在Python的multiprocessing模块提供了几件趁手的工具Queue、Pipe、Manager、Value和Array。在不同的场景下选不同的工具。5.1 队列生产者消费者模型最省心multiprocessing.Queue是进程安全的队列多个进程可以安全地往里放数据和取数据。它在底层用的是线程加锁配合内存共享或者管道对调用方是透明的用起来和普通queue.Queue区别不大。import multiprocessing def worker(q): while True: item q.get() if item is None: # 用None作为结束信号 break print(fworker processed {item}) if __name__ __main__: q multiprocessing.Queue() processes [multiprocessing.Process(targetworker, args(q,)) for _ in range(4)] for p in processes: p.start() for i in range(100): q.put(i) for _ in processes: q.put(None) # 给每个worker发一个结束信号 for p in processes: p.join()这里有个小技巧用None作为结束信号。多个worker时要确保每个worker都收到一个None不能只发一个。如果你用q.close()加q.join_thread()还需要注意进程退出时队列数据是否全部写入join_thread会等待后台线程排空数据这对保证数据不丢失非常重要。5.2 Pipe和Manager的取舍Pipe()用于两个进程之间的双工通信适合一对一的场景。用起来很简单但要注意Pipe()创建的两个连接端不能同时被多个进程读写否则数据会乱一定要做一个端的写入、另一个端的读取不要两端同时做两件事。Manager是另一类工具它创建一个独立的服务器进程来管理共享对象子进程通过代理proxy访问它。比如manager.dict()可以跨进程共享字典很方便但每次读写都有代理通信的额外开销性能不高不适合高频访问。我一般只用Manager做低频率的共享状态比如任务进度、配置项高频数据传递优先用Queue或内存共享的Array/Value。5.3 进程间的同步一样需要锁多进程共享文件、共享数据库连接池时同样会遇到竞争条件。multiprocessing.Lock可以保证多个进程互斥访问共享资源用法和threading.Lock几乎一样。比如多个进程同时在同一个日志文件里追加内容不使用锁的话写出的内容会互相穿插、丢行加上锁之后才正常。import multiprocessing def write_log(lock, msg): with lock: with open(app.log, a) as f: f.write(msg \n)5.4 fork还是spawn启动方式影响你的代码怎么写这也是最容易踩出莫名其妙错误的环节。multiprocessing在Linux/macOS上默认用fork方式启动子进程在Windows上默认用spawn。fork是直接复制父进程内存快照子进程里的全局变量和父进程启动时一致spawn是重新导入模块、从头执行一遍所以spawn模式下必须把多进程入口放在if __name__ __main__:保护块里否则会无限递归生成子进程。即使你在Linux上开发也要做好这个保护因为代码经常会拿到Windows或macOS上跑尤其macOS默认早就切换成spawn了。这个雷很多人不跨平台就永远不会踩到一旦踩到就一脸懵。现在我写多进程代码时默认统一在入口判断if __name__ __main__:进程池和Process都放在保护块内从源头避免平台差异问题。6. 进阶方向虚拟线程、自由线程和等待机制Python并发的下一站基础内容讲完了说一下几个大家都在关注的新方向。热搜词里出现了虚拟线程原理自由线程进程等待wait这些词说明并发这个话题的热度一直在涨。6.1 Python 3.13的free-threadingGIL真的可以关了Python 3.13推出了实验性的自由线程模式也就是不带GIL的解释器。在这个模式下多线程终于可以真正在多个CPU核心上并行执行了。这对CPU密集型且不想用多进程的人来说是个大好消息。但要注意这是实验特性而且是以损失单线程性能为代价的。由于GIL保护了很多C扩展的线程安全无GIL模式会让部分C扩展库变得不再安全或者需要重新编译。我的建议是普通项目暂时不要在生产环境全面切自由线程。可以先在本地用3.13自由线程版本跑自己的多线程程序看看实测加速效果再决定。现在很多纯Python的CPU密集任务直接换自由线程版本可能就有提升这是未来值得跟进的方向。6.2 Java虚拟线程和Python的关联协程是更轻量的选择热搜里有虚拟线程原理那是Java 21引入的轻量级线程概念它的核心思想是让线程不再绑定操作系统内核线程而是由JVM自己在用户空间调度这样几十万并发也能撑住。Python里和这个思路最接近的概念其实是协程asyncio。asyncio的任务调度也是用户态完成的只要任务里有IO等待就可以切换给其他任务用极少的线程支撑大量并发。如果你面对的是动辄上万的长连接场景比如WebSocket网关用asyncio通常是比线程池更合理的选择。代价是需要写异步代码需要配合await的思维模式切换成本高。但从资源占用率来看几千个协程比几千个线程省太多了。我实际测过一个简单的WebSocket服务asyncio版本大概能省掉三分之二的内存占用。6.3 进程等待wait机制join和waitpid的细节热搜词里的进程等待wait对应的是主进程等待子进程结束的机制。在Python里最常见的是Process.join()它的语义是阻塞主进程直到该子进程退出。这和多线程的Thread.join()类似但底层机制不同——join()在multiprocessing实现里会调用操作系统的等待接口来回收子进程状态。如果你用过os.waitpid就能理解join本质上就是对这个系统调用的封装。这里有个细节如果你在子进程里产生了新的孙进程且没有正确回收可能会出现僵尸进程。Python的multiprocessing在进程退出时一般会自动回收但如果自己用os.fork直接创建子进程记得调用os.waitpid回收否则僵尸进程会积累。我曾经在某个项目里发现一堆defunct进程占着系统资源就是排查后才发现是某个子进程自己fork了孙进程但没回收。文章写到这里基本上把Python进程和线程从概念、选型、踩坑到进阶方向都串了一遍。最后说点个人体会。我用了这么久的Python并发最大的感受是进程和线程没有绝对的谁优谁劣只有在这个场景里哪个更合适。CPU密集就老实用多进程、IO密集就用多线程或协程、混合型就两层都上。还有一件事我反复强调多线程代码一定要控制共享状态能传参就不共享能锁就锁能拆进程就拆进程别怕麻烦。这些原则看上去朴素但每个都是我拿线上事故换来的教训。如果你打算深入这块我建议下一步可以做两件事一是拿自己的真实任务写成单线程、多线程、多进程三个版本跑一遍对比时间这种体感比看任何文章都深刻二是认真读一遍concurrent.futures和multiprocessing的标准库文档把Queue、Event、Lock这几个组件用一遍配合今天这篇文章里的代码示例基本就能形成自己的并发工具箱了。等到了用工具像呼吸一样自然的程度你再看进程线程的讨论就会觉得没那么玄乎了。