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.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.sleep或await asyncio.sleep_ms。对于
csi.CSI.snapshot:使用捕获页面所构建的 异步快照包装器。对于长时间的计算(图像处理、JPEG 编码):要么接受这一开销,要么把工作拆分成多个块,在各次迭代之间
await。
Asyncio 无法让一个同步调用变成非阻塞的。它只能在某个东西正在被 await 期间 让其他协程运行。