ARTICLE DETAIL

资讯详情

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

Python asyncio实战:事件循环、协程与高并发爬虫全解析

Python asyncio实战:事件循环、协程与高并发爬虫全解析 其实在很多项目里真正需要一个“高性能并发方案”的时候你翻开Python的官方文档十有八九会被引导到asyncio这个库。我最早接触它是因为要写一个批量爬虫几百上千个URL要并发抓取用线程池写起来倒也不难但系统线程一多内存和上下文切换的开销就变得有点肉疼。后来换成asyncio同样一批任务单线程跑吞吐量反而上去了。这个反差让我对这玩意儿产生了兴趣——不过说实话刚上手那会儿也踩了不少坑什么事件循环没关、协程没挂起、同步代码卡死异步流程……都遇到过。这篇就当作是一个实操过的老伙计跟你聊聊asyncio怎么才能真正用起来而不只是看文档。先说清楚它适合谁如果你写的是I/O密集型程序网络请求、数据库读写、文件下载、Web服务想明显提升并发吞吐那你非常适合学。如果是CPU密集型计算你选多进程更靠谱这不是asyncio的主场。我会从设计思路、核心概念、实操代码到问题排查一条龙拆开讲尽量把原理和代码结合起来让你看完能直接上手改自己的项目。1. 整体设计与思路拆解为什么是事件循环而不是线程1.1 从同步阻塞到事件循环Python走了哪一步传统同步代码是“按顺序执行”请求A没返回请求B只能干等着。比如你用requests连续抓三个网页每个网络往返假设要200毫秒三个串行下来就是600毫秒但如果让它们“同时发起”等各自返回再处理理论上总耗时就压缩到200毫秒左右。前面说的线程能实现这一点可线程有自己的代价——一个操作系统线程要占几MB的栈空间成千上万个线程同时跑系统第一个受不了的是内存然后就是频繁切换上下文导致的CPU损耗。事件循环的思路不一样它是在一个线程里管理成千上万个“待办事项”谁的数据还没回来就先去处理那些已经就绪的任务数据一到就回过头接着往下走。这就像餐厅里一个很熟练的点菜员同时记住十来桌客人的需求哪桌菜好了就先端过去而不是每桌专门配一个服务员一直站在那里等着。asyncio的核心就是这样一个点菜员它不需要把你的程序拆成N个线程而是在单个线程内部做“协作式调度”。这里的关键词是“协作式”。事件循环要接管你的代码执行权就必须让你的代码在合适的时机主动把控制权交回去——这个时机就是await。所以你写的每个异步函数都主动告诉事件循环“我要去等网络响应了这段时间你可以去处理别的事。”这时候你就能理解为什么asyncio常被吐槽“必须整个项目都异步化”——它不是魔法是一种编码范式参与方都要守规矩。1.2 技术选型对比asyncio、多线程、多进程各管哪一摊选型这步特别重要很多初学者一上来就写asyncio结果发现耗时反而更久于是得出结论“异步是骗人的”。实际上是没用对地方。我把三种方案的适用场景整理成了一张表照着选基本不会出大错场景推荐方案原因高并发网络I/O爬虫、API调用、WebSocketasyncio单线程处理海量连接资源开销极低阻塞型I/O库无法替代像老版requests、部分数据库驱动多线程/线程池阻塞逻辑让线程自己等不拖垮主流程CPU密集型计算图像处理、数值运算多进程绕开GIL真正利用多核CPU混合型少量计算大量I/Oasyncio配合进程池主流程异步CPU密集部分丢给进程池处理我自己的经验是项目里最理想的结构是“asyncio作骨架、集成线程池/进程池作补充”。Python生态不可能所有库都支持异步比如很多官方库和第三方库仍然是同步阻塞的硬着头皮想用asyncio把它们全包了只能徒增烦恼。正确姿势是把网络、数据库这类容易成为瓶颈的I/O操作做成异步把那些战术上必须同步的“钉子户”封装成任务丢给执行器让事件循环不被卡死。你还需要想清楚一件事asyncio能解决的是“等待时间”不是“计算时间”。一个异步任务写得再好该算10亿次浮点数还是得算10亿次而且单线程情况下只会更慢。所以做选型前先拿代码分析一下你的程序到底是“等得多”还是“算得多”前者丢给asyncio后者直接开多进程别搞反。2. 核心概念拆解协程、事件循环、Task与Future2.1 协程能被挂起和恢复的函数async def定义一个协程函数。你调用它不会得到结果而是得到一个协程对象。官网的说法是“调用协程函数不会立即执行”这句话新手很难消化我换个方式描述协程对象就像一个写好了执行步骤清单的演员但它需要导演事件循环喊“开始”才会上台演出。看这个最简单的例子async def hello(): print(hello) return world # 这行只是创建了协程对象函数体内的print不会执行 coro hello() # 想拿到结果必须让事件循环跑起来 # print(coro.send(None)) # 手动驱动实际基本不用手动驱动协程只是加深理解的手段。真实项目中没人直接用send而是通过await、asyncio.run、asyncio.create_task这些高层接口来驱动。你只要记住一个原则协程必须被await或者被调度执行否则它会变成“孤儿对象”有的版本还会直接警告“coroutine was never awaited”。这个警告不是说着玩的它意味着你写的逻辑根本没跑排查起来还挺头疼。在协程内部await something()的意思是“在这里挂起等待something完成后继续”。被等待的对象必须是可等待对象awaitable大致分三类协程对象、Task、Future。后面两个我会在2.3小节细讲。2.2 事件循环所有异步的心脏事件循环Event Loop是一个不停转动的调度器它维护着两类东西就绪队列可以立刻执行的任务和等待队列正在等待I/O或某种条件的任务。循环每转一圈就把就绪队列里的任务往前推一推把那些已经等到结果的任务恢复继续执行。asyncio.run()在Python 3.7之后是官方推荐的入口它会帮你做三件事创建新的事件循环、把传入的协程跑完、关闭事件循环。我建议所有人入门阶段无脑用asyncio.run。什么get_event_loop()、loop asyncio.new_event_loop()这些先别管它们在不同版本行为有差异新手很容易踩坑。import asyncio async def main(): print(start) await asyncio.sleep(1) print(end) asyncio.run(main())这里asyncio.sleep(1)就是标准的可等待操作它主动挂起1秒事件循环利用这段时间去处理别的任务。实际项目里这个“等待1秒”往往是一次网络请求、一次数据库查询或者文件读写——它们共同的特征是CPU其实没在干活只是在等外部设备响应。有人说事件循环是“单线程里的多任务系统”我非常认同。它本质上是把“并发”从多线程的强制调度变成了协作式的手动让位。后者少了系统级别的那种抢占式切换开销自然更轻快。2.3 Task与Future协程的“包装器”Task是事件循环对协程的一层包装。你把一个协程交给asyncio.create_task()它就会立刻被安排到事件循环里准备调度你不必手动await也能让它跑起来当然通常还是会await不然程序退出它就没跑完。task对象自动被事件循环“盯上”这样协程就有了自己的调度状态、结果和异常处理。Future更底层一点它是一个“未来会获得结果”的占位符。Future在asyncio内部到处使用比如loop.run_in_executor()返回的就是Future对象当你在代码里操作Task时它其实也继承自Future。理解Future的真正价值它代表了异步编程的基本模型——“先给你个票货到了再领”。看个实际例子创建多个后台任务import asyncio async def fetch(url): print(ffetching {url}) await asyncio.sleep(1) return fdata from {url} async def main(): urls [url1, url2, url3, url4] tasks [asyncio.create_task(fetch(url)) for url in urls] # 重要这里用gather把所有task聚合成一个可等待对象 results await asyncio.gather(*tasks) print(results) asyncio.run(main())这个例子就是最典型的并发抓取模型创建了4个任务但不需要按顺序等它们四个任务会交错执行总耗时约1秒而不是4秒。gather的作用是等待所有任务结束并把它们的返回值按传入顺序收集成列表——顺序是稳定的这一点在实际处理结果时非常方便。3. 实操过程与核心环节实现从零搭一个异步并发框架3.1 环境准备与版本选型建议想在本地跑下面的代码你需要Python 3.7以上推荐3.10或更高我用3.11做示例。Python 3.10引入了更清晰的类型提示和优化后的asyncio实现用起来顺手很多。不需要额外安装第三方库标准库的asyncio就够用只有做真正的HTTP请求时我会建议你安装aiohttp这类异步HTTP客户端。python --version pip install aiohttp # 如果做网络请求安装Python本身就不展开了相关热搜词里也提到了“python安装”只提醒一点Windows用户注意勾选把Python加入PATHmacOS用户建议用Homebrew统一管理Linux用系统包管理器就好。版本这点别将就老版本Python3.6及以下的asyncio API差异很大很多教程代码你根本跑不起来版本是个硬门槛。3.2 第一个异步程序从sleep到真实网络请求我们从最经典的asyncio.sleep开始它不是玩具是理解时间调度的最佳标本。下面的代码展示了所谓“并发”是如何运行的import asyncio import time async def worker(name, duration): print(f{name} 开始时刻{time.strftime(%H:%M:%S)}) await asyncio.sleep(duration) print(f{name} 结束时刻{time.strftime(%H:%M:%S)}) return name async def main(): # 三个worker并发执行而不是依次执行 results await asyncio.gather( worker(A, 3), worker(B, 2), worker(C, 1), ) print(所有任务结果:, results) asyncio.run(main())运行后你会发现“B结束”出现在“A结束”之前——三个worker是交错执行的总共花了约3秒最长那个任务的耗时而不是3216秒。如果你把asyncio.sleep换成普通time.sleep结果就完全不一样了因为time.sleep是标准库里的同步阻塞函数它会卡住整个线程事件循环根本没有机会调度其他任务。这就是asyncio最核心的“坑之一”混用同步阻塞函数会摧毁异步效果。一旦事件循环被time.sleep或者同步的requests.get()卡住整个“单线程并发”的魔法当场失效。标准解法是如果某个库只有同步版本比如老牌requests就用asyncio.to_thread()或loop.run_in_executor()把它丢到线程池里跑这样你的主循环不会被堵住。3.3 真实网络爬虫用aiohttp实现高并发抓取我拿一个模拟爬虫场景来做完整演示。假设要抓100个页面每页花费约0.1秒。同步串行跑需要10秒而并发跑理论耗时约0.1秒×并发数分片取决于你同时发多少请求。用aiohttp实现起来非常直观import asyncio import aiohttp async def fetch_page(session, url): async with session.get(url) as resp: # 模拟读响应体实际项目里按需处理 data await resp.text() return len(data) async def fetch_many(urls, concurrency20): # 连接池复用避免每次请求都重新建立TCP连接 async with aiohttp.ClientSession() as session: sem asyncio.Semaphore(concurrency) async def bounded_fetch(url): async with sem: return await fetch_page(session, url) tasks [asyncio.create_task(bounded_fetch(url)) for url in urls] return await asyncio.gather(*tasks) async def main(): urls [fhttps://example.com/page/{i} for i in range(100)] lengths await fetch_many(urls, concurrency20) print(f抓取完成共 {len(lengths)} 个页面) asyncio.run(main())注意这里我用了Semaphore信号量来限制并发数。原因很简单如果你一口气创建上百个task全部发出去目标服务器可能直接把你IP封了或者你自己的系统文件句柄不够用。信号量就像一个闸门保证同一时间最多有20个请求在飞剩下的排队等待。实际工程里“限制并发”几乎总是必需的这是从demo代码到生产代码的第一道坎。ClientSession还有一个好处它会自动维护HTTP连接池复用TCP连接大幅减少握手开销。但注意session应全局复用而不是每次请求都新建——这个我见过太多人写错了结果并发量上去了但性能还是上不去因为连接彻底没法复用。3.4 超时控制与异常处理让并发程序更健壮并发程序最容易出问题的就是“某个请求卡住不动了整个流程跟着卡死”。asyncio里超时控制是必备技能。最常见做法是asyncio.wait_forimport asyncio async def slow_operation(): await asyncio.sleep(10) return done async def main(): try: result await asyncio.wait_for(slow_operation(), timeout2) print(result) except asyncio.TimeoutError: print(任务超时了)wait_for会在超时后取消被包裹的协程并抛出asyncio.TimeoutError。但这里有个容易忽略的细节如果一个task真的卡住了你光超时取消还不够还得确认取消动作真正完成。现实中很多网络库在被取消时可能还要清理连接、释放资源所以更稳妥的写法是配合Task来管理async def main(): task asyncio.create_task(slow_operation()) try: result await asyncio.wait_for(task, timeout2) print(result) except asyncio.TimeoutError: print(已超时) if not task.cancelled(): task.cancel() # 等待任务真正结束处理它的CancelledError try: await task except asyncio.CancelledError: pass这里的逻辑很严谨先尝试等结果超时了就主动取消task再用await task确保它把取消流程走完。你在处理任务异常时也可以依赖Future自带的异常传播机制——task内部抛出的异常会在await task或gather的返回结果中被重新抛出不用自己去抓。to_thread还能帮你处理那种“同步阻塞库”的并发调用import asyncio import requests def fetch_sync(url): # 这是一个阻塞函数 resp requests.get(url, timeout3) return resp.text async def main(): urls [https://example.com/] * 10 # asyncio.to_thread会在线程池里执行这个阻塞函数 results await asyncio.gather( *[asyncio.to_thread(fetch_sync, url) for url in urls] ) print(len(results))asyncio.to_thread在Python 3.9加入更早版本可以用loop.run_in_executor(None, func, arg)。它的作用就是“异步的壳同步的心”把阻塞代码丢给线程池让主事件循环不被拖死。用这个技巧的时候你其实是在容忍牺牲一点线程切换的开销换来了“代码不用重写”的巨大便利——在长线维护老项目时这个妥协非常宝贵。4. 常见问题与排查技巧实录那些坑我基本都踩过4.1 “coroutine was never awaited”与事件循环崩溃这个警告在asyncio里出场率极高。触发原因很简单你创建了一个协程对象但谁都没有await它也没有把它create_task。Python检测到它被丢弃了就会在程序退出时吐出这句警告。遇到这种问题排查路径是往代码里搜协程函数调用处看看返回值是不是被遗忘了。最典型的是这个错误写法async def task(): await asyncio.sleep(1) async def main(): # 少了await协程对象被创建但没被执行 task() asyncio.run(main())正解是加await task()或者asyncio.create_task(task())。另一个常见崩溃是重复跑事件循环在一个已经运行的事件循环里再调用asyncio.run()会直接报错RuntimeError: This event loop is already running。特别是用Jupyter Notebook写实验代码时特别容易碰到——因为有些交互式环境本身就跑着一个事件循环。这种情况下应该改用await直接等待协程而不是重新run。4.2 并发数上不去阻塞泄漏和死锁经常有朋友问我为什么我加了async、await并发效果却和同步没区别十有八九是代码里混进了同步阻塞调用比如time.sleep、requests.get、read()这类操作。一旦它们出现在协程路径上就相当于高速公路正中间停了一辆车后面的车全得堵着。检查技巧很简单在协程里搜索那些常见的阻塞函数凡是标准库自带的同步I/O在不支持异步的第三方库里尤其要小心。再进一步可以用asyncio.get_running_loop().run_in_executor()或者asyncio.to_thread包一层。如果不确定某个库是否支持异步直接看它的文档看它的网络请求是否基于asyncio/aiohttp。死锁问题主要是因为await了一个永远不会完成的东西。常见场景你在协程里直接调用了一个用asyncio.run()包装的完整逻辑而这个被包装的逻辑又依赖当前事件循环的状态两相等待直接卡死。要记住一个原则在异步代码里不要再随便创建新事件循环而是基于当前运行的loop和task来组织调度。4.3 Task泄漏、忘记取消与“半死不活”的进程用create_task创建了后台任务却不加管理程序结束时任务还没跑完容易导致“看起来程序已经退出但进程迟迟不结束”。原因很直接事件循环里还有待处理的task进程会等到它们结束才退出。你可能觉得这不是什么大事但在长时间运行的服务里这些“孤儿任务”会不断累积最终拖垮内存。解决办法是合理地用gather或者是TaskGroupPython 3.11。TaskGroup比gather有个巨大优势只要组里任何一个任务失败其他任务会被自动取消并统一抛出异常。这种“原子性”在服务端编程里太重要了能防止一个异常任务跑成野马把整个流程搅得天翻地覆。import asyncio async def may_fail(val): await asyncio.sleep(1) if val 3: raise ValueError(bad val) return val async def main(): try: async with asyncio.TaskGroup() as tg: for i in range(5): tg.create_task(may_fail(i)) except ExceptionGroup as eg: print(捕获到异常组:, eg) asyncio.run(main())这个代码里i 3的任务会抛异常TaskGroup收到后立即取消组内其他任务并把异常封装成ExceptionGroup抛出。这个行为让资源清理变得可控得多我实测下来非常稳。4.4 调试技巧合集日志、事件循环调试模式与追踪工具asyncio程序调试起来比普通同步代码要抽象但有几个实际的技巧能救命。第一开启事件循环调试模式在asyncio.run(main(), debugTrue)这样当一个协程被卡住超过一定阈值时会输出Task was destroyed but it is pending之类的警示。调试模式还会检测哪些回调耗时过长帮你定位潜在的阻塞点。第二用好日志打印关键点的“时刻”。异步代码的执行顺序和直觉不一致打印“开始”“结束”不要用裸的print最好带上asyncio.get_running_loop().time()或者time.monotonic()计算相对耗时方便你观察调度顺序和时延。第三用标准库的asyncio.all_tasks()检查当前未完成任务。在测试环境里在程序退出前打印这些task的名字和状态能看到谁没有被回收。配合traceback模块把每个task的栈信息打出来很多诡异的问题就能定位了。import asyncio import traceback async def main(): for task in asyncio.all_tasks(): if not task.done(): print(fTask pending: {task.get_name()}) # 打印栈帧能看到它到底卡在哪个await traceback.print_stack(task.get_stack())我用这个方法救过不只一次——尤其是在排查那些“程序偶尔卡住但日志没有任何输出”的诡异问题时。打印一下pending task的栈立刻就知道它停在了哪一行await上。结语从会用到用好还差一次真实项目的历练我实际使用asyncio快三年的最大体会是入门其实不难难的是把它放进真实项目后各种边界情况会逼着你加深对“协作调度”这个模型的理解。纸上写一万字demo不如自己在本地写一个“并发抓取1000个页面”的小项目然后故意把超时设成1秒、故意不处理异常、故意混入同步请求观察程序如何崩、如何卡再一个个修。这个过程比任何教程都管用。最后再分享一个小技巧当你新增一个异步库到项目时先写一个最小测试脚本单独验证这个库的await是否真的让事件循环“有空闲”。很多第三方库标榜支持asyncio实际实现里却藏着阻塞调用光看文档根本发现不了。用loop.slow_callback_duration设为很小值再跑一遍如果警告密集冒出来基本可以断定这个库有问题。这种防患于未然的方式能帮你在项目早期就筛掉不少隐患。希望能帮到在这个话题里摸爬滚打的你。asyncio绝对是个好工具但它更像是一套思维方式——你的代码从“一步步执行完”变成了“安排好所有事情再等着它们陆续完成”。一旦想通这一点很多异步代码的写法几乎就是自然而然的了。
返回列表