8.1. 协作式并发¶
Asyncio 的调度模型是协作式的,而非抢占式的。这一区别是本节其余内容所依赖的最重要的心智模型,因此在任何代码出现之前都值得先把它弄清楚。
8.1.1. 抢占式与协作式¶
抢占式调度器——桌面操作系统用来同时让许多程序保持运行的那种调度器——可以在任意时刻暂停当前正在运行的那段代码并切换到另一段。正在运行的代码无需做任何特殊处理;调度器会中断它。这使得抢占式调度非常灵活(没有哪一段代码会因为运行缓慢而让其他代码挨饿),但这也意味着任何共享变量都必须小心地加以保护,因为切换可能发生在任何地方——甚至是在写入某个值的过程中,或者读取一个列表的中途。
协作式调度器只能在当前正在运行的那段代码显式交还控制权的位置切换到其他代码。在 asyncio 中,这些位置就是每一个 await,以及每一次对内部会让出执行权的协程的调用(最常见的是 asyncio.sleep())。在两个 await 之间,正在运行的协程独占 CPU。
由此引出两个结果:
从不 await 的协程永远不会被暂停。 如果一个协程停留在一个内部没有
await的紧凑循环中,它就会独占调度器,其他任何代码都无法取得进展。解决方法是在循环中合适的位置执行await asyncio.sleep_ms(0)(或其他等待调用)。共享状态在 await 之间是安全的。 两个协程无法在一个内部没有
await的操作进行到一半时交错执行。当抢占落在一次多步骤更新的中途时所产生的那种损坏——一段代码读取某个值,而另一段代码正在更改它的中途——在这里根本不可能发生。当多个协程必须跨越 await 共享某个资源时,仍然需要在协程之间进行协调,但语句执行到一半被交错的问题在这里并不适用。
8.1.2. 三个层次¶
每个 asyncio 脚本都由相同的三个层次构建而成。接下来的两页会详细介绍它们;这里给出的是阅读它们时需要记住的名称。
协程——用
async def声明的函数,每个都是一个自包含的工作单元,会在适当的位置进行 await。Python 概述介绍了async/await关键字;在 asyncio 中,它们正是协程向调度器让出执行权的方式。任务——
asyncio.create_task()在协程外面包裹的一层封装,用于让它与当前协程并发地被调度。应用程序通常会为那些长时间运行的工作(快照循环、网络客户端、UART 读取器,……)创建少量任务。事件循环——底层的引擎,它跟踪哪些协程正在等待、哪些已准备好运行,并在每一个
await处在任务之间切换。应用程序并不编写这个循环;它把一个顶层协程交给asyncio.run(),然后由这个循环从那里开始驱动一切。
当应用程序被这样描述时——作为一小组由事件循环组合起来的协程——并发就成了程序结构本身的一种属性,而不是应用程序需要一步步去管理的东西。