ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python并发编程实战:进程、线程、协程的选型与踩坑经验

Python并发编程实战:进程、线程、协程的选型与踩坑经验 在 Python 的并发编程里很多人最开始都是被“进程、线程、协程”这几个词劝退的。之前我带过一个爬虫项目最初是单线程循环跑一批任务要三四个小时后来盲目加 threading结果 CPU 没真的跑满反而频繁出现数据错乱最后换成 asyncio 进程池的组合才把整个任务压进二十分钟以内。这篇文章不打算给你堆概念而是从进程、线程、协程的实际作用出发把 Python 并发这条线彻底捋清楚包括每个方案背后的适用场景、坑点以及我在生产环境里沉淀下来的经验。如果你是刚开始用 Python 处理并发的新手或者写过一堆线程代码却总出问题这篇文章应该会比抽象的理论文档更对胃口。1. 从并发需求出发先搞清楚这三种并发单元分别解决什么问题1.1 为什么需要并发计算机和人一样同一段时间内能处理的事情是有限的。在单核时代CPU 的速度远远快于硬盘和网络程序大部分时间都在等待外设返回数据这种等待对算力是巨大的浪费。并发编程的核心思路不是让 CPU 同一时刻多干活而是当一个任务在等待 IO 的时候让另一个任务插进来使用 CPU把空闲时间补上。后来多核普及并发又多了另一层含义真正同时利用多个核心把一个大任务拆成多个子任务并行计算。我见过不少新手把并发和并行混为一谈总问多线程是不是每个线程占一个核其实在 Python 里这个问题要分两层看。如果任务是计算密集型的比如大量数值运算那么线程因为受限于 GIL很难真正并行但如果是 IO 密集型的比如读写文件、请求接口、等待数据库返回那么线程切换的代价远小于等待的时间并发能带来巨大收益。所以脱离任务类型谈并发方案基本等于耍流氓。1.2 进程、线程、协程的概念类比用开餐厅来类比会好理解得多。进程就像一家独立的餐厅自己拥有厨房、餐厅和营业执照跟其他餐厅物理隔离互不干扰。线程是餐厅里的多个服务员共享同一套后厨和餐桌翻台效率更高但也容易抢东西出现脏读、死锁之类的问题。协程更像是服务员在自己的工位上灵活地切换面前的任务不需要离开工位调度成本极低但不能像线程那样被操作系统随意切走。这个比喻虽然粗糙但能说明问题进程之间最安全线程之间方便协程切换最轻。三者的资源开销也是递减的这点很关键。创建一个进程需要申请独立的地址空间启动成本高启动一个线程要占用几十 KB 到几 MB 的栈空间而协程本质上是用户态上下文的切换内存占用和切换开销是所有方案里最小的。具体到 Python 里多进程通常靠 multiprocessing 实现多线程靠 threading协程靠 asyncio。选择哪个取决于你手里的任务到底属于哪种类型。2. 进程multiprocessing 的落地细节与踩坑记录2.1 进程池能解决什么问题在大规模任务拆解时直接用 for 循环去创建成百上千个进程是极不划算的。进程池multiprocessing.Pool就是为了复用进程、降低重复创建销毁的开销。我有一次写数据处理脚本要处理十万个小文件最开始用 Process 逐个启动进程跑到一半就各种卡死换成 Pool 之后指定一个合适的进程数把任务通过 apply_async 丢进去整体时间直接降了一个量级。这里的原则是“有状态的任务可以用独立进程无状态的任务尽量交给池”。选进程数也有讲究。如果是纯 CPU 密集型任务进程数可以设置为os.cpu_count()甚至稍小的值因为操作系统本身还要占用资源如果是 IO 密集型任务进程数可以适当超出核心数但也不能无限大。我一般从 4 开始压测逐步上调到 8、16找到曲线拐点再定。还有一种常见做法是把 CPU 密集部分和 IO 密集部分拆开IO 部分用协程或线程CPU 部分才交给进程池这样能压榨出更多性能。import os from multiprocessing import Pool def worker(x): return x * x if __name__ __main__: # CPU 密集型任务时进程数不宜超过核心数 pools max(1, os.cpu_count() - 1) with Pool(processespools) as pool: results pool.map(worker, range(10000))2.2 子进程退出状态与等待回收多进程代码里最容易漏掉的是“子进程的退出状态没人读”。比如用Process.join()可以等待子进程结束但很多人在主进程退出前没调 join导致子进程变成孤儿进程或者没有及时处理子进程的管道输出导致缓冲区写满整个程序卡死。我有一次用subprocess.Popen启动一个外部命令只调了communicate()没设置 timeout结果外部命令因为网络卡住Python 脚本也一起挂死。后来养成了习惯所有外部命令调用都必须加 timeout并在异常分支里杀掉残留进程。进程之间如果要共享数据不要直接改全局变量那是不会同步的。正确做法是使用Queue、Pipe或Manager。进程安全的队列本质上是一根管道加一把锁所以只适合传递可序列化的对象千万别指望它像普通队列一样随意放各种复杂对象。另外在 Windows 上跑多进程代码入口必须写在if __name__ __main__下面否则子进程会递归执行模块代码一次就能让新手摸不着头脑。这个坑我在跨平台项目里踩过添加入口判断后问题立刻消失。3. 线程GIL 之下的 threading 真实战斗力3.1 GIL 到底锁住了什么很多人对 Python 多线程失望就是因为 GIL。这把锁保证了同一时刻只有一个线程能执行 Python 字节码所以计算密集任务在多线程下不仅不加速还会因为锁竞争变慢。但 GIL 并不是绝对禁止线程并发执行在执行 IO 操作、sleep、大多数网络库的读写时锁会被释放所以 IO 密集型任务用多线程依然有效。换句话说GIL 锁的是“执行 Python 代码”这个动作不等于锁住所有系统调用。这里有个天然的历史原因Python 的内存管理引用计数需要保证同一对象的引用计数操作是原子的早期设计为了保证解释器的线程安全干脆加了一把全局锁。这个问题由来已久Python 3.13 开始有了自由线程的实验性构建但生产环境暂时还不会大规模切换。站在选型角度如果你要处理的任务主要是网络请求或文件读写多线程完全足够如果任务是纯 CPU 计算应该选择多进程或者把热点代码交给 C 扩展、numpy 等能释放 GIL 的库来处理。总之一句话不是 Python 的线程没用是你没选对场景。3.2 线程同步、死锁与线程池配置谈到线程绕不开锁。threading.Lock本质上是一个只能装一把钥匙的抽屉工作线程要先拿到钥匙才能访问共享资源。用不好就会出现死锁两个线程各自握着一把锁然后在等对方手里的锁谁都等不到。死锁虽然经典但现代代码里更常见的是“可重入与不可重入”的坑。同一线程两次acquire一个普通Lock会把自己锁死而使用RLock就不会因为每个线程自己可以持有重入计数多次获得同一把锁不会因为自我竞争而卡住。线程池方面我从 Python 3.2 开始就用ThreadPoolExecutor它内部有队列、工作线程和调度逻辑比手动管理线程要安全得多。配置max_workers时如果你处理的是几百个请求开 10 个线程可能优于开 50 个线程因为线程太多会抢占资源并带来上下文切换开销。阻塞队列的选型也会影响吞吐无界队列可能让内存暴涨有界队列配合调用方的超时策略会更稳。常规经验是把max_workers设置为“预期同时阻塞的 IO 任务数”比如 32 个并发请求那就开 32 或稍小。from concurrent.futures import ThreadPoolExecutor, as_completed def fetch(url): # 模拟网络请求 return fok-{url} urls [fhttp://example.com/r/{i} for i in range(100)] with ThreadPoolExecutor(max_workers10) as executor: futs [executor.submit(fetch, u) for u in urls] for fut in as_completed(futs): result fut.result(timeout5)4. 协程asyncio 的事件循环与异步编程4.1 事件循环与调度机制协程在 Python 里的主要载体是 asyncio它不靠操作系统切换线程而是在一个线程内通过事件循环调度多个协程。事件循环可以理解成一张待办清单主线程把一个个异步任务记录在案遇到 IO 时挂起当前协程去执行别的协程等 IO 完成后再通过回调把它唤醒。这个机制让成千上万个任务可以在单线程里共存最重要的是用户态切换没有操作系统级的线程上下文切换开销。从生成器开始Python 的协程经历了yield、asyncio.coroutine再到现在的async/await核心都是“挂起-恢复”两件事。asyncio.run()是最常用的入口它负责创建事件循环、运行主协程、结束后清理掉资源。代码里用await表示让出控制权这正是协程的核心动作。但是要注意在 async 函数里不能直接调用阻塞的time.sleep()必须用asyncio.sleep()否则会把整个事件循环卡住其他协程全部停摆。这跟多线程完全不同很多刚上手的人会在这一步翻车。4.2 async/await 常见误区和性能关键点常见误区一协程函数调用后不await它不会执行只是生成一个协程对象。必须把它丢进事件循环里调度到才会真正跑。常见误区二以为协程能并行加速计算任务实际上协程在单线程内是并发而非并行写一个死循环照样会阻塞所有任务。真正适合协程的是海量 IO 任务比如每分钟处理几万个 HTTP 请求的场景asyncio 的优势非常明显。因为它的内存占用很低一个协程对象只是一个比较轻量的状态机。要做成规模的并发任务建议用Semaphore限制最大并发数避免一瞬间打爆目标服务。还需要注意超时控制用asyncio.wait_for给每个任务加上时限否则任何一个远端卡住都会拖住整个协程组最终像雪崩一样堆积任务。另一个性能关键点是尽量复用连接而不是每次都新建连接比如aiohttp.ClientSession它内部有连接池复用后吞吐量会有明显提升。我经常看到有人把连接创建写在单个请求里导致队列反复握手握手性能反而下降。import asyncio async def request(sem, url): async with sem: await asyncio.sleep(0.1) # 模拟 IO return url async def main(): sem asyncio.Semaphore(20) tasks [request(sem, furl-{i}) for i in range(1000)] return await asyncio.gather(*tasks) if __name__ __main__: asyncio.run(main())5. IO 模型为什么并发场景的核心常常是 IO 而不是计算5.1 阻塞、非阻塞与 IO 多路复用要真正理解并发绕不开 IO 模型。阻塞 IO 是最直白的我读文件时你不返回我就一直等非阻塞 IO 是读之前先看数据准备好了没没有就先干别的过会儿再来问IO 多路复用则是用一个线程同时盯着很多个文件描述符一旦谁有数据就去处理谁。操作系统的select、poll、epoll做的就是这件事它们让单个线程能处理成百上千的连接这是 Nginx 和各类异步框架的基石。最好理解的类比是餐厅点餐阻塞是一个人盯着厨师做你的菜非阻塞是你每隔一小会儿去问一次好了没多路复用是门口只站一个叫号员谁的小票响了就叫谁来取餐。Linux 的epoll相比select最核心的改进是不用每次都把所有 fd 重新传给内核内核会维护一个事件列表fd 数量大了之后效率是数量级提升。select还有一个让人头疼的限制是默认最大 fd 数量通常是 1024高并发连接数下根本不够用。5.2 Python 中各 IO 模型的落点Python 的 asyncio 在 Linux 上默认使用 epoll 事件循环本质上是 IO 多路复用加协程调度。用这种方式处理几万个并发连接比创建几万个线程要现实得多。而ThreadPoolExecutor更适合包装那些内置是阻塞模式的库用多线程模拟一点伪异步。对于文件读写asyncio 在不同系统上的实现不太一样在 Windows 上底层是 IOCP在 Linux 上则通过线程池模拟所以“只要用异步文件读写一定更快”这种结论并不成立。对普通业务开发者来说最终选择依据可以简化如果底层库本身支持 async/await就优先用 asyncio如果库是同步阻塞的并发量又不大用ThreadPoolExecutor包一层往往更省事。不要为了追求“所有东西都异步”而去重写所有代码成本很高收益却不一定好。我见过一些团队把已经很成熟的同步代码硬改异步结果引入一堆回调问题和超时问题最后又改回来。正确的思路是保持核心链路清晰按热点和瓶颈点局部改造。6. 常见问题与排查技巧实录6.1 高频并发错误速查表这里整理一份我经常在社区里看到、也亲自踩过的并发错误表方便你排查时对号入座典型现象可能原因解决方向进程脚本启动后卡死无输出子进程读管道缓冲区满没人消费及时读取 stdout/stderr或设置 flush 策略线程池任务堆积内存上涨无界队列 生产速度太快改用有界队列加退避和超时多个线程同时改变量结果错乱缺少锁或用了线程不安全的容器加 Lock/RLock或改用 Queue 传递数据协程任务一个个卡住不前进async 函数里调用了阻塞 sleep 或同步库换成 asyncio.sleep 或异步网络库程序退出后有僵尸进程子进程未 join 或未调用 wait()主进程结束前统一回收子进程CPU 占用 100% 却没拉满并发频繁切换 锁竞争严重调整线程数改用进程池或协程6.2 真实排查并发问题的手段遇到并发问题我一般先画一个“等待-资源-共享数据”的三角图每一步任务在等什么资源够不够共享数据要不要加锁然后配合 py-spy 或 faulthandler 查看卡住的现场。比如线程池不干活了先 dump 出当前所有线程的状态看它们停在哪个调用上如果停在 socket 连接再去检查远端超时和连接数。py-spy 的好处是不用改代码可以直接对运行中的 Python 进程执行 dump对线上排查特别有用。另一个关键手段是加日志和监控。我不会在并发代码里频繁打印日志但会在任务入库、完成、失败回收三个节点各打印一条结构化日志这样出了问题可以按任务 ID 去对流程。没有时间戳和任务 ID 的并发日志排查起来基本靠猜。压测也很重要先用几倍于预期的流量压测观察进程数、线程数、协程任务的曲线找到资源拐点再回填到配置里。这个习惯能提前暴露绝大多数并发问题。6.3 并发代码的调试小教训最后补一条调试教训。我在看别人并发代码时见过最多的问题是“没有明确的生命周期”。线程启动后就没人管了协程任务创建后也没人 gather进程退出时也不回收。造任务的时候很开心收尾的时候集体失忆。代码写完后第一件事是检查所有 create、submit、create_task 出来的东西是否有对应的 join、shutdown、gather 或 await。一个好的并发程序从创建到回收的生命周期必须是完整闭环的只要有一个任务漏了回收后续就可能埋雷。我现在写很多并发代码时会刻意遵守一个原则能不给共享变量加锁就不加锁能不用全局状态就不用。多进程用队列传数据多线程用闭包和局部变量隔离数据协程则坚持“同一个事件循环里只通过 await 传递结果”。这个习惯帮我避开了大量偶现的诡异问题。你在自己的项目中也可以试试哪怕只做到一半查错的时候都能轻松不少。
返回列表