先聊一个我自己在项目里踩过的真实场景。某次写一个抓取多个内部接口的数据汇总脚本,用requests写了一个 for 循环,四十个请求平均每个响应六百毫秒,脚本就在那里干等,跑一圈将近半分钟。第一版逻辑只有二十行,问题就是慢;于是换成线程池,快是快了,但连接复用、日志顺序、失败重试全挤在一起,调试起来更头疼。后来认真啃了一遍asyncio的核心原理,我才真正理解异步编程不是“并发执行”四个字那么简单,而是把“等待”这件事本身变成可以被调度的事件。
这篇文章就是那段时间用出来的总结。我会按asyncio核心原理从上到下拆解:阻塞从哪来,事件循环如何调度,协程、任务、Future 怎么配合,编排 API 怎么选,限流、超时、取消怎么处理,以及同步代码混进来之后的现场怎么救。适合已经会用 Python 基础、接触过 async 但感觉全在背 API 的人。看完之后,希望你看到一句await就能知道事件循环里发生了什么。
1. 一切从阻塞开始:I/O 等待为什么值得重新设计
1.1 一个再熟悉不过的卡顿场景
先看最原始的同步写法。假设我们要依次请求十个服务,每个响应耗时 200 毫秒:
import time def fetch(url): time.sleep(0.2) # 模拟网络响应 return f"data from {url}" start = time.perf_counter() for i in range(10): data = fetch(f"http://service-{i}.example.com") print(data) print(f"总耗时 {time.perf_counter() - start:.2f}s")这段代码没有任何逻辑错误,跑起来也很稳定,唯一的问题就是:十次请求依次等待,总时间恰好是 2 秒。CPU 大部分时间闲置,程序停留在sleep上,既不能提前发起下一个请求,也无法处理其他数据。
这种模式叫同步阻塞。请求发出后,当前线程必须等内核把数据读回来,才能继续向下执行。在单请求、低频调用的场景里完全够用,一旦请求量到几十上百,线性等待的时间就变得不可接受。
1.2 CPU 在等,线程也在等:同步模型的时间账
第一反应通常是多线程。十个请求开十个线程,理论上能把 2 秒压到 0.2 秒左右。这个方向没有错,但它有隐含成本。
线程是操作系统管理的资源,每个线程都有独立的栈空间,线程的创建、切换、销毁都需要内核参与。线程一多,上下文切换的开销会逐渐显现;更麻烦的是,多线程代码访问共享资源时需要加锁,而锁会让代码复杂度直线上升。Python 的 GIL 又决定了同一时刻只有一个线程能执行字节码,所以遇到纯计算任务时,多线程不但不快,反而更慢。
把时间账算清楚,结论就明显了:对于网络请求、文件读写、数据库查询这类 I/O 密集任务,程序的大部分时间不是在“计算”,而是在“等数据回来”。等数据的过程中,CPU 和线程都是闲置的。多线程只是把等待分摊到多个线程上,并没有减少总的等待资源占用。真正合理的做法是:让一个线程同时管理大量等待中的 I/O,谁的 I/O 先就绪,就先处理谁的。
这听起来像操作系统的多路复用,也是asyncio的核心思路。
1.3 事件循环思想的诞生:把等待集中管理
asyncio里有个核心组件叫事件循环,英文是 event loop。你可以把它理解成一个专门负责“盯梢”的调度员:协程遇到 I/O 等待时,不是抱住线程不放,而是告诉事件循环“我在等某件事,等好了叫我”,然后立刻让出执行权,去跑其他已经就绪的协程。
事件循环盯梢不是靠空转 CPU,底层依赖操作系统提供的多路复用接口,比如 Linux 上的epoll,macOS 上的kqueue,Windows 上的 IOCP。这些接口允许程序一次性注册一批关注的文件描述符,然后阻塞等待其中任意一个就绪,内核在有数据到达时唤醒程序。这样,一个线程就能同时追踪成千上万个连接的“等待事件”。
因此,asyncio并不是一种黑魔法,它只是把“等待状态”从业务代码里抽出来,交给事件循环统一管理。理解这一点再看后面的代码,很多让人困惑的行为就说得通了。
2. 事件循环里真正转的东西:协程、任务与 Future
2.1 协程对象不是函数调用:理解“惰性”的起点
很多初学者第一次写异步代码,会这么干:
async def hello(): return "hello" coro = hello() print(coro)打印出来的不是字符串,而是一个<coroutine object hello at 0x...>。原因很简单:调用一个async def函数不会执行函数体,而是创建一个协程对象。协程对象是惰性的,你必须把它交给事件循环运行,或者用await去驱动它。
这个“惰性”设计并非多此一举。它让协程可以被创建、传递、编排,而不需要立刻占用执行资源。你可以把协程对象看成一张菜谱,真正炒菜的是事件循环。
import asyncio async def hello(): return "hello" async def main(): result = await hello() print(result) asyncio.run(main())这里asyncio.run(main())创建事件循环,运行main()这个协程,await hello()表示在main协程内部挂起,等待hello()返回结果,然后再继续执行。
2.2 Task 是什么:被事件循环排队的协程
如果我们的程序里只有main一个协程,代码看起来和普通函数几乎一样。真正的并发发生在多个协程交错执行时。要让多个协程“同时”跑,需要借助 Task。
Task 可以理解成“协程的调度单元”。协程对象本身只是蓝图,只有包装成 Task 后才会被注册到事件循环,获得独立执行的资格。
import asyncio async def worker(name, delay): print(f"{name} 开始") await asyncio.sleep(delay) print(f"{name} 结束") async def main(): task1 = asyncio.create_task(worker("任务A", 1)) task2 = asyncio.create_task(worker("任务B", 0.5)) print("main 分发完成") await asyncio.sleep(0.2) print("main 等了一段时间") await task1 await task2 asyncio.run(main())输出大致是:
main 分发完成 任务A 开始 任务B 开始 main 等了一段时间 任务B 结束 任务A 结束注意,create_task只是把任务排进事件循环,并不会立刻执行任务内部代码。当下一个await让出执行权时,事件循环才会回头调度这些新任务。这也是为什么main的“分发完成”会先打印。
asyncio.create_task是 Python 3.7 以后推荐的方式。老代码里常见的asyncio.ensure_future也能用,但create_task语义更清晰,日常写作优先用它。
2.3 Future 与 Task 的区别,为什么说 Task 就是跑起来的 Future
Future是asyncio里的另一个基础概念,它是一个未来结果的占位符。你可以把它看成快递单:结果还没到,但单子已经存在,等快递到达后,可以查询状态、取件,或者注册一个“到了给我打电话”的回调。
Task 是Future的子类。它除了具备 Future 的结果保存能力,还额外包了一个协程,负责驱动协程运行、处理异常和取消。换句话说,Task 是“会执行协程的 Future”。
实际开发中,你直接操作 Future 的场景很少,更多是间接接触。比如asyncio.wait_for内部依赖 Future 的超时完成逻辑,asyncio.gather内部也涉及 Future 的状态管理。理解它们的关系,有助于看懂第三方库源码和排查异步框架里奇怪的错误。
一句话记忆法:协程是代码,Task 是运行中的协程,Future 是运行结果的状态容器。
3. await 的断点机制:一行一行的执行权交接
3.1 await 不只表示“等待”,更表示“挂起”
很多文章说await就是等待结果,这个解释太容易误导人。await更重要的作用是“挂起”——当前协程执行到这里,会把执行权交还事件循环,事件循环利用这段时间去跑其他协程,直到当前等待的条件满足,再把控制权交回来。
用生活化类比:在食堂排队打饭,同步模型是每个人都站在窗口前等自己的饭打好才离开;异步协作模型是你说“我要一份宫保鸡丁”,服务员给你一个取餐号,你听到叫号后才去取,等餐期间可以去干别的事。await asyncio.sleep(1)就是那句“服务员,我等着,好了叫我”。
关键在于:await后面跟的东西不能是普通对象,必须是可等待对象。
3.2 三种可等待对象各有各的调度语义
Python 里常见的可等待对象有三种,它们进入await后的行为不太一样:
| 可等待对象 | 调度语义 | 典型场景 |
|---|---|---|
| 协程对象 | 事件循环直接运行它,运行完才返回结果 | 模块化拆分逻辑,但无独立调度 |
| Task 对象 | 事件循环把它调度到任务列表,多任务并发执行 | 需要并发跑多个独立协程 |
| Future 对象 | 等待外部结果填充,填充后回调唤醒 | 底层异步操作、自定义桥接 |
如果await后面跟一个协程对象,本质上是把控制权交给这个新协程,新协程执行完毕后再回到当前协程。此刻并没有并发,只是一种“先做这件事再做那件事”的顺序执行。
如果await后面跟 Task,当前协程会在 Task 未完成时挂起,其余 Task 却不受影响,可以同时运行。
3.3 最小实战:三个任务的执行顺序时间线
模拟三个 I/O 任务,每个任务里既有自己的异步等待,也有日志打印,我们看看事件循环的时间线:
import asyncio async def a(): print("a: 开始") await asyncio.sleep(1) print("a: 结束") async def b(): print("b: 开始") await asyncio.sleep(0.5) print("b: 结束") async def main(): print("main: 创建任务") ta = asyncio.create_task(a()) tb = asyncio.create_task(b()) await asyncio.sleep(0.2) print("main: 我做完了自己的等待") await ta await tb print("main: 全部完成") asyncio.run(main())输出顺序是:
main: 创建任务 a: 开始 b: 开始 main: 我做完了自己的等待 b: 结束 a: 结束 main: 全部完成main分发完两个任务后,在await asyncio.sleep(0.2)处挂起,事件循环趁机执行a和b的开头部分。随后main的 0.2 秒睡眠先结束,回到 main,但 main 紧接着又await ta,于是继续等待。0.5 秒时 b 完成,1 秒时 a 完成,最后 main 收尾。
这段输出简洁地展示了单线程内的协作调度:没有任何两个协程同时运行,只是轮流利用等待时间。
4. 并发的编排方式:create_task、gather 与 wait 的取舍
4.1 用 create_task 自己掌控任务生命周期
create_task是并发编排里最底层、最灵活的手动方式。它可以让你决定任务何时创建、何时等待、如何收集结果。
比如一个数据抓取调度器,想先创建三十个任务,但后续要根据任务状态分批处理,用create_task最直观:
async def fetch(item): await asyncio.sleep(0.1) return item * 2 async def main(): tasks = [asyncio.create_task(fetch(i)) for i in range(30)] for i, t in enumerate(tasks): result = await t print(f"第 {i} 个结果: {result}")这种方式最大的坑是:创建了任务却忘记await。协程跑完还是没跑完,循环不知道,任务对象一旦被垃圾回收,可能出现RuntimeWarning: coroutine ... was never awaited的警告。
我的经验是:只要用create_task,就要保证在合适的位置统一await或cancel,否则任务生命周期会失控。
4.2 gather:批量执行的便利与昂贵代价
asyncio.gather是最常用的批量执行 API。它接收多个可等待对象,并发运行,等全部完成后一次性返回结果列表:
import asyncio async def worker(delay, value): await asyncio.sleep(delay) return value async def main(): results = await asyncio.gather( worker(1, "A"), worker(0.5, "B"), worker(0.8, "C"), ) print(results) # ['A', 'B', 'C'] asyncio.run(main())gather的便利在于帮你管理任务创建、等待、结果收集的全过程。但它也有一个容易忽略的成本:其中任何一个协程抛出异常,整个gather都会抛异常,其他正常任务也会被连带取消,除非你传入return_exceptions=True。
results = await asyncio.gather(ok_task(), bad_task(), return_exceptions=True)关闭异常传播后,bad_task()的异常会作为结果返回,而不是中断整个程序。批量任务里希望某个失败不影响整体时,这个参数很实用。
4.3 wait 的精细控制:响应最早完成的那一个
asyncio.wait和gather不同,它返回两个集合:已完成的任务集合done和未完成的任务集合pending。如果只关心最先完成的任务,它比gather更合适。
一个典型场景是同时请求多个镜像源,谁先返回就用谁,剩下的全部取消:
import asyncio async def request_site(name, delay): await asyncio.sleep(delay) return f"{name} 的数据" async def main(): tasks = [ asyncio.create_task(request_site("镜像A", 0.8)), asyncio.create_task(request_site("镜像B", 0.3)), asyncio.create_task(request_site("镜像C", 0.6)), ] done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) for task in done: print(task.result()) for task in pending: task.cancel() asyncio.run(main())return_when的取值还有ALL_COMPLETED和FIRST_EXCEPTION。需要精细控制“等待哪些”“等多久”“完成后其他任务怎么办”时,wait的灵活性是gather不具备的。
5. 工程化必修:限流、超时与任务取消
5.1 用 Semaphore 控制并发窗口
很多人一上来就把所有请求全部create_task,十次可以,一千次就会把外部服务击垮,也容易耗尽本机连接资源。异步并发能同时发起上千个任务,不代表外部系统能承受,所以必须限流。
asyncio.Semaphore提供一个简单的异步信号量:
import asyncio sem = asyncio.Semaphore(5) async def fetch(url): async with sem: await asyncio.sleep(0.1) return f"data from {url}" async def main(): urls = [f"http://service-{i}.example.com" for i in range(20)] results = await asyncio.gather(*(fetch(url) for url in urls)) print(len(results)) asyncio.run(main())这里Semaphore(5)的意思是同一时刻最多有 5 个协程进入async with sem内部。其余 15 个协程会排队等待,前面的任务释放信号量后才继续。
注意,asyncio.Semaphore只能在事件循环里使用,不能跨线程操作。它和threading.Semaphore是完全不同的实现,不要混用。
5.2 wait_for 和 timeout:超时语义没那么简单
网络请求最怕挂死,所以超时是异步程序的标配。asyncio.wait_for可以给单个协程或任务设置超时:
async def slow_worker(): await asyncio.sleep(100) return "永远到不了" async def main(): try: result = await asyncio.wait_for(slow_worker(), timeout=2) except asyncio.TimeoutError: print("请求超时")需要注意,wait_for超时后,内部协程会被自动取消。也就是说,底层任务会收到CancelledError并尝试清理。Python 3.11 以后还推荐用asyncio.timeout上下文管理器,写法更灵活:
async def main(): try: async with asyncio.timeout(2): result = await slow_worker() except asyncio.TimeoutError: print("请求超时")我踩过的坑是只捕获了TimeoutError,没有考虑CancelledError。因为wait_for内部取消任务时,任务可能在清理阶段又抛异常,结果错误类型不是TimeoutError而是别的。捕获异常时尽量把CancelledError也考虑进去。
5.3 CancelledError:取消后的清扫工作
任务被取消时,事件循环会向协程抛出asyncio.CancelledError。注意,从 Python 3.8 开始,CancelledError继承自BaseException,不是Exception,所以普通的except Exception捕不到它。
如果协程内部持有需要释放的资源,取消后不做清理,资源就会泄漏:
async def worker(): try: while True: await asyncio.sleep(1) finally: print("清理连接和临时文件") async def main(): task = asyncio.create_task(worker()) await asyncio.sleep(2) task.cancel() try: await task except asyncio.CancelledError: print("任务取消完成")在finally里做清理是标准做法。但要注意,finally里不要做太长时间的await,因为取消流程会一直等待清理完成。如果在清理里又需要等待很久,取消操作反而会卡住。
6. 别把同步阻塞带进事件循环:灾难现场与调试
6.1 time.sleep 卡死循环,日志却正常输出
这是我见过最频繁的异步事故。一个协程里用了time.sleep(5),表面看只是“让程序慢一点”,实际却让整个事件循环冻住 5 秒:
import asyncio import time async def bad_worker(): time.sleep(5) # 灾难来源 async def good_worker(): await asyncio.sleep(1) async def main(): await asyncio.gather(bad_worker(), good_worker()) asyncio.run(main())time.sleep是同步阻塞调用,会卡住当前线程,而事件循环就跑在这同一个线程上。它一睡,整个循环里的其他协程全都没机会执行,异步程序退化成同步程序。
判断一个调用是不是阻塞,不能看名字,而要看它是否主动让出执行权。普通 Python 函数里的sleep、requests.get、read、write大多数是阻塞调用,放进协程会破坏事件循环。
6.2 与阻塞式第三方库共存:to_thread 策略
项目里总会遇到“只有同步版本,没有异步版本”的第三方库,比如某些老牌数据库驱动。一种做法是把这些阻塞调用丢进线程池执行,让事件循环不被卡住:
import asyncio def legacy_db_query(user_id): import time time.sleep(1) # 同步阻塞 return f"user {user_id}" async def main(): result = await asyncio.to_thread(legacy_db_query, 42) print(result) asyncio.run(main())asyncio.to_thread是 Python 3.9 引入的便捷 API,底层逻辑是loop.run_in_executor。它把阻塞函数执行放到默认线程池,事件循环继续处理其他协程,线程池里的函数完成后再把结果送回协程。
要注意,to_thread只是解决了“阻塞”的外层问题,并没有让库本身变成异步。如果并发很高,线程池同样有瓶颈,最好还是寻找真正支持异步的驱动。
6.3 调试三件套:DEBUG 模式、警告日志与 slow callback
排查asyncio问题,我习惯先开 DEBUG 模式。设置环境变量或者代码里显式开启:
import asyncio async def main(): loop = asyncio.get_running_loop() loop.set_debug(True) await asyncio.sleep(1) asyncio.run(main())DEBUG 模式下,事件循环会记录更多调度信息,包括未完成的 Future、延迟过长的回调,以及耗时超过阈值的同步回调。环境变量方式更省事:
PYTHONASYNCIODEBUG=1 python your_script.py最常见的警告是RuntimeWarning: coroutine 'xxx' was never awaited,它说明协程对象被创建但没有被驱动。原因无非是忘记await,或者写了create_task却没保存任务对象。这类警告通常不是致命的,但它往往是任务泄漏、资源不释放的起点。
还有一个排查技巧:查看事件循环里是否存在 slow callback。如果日志显示某个回调运行了 100 毫秒以上,你几乎可以断定那里藏着同步阻塞代码。顺着回调栈一路找下去,十有八九能找到罪魁祸首。
import asyncio import time async def main(): loop = asyncio.get_running_loop() loop.set_debug(True) # 设置 slow callback 阈值,默认 100ms loop.slow_callback_duration = 0.05 time.sleep(0.2) # 大概率被上报 await asyncio.sleep(0) asyncio.run(main())如果用的是成熟异步框架,比如aiohttp等,它们内部集成的事件循环同样适用这套调试手段。开启 DEBUG 后,框架日志会明显变啰嗦,但这正是定位问题的关键线索。
把上面这些经验沉淀成一套固定动作,我的标准流程是:先写信号量控制并发窗口,再用统一的超时包装网络调用,最后跑一遍 DEBUG 模式检查慢回调。这套流程固定下来后,asyncio项目很少让我抓瞎。你自己动手写异步代码时,遇到卡顿、警告或任务泄漏,顺着事件循环这条线去查,往往比翻文档更有效。