ESP32 In MicroPython: Asyncio
Written by Harry Fairhead & Mike James   
Monday, 09 September 2024
Article Index
ESP32 In MicroPython: Asyncio
Awaiting Sleep
Sequential and Concurrent
Shared Variables and Locks
Using uasyncio

Awaiting Sleep

The simplest asynchronous coroutine is uasyncio.sleep:

uasyncio.sleep(delay)

which returns after delay seconds.

There is also a MicroPython extension:

uasyncio.sleep_ms(delay)

which returns after delay milliseconds.

Notice that these are not the same as the corresponding sleep function in the time module and it is important to understand the difference. The time.sleep function suspends the current thread for the specified amount of time. That is, the one thread that you were depending on to do the work would be frozen and if used in an asynchronous program so would the task loop. In fact time.sleep is a very good way to keep the thread busy and so simulate a coroutine that doesn’t give up the thread.

Compare this to uasyncio.sleep which doesn’t suspend the thread at all - it suspends the coroutine. The main thread stops running the coroutine and returns to the event loop to find another coroutine to run. When the time delay is up and the main thread next visits the task loop for more work, the suspended coroutine is restarted. Of course, this means that the coroutine might be suspended for longer than the specified time and this is usually the case. The coroutine is restarted when the main thread is free to run it and the time delay is up.

The uasyncio.sleep function suspends the current coroutine and not the thread.

A standard idiom is to call sleep with a value of zero seconds:

await uasyncio.sleep(0)

or

await usasyncio.sleep_ms(0)

This gives up the thread to the task loop with the minimum delay if there is nothing to be done in the event loop queue. That is, all that happens is that the main thread is freed and, if there is nothing waiting to be executed in the task loop, it returns at once to running the coroutine. This gives the event loop a chance to run other coroutines and it is a good idea to include any coroutine that runs for a long time. The call sleep(0) is equivalent to DoEvents in other languages, i.e. an instruction to process the event loop’s queue.

We can easily add an await for 10 seconds to our example:

import uasyncio
async def main(myValue):
    print("Hello Coroutine World")
    await uasyncio.sleep(10)
    return myValue
result= uasyncio.run(main(42)) print(result)

There is now a ten-second delay between displaying Hello Coroutine World and the result, i.e. 42. In this case the main thread is freed when the await starts and has 10 seconds in which to run any other coroutines waiting in the task loop. In this case there aren’t any and so it just waits for the time to be up. We next need to know how to add coroutines to the task loop so that they can be run when the main thread is free.

Tasks

A Task is a coroutine that you have added to the task loop for execution by the thread when the opportunity arises. A Task has some additional methods added to the basic coroutine and in Python it inherits from a Future, but in MicroPython things are simpler.

To add a Task to the task loop you need to use:

uasyncio.create_task(coroutine)

This adds coroutine to the task loop as a Task. It adds the coroutine to the task loop queue ready to be executed. It doesn’t actually get to run until the main thread is free to return to the task loop and run the tasks that it finds there. This only happens when the currently executing coroutine awaits an asynchronous coroutine or terminates.

The uasyncio.create_task function returns a Task which behaves like a Future in Python. The only important point for MicroPython is that any result that the task returns is only available after the task has completed, or “resolved” in the terminology. To get the result we need to use an await to retrieve a result. For example, if we create a coroutine that prints a range of numbers then this can be added to the event loop within main:

import uasyncio
async def count(n):
    for i in range(n):
        print(i)
    return n
async def main(myValue):
    t1 = uasyncio.create_task(count(10))   
    print("Hello Coroutine World")
    await uasyncio.sleep(5)
    result = await t1
    print("The result of the task =",result)
    return myValue
result= uasyncio.run(main(42))
print(result)

The count coroutine is added to the task loop before the print, but it doesn’t get to run until main awaits sleep for 5 seconds and so frees the thread. This allows the count function to display 0 to 9 but then the thread continues to wait till the full five seconds are up. At this point it awaits the task so as to get its result. If t1 hadn’t completed then the thread would execute it until it was complete. As it is, t1 is complete and so returns its result at once and there is no delay before it is printed.

What you see displayed is:

Hello Coroutine World
0
1
2
3
4
5
6
7
8
9
The result of the task = 10
42

Make sure that this example makes sense before you move on to more complex things.

At this point you should be wondering why we bothered creating a Task and adding it to the event loops’s queue? Why not just use await count? This produces the same result, but for different reasons. In this case the Task isn’t added to the task queue until the await and then the Task is run to completion. This means that it isn’t in the queue earlier and cannot benefit from any time that the thread might be free.

You will sometimes see instructions like:

    value = await asyncio.create_task(count(10)) 

This adds the coroutine to the queue as a Task and then immediately awaits it, which of course, starts it running. There is no point in doing this and it is entirely equivalent to:

    value = await count(10) 

To summarize:

  • create_task(coroutine) runs the coroutine at a later time. It adds it as a Task to the task loop’s queue for execution when the thread is free

  • await coroutine runs the coroutine immediately

  • await Task runs the Task to completion taking into account any process it might have already made by being on the task queue.

Another way of thinking about this is to use create_task when you want the coroutine to run when the thread is free and use await when you need the coroutine to run immediately.

There is also a way to await a coroutine with a timeout:

uasyncio.wait_for(aw, timeout)

which waits for aw, an awaitable, to complete or until the timeout in seconds is up.

MicroPython also adds:

uasyncio.wait_for_ms(aw, timeout)

which, of course, specifies the timeout in milliseconds. If the timeout happens the task raises a uasyncio.TimeoutError in the waiting code and a uasyncio.CancelledError in the task. Each of these methods is a coroutine so to use them you write:

await uasyncio.wait_for(t1,1)



Last Updated ( Tuesday, 10 September 2024 )