ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入理解Python await:事件循环与协程并发实战指南

深入理解Python await:事件循环与协程并发实战指南

1. 从一次线上事故说起:await写错位置的代价

先说个真实发生过的事。某天晚上线上有个接口响应时间从200毫秒飙到8秒,监控告警疯狂刷屏。排查了一圈,最后发现问题出在一行看起来人畜无害的代码上——有人在async函数里直接调了同步的requests.get(),而且外层既没有加await,也没有包一层Task。更讽刺的是,代码里确实写了不少await,但全都await在了错误的地方,真正该并发的请求反而被串行执行了。

那次事故之后,我把Python异步这套逻辑从头到尾捋了一遍,才发现大多数人对async/await的困惑根本不是"不会写",而是没搞明白await背后的执行模型。你问十个人"await是干嘛的",九个人会告诉你"等待异步操作完成",但再追问一句"等待期间程序在干什么",基本就答不上来了。

这篇文章针对的就是这类问题。我会从await的底层执行逻辑讲起,拆解事件循环和协程状态机的工作方式,然后落到实战中常见的陷阱和排查思路。适合三种人看:刚接触asyncio想搞懂原理的初学者、写异步代码但总遇到莫名其妙阻塞的进阶开发者、以及需要在生产环境里排查异步问题的运维和SRE同学。

2. await的底层逻辑:事件循环、协程与状态机

2.1 await到底在等什么:awaitable对象的三种形态

Python文档里定义了一个术语叫awaitable(可等待对象),凡是能放在await后面的东西,都属于这个范畴。具体来说有三种:协程(coroutine)、Task、Future。

很多人把这三者混为一谈,其实它们的层次完全不同。协程是语法层面的东西,就是你用async def声明的那个函数调用后返回的对象。Future是底层的事件循环层面的东西,代表一个"将来才会有结果"的占位符。Task则是Future的子类,专门用来包装协程,负责任务的调度和取消。

打个比方:协程像是一个已经写好剧本的演员,Future像是剧场里挂的那块"演出中"的牌子,Task就是把演员和牌子绑在一起,再由导演(事件循环)决定什么时候让谁上台。演员不能自己上台,牌子也不会自己写字,只有Task才是真正被事件循环管理的单元。

需要特别留意的是,在async函数里直接调用另一个async函数,得到的只是一个协程对象,并不会真正执行。如果你写了:

async def fetch_data(): return "data" async def main(): # 错误示范:这里只是创建了协程对象,函数体根本没执行 result = fetch_data() # 正确示范:必须await才会真正执行 result = await fetch_data()

第一行代码既不报错也不执行,只是默默创建了一个协程对象。这种"静默失效"是新手最容易踩的坑,因为程序看起来没崩,但数据一直不对。

2.2 协程不是线程:await切换的本质是让出控制权

要理解await,必须先破除一个根深蒂固的误解——协程不是线程,async/await也不是让Python实现多线程并行。

线程是操作系统级别的调度单元,多个线程可以真正跑在多个CPU核心上,并行执行计算任务。而协程是用户态的东西,它跑在单线程里,靠的是事件循环这个"调度器"来回切换执行权。

那await在这个机制里扮演什么角色?一句话:await是协程主动让出控制权的开关。当一个协程执行到await时,它会把CPU的执行权交还给事件循环,自己则挂起在那里等待某个条件满足。事件循环拿到控制权后,会去检查还有没有别的协程可以执行,如果有,就切过去运行。

这就像你去银行办业务,如果只有你一个人,你占着柜台多久都行。但如果有好几个人排队,你办到一半发现还需要填个表,如果你死占着柜台填完再继续,后面的人就得一直等。await就是你主动说"我先去旁边填表,你们谁需要办业务先上"。

关键点在于:切换的时机是由协程自己控制的,而不是被系统强制的。一个协程里如果没有await语句,它就会一直占用事件循环不撒手,其他协程根本没有机会运行。

2.3 用状态机视角理解协程的挂起与恢复

如果你看过CPython的源码,会发现每个协程对象内部维护着一组状态,本质上就是一个状态机。协程的生命周期可以划分为:创建(created)、就绪(ready)、运行(running)、挂起(suspended)、完成(finished)这几个状态。

  • 调用async函数返回协程对象时,协程处于created状态,函数体一行都没执行。
  • 把协程交给事件循环后,变为ready,等待被调度。
  • 事件循环选中它,开始执行函数体,变为running。
  • 执行到await语句时,协程挂起,变为suspended,控制权回到事件循环。
  • 当await的条件满足(比如IO完成、Future设置结果),协程重新变为ready,等待下轮调度。
  • 函数体全部执行完毕,变为finished。

这个状态机的精妙之处在于,协程的局部变量、执行位置、调用栈都会被保存下来。挂起时,Python记录下当前执行到哪一行、局部变量是什么;恢复时,从上次挂起的地方继续往下走,仿佛中间什么都没发生过。

这也是为什么await能实现"看起来像同步代码的异步逻辑"——你写的时候是自上而下的顺序代码,但底层执行过程其实是"执行一段、挂起、再执行一段"的多次切换。这种让写代码的人感觉不到切换的语法设计,就是async/await相对回调函数最大的优势。

3. 掌握await的三大关键方法:create_task、gather与wait

3.1 await一个任务和await一堆任务的区别

理解了协程的挂起机制后,下一个关键问题是:怎么让多个协程真正并发执行?

直接写多个await是串行的,这大概是异步代码里最常见的性能杀手。看这个例子:

import asyncio async def fetch(url: str) -> str: # 模拟网络请求 await asyncio.sleep(1) return f"result from {url}" async def serial_main(): # 串行:总耗时约3秒 r1 = await fetch("url1") r2 = await fetch("url2") r3 = await fetch("url3") return [r1, r2, r3]

三个fetch虽然是协程,但被依次await,每个都要等前一个完成才开始,总耗时是三者之和。这就是很多人写着async代码却一点性能提升都没有的根本原因——你把异步语法写出来了,执行路径却是同步的。

要让它们并发,得先把协程包装成Task,让事件循环"同时"调度它们:

async def concurrent_main(): # 并发:先创建三个Task,再统一await task1 = asyncio.create_task(fetch("url1")) task2 = asyncio.create_task(fetch("url2")) task3 = asyncio.create_task(fetch("url3")) results = [await task1, await task2, await task3] return results

create_task的作用是把协程立刻注册到事件循环里,让它进入ready状态,一旦发生await,事件循环就会在挂起的间隙去调度这些Task。上面的代码总耗时约1秒,而不是3秒。

这里的核心认知是:await一个Task不是"发起调用",而是"等待结果"。发起调用的动作是create_task,await只负责等结果。task被创建的那一刻,它就已经开始被调度了,不管你有没有await它。

3.2 gather与wait的差异和选择

手动create_task再一个个await写起来太啰嗦,更常见的做法是用asyncio.gather:

async def gather_main(): results = await asyncio.gather( fetch("url1"), fetch("url2"), fetch("url3") ) return results

gather做的事情本质上和create_task一样,会自动把协程包装成Task并调度执行。区别在于:gather返回的结果列表顺序和传入的协程顺序一致,不管实际完成顺序如何。这个特性在需要按固定顺序处理结果的场景下特别有用。

还有asyncio.wait,它和gather的差别比较微妙:

对比项asyncio.gatherasyncio.wait
输入协程对象、Task、Future均可只能是Task/Future的集合
返回按传入顺序排列的结果列表done和pending两个集合
异常处理默认第一个异常会立即向外抛出可以通过return_when参数控制返回时机
取消处理可以通过return_exceptions参数捕获异常更灵活,能拿到未完成的任务集合

wait的核心价值在于return_when参数,支持FIRST_COMPLETED、FIRST_EXCEPTION、ALL_COMPLETED三种模式。比如你要并发请求多个服务,只要有一个成功就行,用wait(FIRST_COMPLETED)就很合适:

async def race(): done, pending = await asyncio.wait( [fetch("backup1"), fetch("backup2"), fetch("backup3")], return_when=asyncio.FIRST_COMPLETED ) # done里至少有一个Task完成了,pending里的可以取消 for task in pending: task.cancel() return done.pop().result()

这种竞态模式在容灾切换、多路冗余请求时很实用。

3.3 TaskGroup:更优雅的并发管理

Python 3.11引入了asyncio.TaskGroup,我建议新项目直接优先用它。它的好处是结构化——所有Task的生命周期被明确绑定在TaskGroup这个上下文管理器里,要么全部成功退出,要么在异常时统一取消,不会有"漏网之鱼":

async def task_group_main(): async with asyncio.TaskGroup() as tg: t1 = tg.create_task(fetch("url1")) t2 = tg.create_task(fetch("url2")) t3 = tg.create_task(fetch("url3")) # 走到这里,三个Task必然都完成了 return [t1.result(), t2.result(), t3.result()]

如果其中任何一个Task抛出异常,TaskGroup会立即取消所有尚未完成的Task,然后统一抛出异常。这比手动管理一堆Task再逐个cancel要省心得多,也更难写出资源泄漏的代码。

我自己在代码评审里看到手动create_task + gather的老写法时,如果项目Python版本允许,都会建议改成TaskGroup。不是因为gather不能用了,而是TaskGroup把"成功一起走、失败一起撤"的纪律写进了语法结构里,少了很多心智负担。

4. 实战中的await陷阱与完整排查链路

4.1 陷阱一:await前后的CPU密集计算

这是async/await最容易被误用的场景。很多人以为在async函数里写普通代码就自动变成"异步的",这是错的。异步非阻塞只对IO等待有效,CPU计算该占用多久就占用多久。

看这个例子:

import hashlib async def compute_hash(data: bytes) -> str: # 这行代码是CPU密集的,会阻塞事件循环 digest = hashlib.sha256(data).hexdigest() # 下面的await根本没机会执行,因为上面的计算把事件循环卡死了 await asyncio.sleep(0) return digest

如果同时有100个协程都在执行compute_hash,每个协程都要完整跑完sha256才会让出控制权。期间事件循环被阻塞,其他所有协程都处于停滞状态——所谓的并发彻底失效。

排查链路是这样的:接口延迟突增,但IO等待时间很低,CPU使用率打满,说明事件循环被CPU密集任务堵住了。这时候要做的不是调整await的位置,而是把CPU密集任务丢出事件循环。可以用asyncio.to_thread(Python 3.9+)把它扔到线程池里:

async def compute_hash_fixed(data: bytes) -> str: digest = await asyncio.to_thread(hashlib.sha256, data).result() if False else None # 上面那行是错误示范,正确的是下面这种 digest = await asyncio.to_thread(_hash_bytes, data) return digest def _hash_bytes(data: bytes) -> str: return hashlib.sha256(data).hexdigest()

实际上更简洁的是:

async def compute_hash_fixed(data: bytes) -> str: digest = await asyncio.to_thread(_sha256_hex, data) return digest

to_thread会把同步函数放到默认的线程池执行器里跑,返回值通过Future传回事件循环,这样事件循环就不会被卡住。

判断标准就一条:如果这个任务是CPU密集的,它就不该在协程里裸跑;如果这个任务是IO密集的,它就要用异步库(如aiohttp、httpx的异步模式),而不是在协程里调用同步库。

4.2 陷阱二:Task数量失控

另一个我在生产环境见过多次的问题是无限创建Task。比如一个异步函数接收到请求就create_task,但从来不控制并发上限:

async def handle_request(request): # 每个请求都无脑创建一个Task task = asyncio.create_task(process(request)) return {"accepted": True}

看起来没毛病,请求进来立刻返回"已接收",处理逻辑在后台跑。但如果process里连着外部服务,而外部服务的吞吐有限,Task只会越积越多,内存持续上涨,最终把进程拖垮。

这就是常说的"背压"问题。解决办法是加信号量(Semaphore)控制并发:

semaphore = asyncio.Semaphore(20) async def process_with_limit(request): async with semaphore: return await process(request)

Semaphore在这里的作用类似停车场入口的闸机,最多放20辆车进去,其他的在外面排队。被阻塞的协程挂在Semaphore上,既不占CPU也不占内存资源,等有空位了再放行。

排查这类问题时,可以用asyncio的调试模式,设置PYTHONASYNCIODEBUG=1,Python会打印出创建后未完成的任务信息,帮助你定位是谁在不断地创建Task却没人负责回收。

4.3 陷阱三:await一个永远不返回的Future

第三种陷阱是协程挂起后没有恢复路径。比较典型的场景是:你在等一个事件,但事件永远不会发生。

举个例子,队列消费者:

import asyncio async def consumer(queue: asyncio.Queue): while True: item = await queue.get() # 如果队列永远为空,这里会一直挂起 await process(item)

如果生产者的协程因为异常退出,消费者就会永远卡在queue.get()上。整个Task变成僵尸状态,吞着内存却不干活。

这类问题在进程退出时尤其明显。asyncio.run()会等待所有Task完成,如果有Task永远挂起,程序就退不出去。表现就是:你发了Ctrl+C,进程却不响应,或者到了超时时间还在运行。

处理思路有三层。第一层是给挂起操作增加超时:

async def consumer_with_timeout(queue: asyncio.Queue): while True: try: item = await asyncio.wait_for(queue.get(), timeout=10) except asyncio.TimeoutError: # 超时后做兜底处理,而不是干等 await handle_idle() continue await process(item)

第二层是在外层给整个任务加超时:

try: await asyncio.wait_for(consumer(queue), timeout=60) except asyncio.TimeoutError: logger.error("consumer stalled, cancelling")

第三层是在进程退出时主动取消所有Task,而不是等它们自己结束:

async def shutdown(): tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True)

排查这类卡死问题时,我常用traceback来定位。Python 3.8之后,可以在协程对象上调用coro.cr_frame拿到挂起时的执行栈,也可以用asyncio.current_task()检查当前任务的状态。如果不好定位,就在关键挂起点前后加日志,确认是哪个await之后没有恢复。

4.4 超时、重试与限流的正确打开方式

聊完陷阱,说几个正常的模式。

超时是异步编程里最重要的一道防线。除了上面用到的wait_for,还有一种方式是给整个生命周期或链路设置超时:

async def fetch_with_retry(url: str, retries: int = 3): for attempt in range(retries): try: async with timeout(5): return await fetch(url) except TimeoutError: if attempt == retries - 1: raise await asyncio.sleep(2 ** attempt) # 指数退避

指数退避是重试的标配,第一次失败等2秒,第二次等4秒,第三次等8秒,避免在外部服务已经过载的情况下继续用高频请求踩油门。

限流方面,除了Semaphore,还有更细粒度的令牌桶算法可以使用,现成的第三方库有limits等。不过如果场景不复杂,Semaphore就够用了。

再强调一个容易被忽略的点:await时机的选择。有些人习惯在进入async函数的第一行就await一个耗时的初始化操作,导致整个并发组都卡在初始化上。正确做法是让初始化只执行一次,后续复用。可以用asyncio.Lock或者直接缓存Future:

_init_cache = None async def get_initialized_client(): global _init_cache if _init_cache is None: async with asyncio.Lock(): if _init_cache is None: _init_cache = await create_expensive_client() return _init_cache

5. await的进阶配合:生成器、上下文管理器与桥接

5.1 async for与异步生成器

await不只是用在函数调用的返回上,它还能和for循环、with语句配合,形成async for和async with这两个语法糖。

异步生成器用async def + yield声明,每次迭代时可以用await去获取下一批数据。比如分页拉取接口:

async def paginate(get_page_url, max_pages=10): url = get_page_url for _ in range(max_pages): page = await fetch(url) # 每取一页等一次网络返回 if not page.get("next"): return yield page url = page["next"]

使用方用async for消费:

async def collect_all(): results = [] async for page in paginate("/api/data"): results.extend(page["items"]) return results

async for的语法看起来像普通for,但它内部实现了异步迭代协议——每次取下一个元素都可能触发await,事件循环可以在迭代间隙调度其他协程。

这种模式特别适合流式处理:边下载边处理、边读边过滤,不需要等全部数据到位才开始干活。注意Python规定async for不能用在同步迭代器上,反之亦然,两边是两套协议。

5.2 async with与异步上下文管理器

异步上下文管理器通过__aenter__和__aexit__两个魔术方法实现。最常见的用例是管理数据库连接、HTTP会话等需要异步获取和释放的资源。

class AsyncSession: async def __aenter__(self): self.conn = await create_connection() return self.conn async def __aexit__(self, exc_type, exc_val, exc_tb): await self.conn.close() async def use_session(): async with AsyncSession() as conn: result = await conn.query("SELECT 1") return result

如果你用aiohttp,它的ClientSession就是标准的异步上下文管理器,官方文档也推荐用async with包裹请求。这里有一个我在真实项目中踩过的坑:忘了用async with,而是手动调用了ClientSession,结果是session没有正确关闭,连接一直占着不放,最终把连接池打满。

异步上下文管理器的价值在于把资源的获取和释放固定成结构化的代码路径,即使中间抛了异常,__aexit__也一定会被调用。这比手写try/finally要简洁得多。

5.3 同步代码与异步代码的桥接

最后聊一个实际工作中绕不开的话题:存量同步代码怎么和异步代码共存。

如果是Web框架(比如FastAPI、aiohttp),handle_request当然是async的,你可以在里面直接await异步库。但如果你的代码库里有大量历史同步函数,不可能一夜之间全部改造成async,这时候有几个思路。

第一个思路是把同步函数单独丢到线程池执行,仍然用asyncio.to_thread或run_in_executor,这样异步流程不会被同步调用阻塞。代价是引入了线程,要注意线程安全问题。

第二个思路是在同步代码里调用异步函数。这个更麻烦,因为同步函数里没法await。常见的做法是用asyncio.run在同步函数里启动一个新的事件循环:

def sync_call_async(): return asyncio.run(async_function())

但要注意:如果调用方本身已经在某个事件循环里,asyncio.run会报错。遇到嵌套场景,更稳妥的做法是用第三方库(比如asgiref的async_to_sync),它会帮你处理"当前是否已有事件循环"的情况。

桥接层的原则是:能在一层搞定就不要跨层,尽量避免在同步和异步之间来回横跳。如果一段业务逻辑既可能被同步调用又可能被异步调用,我会把核心逻辑写成纯同步函数,异步层只做一层薄薄的封装,用await asyncio.to_thread去调它。这样异步的事件循环不会被阻塞,同步的调用方也不用处理事件循环的复杂性。

6. 我对await逻辑的一些实操体会

写了几年异步代码,处理过几起线上事故之后,我逐渐形成了一套自己的判断标准,分享出来供参考。

第一,每写一个await,都问自己一句:这个await让出控制权之后,事件循环有没有别的事可做?如果答案是"没有",说明这里根本不需要异步,写成同步也没区别,异步反而增加心智负担。

第二,凡是IO边界都要用异步库。requests换成httpx或aiohttp,psycopg2换成asyncpg,redis-py换成redis.asyncio。如果某个库只有同步版本,宁可接受用to_thread把它丢出去,也不要直接在协程里裸调同步库。

第三,异常处理要前置。await后面的异常如果没有被捕获,它会沿着Task向上传播,可能触发gather或TaskGroup的取消逻辑。我见过不少因为一个协程抛异常导致整批请求被取消的事故。处理方式无非两种:要么在协程内部就捕获并处理好异常,要么用return_exceptions=True明确告诉gather"有异常也给我返回来,别直接往外抛"。

最后,调试异步程序时,我强烈建议开asyncio的debug模式,并善用事件循环的slow_callback_duration参数。默认情况下,如果某个回调或协程执行时间超过0.1秒,debug模式就会打warning,能帮你快速揪出那些偷偷阻塞事件循环的同步调用。

async/await这套机制说复杂也复杂,状态机、事件循环、Task调度,每一个概念单独拎出来都能讲半天;说简单也简单,核心其实就是"协作式多任务"这六个字——每个任务在合适的时候主动让出控制权,大家轮流用同一个线程。搞懂了这一点,你再看那些async代码,思路会通明很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进