8.15. 陷阱

正是那些让 asyncio 变得好用的特性——没有抢占、显式的 await——也给它带来了一系列会咬人的形态。本页面是对那些足够常见、值得了解的陷阱的汇总。

8.15.1. 忘记 await

调用一个 async def 函数会返回一个 协程对象。它并不会运行该函数的函数体。要真正执行它,必须 await 该协程或将其包装进一个任务中:

async def main():
    send_request()              # bug: returns the coroutine, does nothing
    await send_request()        # right: run it to completion
    asyncio.create_task(send_request())  # right: run it concurrently

这个 bug 是无声的——协程对象被创建、被丢弃,却从未被执行。应用程序会像一切正常那样继续运行。MicroPython 有时会记录一条警告,提示某个协程 从未被 await;有时则不会。请在每一个看起来像函数调用的调用点审查是否漏掉了 await

8.15.2. 没有 await 的紧密循环

一个在循环中运行且从不 await 的协程会独占事件循环。在该循环退出或让出之前,没有其他任务能够推进:

async def counter():
    n = 0
    while True:
        n += 1               # bug: starves the loop

解决办法是在循环内部加入一次让出——通常是 await asyncio.sleep_ms(0)——这样其他就绪的任务才有机会运行。计算密集型工作也属于这种情形:一个每次迭代要运行数百毫秒的图像处理循环,应当在每次迭代中至少让出一次,以免程序的其余部分陷入停滞。

8.15.3. 吞掉 CancelledError

取消页面 已经详细介绍过这一点。这里重复一遍,是因为它是“我的应用程序无法关闭”的最常见原因:一个协程为了清理目的而捕获了 asyncio.CancelledError,却忘记重新抛出它。于是该任务继续运行;请求取消的调用者则永远挂起,一直等待它结束。请务必在清理之后重新抛出,或者使用 try/finally 代码块来代替显式的 except

8.15.4. 跨 await 修改共享状态

协作式调度保证一个协程在两次 await 之间 独占 CPU,但只要它一 await,另一个协程就能运行。如果两个协程在包含 await 的步骤中修改同一个数据结构,它们的操作就可能以破坏该结构的方式交错执行:

# bug: two tasks running do_work simultaneously can
# interleave around the await and corrupt items
async def do_work():
    n = len(items)
    await asyncio.sleep_ms(0)
    items.append(some_work(n))

对于 在一个协程内部、且只在两次 await 之间被修改的状态,不需要任何同步。对于跨 await 被修改、且被一个以上协程访问的状态,请用 Lock 把临界区包裹起来。

8.15.5. 模块级的 await

await 只在 async def 函数体内部才有效。在模块级——任何协程之外——书写它是一个语法错误:

# bug: not inside an async def
result = await fetch()

解决办法是把相关工作放入一个协程中,并从程序的 asyncio.run() 入口点调用它。

8.15.6. 多次调用 asyncio.run

MicroPython 只有 一个 事件循环。连续两次调用 asyncio.run()——一次用于设置,一次用于主要工作——使用的仍是同一个循环。而 从一个正在运行的协程内部 调用它则是一个错误:循环已经在运行了。这两种情况最常出现在脚本随需求自然增长时,作者试图通过添加更多 run() 调用来扩展它,而不是把新工作折叠进现有的 main 中。

8.15.7. 从中断中使用 Event

asyncio.Event.set() 只有在事件循环内部调用才是安全的。从一个 GPIO 中断处理程序中调用它会有破坏数据的风险。要从中断中唤醒一个任务,请改用 ThreadSafeFlag——关于它的页面 介绍了这种形式。

8.15.8. 长时间的同步调用

协程可以 await asyncio 自己的等待原语;它调用的其他任何东西都会同步运行,并阻塞循环直到返回。一次 200 毫秒的阻塞式 time.sleep()、一次需要 80 毫秒才能刷新完成的 SD 卡写入、一次大型 JPEG 压缩、一次 csi.CSI.snapshot() 调用——这些都会在其整个持续时间内占住事件循环。解决办法取决于具体的调用:

  • 对于 time.sleep:将它替换为 await asyncio.sleepawait asyncio.sleep_ms

  • 对于 csi.CSI.snapshot:使用捕获页面所构建的 异步快照包装器

  • 对于长时间的计算(图像处理、JPEG 编码):要么接受这一开销,要么把工作拆分成多个块,在各次迭代之间 await

Asyncio 无法让一个同步调用变成非阻塞的。它只能在某个东西正在被 await 期间 让其他协程运行。