Python并发编程:进程、线程、协程选型与实战排障指南

发布时间:2026/10/6 14:01:30
Python并发编程:进程、线程、协程选型与实战排障指南 Python并发编程里进程、线程、协程这三个词几乎每个开发者都遇到过。网上讲概念的帖子一大堆但真到自己写multiprocessing的时候进程莫名其妙僵住线程池一调大就把机器拖垮asyncio写出来不知道为啥不并发这些问题才是让人头大的部分。这篇文章我用实际踩过的坑做主线把三者背后的原理、适用场景、常见误区和排障经验一次性讲清楚给准备入门并发编程或者正在被线程死锁折磨的人一份可以直接参考的实操笔记。先说一个总体的认知进程、线程、协程不是三个互斥的技术选项而是应对不同场景的三层工具。进程处理CPU密集任务线程处理中等并发IO任务协程处理高并发IO任务。理解了这个大前提后面的细节才有意义。1. 先把概念掰开进程、线程、协程到底差在哪1.1 三者的本质区别经常有人把并发和并行混为一谈。并发是看起来像同时进行并行才是同一时刻真的同时执行。进程和线程的调度由操作系统内核负责协程的调度则在用户态完成这是理解三者差异的起点。进程是资源分配的基本单位。每个进程有自己独立的内存空间、文件描述符、环境变量。进程之间天然隔离一个进程崩溃不会直接拖垮另一个进程但代价是数据不能直接共享要传信息得走管道、队列、共享内存那一套IPC机制。线程是CPU调度的基本单位。同一进程内的线程共享内存空间所以线程之间传数据很方便但便利也带来了麻烦——多个线程同时改同一个变量就出现竞争条件。线程切换由内核完成切换时要保存上下文、刷新缓存成本不低线程数量越多这个开销越大。协程则完全是另一套思路。协程本质是可以暂停的函数在同一个线程里协作式调度。一个协程执行到await时主动让出控制权事件循环再决定下一个轮到谁。没有内核参与切换所以协程切换的代价极小可以轻松创建成千上万个。我用一个餐厅的类比来帮助理解。进程好比一家家独立的餐厅各自有独立的后厨和仓库能同时营业但食材不能互相借。线程是同一家餐厅里的一群厨师共享同一套厨具配合得好效率极高配合不好就是抢锅抢勺。协程更像一个全能厨师同时管着红烧肉、蒸鱼、炖汤火候到了就去翻一下锅来回换着做看似忙乱但每个菜都在推进。理解了基本概念再看一个关键数据对比。对比维度进程线程协程调度方操作系统操作系统程序自身用户态切换成本高毫秒级中等微秒级极低纳秒级内存隔离完全隔离共享内存共享内存通信方式IPC队列、管道等直接读写共享变量直接读写共享变量适用场景CPU密集、需要隔离IO密集中等并发IO密集高并发创建数量上限几十到几百几百到几千成千上万1.2 为什么Python并发让人头疼GIL聊Python并发绕不开GIL全局解释器锁。GIL是CPython解释器的一个互斥锁保证同一时刻只有一个线程在执行Python字节码。GIL的历史存在是为了简化CPython的内存管理——引用计数机制在多线程同时操作对象时容易出错有了GIL解释器的内部状态就安全了。这个设计带来的直接影响就是Python的多线程无法利用多核并行处理CPU密集任务。四个线程跑四个计算任务实际还是轮流用一个核心甚至因为线程切换和锁竞争比单线程还慢。我见过不少人写多线程跑费CPU的分析程序跑起来CPU占用只有100%以为程序写错了实际上就是被GIL限制住了。所以选择方案的第一道分水岭是判断任务类型。计算密集型的任务直接选多进程每个进程有独立解释器和GIL可以真正并行多核执行。IO密集型任务则不一样这类任务的瓶颈在等待网络响应、磁盘读写线程阻塞等待时GIL会被释放其他线程可以利用这个空档执行。这也是为什么Python多线程做爬虫、处理网络请求依然有效的原因。GIL不等于线程安全。很多初学者听说有GIL就以为写多线程时可以不用加锁。实际上GIL保护的是解释器层面的字节码执行并不保护业务层面的复合操作。比如多个线程同时执行 counter 1这一行代码在字节码层面是读取值、加一、写回三步线程A刚读到值还没来得及写回线程B就来读了两边写回的结果就丢了。Java里有AtomicInteger这样的原子类保证单变量的原子性Python标准库里没有直接对应的东西复合操作还是老老实实加锁。Python 3.13推出了free-threaded构建不带GIL的实验特性我实际在3.13t解释器上跑过多线程计算确实能跑满多核但单线程性能略有下降部分第三方扩展库的兼容性也还不完善。这个方向值得关注但生产环境用GIL版本依然是稳妥选择。2. 进程篇multiprocessing与进程池实战2.1 创建进程与join等待multiprocessing模块的使用方式跟threading很像但这个像恰恰是很多人踩坑的起点。先看一个最基本的例子。import multiprocessing import time def worker(name): time.sleep(2) print(f{name} finished) if __name__ __main__: p multiprocessing.Process(targetworker, args(job1,)) p.start() p.join(timeout3) print(main process exiting)Process的start方法启动子进程join方法让主进程阻塞在这里等待子进程结束。join(timeout3)的意思是最多等3秒如果子进程还没结束主进程就先往下走了。这个timeout参数在工程上非常重要因为子进程一旦卡死无超时的join会让整个程序跟着卡住。关于进程等待还有一个很常见的坑daemon属性。daemonTrue的进程被设置成守护进程主进程退出时这类子进程会被强制终止不会阻塞主进程退出。daemonFalse默认值的子进程会等待主进程退出前自然结束。这里说的守护进程跟Linux系统里的daemon服务是完全两回事只是在multiprocessing里借用了这个叫法。如果主进程直接退出没有调用join非daemon子进程会继续运行直到自己结束而daemon子进程会立刻被终止。这个差异在生产环境会导致一个诡异现象程序主函数已经跑完了终端却一直不退出如果子进程又是个死循环程序就永远挂在那。排查思路就是去查有没有遗留的daemonFalse子进程没有join。Linux下改进程名这件事也有讲究。multiprocessing里可以通过current_process().name设置进程的显示名称但如果用工具直接看ps输出会发现名称被截断了。这是因为Linux内核的comm字段最多只能存15字节超过部分会被裁掉。想让ps里显示完整名称得通过prctl直接改进程标题的cmdline或者用setproctitle库。这是个冷门细节但排查多进程服务时经常被它误导——你改了名字ps里看的还是旧名字以为没生效。2.2 进程池Pool的正确使用进程的创建和销毁成本很高频繁创建销毁进程会浪费大量时间所以实际项目里用的最多的是进程池。Pool帮你维护一批常驻的子进程往里面丢任务子进程执行完继续等下一个。from multiprocessing import Pool def square(n): return n * n if __name__ __main__: with Pool(4) as pool: results pool.map(square, range(100)) print(results)Pool(4)表示创建4个worker进程。pool.map会阻塞主进程直到所有任务完成一次性返回所有结果这对数据量大的场景不太友好——100万个任务的结果全堆在内存里很容易内存爆炸。这种情况应该用pool.imap它会像生成器一样一个个产出结果配合循环处理内存占用会小很多。需要多个参数的函数用starmap它会自动把元组拆成多个参数传入。进程池最常见的坑是任务里嵌套任务。比如任务队列本身需要执行一个子任务而子任务又用到同一个进程池就会出现经典的死锁进程池中的worker全部占满等待的新任务没人执行整个程序卡死。另一个坑是给worker传Queue对象但任务结束后没有正确关闭队列子进程会一直挂在队列的写入等待上结果close()之后进程池怎么都退不出来。我自己的经验是进程池里的任务函数要尽量保持简单不要在里面动态创建额外进程或线程。复杂编排逻辑放在主进程里做worker只做拿到一个任务算出结果这一件事。这样出问题的概率会小很多。Windows平台上还有个必须注意的点multiprocessing在Linux默认用fork方式创建子进程Windows不支持fork只能用spawn方式它会重新导入主模块所以代码必须放在ifname main:保护起来否则子进程会无限递归地重新执行主模块内容。这不仅是规范问题在Windows下不加这个保护程序根本没法定跑起来。2.3 进程间通信IPC进程间数据隔离的代价是需要专门的通信机制。multiprocessing提供了几种手段Queue队列、Pipe管道、Value/Array共享内存、Manager管理器。选择的标准很简单消息传数据用Queue数据量大且需要高效共享用共享内存。from multiprocessing import Process, Queue def producer(q): q.put(hello) def consumer(q): print(q.get()) if __name__ __main__: q Queue() p1 Process(targetproducer, args(q,)) p2 Process(targetconsumer, args(q,)) p1.start() p2.start() p1.join() p2.join()Queue的put和get默认是阻塞的如果队列满或者空调用方会一直卡住等待所以生产代码里务必使用put(timeout)和get(timeout)防止通信对方异常导致自己无限阻塞。Value和Array走的是真正的共享内存读取效率比Queue高很多但它们能共享的数据类型有限而且并发修改时需要自己加锁。Manager则提供了一种更灵活的代理方式可以在进程间共享list、dict、Namespace这样的Python对象但性能比共享内存慢不少因为所有操作都走了一层序列化和进程间通信。我实际用下来简单场景用Queue就够了追求性能用共享内存Manager是最后的选择它虽然方便但速度确实不敢恭维。还有一个容易被忽略的要素跨平台时Queue对象的创建必须在主进程里完成Windows的spawn模式下子进程内部的全局Queue对象无法正常工作因为子进程不会继承主进程的内存状态。这个问题在Linux下用fork测试一切正常部署到Windows才暴露排查起来非常难受。3. 线程篇threading与线程安全3.1 线程的创建与生命周期线程的写法比进程更简单Thread对象加start和join和multiprocessing的API高度相似。import threading import time def fetch(url): time.sleep(1) print(ffetched {url}) threads [] for i in range(10): t threading.Thread(targetfetch, args(fhttps://example.com/{i},)) t.start() threads.append(t) for t in threads: t.join()这个并发下载的例子里十个线程同时发出请求总的运行时间大约就是最慢的那个请求的时间而不是十个请求的时间之和。原因在于线程阻塞在网络等待时GIL被释放其他线程可以继续执行。这是Python多线程存在的最大理由。关于线程的生命周期有个网上经常讨论的问题线程切换时会泄漏吗正确地说线程切换本身不会泄漏内存但如果程序里不断创建新线程又没有妥善管理线程的生命周期就会出现线程只增不减的情况。线程对象创建后如果无法正常退出底层占用的内存和句柄会一直留在那里长期运行的服务就会有资源泄漏。排查的时候可以用threading.enumerate()看看当前存活线程有多少对比预期数量往往能发现线程泄漏的源头。守护线程daemonTrue的概念在threading里也存在主线程退出时守护线程会被强制终止。如果你的线程是后台监控、心跳上报这类无需等待的任务可以设成daemon但如果线程里有重要的数据落地操作千万别设daemon否则主线程一退出数据就静默丢失了。3.2 同步机制锁、互斥、条件变量线程之间共享内存这让数据交换变得很方便但多线程同时读写共享数据的时候就会出问题。看这个经典的案例import threading counter 0 lock threading.Lock() def worker(): global counter for _ in range(100000): with lock: counter 1 threads [threading.Thread(targetworker) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter)不加锁的时候十个线程各自累加10万次理论上结果应该是100万但程序跑下来结果总是小于100万而且每次运行都不一样。原因就是之前提到的counter 1不是原子操作读、加、写三步之间会被切换打断。加上Lock之后同一时刻只有一个线程能执行这段累加代码结果就稳定在100万。Lock的问题在于不可重入。如果同一个线程在已经持有锁的情况下再次acquire同一个锁程序会直接死锁。这种场景通常出现在一个函数调用了另一个也需要加锁的函数时。解决办法是用RLock可重入锁它记录持有次数同一个线程可以多次acquirerelease时计数减到0才真正释放。死锁是线程编程最经典也最头疼的问题。两个线程各自拿着一把锁同时又在等对方的锁于是互相等永远等待下去。比如银行转账场景账户A给账户B转账要锁A再锁B账户B给账户A转账要锁B再锁A两个操作同时发生时就可能出现一个线程锁着了A等B另一个锁着B等A的循环等待。解决循环等待的办法一般有两个一是给所有锁规定一个全局的加锁顺序比如按账户ID排序所有线程都先锁ID小的再锁ID大的二是用lock.acquire(timeout5)代替无超时的acquire获取失败就回滚重试。后者工程上更容易实现等于给死锁加了一个熔断器。Condition条件变量解决的是另一类问题等某个条件成立再继续。典型场景是生产者消费者模型消费者队列空了要等着生产者生产完要通知消费者。Condition内部的wait会释放锁让其他线程进临界区修改变量被notify唤醒后再重新获取锁继续执行这个机制比用while循环空转轮询高效得多。Event和Semaphore也很常用Event是一个简单的开关Semaphore则像限流令牌限制同时访问某个资源的线程数量。3.3 线程池配置与实操工程上不建议直接反复创建Thread线程的创建和销毁也很贵。用ThreadPoolExecutor可以复用线程顺便规避了线程管理的大部分麻烦。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch(url): # 实际这里发HTTP请求 return url with ThreadPoolExecutor(max_workers32) as executor: futures [executor.submit(fetch, furl-{i}) for i in range(200)] for future in as_completed(futures): print(future.result())executor.submit提交任务返回future对象as_completed按完成顺序迭代结果。用with语句可以在执行完成后自动关闭线程池等待所有任务结束。线程池的max_workers怎么定这是被问得最多的问题。一个常用的经验公式核心线程数 CPU核数 × (1 平均等待时间 / 平均计算时间)。对于纯IO等待的场景等待时间远大于计算时间线程数就可以设得比较高。实际操作中网络请求类任务我一般从20到50起步做压测看吞吐量拐点在哪性能不再明显提升甚至下降的那个值就是合适的线程数。盲目追求高并发把max_workers设成几千线程上下文切换的开销会吞掉并发收益CPU飙升但任务反而慢。还有一个容易踩的坑ThreadPoolExecutor执行的任务函数里又嵌套了异步操作或者任务函数内部再加锁形成复杂的依赖关系非常容易出现线程池内所有线程都阻塞在等待某个任务上而那个任务恰好排在线程池队列里没人执行最后整个程序卡死。这就跟进程池的嵌套问题一样复杂逻辑尽量放在任务提交层面编排保持任务函数简单直接。4. 协程篇asyncio与异步编程4.1 协程的核心机制事件循环与async/await协程跟进程、线程是完全不同的运行模式。协程不需要操作系统参与调度它在单线程内部实现多个任务轮流执行每个任务在await处主动让出控制权。这个机制的关键在于协程的切换时机完全由程序员写的代码决定所以叫做协作式调度。import asyncio async def fetch_data(url): print(fstart fetching {url}) await asyncio.sleep(1) print(fdone fetching {url}) return url async def main(): tasks [asyncio.create_task(fetch_data(fhttps://example.com/{i})) for i in range(5)] results await asyncio.gather(*tasks) print(results) asyncio.run(main())asyncio.run创建事件循环并运行main协程create_task把协程包装成Task并调度到事件循环里gather并发等待多个任务全部完成。await asyncio.sleep(1)模拟IO等待在这1秒里事件循环会转向执行其他Task所以五个任务是并发推进的总耗时约1秒而不是5秒。协程为什么适合高并发IO场景因为它省掉了线程的建造成本和上下文切换成本单线程内可以轻松跑上万个协程。同时协程之间不需要加锁因为同一时刻只有一个协程在运行没有真正的并行也就没有竞争条件。这跟线程完全不一样协程的并发安全是天然保证的。但要注意这不是绝对的安全——如果协程内有跨await的共享状态变更仍然可能因为await切换导致意外只是这类问题远少于线程。协程不是魔法async函数里如果有等待必须用await标注。如果一个async函数里没有await语句它执行时不会让出控制权会一直霸占事件循环其他协程就没机会运行了。很多新手写协程发现不并发其中一个常见原因就是这个协程函数里全是同步代码没有任何await让位点看起来在并发实际还是一个跑完才跑下一个。要真正并发就必须在关键操作处合理用await把控制权交还给事件循环。4.2 协程实践中的常见误区协程和线程的配合也是个重要话题。事件循环是单线程的如果某个协程里调用了同步阻塞操作比如requests.get或者time.sleep整个事件循环就会被这个阻塞操作卡住其他协程全部停摆。正确的做法是使用asyncio.sleep代替time.sleep用aiohttp代替requests做HTTP请求或者把必须同步执行的阻塞操作丢给线程池。Python 3.9提供了asyncio.to_thread可以把同步函数放到线程池里执行返回一个可await的协程对象。import asyncio import time def sync_heavy_task(): time.sleep(3) # 模拟同步阻塞 return result async def main(): result await asyncio.to_thread(sync_heavy_task) print(result) asyncio.run(main())这样既保持了协程架构的整洁又不会被阻塞操作卡死整个事件循环。但to_thread的底层还是线程不要无节制地往里面塞海量任务线程池满了也会排队等待。协程还有几个新手极容易踩的坑。第一个是忘记await直接调用async函数会得到RuntimeWarning: coroutine never awaited的警告函数实际上根本没执行。第二个是asyncio.run不能在一个已经运行的事件循环里再被调用只能在主线程里作为入口使用。第三个是协程里的异常处理如果用gather时必须小心一个任务抛异常会改变gather本身的返回行为重要任务建议给每个Task单独加try/except或者用return_exceptionsTrue收集异常而不是直接中断。协程内部最好也不要再同总动创建Thread除非确有隔离需求。混合使用两种并发模型会让问题排查变得复杂你没法判断是线程池卡住还是事件循环卡住日志里还可能出现两个线程各自跑事件循环的混乱情况。如果确实需要多事件循环一个线程跑一个事件循环是可行的但要非常清楚自己在做什么。5. 选型决策与实战排查经验5.1 进程、线程、协程到底怎么选先说结论再解释依据。计算密集型任务用多进程IO密集型中等并发用多线程IO密集型高并发用协程需要故障隔离时优先考虑多进程。这是我从实际项目中沉淀下来的选型逻辑。判断任务类型是第一步。CPU密集任务的特点是一个任务占了绝大部分执行时间在计算比如图像处理、数值模拟、大型矩阵运算。这类任务在Python里选多进程几乎是唯一出路因为进程有独立的解释器和GIL可以真正并行利用多核。我之前做批量视频转码用多线程跑半天CPU占用才100%改成多进程后四个进程并行总耗时直接砍到原先的四分之一左右。IO密集任务的特点是大部分时间在等待网络、磁盘、数据库响应CPU实际使用时间很短。这类任务选择范围就比较宽并发量不高几十到几百的时候线程简单省事写起来也跟同步代码差不多并发量高上千甚至上万的时候线程和线程池都撑不住协程才是正解。做高性能爬虫、实时消息推送这类服务asyncio的优势非常明显。故障隔离也是重要的考虑维度。进程内存独立某个进程崩溃不会影响其他进程线程和协程都共享内存一个线程里段错误可能整个程序挂掉。需要处理不可信输入、第三方库质量不稳的场景用多进程包一层更安全。决策因素推荐方案理由CPU密集计算多进程绕过GIL真正并行多核IO密集中等并发多线程或线程池实现简单开发效率高IO密集超高并发协程切换成本极低可创建海量任务需要故障隔离多进程独立内存崩溃不影响整体需要共享复杂状态协程/线程共享内存数据协同方便5.2 死锁、卡死、资源过高的排查思路并发程序出问题最典型的表现就是程序卡住不动、资源飙高、任务进度不推进。我把实战中积累的排查经验整理成几条可以按顺序执行的路径。第一类问题程序卡死。先用faulthandler模块让程序在无响应时自动打印当前所有线程的堆栈。import faulthandler faulthandler.dump_traceback_later(10, exitTrue)这行代码放在入口处10秒后如果程序还卡着会自动把所有线程的调用栈打出来一看就知道线程停在哪把锁上。这个方法救了我好几次比自己猜卡在哪里高效得多。配合在加锁前打日志、给所有锁设置超时排查死锁基本够用。第二类问题进程池不返回。Pool.map一直不结束常见原因有三个某个worker异常崩溃后进程池状态错乱、Queue任务写入了但没处理好导致worker不退出、任务函数里死循环。排查时先用ps看看存活的子进程数和状态再用strace或者日志确认每个worker到底在等什么。一个实用技巧是所有任务函数入口和出口都打日志一旦某个worker没有出口日志目标立刻锁定。第三类问题线程数量只增不减。这是线程泄漏先threading.enumerate()看当前存活的线程列表再看看这些Thread对象是不是都持有可无限运行的target。如果某个线程的target是个while True循环且没有退出条件它永远占着一个线程名额反复创建就会持续泄漏。代码库里要严格限制无限循环线程的数量每个这样的线程都需要有明确的退出机制。第四类问题CPU占用打满但任务没进展。这多半是GIL导致的多线程争抢。四个线程跑计算任务CPU占用100%就是没绕过GIL的信号。改用multiprocessing后CPU占用能到400%任务进度也会明显推进。5.3 我的最终建议项目里用哪种并发模型不是看哪种技术最先进而是看代码最容易维护、出问题最容易排查、性能刚好满足需求。进程、线程、协程这三套方案我都在生产环境用过最终形成的习惯是默认先用最简单的方式触到瓶颈了再换。比如先写同步代码性能不够就改线程线程还不行再考虑协程或多进程每一步都有明确依据不盲目跟风。调试并发程序时日志永远是最忠实的帮手。每个关键节点打日志入参出参、锁的获取和释放、任务的开始和结束信息越详细排查问题越省力。我见过很多同行为了性能移除日志结果出问题后花了几倍的时间去猜代码在干什么得不偿失。还有一个小技巧并发程序的超时设置不要只在网络请求上用锁的acquire、队列的get、join等待都应该带上timeout参数。这些超时不是用来处理正常情况的而是作为最后一道保险保证程序在异常情况下哪怕退得难看也不会永远卡在那里。如果让我给一条最朴素的经验那就是把并发逻辑的控制面放小单段并发代码只负责一件简单的事复杂任务拆成多个小并发单元用管道组合起来。这样设计每个环节都容易测试验证出现问题时也能快速定位是哪个并发单元出了问题。我用这个思路重构过几个线上服务稳定性提升明显排查问题的时间也缩短了很多。