资讯动态

Python异步编程入门:asyncio协程与事件循环实战指南

发布时间:2026/10/15 11:03:51 来源:尧图企业网站定制
如果你用Python写过爬虫大概率经历过这种尴尬请求发出去之后程序眼巴巴等着服务器返回同一时刻CPU几乎闲置可整个脚本的耗时却全被网络延迟拖垮。几千个URL要抓每个请求两三百毫秒同步写下来就得跑十几分钟人守着命令行干着急。这种场景下Python异步编程里的asyncio库就是最对症的解法——它能在单线程内反复调度任务让程序在等待网络响应的时候转手去处理其他请求。换句话说异步不是让CPU“跑得更快”而是让CPU“不再闲着”。如果你是刚开始接触asyncio这篇内容会从“为什么需要异步”讲起把事件循环、协程、Task这些基础概念一个个掰开揉碎再带你把代码跑起来最后整理一些我在实际项目中踩过的坑。不需要你先懂并发理论只要写过Python、知道函数怎么调用就能跟上节奏。1. 为什么要学异步先算清“阻塞”这笔时间账1.1 一次网络请求的“时间账本”很多初学者学异步时第一个疑问是我的循环不是照样在跑吗换成异步怎么就能快那么多要回答这个问题最好把一次普通HTTP请求的时间账算清楚。假设你向某个接口发一个请求它的响应时间是100毫秒。同步代码里这一行requests.get(url)会发生什么你的线程要把请求发出去然后原地等待直到100毫秒后响应数据抵达代码才继续往下走。在这100毫秒内这个线程除了盯着网络套接字什么也干不了CPU也处于“无事可做”的状态。如果你有一万个请求要发每个请求都这么等待总耗时就是一万乘以100毫秒约等于16分40秒。但如果你把“等待响应”这个时间段单独拿出来看其实每个请求真正需要CPU参与的时间只有几毫秒——构造请求、解析响应、保存数据。剩下的95%以上的时间都花在等待上。同步脚本的问题不是“干活慢”而是“等待多”线程在等待时被牢牢占住一个线程一次只能等一个请求资源利用率低得惊人。我常跟朋友打一个比方同步代码像一个人在食堂打饭点完A窗口的菜之后就一直站在窗口前面等等到出餐了才离开。如果一次要打五六个菜这个人就只能在各个窗口前轮流干等。异步代码则是点完A窗口之后先跑去B窗口点单再跑去C窗口等大家都快好了再回头挨个把菜端走。窗口的出餐速度没变但同一个人在同一段时间里能同时张罗的事情多得多。1.2 并发不是为了“跑得快”而是为了“不闲着”这里要澄清一个很常见的误区异步并发并不会让单个任务的执行速度变快它提升的是整体吞吐量。所谓吞吐量就是单位时间内能处理多少个任务。网络爬虫、批量接口调用、大量文件读写、数据库查询——这些场景的共同特点是“任务和任务之间互不依赖”等待的间隙完全可以用来做别的事情。只要等待的时间远大于处理的时间异步就能带来肉眼可见的收益。反过来如果你的任务是纯计算型的比如跑一个大数乘法、做图像像素级的滤镜处理这种场景CPU在持续工作时本来就没有“闲着”asyncio不但不会提速反而会因为任务调度的开销让性能更差。所以判断一个场景适不适合上asyncio就看一个指标你的程序里CPU真正忙的时间占比高不高。不高就非常适合异步。2. 四个核心概念协程、事件循环、Task与Future2.1 async def与协程对象调用它不等于执行它初学者的第一个崩溃点往往在这里为什么我调用了一个async def定义的函数里面什么都没打印import asyncio async def hello(): print(Hello) await asyncio.sleep(1) print(World) coro hello() # 这里没有打印任何东西用async def定义的函数调用时不是执行函数体而是创建一个协程对象。这个对象像一个“尚未开始的计算”它等着被“调度”才会真正运行。要让协程里的代码执行有两种方式第一种在另一个协程内部用await去等待它第二种把它包装成Task交给事件循环去调度。async def main(): await hello() # 这时候才会打印 Hello / World asyncio.run(main())await这个关键字可以理解为“在这里等待同时把控制权交还给事件循环”。当协程执行到await的时候它会检查被等待的那个东西是否已经完成如果没有完成就把自己“挂起”让事件循环抽出时间去运行其他任务。等到被等待的东西准备好了再回到这里继续往下执行。这个“挂起-唤醒”的机制就是异步编程的核心动作。2.2 事件循环全局调度枢纽你可以把事件循环想象成一个调度中心。所有的协程都注册到这里事件循环手里维护着多个队列哪个协程在等待网络I/O、哪个协程在等待定时器、哪个协程已经准备好被继续执行。它不断地循环问自己现在有没有什么任务准备好了有就唤醒它让它跑一小段没有就让出CPU进入休眠等待下一次外部事件。在Python 3.7之后普通异步程序的入口非常简单asyncio.run(main())这一行代码干的事情其实不少它内部会创建一个新的事件循环把main()这个协程丢进去执行等main()彻底运行结束后关闭这个事件循环完成清理工作。事件循环既不需要你手动创建也不需要你手动关闭asyncio.run()把这一切都自动化了。2.3 Task与Future谁负责让协程真正跑起来单纯的协程对象还不足以被事件循环调度。要让协程真正“并发”起来你得把它包装成Task。Task在事件循环中代表一个正在并发运行的子任务。看个例子async def main(): task1 asyncio.create_task(hello()) task2 asyncio.create_task(hello()) await task1 await task2调用asyncio.create_task(hello())的那一刻协程就已经被登记到事件循环里即将开始运行。注意这里不需要再await hello()而是await task1——Task对象在后台持续推进你随时可以等它完成。那Future又是什么Future是一个更底层的概念你可以把它理解成“一个未来的结果”。它本身不包含任何业务逻辑只是一个占位对象表示某项操作的结果“早晚会来”。Task继承了Future相当于把“协程任务”和“结果占位符”两者合一一次任务调度的结果最终都会落到Task这个Future子类里面。这三个概念的关系可以归纳成一句话协程是任务的内容事件循环是任务的调度器Task是任务的调度单位Future是任务结果的容器。搞懂这四个词asyncio的地基就算打牢了。2.4 概念关系速查概念一句话理解常见创建方式协程对象由async def函数调用产生的可挂起流程hello()事件循环全局调度中心负责唤醒和让出任务asyncio.run()内部创建Task协程被调度后的任务单元可并发推进asyncio.create_task(coro)Future底层延迟结果占位符Task继承自它一般不直接创建3. 第一个异步程序与三种并发调度方式3.1 用sleep体验并发同样的两秒任务从4秒变2秒最直观的体验并发方式是写两个 каждый睡2秒的任务分别用同步和异步来跑。下面是完整的对比代码import asyncio import time async def worker(name, delay): print(f任务 {name} 启动) await asyncio.sleep(delay) print(f任务 {name} 完成) return f{name} 的返回值 async def main(): # 同步串行的写法 start time.perf_counter() await worker(A, 2) await worker(B, 2) print(f串行耗时: {time.perf_counter() - start:.2f}s) # 异步并发的写法 start time.perf_counter() results await asyncio.gather( worker(C, 2), worker(D, 2), ) print(f并发耗时: {time.perf_counter() - start:.2f}s) print(results) asyncio.run(main())输出里你会看到两段非常直观的差异串行版本打印“A启动”后等2秒再打印“B启动”总耗时4秒并发版本中“C启动”和“D启动”几乎同时出现2秒后两个任务一起完成总耗时2秒。asyncio.sleep和time.sleep的区别就在这time.sleep会把线程往死里阻塞CPU空转asyncio.sleep则是一个可等待对象遇到它时协程会让出控制权等时间到了再被唤醒。3.2 create_task、gather、wait三者怎么选asyncio.create_task、asyncio.gather、asyncio.wait都可以实现多个任务的并发调度但它们的设计目标有微妙差别新手很容易搞混。create_task适合“创建后台任务后主流程还要继续做别的事”的场景。比如先启动三个下载任务再去做数据清洗最后统一等待结果。用create_task创建Task时任务已经在运行了不需要await它也能推进。但要注意如果你创建了Task却从不等待它程序退出时它可能被静默取消。一个稳妥习惯是给所有Task都留一个await点。gather适合“等一批任务全部完成然后收集结果”的场景。它接受多个协程或Task按传入顺序返回对应的结果列表。gather还有个实用细节如果其中某个任务抛异常默认情况下gather会直接把异常抛出来其他任务的结果即使成功你也拿不到。如果想保持“一个失败不拖垮整体”的行为得把return_exceptionsTrue传进去。wait比gather更底层它接收的是Task集合允许你通过return_when参数控制返回时机。asyncio.ALL_COMPLETED是所有任务完成后返回asyncio.FIRST_COMPLETED是只要有一个任务完成就立刻返回。后者在“多个上游接口任一个返回即可继续”的场景很有用。wait返回一个元组(done, pending)分别表示已完成和未完成的Task集合。从实践经验来说日常业务里90%的情况用gather就够了。等到你需要“先到先得”的响应策略或者要对任务集合做更精细的生命周期管理时再考虑用wait和create_task组合。3.3 asyncio.run()的版本变迁与使用边界asyncio.run()是Python 3.7引入的顶层入口函数它帮你处理了事件循环的创建、运行、关闭这些杂活。到了Python 3.10官方把asyncio.run()里的loop参数移除了也就是说你不能再显式传入一个现成的事件循环。这在日常使用中反而是好事省去了一堆“循环重用”的坑。有几个边界值得记住第一同一个线程中不能嵌套调用asyncio.run()否则会抛出RuntimeError: This event loop is already running第二asyncio.run()执行完后会关闭事件循环所以不能指望它被反复调用去“重启”同一个loop第三在Jupyter Notebook这类本身已经维护着事件循环的环境里直接调用asyncio.run()可能出问题推荐用await main()代替。4. 异步生态的进阶工具箱4.1 async with与async for不仅函数可以异步上下文也可以如果你只学会了async def和await能覆盖的场景其实有限。真实项目里还有两个高频需求异步资源的获取与释放以及异步流的逐条处理。这就是async with和async for的用武之地。异步上下文管理器对应普通上下文管理器只不过它的__enter__和__exit__换成了异步版本__aenter__和__aexit__。写起来是这样class AsyncResource: async def __aenter__(self): print(获取资源) await asyncio.sleep(0.1) return self async def __aexit__(self, exc_type, exc, tb): print(释放资源) await asyncio.sleep(0.1) async def demo(): async with AsyncResource() as res: print(使用资源中)最典型的使用场景就是异步HTTP会话。很多异步请求库要求你手动管理会话连接用async with可以保证会话在使用完后被正确关闭避免连接泄漏。async for则用于异步迭代器。它背后的协议是__aiter__返回异步迭代器自身__anext__返回一个可等待对象迭代结束抛StopAsyncIteration。下面是一个简单的例子class AsyncCountdown: def __init__(self, n): self.n n def __aiter__(self): return self async def __anext__(self): if self.n 0: raise StopAsyncIteration self.n - 1 await asyncio.sleep(0.2) return self.n 1 async def main(): async for num in AsyncCountdown(3): print(num)实际项目里你很少会自己手写异步迭代器更多是消费别人提供的异步流比如逐行读取一个大文件、分批消费消息队列都可以用async for写出非常干净的代码。4.2 超时与取消wait_for和cancel是异步程序的安全带异步程序跑起飞之后最怕的就是某个协程迟迟不返回——可能是下游接口挂了可能是对方网络断了。如果你不设超时整个程序会一直挂在那里。这就是asyncio.wait_for的用武之地async def main(): try: result await asyncio.wait_for(worker(A, 5), timeout2) except asyncio.TimeoutError: print(任务超时已经被自动取消)wait_for会在超时后自动取消内部任务。注意它抛出的超时异常是asyncio.TimeoutError在Python 3.11之前它其实是concurrent.futures.TimeoutError的别名捕获的时候写asyncio.TimeoutError最稳妥。除了超时你还可以手动取消任务task asyncio.create_task(worker(B, 10)) await asyncio.sleep(1) task.cancel() # 请求取消 try: await task except asyncio.CancelledError: print(任务被取消了)取消一个Task后在await它的地方会抛出CancelledError。如果你的业务代码里有finally清理逻辑它仍然会被执行这给了你处理“被取消后释放资源”的机会。Python 3.11之后还推出了asyncio.timeout异步上下文管理器语义上更清晰允许你把超时和业务代码放得更贴合async def main(): try: async with asyncio.timeout(2): await worker(C, 5) except asyncio.TimeoutError: print(超时啦)4.3 同步代码改异步一个库替换清单要真正享受异步的好处光写async def没用底层那些阻塞调用必须换成异步版本。最常见的替换是这三个time.sleep换成asyncio.sleep这是最简单的。最难改的是用requests这种同步HTTP库。requests的每个请求都会阻塞线程在异步协程里调用它等于把整个事件循环都堵住再多并发任务也白搭。解决办法是换成aiohttp或者httpx的异步客户端。httpx的API设计更接近requests迁移成本相对低一些import httpx async def fetch(url): async with httpx.AsyncClient() as client: resp await client.get(url) return resp.text如果某个同步库实在没有异步替代品还有一个退路asyncio.to_thread。它可以把一个同步函数丢到单独的线程池里执行避免阻塞事件循环import requests result await asyncio.to_thread(requests.get, url)注意to_thread本质上是“用多线程掩盖同步阻塞”线程本身还是有开销的适合偶尔调用不适合一秒钟几百次高频使用。真正的解法永远是找到对应的异步库。5. 实战排查那些坑过无数人的问题5.1 症状异步里塞了同步阻塞库整个程序卡住这是我在实际项目里遇到最多的问题。有人会把requests放在异步函数里然后跑gather结果发现程序不但没变快其他任务反而全都被拖住了。原因其实不复杂事件循环只有一个线程当这个线程去执行requests.get时它是在同步地等待网络响应根本不会回到事件循环去调度其他协程。哪怕你对十个URL并发请求结果还是十个请求排队等待。排查方式也很直接看看协程里调用的库是不是支持异步。如果某个库的文档里没有“async”字样它的调用基本都会阻塞线程。判断不了的时候可以在代码里临时加打印看任务是不是按顺序挨个完成——如果是那肯定有同步阻塞混进来了。5.2 症状嵌套asyncio.run()抛RuntimeError有些同学写了一个异步的主入口里面又调用了另一个使用asyncio.run()的辅助函数结果直接报错。原因前面提过同一个线程的事件循环只能存在一个asyncio.run()发现当前线程已经有运行中的loop就不肯再创建新的。正确的做法是一个程序只保留一个asyncio.run()入口内部所有子系统都通过await、create_task或者gather协同工作。如果你在写某个需要被反复调用的工具库让它提供协程函数就好由最上层的应用去决定如何调度。5.3 症状运行完出现“coroutine was never awaited”警告这通常是忘记在协程前面写await导致的。忘了await时协程对象永远不会执行程序虽然不出错但会留下黄色警告提醒你某个协程“生而不养”。我的经验是只要看到这个警告就回头检查所有async def的调用点。如果确实不想等待某个协程说明它应该被asyncio.create_task包装而不是裸调用。这个警告在Python 3.8以后非常显眼不要忽略它一般都能顺藤摸瓜找到逻辑漏洞。5.4 常见问题速查表问题现象根本原因解决思路异步并发后没有提速协程内隐藏着同步阻塞调用换成对应异步库或临时用to_threadRuntimeError: This event loop is already running嵌套调用了asyncio.run()只保留一个顶层入口内部用await和create_taskcoroutine ... was never awaited警告调用async def函数时漏了await补上await或改成create_taskwait_for超时后报奇怪异常捕获了错误的超时异常类型统一捕获asyncio.TimeoutErrorTask被静默取消结果没拿到创建Task后没有保留它的引用把Task存进列表统一await程序在Windows上跑asyncio出现ProactorEventLoop报错某些子进程相关功能在Windows下有兼容性问题业务代码避免强依赖子进程网络IO不受影响结尾最后分享一点我的个人体会。刚开始学asyncio时我一度沉迷于把每个函数都改成async结果代码变复杂了性能却没提升多少。后来想明白一件事异步的核心是找到“等待时间占比高”的环节然后集中精力改造这些环节。爬虫、批量接口调用、文件收集这类场景收益最明显但普通的小脚本、CPU密集的算法流程强行改异步只会增加心智负担。如果你现在手头有一个慢吞吞的同步脚本建议先别急着全盘重写。挑一个IO最密集的函数把它改成异步版本跑一遍对比耗时。当你亲眼看到同样的任务从十几分钟压缩到几十秒那种满足感会成为你继续深入学习的最大动力。之后再多踩几个坑多看看Task和超时这些细节异步编程带来的收益会非常稳定。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑