| # Best practices on using async Python in Fuchsia Controller |
| |
| This page discusses the common task flows of async Python as well as possible |
| pitfalls when using async Python. |
| |
| Before you continue, check out this |
| [official documentation][python-async-docs]{:.external} on `asyncio` and |
| the [tutorial][scripting-remote-actions] for Fuchsia Controller. |
| |
| ## Background |
| |
| The following items concern asynchronous code in general, not necessarily specific |
| to Python: |
| |
| * Async code is not necessarily multi-threaded. In the case of |
| Fuchsia Controller related code, almost everything is single-threaded. |
| * Async code runs inside of a loop (also known as an executor), which is |
| yielded to whenever an await happens. |
| |
| Because of these, it can make certain things difficult to debug, like |
| if you have a background task that intends to run forever but raises an |
| exception. If you never await this task, the exception is only visible in |
| logs. |
| |
| ## Common pitfalls |
| |
| ### Not holding references to tasks |
| |
| If you launch a task in a loop, for example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| # Don't do this! |
| asyncio.get_running_loop().create_task(foo_bar()) |
| ``` |
| |
| and you do not hold a reference to the value returned, it is possible that the |
| Python garbage collector may cancel and delete this task at an arbitrary point |
| in the future. |
| |
| Always make sure to store the tasks you create in a variable, for example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| # Always store a variable! |
| foo_bar_task = asyncio.get_running_loop().create_task(foo_bar()) |
| ``` |
| |
| Then ensure that the lifetime of the task is tied to what needs it. If this is |
| a specific test case, store the task as a member of the test case class, |
| canceling it during teardown. |
| |
| ### Mixing blocking code with async code |
| |
| While testing your code, it's common for some experiments to throw in a `sleep` |
| statement for certain events. Make sure not to mix up `asyncio.sleep` with |
| `time.sleep`. The former returns a coroutine while the latter blocks the whole |
| loop. |
| |
| For example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| import asyncio |
| import time |
| |
| |
| async def printer(): |
| """ Prints a thing.""" |
| print("HELLO") |
| |
| |
| async def main(): |
| task = asyncio.get_running_loop().create_task(printer()) |
| {{ "<strong>" }} time.sleep(1) # THIS WILL BLOCK EXECUTION. {{ "</strong>" }} |
| task.cancel() |
| |
| if __name__ == "__main__": |
| asyncio.run(main()) |
| ``` |
| |
| The code above would print out nothing. However, it would print `HELLO` if you |
| replaced the `time.sleep(1)` with `await asyncio.sleep(1)`, which yields to the |
| async loop. |
| |
| ### FIDL server errors |
| |
| Given the nature of async programming, it's possible that you can have a runtime |
| error in your FIDL server implementation and not know it, especially if your |
| FIDL server implementation is connected to a component on a Fuchsia device. |
| |
| This, unfortunately, is difficult to debug and may only surface as a |
| `PEER_CLOSED` error on the device due to your FIDL server implementation crashing. |
| |
| One approach (for debugging) is to add a callback to the task that checks if |
| an exception has occurred in the task and sends something to logs (or even hard |
| crash the program). |
| |
| For example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| 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) |
| ``` |
| |
| ## Common task flows |
| |
| ### Synchronizing tasks |
| |
| To start, it's recommended to take a look at the |
| [Python doc page][python-synchronization-docs]{:external} for a primer on |
| synchronization objects available. Note that these objects are _not_ thread-safe. |
| Some of them merit examples, however. |
| |
| #### Waiting for multiple tasks |
| |
| You'll likely need to wait for multiple tasks to complete. This can be done |
| using [`asyncio.wait`][asyncio-wait]{:.external}. If you want to do something |
| similar to the `select` syscall, set the `return_when` parameter to |
| `asyncio.FIRST_COMPLETED`, for example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| done, pending = await asyncio.wait( |
| [task1, task2, task3], |
| return_when=asyncio.FIRST_COMPLETED |
| ) |
| ``` |
| |
| The result contains a tuple of tasks (in this case, `done` and `pending`). Which |
| can be treated like any other task object. So for the `done` object, one can |
| iterate over these and collect the results by checking the `result()` function. |
| |
| #### Running async code inside synchronous code |
| |
| At the time of writing, most of Fuchsia Controller code is being called from |
| synchronous code. To ensure tasks can run in the background, it may make sense |
| to keep an instance of a loop via `asyncio.new_event_loop()`. |
| |
| The reason for this is that `asyncio` tasks must remain on a single loop. This |
| is also the case for synchronization objects like `asyncio.Queue`. |
| |
| If the code you interact with is sufficiently simple (running a single FIDL |
| method at a time), you can do this using: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| asyncio.run_until_complete(...) |
| ``` |
| |
| But if you need to run tasks or handle synchronization, you'll want to |
| encapsulate things in a class containing a loop. Just make sure that if this is |
| in a test or an object, there is teardown code for shutting down the loop, |
| for example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| 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() |
| ``` |
| |
| You can also extend the class with a decorator so that the loop is implicitly |
| used in all public functions (though they will appear synchronous to callers) |
| for example: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| from functools import wraps |
| |
| class FidlInstance(): |
| |
| def __init__(self, proxy_channel): |
| self._loop = asyncio.new_event_loop() |
| self.echo_proxy = fidl_fuchsia_developer_ffx.EchoClient(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) |
| ``` |
| |
| Then the class above can be invoked using the following code: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| client_channel = ... |
| inst = FidlInstance(client_channel) |
| print(inst.echo("foobar")) |
| ``` |
| |
| While this instance can run inside a non-async context, it is still runs |
| async code. However, this can make it much easier to read and write overall. |
| |
| ### Adapting code to support async Python |
| |
| FIDL is a language designed around being async. However, test frameworks like |
| `Mobly` expect synchronous Python functions for running tests. If you have an |
| interface that you'd like to test and it is primarily used in synchronous |
| Python, then this applies to you. |
| |
| `fuchsia-controller` comes with some mixin code under its `wrappers` module to |
| support using async code in synchronous contexts, in particular `AsyncAdapter`, |
| which can be used as a wrapper or a mixin, and the decorator `asyncmethod`. |
| |
| When used in conjunction with each other, this creates a setup where all code |
| run in a given instance will execute against a common `asyncio` event loop. |
| |
| For most cases async Python can be run in synchronous Python with the use |
| of `asyncio.run()`. However, when you may have queues or async tasks expected to |
| check on state across function calls, this becomes prohibitively difficult to |
| do. Each invocation of `asyncio.run()` creates a totally new event loop, which |
| can create headaches for managing asynchronous data structures, which expect to |
| be used in just one event loop at a time. By keeping a single `asyncio` event |
| loop, you can avoid running into these exceptions. |
| |
| For example, let's say you're writing a `Mobly` test case, but the class you're |
| exposing relies on some underlying async Python, you can write your code like |
| so to allow for its use in `Mobly`: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| import asyncio |
| from mobly import asserts, base_test, test_runner |
| from fuchsia_controller_py.wrappers import AsyncAdapter, asyncmethod |
| |
| # AsyncAdapter should be included first in the list of base classes. |
| class ClassYouWantToTest(AsyncAdapter, SomeBaseClass): |
| |
| def __init__(self): |
| super().__init__() |
| self.async_init() |
| |
| @asyncmethod |
| async def async_init(self): |
| self.queue: asyncio.Queue[int] = asyncio.Queue() |
| await self.queue.put(1) |
| |
| @asyncmethod |
| async def function_we_care_about(self) -> int: |
| got = await self.queue.get() |
| self.queue.task_done() |
| return got |
| |
| |
| class ExampleTest(base_test.BaseTestClass): |
| |
| def test_case_example(self) -> None: |
| """Example... doesn't really do much useful.""" |
| c = ClassYouWantToTest() |
| asserts.assert_equal(c.function_we_care_about(), 1) |
| |
| if __name__ == "__main__": |
| test_runner.main() |
| ``` |
| |
| The method `function_we_care_about` will be wrapped so that functionally the |
| following code is being executed when the `Mobly` test case is executed: |
| |
| ```py {:.devsite-disable-click-to-copy} |
| def function_we_care_about(self) -> None: |
| coro = self._function_we_care_about_impl() |
| self._mixin_asyncio_loop.run_until_complete(coro) |
| ``` |
| |
| Note: If using `AsyncAdapter` as a mixin, it should be the first thing included |
| in the inheritance, particularly if using `Mobly`, as this will ensure that the |
| loop logic is initialized properly. |
| |
| <!-- Reference links --> |
| |
| [asyncio-wait]: https://docs.python.org/3/library/asyncio-task.html#asyncio.wait |
| [scripting-remote-actions]: /docs/development/tools/fuchsia-controller/scripting-remote-actions.md |
| [python-async-docs]: https://docs.python.org/3/library/asyncio.html |
| [python-synchronization-docs]: https://docs.python.org/3/library/asyncio-sync.html |