如果你用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 / asyncio | GIL在等待时释放,多线程效果明显 |
| 高并发连接 | 大量长连接但同时活跃度不高 | 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_workers=8) as executor: results = list(executor.map(heavy_calc, tasks)) print(results[:5])这里的max_workers=8是配合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, timeout=10) return len(resp.content) if __name__ == "__main__": with concurrent.futures.ThreadPoolExecutor(max_workers=10) 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_workers=4) 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, timeout=10) return resp.content def process_data(data): # 模拟CPU密集计算 return hash(data) % 1000 if __name__ == "__main__": with concurrent.futures.ProcessPoolExecutor(max_workers=4) 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(target=worker1) t2 = threading.Thread(target=worker2) 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(timeout=5),拿不到锁就放弃并重试。第三是能用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_workers=10) 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(maxsize=N)配合put的阻塞特性来控制积压,别让它无限膨胀。
4.3 进程池的隐藏坑:全局变量、pickle序列化和内存放大
进程池的坑主要集中在三个方面,我一个个说。
首先,进程池里的全局变量不共享。在multiprocessing里每个子进程都有自己独立的内存空间,你在主进程里设置的全局变量、修改的字典,子进程里看不到,更别提共享修改了。要想跨进程共享数据,得用multiprocessing.Manager、Array、Value这些专门机制。但共享机制本身有性能开销,能不用就不用,优先靠任务入参和返回值传递数据。
其次,pickle序列化的限制。进程池传递任务和返回结果时都是通过pickle把对象序列化进管道或队列。因此任务函数必须是模块内定义的顶层函数,lambda不行,局部函数也不行,类实例方法通常也不行。我在写多进程代码时经常提醒自己:所有给进程池用的函数,一律定义在模块顶层。
最后,内存放大问题。进程虽然各用各的内存空间,但fork出来的子进程在Linux下会继承父进程的内存映像,写的时候才真正复制(COW机制)。如果你的父进程已经加载了大量数据(比如几十GB的DataFrame),fork出的每个子进程的虚拟内存都会先指向同一块物理内存,看似不占额外空间,一旦子进程对数据做了修改,就会触发复制,内存占用倍数增长。应对做法是:在提交任务之前尽量压缩内存占用,或者把大数据的加载延迟到子进程内进行,而不是提前加载到父进程再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(f"worker processed {item}") if __name__ == "__main__": q = multiprocessing.Queue() processes = [multiprocessing.Process(target=worker, 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-threading:GIL真的可以关了
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这几个组件用一遍,配合今天这篇文章里的代码示例,基本就能形成自己的并发工具箱了。等到了用工具像呼吸一样自然的程度,你再看进程线程的讨论,就会觉得没那么玄乎了。