
说实话我最早接触Python异步编程时差点被各种说法劝退。有人跟我说asyncio难学、难调试还有人跟我说“以后只要是Python项目都应该异步”。等我真的在一个项目里把同步代码改成asyncio之后才发现这些说法各对一半。asyncio不是性能神药但当你需要并发处理网络请求、Web服务、爬虫这类I/O密集任务时它带来的效率提升是实实在在的。这篇文章我打算以一个普通开发者的视角把Python异步编程里最常用的Asyncio库完整拆开讲一遍——事件循环、协程、任务、并发控制、超时处理、调试手段全部用能直接跑起来的代码说明。适合刚学完Python基础、准备进阶并发编程的读者也适合被异步概念绕晕、想从头理顺的开发者。读完你会有一种感觉异步编程的底层逻辑其实并不玄学它只是换个方式利用等待时间。1. 先搞清楚同步、多线程和协程到底差在哪1.1 同步代码为什么“慢”一个非常直观的例子对于还没深入接触并发编程的开发者来说最朴素的问题往往是我的代码明明没什么复杂计算为什么一遇到网络请求就卡住我用一个最典型的场景解释“同步阻塞”。假设你要下载5个网页用requests库最简单的循环写法import requests import time urls [fhttps://example.com/page/{i} for i in range(5)] start time.perf_counter() for url in urls: resp requests.get(url) print(resp.status_code, url) print(总耗时, time.perf_counter() - start)这段代码看起来没毛病实际上每一步都在“空等”。requests.get发出请求后要等服务器返回数据这段时间CPU几乎什么都不做就是在等待网络数据到达。假如每个请求需要1秒5个请求串行执行总耗时就是5秒。这5秒里CPU真正处理的只是极少量的字节拷贝和状态判断大量时间都被浪费掉了。也许你会说那我开5个线程不就行了对线程确实能做到并发但线程有它的代价。Python的线程受GIL全局解释器锁限制网络IO时GIL会释放能解决一部分并发问题但线程本身的内存和切换开销摆在那里。一个线程在操作系统层面需要独立的栈空间创建和销毁都有成本当并发量上升到几千甚至上万时线程方案会变得非常吃力。协程的思路完全不同在单线程里维护成千上万个轻量级任务每个任务都像是一个“可暂停的函数”该等待的时候主动让出执行权等结果准备好再回来继续跑。这个过程也叫协作式调度。我用生活场景打个比方。同步做饭的流程是把米放进电饭煲然后站在旁边干等饭熟期间什么都不碰。多线程方案相当于请了三个厨师一人负责一道菜但每个厨师占一个操作台人多了厨房就挤不下。而协程方案更像是一个厨师把米饭放进电饭煲后不等它熟立刻去切菜、炒菜等菜快炒好了饭也熟了再回头处理饭。这个“回头处理”的动作在编程里就是协程的挂起和恢复。1.2 协程与线程的本质区别为什么说“轻量”很多人一听“协程更轻量”第一反应是“多轻”从数据结构上看线程有独立的内核栈和用户栈切换由操作系统调度两次切换可能要消耗几微秒同时还有资源竞争。协程的切换发生在用户态由程序自己控制不需要操作系统介入切换成本通常可以降到几百纳秒甚至更低。更关键的是数量级差异单个进程创建几万个线程几乎没有可能但asyncio在单线程里管理几万个任务却是实际可行的场景。这个数量差距决定了在I/O密集场景下协程往往是更合适的选择。这里有个很容易被忽略的点协程不代表自动并发。你定义一个async函数调用它并不会立即执行而是返回一个协程对象必须把它交给事件循环去调度。很多人第一次写异步代码总觉得“怎么不生效”往往就是这个原因——协程对象被创建了但根本没被运行。后面我会专门展开讲这个坑。也顺便说一下适用边界。异步适合I/O密集型任务比如网络请求、读写文件、数据库操作、消息队列消费但如果一个函数纯粹在做CPU计算比如解压大文件、图像处理、加密解密那协程帮不上忙反而因为调度开销变得更慢。这类场景应该考虑multiprocessing或把任务交到线程池里运行。我在实际项目里见过有人把CPU密集的加解密函数硬写成async结果整体性能不升反降就是因为没有注意这个边界。2. 核心机制拆解事件循环与async/await语法2.1 事件循环异步引擎的核心调度中心asyncio的最底层是一个事件循环英文叫event loop。你可以把它理解成一个调度中心它维护一个任务队列循环扫描哪些任务可以继续执行、哪些还在等待I/O。当协程执行到await时就暂停把控制权交还给事件循环事件循环再去调度其他就绪的任务。这样一个线程就被循环利用到了极致。绝大多数情况下我们不需要直接操作事件循环。Python 3.7之后asyncio.run是最简单的启动入口它一次性帮你做好三件事创建事件循环、运行传入的协程或任务、等所有内容结束后关闭循环。看一个最基础的例子import asyncio async def hello(name: str): print(f开始 {name}) await asyncio.sleep(1) print(f完成 {name}) async def main(): # 注意这里依次await两个协程是顺序执行 await hello(A) await hello(B) asyncio.run(main())这段代码的运行时间是2秒。尽管hello是协程但因为main里用了两次await第一个协程完全结束后才轮到第二个所以没有任何并发效果。想让它变成并发需要改成创建任务的方式async def main(): task_a asyncio.create_task(hello(A)) task_b asyncio.create_task(hello(B)) await task_a await task_b现在总耗时就变成大约1秒。asyncio.create_task会把协程包装成任务交给事件循环立即调度两个任务交替执行。这里有个实践要点任务一旦创建就开始运行除非你主动等待它或者后续通过gather这类接口统一收口。2.2 async/await语法与三种可等待对象接着说说语法本身。async def定义一个协程函数但调用这个函数不会执行函数体而是返回一个协程对象。协程对象只能被事件循环运行或者被另一个协程用await执行。普通函数里直接用await会直接报语法错误所以顶层代码通常要包一层async def main()再用asyncio.run(main())启动。await后面跟的东西必须是“可等待对象”。asyncio里主要有三类对象类型作用典型使用场景协程对象尚未被调度的异步函数await some_coro()Future异步操作结果占位符低层封装封装回调、桥接底层IOTask被调度的Future包装一个协程create_task、gather中管理并发Future这个概念很多人一开始会绕晕我直接理解成“一个暂时没有拿到结果但未来一定会填上结果的容器”。Task则是Future的子类专门用来包装协程并推进执行。日常开发中直接接触Task的时候最多Future通常隐藏在框架内部。顺带说一个细节await只能写在async函数内但asyncio.create_task只能在一个运行中的事件循环里调用。如果在事件循环之外调用会报RuntimeError: no running event loop。你不在async函数里做这件事就得先想办法拿到运行中的循环比如用asyncio.get_running_loop()。这个API从3.7开始就是官方推荐的做法比老旧的get_event_loop()更安全。3. 任务操作从协程到真正并发3.1 创建任务与任务生命周期管理任务Task是asyncio里最重要的概念之一。它把协程对象包装起来立刻调进事件循环的调度队列让协程在后台并发运行。最简单的创建方式就是用asyncio.create_taskimport asyncio async def worker(name: str, delay: float): await asyncio.sleep(delay) return f{name} done after {delay}s async def main(): task asyncio.create_task(worker(one, 2)) result await task print(result) asyncio.run(main())任务创建后有几个状态需要熟悉done()方法判断是否已经结束cancelled()判断是否被取消result()可以拿到协程的返回值但如果协程内部抛了异常调用result()时异常会被重新抛出。通常我们直接await task就够了await会等任务完成并把返回值或异常传递出来。有个新手很容易踩的坑创建了任务却不在任何地方await它程序结束时控制台会飘出Task was destroyed but it is pending的警告。原因很简单——任务还在运行但事件循环已经没有引用它的地方了循环直接退出任务被强行销毁。如果你创建任务确实不打算立刻等待比如暂时放到一个列表里也要保证最终有统一的gather或await收口否则就会出现这种不明不白的警告。任务取消也是一个必备操作。task.cancel()会向任务注入CancelledError这个异常在Python 3.8之后继承自BaseException和SystemExit、KeyboardInterrupt同级。普通用except Exception是捕不到的必须显式处理。关于取消和超时的坑我会在第4章专门展开。3.2 收集并发结果gather、wait和wait_for的分工管理多个任务时最常用的接口是asyncio.gather。它可以同时接收多个协程或任务并发执行并按输入顺序返回结果async def fetch_a(): await asyncio.sleep(1) return A async def fetch_b(): await asyncio.sleep(2) return B async def main(): results await asyncio.gather(fetch_a(), fetch_b()) print(results) # [A, B] asyncio.run(main())gather有几个行为特点值得记住。第一默认情况下任何一个协程抛出异常gather会立即把异常抛给调用方其他协程不会自动取消这是很多线上事故的来源。第二把参数return_exceptionsTrue打开后异常对象会作为结果元素返回不会中断整体流程。第三如果传入的是任务而不是原始协程gather不会自动取消它们如果传入的是协程对象gather会先把它们包装成任务。asyncio.wait则是更低层一点的接口。它接收一个任务集合返回(done, pending)两个集合常用于“等待一部分任务先完成”的场景。比如配合return_whenasyncio.FIRST_COMPLETED可以把并发请求中最快返回的结果拿出来先行处理。asyncio.wait_for用来给单个协程设置超时超过指定时间没完成就取消这个任务并抛出TimeoutError。三者分工大致如下接口核心能力典型用途create_task把协程包装成任务并发调度任务后台运行后续统一等待gather并发收集多个结果按传入顺序返回批量并发请求汇总结果wait等待任务集合按条件返回完成/未完成处理部分结果自定义退出条件wait_for为协程设置超时超时自动取消防止慢接口拖垮整体流程我自己的使用习惯是纯做结果汇总用gather需要限时等待或部分结果就用wait给单个协程上超时用wait_for。把这几样用熟大部分异步并发场景都已经覆盖了。4. 实战场景并发HTTP请求与超时、取消4.1 一个能直接跑的并发抓取脚本日常开发里最常遇到异步场景的应该是批量网络请求。这里必须说一句requests库是同步阻塞的如果在协程里直接调用requests.get整个事件循环会被卡死并发立刻失效。要用异步网络库最常用的是aiohttp此外还有httpx等选项。下面我写一个抓取20个URL但最多同时并发5个请求的例子import asyncio import aiohttp async def fetch_one(session: aiohttp.ClientSession, url: str): async with session.get(url) as resp: return resp.status, await resp.text() async def bounded_fetch(semaphore: asyncio.Semaphore, session: aiohttp.ClientSession, url: str): async with semaphore: return await fetch_one(session, url) async def main(): urls [fhttps://example.com/api/{i} for i in range(20)] semaphore asyncio.Semaphore(5) timeout aiohttp.ClientTimeout(total10) async with aiohttp.ClientSession(timeouttimeout) as session: tasks [bounded_fetch(semaphore, session, url) for url in urls] results await asyncio.gather(*tasks) for status, body in results: print(status, len(body)) asyncio.run(main())这段代码里有三个关键点。第一aiohttp.ClientSession作为连接池被所有任务共享不要为每个请求单独创建session那是非常严重的资源浪费。第二信号量Semaphore(5)限制了同时执行的任务数量避免瞬间发太多请求。很多人图省事不加限制结果对目标服务造成压力轻则被限流重则被封IP。第三ClientTimeout(total10)统一设置请求超时后续就不用为每个请求单独写超时判断。你可能会问为什么不用纯asyncio也能发的HTTP请求其实asyncio本身不提供HTTP协议实现它只负责调度。HTTP协议解析、连接复用、SSL握手这些工作还是要由库来完成。aiohttp在协程层面完成了这些底层操作才让每个请求都能作为异步任务被调度。4.2 超时和取消防止协程失控网络请求是最容易出现长时间挂起的地方。某个接口迟迟不返回后续任务全被堵着这在同步代码里是灾难在异步代码里也一样。处理方式有两种asyncio.wait_for和Python 3.11引入的asyncio.timeout。先看wait_forimport asyncio async def slow_task(): await asyncio.sleep(30) return ok async def main(): try: result await asyncio.wait_for(slow_task(), timeout5) print(result) except TimeoutError: print(任务超时已取消)关键点是timeout到了之后wait_for会向任务发送取消请求也就是调用task.cancel()然后抛出TimeoutError。如果你的协程内部没有处理好取消它可能不会立刻停下来。一个典型场景是协程里有清理逻辑async def worker(): try: await do_work() finally: await cleanup()这里有个隐藏问题。任务被取消时CancelledError会在do_work()的await处抛出代码进入finally但如果cleanup()里又出现了await任务不会真正结束而是继续等待清理完成。如果清理逻辑本身又要等很久超时取消就失效了。更麻烦的是如果你在清理中抛出了其他异常还会覆盖原本的取消状态导致任务看起来是异常退出而不是被取消。我的建议是finally块里不要执行可能长时间阻塞的异步操作能用同步方式清理就尽量用同步方式。如果确实需要异步清理也要给清理操作加上自己的超时限制。理解CancelledError发生在哪一步、以及finally如何影响任务退出是async编程里最需要经验的细节之一。还有一个相关概念叫asyncio.shield它可以把一个协程保护起来让外部的取消不影响它。使用场景是你希望任务有一个超时等待但超时后让背后的操作继续在后台跑完而不是被强行中断。wait_for(shield(coro), timeout)就能实现“等待超时但协程本身不取消”的效果。不过这个操作要谨慎使用被遗留在后台的任务如果一直不结束最终还是会成为事件循环的一个隐患。5. 常见问题排查与性能调优5.1 新手最容易踩的五个坑我总结了自己和身边同事实际踩过的坑按出现频率排个序。第一个坑在异步代码里用了同步阻塞库。有人把requests、time.sleep直接写在协程里结果表面上看协程是并发了实际整个事件循环还是被卡到一动不动。解法很简单要么换aiohttp/httpx这种异步库要么用asyncio.to_thread把同步函数丢到线程池里去执行让事件循环腾出手来调度其他任务。第二个坑协程函数调用后忘了await。控制台一旦出现RuntimeWarning: coroutine ... was never awaited基本就是这个原因。写代码时要习惯性地想协程对象不是结果await才是真正执行它。第三个坑在多个任务里共享可变对象而不加锁。虽然asyncio是单线程的但多个协程之间的切换可能发生在任意一个await处这意味着一个协程读到某个状态、还没写回时另一个协程可能已经修改了同一个状态。解决方式是用asyncio.Lock用法和线程锁类似但它不会阻塞线程只会挂起当前协程。第四个坑一次性创建几千个任务却没有信号量限流。asyncio管理任务虽然轻量但下游服务和数据库不一定承受得住。养成随手加Semaphore的习惯尤其是在批量IO场景里能省掉很多线上告警。第五个坑事件循环用了一个又一个。有人写了多个asyncio.run嵌套调用或者在多线程里创建多个事件循环导致任务挂在不同循环上互相等不到结果。解决办法是尽量让一个进程里只有一个主事件循环顶层入口用asyncio.run内部统一用create_task和gather管理。为了对照方便我整理了一个问题速查表症状可能原因解决方案并发代码比预期慢很多协程里混入同步阻塞调用换成异步库或to_threadnever awaited警告协程对象被创建但未执行补上await或包装成任务数据出现竞态多个协程共享可变对象用asyncio.Lock保护临界区下游服务频繁超时并发过高或缺少限流使用Semaphore限制并发数任务被销毁但仍在pending创建任务后没有收口用gather或await统一管理任务协程超时后仍不结束finally中有异步阻塞清理清理操作不要长时间await5.2 日志、调试与性能分析刚开始调试asyncio确实比普通代码要费劲因为任务切换交错打印顺序经常让人摸不着头脑。我自己的经验是先开debug模式。Python 3.7之后可以直接在asyncio.run里传入debugTrue它会输出很多额外诊断信息包括那些耗时超过100毫秒的协程、忘记等待的协程、以及阻塞事件循环的宿主调用。我常用的调试手段还有一个给每个任务起名字。create_task(..., namefetch-x)之后在日志里能看到任务名排查问题时能快速定位到具体是哪个请求出了问题。此外Python的日志库会为asyncio提供专门的日志记录器设置好asyncio日志级别有时候能直接看到事件循环内部警告。性能分析方面我一般先确认到底是I/O瓶颈还是CPU瓶颈。I/O密集就检查并发度和超时设置是否合理CPU密集则要考虑把计算移出事件循环。计算密集的部分可以丢给loop.run_in_executor或asyncio.to_thread把它们放到线程池或进程池里避免阻塞调度。比如一个协程要做数据压缩await asyncio.to_thread(compress, data)就能让压缩工作在线程池里进行事件循环可以继续服务其他请求。还有个小技巧是主动让出调度。当你发现某个协程在大量文本处理后需要让其他任务有机会运行可以在合适的位置加一个await asyncio.sleep(0)。它的作用是触发一次任务切换把执行权交还给事件循环。这个操作很轻量但在某些长循环里非常有用。最后聊一下事件循环被阻塞的现象。就算你代码里全部用了异步库如果在协程里有大循环或者连续的同步计算事件循环依然会被卡死。可以通过debug模式下的额外输出找到这些热点或者用性能分析工具采样。以前我在处理一批解析逻辑时没注意某个函数内部有大量正则匹配结果整个异步服务周期性卡顿后来用debug模式定位到那个函数把它包上to_thread才恢复正常。我的一点实际使用体会文章写到这儿我想把自己总结的几个结论再强调一下。别一见“异步”就觉得高端也别以为把函数声明成async就能自动变快。真正决定性能的是任务的I/O等待占比和事件循环的调度方式。异步适合I/O密集型场景如果一个函数纯粹在算CPU异步不但不会变快可能还会增加不必要的调度开销。我建议你先把单个协程、事件循环、任务、超时、限流这些基础概念玩熟再往项目里大规模铺开。第一次尝试异步改造时不要一个文件里混着同步库和异步库先彻底切干净否则排查问题时非常痛苦。刚开始调试asyncio确实会有点难受但一旦理解了事件循环的调度方式你会觉得写并发代码其实比想象中更可控。如果这篇文章里列出的哪个坑你也踩过说明你已经走在真正使用它的路上了。你在用异步的时候感受最深的坑是什么也可以顺着这个思路自己验证一下。