blob: 6dcfd6f54ccc4e408aea92d7dcfec987e1d4e67a [file] [log] [blame] [view]
# 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