Asyncio in PyScript
Published October 21, 2022
When running Python in a terminal or desktop, there's a myriad of ways to allow your code to do multiple things at once. You can spin off a new thread to handle computations, create a new process to offload work to other CPUs, even load up a while new subinterpretter (someday!) to execution more code.
When running Python in the Browser, you get one process and (at least for now) one thread. That's it. And it's the same thread that the browser window's event loop runs on. So we can't block - ever - or things fall apart.
So what if we want to do more than one thing at once? Asyncio to the rescue! In this post, we'll look at using async/await/asyncio
in PyScript/Pyodide to write concurrent code.
An Async/Await Recap
There are many ways of achieving the goal of "do multiple things at once" in Python - using multiple processes, using multiple threads within a single process, or making one thread do the work of many by requiring each piece of code to declare when it it wants to 'release' the thread to do other work. The asyncio package in the python standard library, as well as the async
and await
keywords in the language, exist to support this last paradigm.
The typical way of writing these "cooperative" pieces of code is to declare Coroutines
using the async def
keyword, then execute them with one of the many asyncio execution methods. Within a coroutine, the await
keyword is used to indicate that control of the event loop (thread) should pause execution of the coroutine and move on to any others that are waiting. A statement like await foo()
means "suspend execution of the surrounding coroutine until the result of foo()
is returned.
An example you can run in a regular terminal:
|
|
This is just a quick and dirty primer - if asnyc/await/asyncio is a wholly new subject for you, I recommend the excellent Real Python article on Asyncio for a deeper understanding before moving on.
Pyodide.Webloop
The Pyodide runtime (which is the most common one used in PyScript at the moment) provides a custom wrapper for the asyncio event loop, that allows async/await
to work with the browser event loop. Many of the methods will be familiar if you've worked with asyncio
, but it's worth highlighting some useful ones, as well as broken ones:
asyncio.create_task()
setTimeout(callback, 0)
callback
to be called in (roughly) delay
seconds, using setTimeout(callback, delay)
. Returns a Handle
object with a cancel()
the call.future.add_done_callback(do_something_with_result)
We can access the Pyodide event loop at PyScript.loop
, so we could write, for example, PyScript.loop.create_task(my_async_function())
. It's worth looking at the full function signatures of the methods linked above - the ones which take Callables all take an *args parameter to pass arguments into your call, so you don't need to wrap them in functools.partial
or the like.
The presence of the Webloop implementation of the asyncio
event loop means that most async concepts translate pretty directly - async for
, async with
, and other constructs which generate or consume coroutines or async iterators/context managers mostly just work. But the above Webloop methods are the most useful in terms of creating behaviors you might want in your program.
Rather than walk through each method individually, I think the most instructive thing to do is simply to present and discuss examples of what I think are the most useful strategies:
create_task
, which schedules a coroutine to be run soon.call_soon/call_later
, which schedules a callable to be called "ASAP" or after a specific amount of timeasyncio.gather
, for running multiple awaitables (coroutines, Tasks, and Futures) concurrently
Webloop Examples
create_task()
clock.py
|
|
As the Python Documentation says: Tasks are used to run coroutines in event loops. If a coroutine awaits [on a future], the Task suspends execution of the coroutine and waits for the completion of the Future.
This is the key behavior we want when we want coroutines (including async functions defined with async def
) to run concurrently.
call_soon()
and call_later()
timer.py
|
|
Let's say you don't have a a coroutine with an internal await
- you just have a regular old function (or Callable) that you'd like to be called either "now" (but allow other Async processes to happen as well) or after an interval (while not blocking in the meantime). For that, we have call_soon()
and call_later()
, respectively.
Notice that this example happens to use both call_soon()
and call_later
, but that's purely to illustrate their functionality. If you wanted to make an async function that counts down from 5, there are probably clearer ways to do it.
Two positive effects of using either of these methods is that they (1) wrap your callable in a PyProxy object, so the browser garbage colletor doesn't throw them away before they're called; and (2) they return a Handle Object which can be used to cancel execution of the Callable prior to its calling. Neat!
asyncio.gather()
race.py
|
|
When you have multiple awaitable objects (coroutines, Tasks, and Futures) that you want to run "in a group" or "as a batch", asyncio.gather()
can simplify your life. If any of the collection of awaitables is a coroutine, it is automatically wrapped in a Task (and scheduled).
In a PyScript/Pyodide context, one can image using gather
for UI management or "backend" work. For example, you might have a collection of onscreen objects (like the example above) that each need to update themselves asynchronously. Or you might gather()
a collection of coroutines that use pyfetch() to retrieve network resources, allowing them to fetch asynchronously while PyScript continues executing on the page.
Implicit Async
PyScript/Pyodide has an interesting quirk that allows an additional way of working with coroutines, that has to to with what's called "Top-Level Await". If you've written async/await code before, you might be familiar with Python yelling at you for trying to use 'await' outside of a coroutine, like so:
import asyncio
print("One plus two is... wait for it...")
await asyncio.sleep(1)
print(1+2)
----
>>> await asyncio.sleep(1)
>>> ^
>>> SyntaxError: 'await' outside function
import asyncio
async def add_slowly():
print("One plus two is... wait for it...")
await asyncio.sleep(1)
print(1+2)
await add_slowly()
----
>>> await asyncio.sleep(1)
>>> ^
>>> SyntaxError: 'await' outside async function
However, if you run those same pieces of code in PyScript, they work just fine!
bad_add.py
|
|
The reason that code with top-level await (i.e. "await
" outside an async function) works in PyScript is due to a design decision on the part of the Pyodide team, whose thinking I imagine goes like this:
- We usually can't just nakedly
await
things in Python, since we need an active event loop to schedule the coroutines into. - In the browser, we always have an active event loop (the browser event loop)
- CPython allows us to compile code with the
PyCF_ALLOW_TOP_LEVEL_AWAIT
, which, if it finds Top-Level 'Await' statements, returns the evaluated code as a coroutine - Therefore, if we evaluate a chunk of code and the result is a coroutine, we have the option to simply schedule it into the browser event loop for the user and execute it. (If the result and discuss is not a coroutine, just return the result as normal.)
This is exactly what the internal Pyodide function runPythonAsync()
does - compiles code with PyCF_ALLOW_TOP_LEVEL_AWAIT, and if the result is a coroutine, schedules it and returns a promise representing the result. It's essentially a convenience function that takes advantage of the fact that, by definition, we always have an every loop available to us. And since PyScript (currently) uses runPythonAsync()
to run every code block, you can write top-level await code wherever you like.
See the following pair of demos, both of which run with top-level await
sleep_1.py
|
|
sleep_2.py
|
|
BUT BEWARE! This is the part that's most likely to change in future versions of PyScript. You'll note above that when we compile our Python Code, if the result is a coroutine, the JavaScript side gets a promise that resolves to the result of the coroutine. Importantly though, at least in PyScript 2022.09.1, we don't await that promise resolving! This is what allows the loader to continue, other scripts to evaluate etc. while the scheduled coroutine resolves in the background.
There's been quite a bit of discussion around what the loader lifecycle and async scripts, so I do expect this to change in the future. At this moment, it doesn't look like it's changing in the planned 2022.10.1, but time will tell!
Conclusions
Personally, I think the implicit style is nice to have for quick-and-dirty examples like those just above, but they do make it hard to reason about execution order and script completion. And like I say, I suspect the details of that are going to continue to change and morph over time, so they might not be the most future-proof solution.
That's why I'd recommend, for any significant projects, you lean toward using the Webloop
methods for handling concurrent tasks. Back when I wrote The 7 Guis in PyScript, I wasn't particularly familiar with Webloop, and so coded everything in the implicit style. All of the async work in those demos breaks down to essentially "do a lot of setup, then run a loop asynchronously forever." Which makes quick, implicit async plausible.
But when I moved on to the much-more-integrated Rich on PyScript Project, I had a hell of a time reasoning about what processes would be completed when, how to cancel and monitor them etc. from the Python side - starting that project with an asyncio/Webloop approach from the beginning would have been radially easier.
Finally, remember that while async/await
in PyScript/Pyodide works mostly like it does on desktop or terminal, because there's an intermediate layer of reimplementation in Webloop, not all behaviors are guaranteed to be exactly the same. Troubleshoot and test thoroughly, and don't block the loop!