在 Fuchsia Controller 中使用异步 Python 的最佳实践

本页面讨论异步 Python 的常见任务流以及使用异步 Python 时可能存在的问题。

在继续之前,请查看介绍 asyncio官方文档和 Fuchsia Controller 的教程

背景

以下几项通常涉及异步代码,不一定特定于 Python:

  • 异步代码不一定是多线程代码。对于与 Fuchsia 控制器相关的代码,几乎所有内容都是单线程的。
  • 异步代码在循环(也称为执行器)内运行,每当出现等待时,系统就会放弃该循环。

因此,可能会导致某些问题难以调试,例如,某项后台任务打算永久运行,但又引发了异常。如果您从未等待此任务,则异常仅在日志中可见。

常见误区

未保留对任务的引用

如果您循环启动某个任务,例如:

# Don't do this!
asyncio.get_running_loop().create_task(foo_bar())

并且您未保留对返回值的引用,那么 Python 垃圾回收器可能会随时取消并删除此任务。

始终确保将您创建的任务存储在变量中,例如:

# Always store a variable!
foo_bar_task = asyncio.get_running_loop().create_task(foo_bar())

然后,确保任务的生命周期与其需求相关联。如果这是特定的测试用例,请将任务存储为测试用例类的成员,并在拆解期间取消该任务。

将阻塞代码与异步代码混合使用

在测试代码时,某些实验常常会针对某些事件抛出 sleep 语句。请勿将 asyncio.sleeptime.sleep 混淆。前者会返回一个协程,而后者会屏蔽整个循环。

例如:

import asyncio
import time


async def printer():
    """ Prints a thing."""
    print("HELLO")


async def main():
    task = asyncio.get_running_loop().create_task(printer())
     time.sleep(1) # THIS WILL BLOCK EXECUTION. 
    task.cancel()

if __name__ == "__main__":
    asyncio.run(main())

以上代码不会输出任何内容。不过,如果您将 time.sleep(1) 替换为 await asyncio.sleep(1),则会输出 HELLO(这会生成异步循环)。

FIDL 服务器错误

鉴于异步编程的性质,您的 FIDL 服务器实现可能会出现运行时错误,而您却对此毫不知情,尤其是当您的 FIDL 服务器实现连接到 Fuchsia 设备上的组件时。

遗憾的是,这种情况很难调试,而且由于 FIDL 服务器实现崩溃,因此可能只会在设备上显示为 PEER_CLOSED 错误。

一种方法(用于调试)是向任务添加回调,用于检查任务中是否发生了异常并向日志发送某些内容(甚至是对程序执行硬崩溃)。

例如:

def log_exception(task):
    try:
        _ = task.result()
    except Exception as e:
        # Can log the exception here for debugging.

task = asyncio.get_running_loop().create_task(foobar())
task.add_done_callback(log_exception)

常见任务流

同步任务

首先,建议查看 Python 文档页面,了解有关可用的同步对象的入门指南。请注意,这些对象不是线程安全的。不过,其中有些方法值得借鉴。

正在等待多个任务

您可能需要等待多项任务完成。您可以使用 asyncio.wait 完成此操作。如果您要执行类似于 select 系统调用的操作,请将 return_when 参数设置为 asyncio.FIRST_COMPLETED,例如:

done, pending = await asyncio.wait(
    [task1, task2, task3],
    return_when=asyncio.FIRST_COMPLETED
)

结果包含一个任务元组(在本例中为 donepending)。可以像处理任何其他任务对象一样处理这些任务。因此,对于 done 对象,您可以遍历这些元素,并通过检查 result() 函数来收集结果。

在同步代码中运行异步代码

在编写代码时,大多数 Fuchsia Controller 代码都是从同步代码中调用的。为了确保任务可以在后台运行,通过 asyncio.new_event_loop() 保持循环的实例可能很有意义。

这是因为 asyncio 任务必须保持在单个循环中。asyncio.Queue 等同步对象也是如此。

如果您与之互动的代码足够简单(一次运行一个 FIDL 方法),则可以使用以下代码执行此操作:

asyncio.run_until_complete(...)

但是,如果您需要运行任务或处理同步,则需要将内容封装在包含循环的类中。只需确保它在测试或对象中,有用于关闭循环的拆解代码,例如:

class FidlHandlingClass():

    def __init__(self):
        self._loop = asyncio.new_event_loop()
        # Can launch tasks here.
        self._important_task = self._loop.create_task(foo())

    def __del__(self):
        self._important_task.close()
        self._loop.close()

您还可以使用修饰器扩展类,以便在所有公共函数中隐式使用循环(尽管它们对调用方看起来会同步),例如:

from functools import wraps

class FidlInstance():

    def __init__(self, proxy_channel):
        self._loop = asyncio.new_event_loop()
        self.echo_proxy = fidl.fuchsia_developer_ffx.Echo.Client(proxy_channel)

    def __del__(self):
        self._loop.close()

    def _asyncmethod(f):
        @wraps(f)
        def wrapped(inst, *args, **kwargs):
            coro = f(inst, *args, **kwargs)
            inst._loop.run_until_complete(coro)
        return wrapped

    @_asyncmethod
    async def echo(self, string: str):
        return await self.echo_proxy.echo_string(value=string)

然后,可以使用以下代码调用上述类:

client_channel = ...
inst = FidlInstance(client_channel)
print(inst.echo("foobar"))

虽然此实例可以在非异步上下文中运行,但它仍然运行异步代码。不过,这样会使得整体的读写变得更容易。