正文
这就要认识异步编程。
先看一种容易理解的安排:程序查询第一份日历,等它返回后,再查询第二份资料;两份都拿到以后,才继续整理通知。这样的处理关系清楚,但如果第二项工作并不依赖第一项,等待期间可能还有其他事情可以推进。
异步编程关注的是怎样组织操作的发起、等待与后续处理,让程序不必把等待外部结果的时间,全都变成当前执行线程上的阻塞。
这里的等待,常见于网络、文件或其他输入输出操作。通常把这类操作简称为 I/O。等待日历服务响应时,本机不一定在持续进行大量计算,但结果还没有准备好。
阻塞式调用会让当前线程停在相应位置,直到操作完成或出现错误。支持异步的操作,则可以与运行时的调度机制配合,在等待时挂起当前任务,让这个线程处理其他已经可以继续的工作。
这不表示结果可以省略,也不表示日历服务处理得更快。异步改变的是程序怎样安排等待与推进。正确生成通知,仍然需要等到相关会议资料已经取得。
在 Python 中,asyncio 是用于组织异步程序的一套标准库能力。它通常通过事件循环,管理能够暂停、恢复的任务。
“事件循环”可以先理解为一个持续调度的过程:查看哪些操作已有结果,哪些任务能够继续,再安排相应代码执行。它需要实际运行,异步任务才有机会推进。
先看一个适用于普通 Python 脚本的小例子:
import asyncio
async def read_meeting():
await asyncio.sleep(0)
return {"title": "项目例会"}
async def main():
meeting = await read_meeting()
print(meeting["title"])
asyncio.run(main())
这段代码只用来演示调度与等待,没有访问真实日历。read_meeting 返回的会议资料,是我们手动写在程序里的内容。
async def 定义一个协程函数。调用这种函数,会得到一个协程对象,可以把它理解为一份可被异步运行机制推进的处理过程。仅调用函数,还不会自动把它安排成正在运行的任务。协程函数与调用方式
main 中的 await read_meeting(),表示当前处理需要取得这项协程的结果。结果回来以后,才继续读取标题并输出。
read_meeting 内部的 asyncio.sleep(0),提供了一次让其他任务获得运行机会的挂起点。这里没有安排固定的正数延时,也不能把它当成网络请求的实现。真实应用需要使用日历接口相应的异步调用方式。协程、任务与可等待对象
最后一行 asyncio.run(main()),为这份普通脚本启动并管理顶层异步执行,直到 main 完成。在已经有事件循环的交互环境中,应当按环境提供的方式运行,不能不加区分地嵌套启动同一线程上的事件循环。顶层异步程序的运行
在这个例子里,只有一条主要任务路线。事件循环并没有因为出现 async 就自动另找许多事情来做;还需要有其他已经安排、能够运行的任务,等待时间才有可能被利用。
这也解释了一个常见误会:把两个操作依次写成 await,并不表示它们已经同时开始。
如果程序先等待第一项完成,再发起第二项,两项仍然沿着这个顺序执行。想让相互独立的工作重叠推进,需要明确地安排相应任务,并处理它们的完成与失败。
但是否独立,应当从任务依赖判断。例如,同时读取两份已知相关资料,可能有并发处理的空间;根据日历结果确定会议,再保存相应通知,则存在先后依赖,不能为了“异步”就提前保存尚未核实的正文。
await 也不是一个保证每次都切换到其他任务的按钮。如果等待对象已经就绪,或者被调用过程没有真正挂起,代码可能继续向下运行。需要看实际操作如何与调度机制配合。
在常见的单线程事件循环中,一个任务正在执行普通代码时,同一线程上的其他任务不能同时执行。当前任务只有在适当位置让出执行机会,其他任务才有机会继续。
因此,把一个很长的普通计算函数放进 async def,并不会自动让它停止占用事件循环。直接调用阻塞式网络库或使用阻塞等待,也可能让其他异步工作一起延迟。事件循环与阻塞代码
需要使用已有的阻塞功能时,可以结合成熟运行机制,安排到合适的线程或进程中。具体方式要考虑操作类型、库的使用要求和资源开销,不是简单地在函数名前面加一个 async。
异步也不等于多核并行。一个线程可以在多项 I/O 工作之间切换,把等待安排得更有效;如果要让大量计算真正同时占用多个处理核心,还需要另外考虑执行方式。下一篇会专门区分并发与并行。
回到会议助手,假设一次查询正在等待,用户又点击了取消。系统需要把这个意图传递给有关任务,不再安排新的后续操作,并处理已经开始的工作。
异步程序通常有自己的取消机制,但取消当前等待,不保证远程服务已经撤回了请求。草稿如果已经创建,不能因为本地协程取消,就假定外部结果自动消失。
超时也需要解释。它表示某一段等待或处理超过了约定限制,不必然说明服务端完全没有执行。后续是否重试,需要结合实际操作和结果查询能力判断。
这与前面学习的终止条件衔接起来了:停止等待、结束当前程序步骤,以及用户任务成功,是不同的状态。程序应当把原因交代清楚,并保留必要的进展。
同时进行多项任务时,还需要确保每项结果能够找到自己的归属。例如,同时查询两场会议,不能因为第二场先返回,就把它当成第一场的资料。记录任务标识和对应参数,有助于避免结果混用。
错误也不能被遗忘。程序安排一个异步任务以后,如果既不等待它,也不读取它的失败结果,就可能出现任务已经报错,外层却仍然以为工作在正常推进。
因此,安排任务时,通常还要明确由谁接收结果、由谁处理异常、由谁负责取消和回收资源。异步提供一种组织执行的能力,这些责任仍然需要程序设计。
异步资源也需要适当清理。例如,使用网络客户端取得结果后,要按库的要求关闭连接;任务被取消以后,仍需要处理占用的资源。相关的异常与资源清理,会在 02.09 中展开。
对于初学者,可以先观察一条简单路线:请求在哪里发出,程序在哪里等待,结果怎样接回,等待期间还有哪些独立工作能够推进。如果这些关系明确,再增加多任务安排会更容易理解。
也不必把所有程序都改成异步。一个只生成本地通知文字的小函数,可能完全不需要事件循环。如果任务的主要时间花在等待多个外部服务上,异步组织才可能带来实际帮助。
是否有帮助,最后仍要结合正确性、等待时间、连接数量和资源使用评价。任务安排得更多,并不能自动证明应用更快或更可靠。
下一篇进入 02.08:并发与并行,进一步区分多项工作交错推进、真正同时执行,以及线程、进程和异步任务之间的关系。
当前学习编号:02.07 异步编程。
下一学习编号:02.08 并发与并行。后续还有其他学习编号。
关联分支:执行安排对应 02.08 并发与并行、02.09 异常处理与资源清理、16.11 多工具依赖与并行边界、18.06 顺序、并行与扇出汇总。
取消与资源对应 18.13 暂停、恢复与取消、24.04 超时与截止时间、24.05 取消传播与任务确实停止、24.08 限流与背压、24.13 资源所有权、清理与泄漏防治。协程、可等待对象、任务、事件循环、阻塞与非阻塞 I/O,是本编号可继续下钻的支系知识。