Python 中的异步编程
3208 字约 11 分钟
摘要通过生活化的比喻和核心机制的讲解,帮助理解 Python 异步编程的原理和实现方式。
编写程序时,经常会遇到这样的情况:程序执行到某一步,比如读取一个很大的文件、访问一个响应很慢的网页,或者通过 API 调用 LLM,整个程序只能停下来等待。在等待期间,CPU 完全空闲,没有做任何有用的工作。对于需要高性能的应用来说,这是很大的浪费。为了解决这个问题,人们提出了异步编程。
为什么需要异步
可以把计算机执行任务想象成餐厅后厨的厨师。同步编程就像一个只会一件事的厨师:他一次只做一道菜。把牛排放进煎锅之后,他会站在锅边盯着,直到牛排煎好才去做下一道菜。在这段时间里,如果有新订单,或者需要准备沙拉,他也只能等着,无法抽身处理。订单一多,整个厨房的效率就会很低。
异步编程的做法不同。厨师把牛排放进煎锅后,转身去切菜、摆盘、接听外卖电话。他不需要一直守着煎锅,只需要设定一个闹钟。闹钟响了,他再回来把牛排盛出来。这样,厨师在等待牛排煎熟的时间里,还能完成其他工作,整个后厨的效率也随之提高。这就是异步编程的核心思路:不让 CPU 浪费在等待耗时操作上。
事件循环的工作方式
理解了目标之后,再看它是如何实现的。在 Python 中,最重要的角色是事件循环(Event Loop)。
可以把事件循环看作一个不知疲倦的任务调度员。他手里有一张清单,记录着所有需要执行的任务。他从清单中取出一个任务开始执行。如果任务遇到需要等待的操作(比如访问网络),任务会主动告诉调度员:“我要等一会儿,你先去处理别的任务。”
这时,事件循环会暂时把这个任务放到一边,转而处理清单中的下一个任务。等待的操作完成后,该任务会再次通知调度员。调度员在下一轮循环中把它取回,从上次暂停的位置继续执行。
这个过程不断重复。由于切换非常快,看起来就像多个任务同时运行。实际上,在任何一个瞬间,只有一个任务在真正执行,但这已经足以大幅提高程序的吞吐量。
协程与 await
为了让这种协作更顺畅,Python 引入了协程(coroutine)。协程本质上是一种特殊的函数。 普通函数一旦开始执行,就会一直运行到返回为止。协程则不同,它可以在执行过程中主动让出控制权,暂停自己,把执行机会交给事件循环去调度其他任务。
定义协程很简单,只需要在函数定义前加上 async 关键字。而让协程交出控制权的操作,由 await 来完成。
当你写下 await some_task(),就是在告诉程序:“这里有一个耗时操作,在它完成之前,你可以先去处理其他任务。”程序执行到这一行时会挂起当前协程,等待 some_task 返回结果。结果返回后,协程再从这里继续执行。
需要注意的是,await 只能在 async 定义的函数内部使用。如果在普通代码中写 await,程序会报错。这相当于规定了一种特定的语法环境:只有进入这种“协程模式”,才能使用异步带来的便利。
最开始运行的 main 函数也需要是 async 定义的吗?是的。承载程序主要逻辑的入口函数也必须是协程。
if __name__ == "__main__": asyncio.run(main())中的asyncio.run()是异步世界的入口。在它执行之前,程序仍运行在普通的同步环境中。调用asyncio.run(main())时,Python 解释器会依次完成以下工作:
创建事件循环: 为当前线程创建一个新的事件循环(Event Loop);
调度任务: 把传入的
main()协程包装成一个 Task 对象,交给事件循环;启动循环: 调用事件循环的
run_until_complete()方法开始运行。为什么不能在模块顶层直接写 await?这与 Python 的执行方式有关。模块顶层代码在脚本启动时会按顺序同步执行。普通的函数调用是同步阻塞的,而
await的作用是挂起当前协程,并把控制权交给事件循环。如果在没有async标记的同步代码中使用await,程序就没有可以接手任务的事件循环,因此无法执行。
Future 与底层回调
在 await 的背后,Python 到底做了什么?这涉及协程、Future 对象和底层调度机制之间的配合。
调用一个异步函数时,它不会像普通函数那样立即执行,而是返回一个协程对象。这个对象只是一个包装,记录了函数的状态和执行逻辑,但还没有开始运行。需要把它提交给事件循环,或者在另一个协程中用 await 触发它。
遇到 await 时,事件循环会创建一个 Future 对象。可以把它理解为一个占位符,代表将来某个时刻才会产生的结果。如果任务正在等待网络响应,Future 就会保持“未完成”状态。事件循环会持续监控它,直到外部事件(例如网络数据到达)触发 Future 的完成信号。
Future 对象是什么?Future 表示一个尚未完成、但将来会完成的操作。它连接了“发起异步操作的一方”和“接收结果的一方”。可以把它看作一个用来装结果的容器:
- 初始状态:Future 刚创建时是空的(Pending);
- 执行过程:异步操作在后台运行,Future 在这段时间内保持等待状态;
- 得到结果:操作完成后,系统会把结果(如网络响应数据)或异常(如连接超时)放入容器,Future 的状态变为完成(Finished)。
早期的异步实现基于生成器和 yield 关键字。之后,Python 引入了更直接的 async 和 await 语法,把底层的复杂性封装起来,让程序员可以用接近同步代码的方式编写异步逻辑。
使用异步时的注意事项
异步编程并不能解决所有问题。它最适合 I/O 密集型任务,例如网络请求、文件读写和数据库查询。在这些场景中,程序大部分时间都在等待外部响应。
如果程序是计算密集型的,例如复杂的数学模型、高频的图像渲染或复杂的加密运算,使用异步不但不会提高性能,还会因为频繁切换任务而增加开销,反而变慢。在这种情况下,应该使用多进程来利用多核 CPU,而不是强行使用异步。
另一个常见的问题是,异步编程具有传染性。一旦底层代码使用了异步,调用它的上层代码也必须是异步的。不能在同步函数中直接调用异步函数并拿到结果,否则整条调用链都需要重构。在大型项目中,这会增加改造的成本。
此外,编写异步代码时必须注意阻塞操作。如果在异步函数中调用 time.sleep(10),整个事件循环都会被卡住,在这 10 秒内,程序无法处理任何任务。应该使用异步版本的 await asyncio.sleep(10),主动让出控制权。
协程的内部机制
前面用厨师做菜的比喻,建立了对异步编程的直观理解。要真正掌握这套机制,还需要了解解释器内部是如何让代码和 CPU 协同工作的。
从生成器到状态机
要理解协程,需要先回到 yield 关键字。在很长一段时间里,Python 的协程都是基于生成器实现的。生成器函数执行时可以通过 yield 挂起,把控制权交给调用方,并保存当前的局部变量和执行位置。
使用 async def 定义函数时,Python 在编译阶段(从源码转换为字节码的过程)就会把它标记为协程。调用这个函数时,它不会立即执行函数体,而是返回一个协程对象。这个对象内部维护着一个状态机。
状态机记录了函数执行到哪一行,以及当前的栈帧状态。对协程执行 await 时,实际上是向事件循环注册了一个唤醒请求。此时协程进入 SUSPENDED(挂起)状态,解释器把控制权交还给事件循环。可以把它理解为给函数设置了一个存档点:保存当前的执行现场,等条件满足时,再从存档点恢复。
事件循环与 selector
事件循环在操作系统层面是如何“循环”起来的?这需要借助操作系统提供的 I/O 多路复用机制。
在 Linux 上,最常用的是 epoll。它可以同时监控成千上万个网络连接的状态。当某个连接有数据到达时,epoll 会通知内核,内核再告知 Python 的事件循环。
Python 的 asyncio 在底层使用了这些系统调用。事件循环内部维护着一个 Selector。当程序中有许多等待中的 Future 时,事件循环会把对应连接的文件描述符交给 Selector 监控。程序运行到 loop.run_forever() 时,就进入了循环。每次迭代时,事件循环会先调用 select 系统调用,阻塞地询问内核:“现在有没有可以处理的事件?”
内核一旦报告有数据可读,Selector 就会返回,事件循环根据结果找到对应的 Future,把结果写入,并将其标记为完成。之前在 await 上等待的协程会被重新放入可执行队列,并在下一轮迭代中继续运行。
内存占用与切换开销
很多人担心大量协程会耗尽内存,或者切换的开销太大。实际上,Python 的协程切换非常轻量。
协程运行在同一个线程中,不需要像多线程那样处理内核级的上下文切换,也不需要维护多套独立的寄存器和栈空间。所谓的“切换”,本质上是 Python 解释器在函数调用栈中移动了一个指针,改变执行顺序,并保存一小份状态。
每个协程对象的大小是固定的,而且很紧凑。成千上万个协程存在时,它们只是堆内存中的一些小对象。因此,Python 可以支持几十万个并发连接,而不会耗尽服务器内存。在处理大量长连接时,异步的内存占用通常比线程池模型少几个数量级。
await 调用链
理解 await 的连锁反应是调试异步代码的关键。当你 await 一个协程时,实际上是在事件循环中发起了一次嵌套调用。如果 A 调用 B,B 又 await 了 C,那么这三者就形成了一条调用链。
当最底层的 C 完成时,它会触发 Future 的回调,回调会唤醒 B。B 恢复执行后继续完成后续逻辑,最后唤醒 A。这一过程由 Task 对象管理。Task 是协程的增强版,除了保存协程的逻辑,还能驱动自身的执行,并与事件循环交互。
通过这种嵌套,复杂的异步逻辑可以写成接近同步的线性代码。链条中任何一个环节抛出的异常,都会沿着 await 的路径向上传递,直到被某个 try...except 捕获,或者导致整个 Task 失败。异步编程的错误处理因此仍然遵循 Python 原有的规则。
总结
从底层机制来看,异步编程的本质是调度:利用操作系统的 I/O 多路复用,把等待的时间转换为高效的任务队列管理;利用生成器和状态机,在语言层面实现函数的暂停与恢复。
这也解释了为什么说“异步编程具有传染性”。底层的调度依赖上层的 async/await 语法,而任何阻塞操作都会破坏这套协作机制。理解底层原理,不仅能帮助你避开常见的陷阱,也能在遇到性能瓶颈时,通过分析 Task 的调度状态找到问题所在。