Asyncio in PyScript

Published October 21, 2022

Tags: pyscript python asyncio pyodide

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.

Note that this page will focus on cooperative multitasking within Python via coroutines; for multitasking by running Python scripts in parallel in the browser, see Pyodide's documentation on Using Pyodide in a web worker.
This post was originally written for PyScript 2022.09.1. It will almost certainly be broken by later releases.

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio

async def up_down():
    print("What goes up")
    await asyncio.sleep(1)
    print("Must come down")

async def throw_things_up():
    # asyncio.gather() runs multiple awaitable things and gathers their return values (or errors)
    await asyncio.gather(up_down(), up_down(), up_down())

asyncio.run(throw_things_up())

# ------ Output ------

What goes up
What goes up
What goes up
# ~1 second gap here
Must come down
Must come down
Must come down

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:

create_task(coro: Coroutine)
Schedules the Coroutine into the event loop, to run concurrently as a Task. Works like asyncio.create_task()
call_soon(callback: Callable, ...)
Schedules calling the Callable in the browser event loop using setTimeout(callback, 0)
call_later(delay: float, callback: Callable, ...)
Schedules callback to be called in (roughly) delay seconds, using setTimeout(callback, delay). Returns a Handle object with a cancel() the call.
run_until_complete(future)
Since we can't block, this just ensures that the future is scheduled and returns the future. As the documentation notes, it's better to use future.add_done_callback(do_something_with_result)
run_forever()
Different from asyncio.loop.run_forever - this is a a no-op! Since we can't block, this method does nothing.
asyncio.run()
This function, like several of the base asyncio functions, can't be called from within an active event loop. And because we're inside the event loop in the browser, my understanding is we're always in an event loop. If you see an error like this, try one of the functions above.

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 time
  • asyncio.gather, for running multiple awaitables (coroutines, Tasks, and Futures) concurrently

Webloop Examples

create_task()

Live PyScript Results:

clock.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from datetime import datetime
import asyncio

async def clock_forever():
    while(True):
        now = datetime.now()
        Element('clock-output').write(f"{now.hour}:{now.minute:02}:{now.second:02}")
        await asyncio.sleep(1)

PyScript.loop.create_task(clock_forever())

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from functools import partial

def finish_in(seconds):
    if seconds <= 0:
        Element('timer-output').write("DONE!", append=True)
    else:
        Element('timer-output').write(seconds, append=True)
        PyScript.loop.call_later(1, finish_in, seconds-1)

PyScript.loop.call_soon(finish_in, 5)
Live PyScript Results:

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import asyncio
from functools import partial
import random

from pyodide.ffi.wrappers import add_event_listener
from js import document

#Our awaitable coroutine - we'll use asyncio.gather() to run lots of these
async def racer(lane_element):
    speed = random.random() + .4
    lane_element.value = 0
    while lane_element.value < 100:
        lane_element.value += speed
        await asyncio.sleep(.1)
    
    #race is over for this lane; change border color
    lane_element.classList.remove('border-yellow-700')
    lane_element.classList.add('border-green-500')
    

NUM_RACERS = 5

def run_race():
    racers = []

    #clear output
    output = document.getElementById('race-output')
    while output.firstChild:
        output.removeChild(output.firstChild)

    for n in range(NUM_RACERS):
        #Create new progress bars as lanes for our "racers"
        new_lane = document.createElement("progress")
        new_lane.id = f"lane-{n}"
        new_lane.max = 100
        new_lane.classList.add('border-4', 'border-yellow-500', 'm-2', 'h-6', 'w-11/12')
        

        #Add the progress bars and labels to the document
        document.getElementById("race-output").appendChild(new_lane)

        racers.append(racer(new_lane))

    # Return a Promise representing the results.
    # If you don't need the results, no need to return or await this
    return asyncio.gather(*racers)

#Run the race over and over
async def race_monitor():
    while True:
        results = run_race()
        await results
        await asyncio.sleep(1)

#Start the monitoring task
asyncio.create_task(race_monitor())

Scroll to see complete code

Live PyScript Results:

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

As predicted, this featurew as removed in PyScript 2022.12.1; it is described here for historical reference.

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!

Live PyScript Results:

bad_add.py

1
2
3
4
5
6
7
8
import asyncio
async def add_slowly():
    Element("out").write("One plus two is... wait for it...")
    await asyncio.sleep(5)
    Element("out").write(1+2, append=True)

#This isn't normally possible:
await add_slowly()

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.

Importantly, runPythonAsync() does not run synchronous Python 'asynchronously'. It simply allows code with Top Level Await statements to compile and be awaited. [1]. While True: pass will still block forever.

See the following pair of demos, both of which run with top-level await

sleep_1.py

1
2
3
4
5
6
7
import asyncio
from itertools import count

output_1 = Element("output-1")
for i in count():
    output_1.write(f"Counted to {i}")
    await asyncio.sleep(1)

sleep_2.py

1
2
3
4
5
6
7
import asyncio
from itertools import count

output_2 = Element("output-2")
for i in count():
    output_2.write(f"Counted to {i}")
    await asyncio.sleep(.7) #Note the smaller sleep time!
import asyncio from itertools import count output_1 = Element("output-1") for i in count(): output_1.write(f"Counted to {i}") await asyncio.sleep(1) import asyncio from itertools import count output_2 = Element("output-2") for i in count(): output_2.write(f"Counted to {i}") await asyncio.sleep(.7) #Note the smaller sleep time!
Output from sleep_1.py
Output from 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!