Introduction

You paused your previous task to read this blog post. Once you finish reading, you will move on to your next task. Whether you know it or not, you have learned to embrace asynchonous programming in your daily life. Take cooking for example - say you need to make a noodle soup, a roast, and a fruit salad. These activities involve waiting between tasks and in order to finish the cook efficiently, you need to arrange the timing of tasks. A possible solution is to first put a pot of water to boil, then prepare the vegetables and meat to oven, then spice up the soup and add dry noodles, then cut up the fruit into a bowl (done), then serve the noodle soup (done), then take out the roast from the oven (done). If you did each task sequentially (or synchronously), then you would have wasted lots of time waiting doing nothing!

In the context of microcontrollers, they live in an environment which they need to respond to hardware events such as data available on UART, buttons, etc (I/O bound tasks) which involve waiting for an input. Your program needs to respond to these asynchronous events but may also have time dependent tasks running. Asynchronous means these events do not happen at the same time.

Why Consider Asynchronous Programming?

Consider the following; the controller in an 16x2 LCD display requires small wait times between commands. If we naively wrote a routine that sent a string to the LCD inserting delays when needed, our code will not be able to respond to other events while it is waiting. The presence of blocking delays to manage timing (which prevents other code from being run), is inefficient. So what are our options? We can try writing an event loop that checks the system time and lump many if statements together checking if certain tasks need to be run and run them during the wait time. This quickly becomes unwieldy for a large number of tasks and increases the cognitive load to manage it all. What we want is some way of submit tasks to a scheduler which can then manage which tasks need to be run at which time for you without the hassles of managing it yourself. In effect, we want to be able to multi-task, be efficient at utilising the CPU's available clock cycles, and produce code that is responsive to events. Enter asynchronous programming.

In this blog post, I will introduce some terminalogy and discuss the advantages of asynchronous programming. At the end, I will show you how you can write asynchronous programs for your Arduino (using C) and Raspberry Pi Pico (using MicroPython). You may want to find an Arduino or Raspberry Pi Pico (with optional extra LEDs and resistors to try blinking multiple LEDs asynchronously), and give the example a go. The example will use the builtin LED. If you are reading this then I think it's safe to say you have some experience in either C/C++ writing code for Arduino or writing MicroPython code for the Raspberry Pi Pico.

Note: For those of you wondering why not threading or multiprocessing? Asynchronous programming offers lower overhead and resources making it preferred for low power microcontrollers. If you are interested, the Raspberry Pi Pico has uasyncio which we will see later and _thread that can run tasks on the second core.
Warning: There is no asyncio-like library for Arduino so my implementation is an attempt at making something similar.
The MicroPython content part of this blog posts was inspired by this intro to Python's asyncio and Application of uasyncio to hardware interfaces.

Terminology

  • Parallelism: The ability to perform multiple operations simultaneously. Multiprocessing is an example that spreads tasks over multiple CPU cores.
  • Concurrency: The ability to execute more than one program or task simultaneously. Tasks can run in an overlapping manner and need not be parallel.
  • I/O bound task: A task dominated by waiting for input/output to complete.
  • Coroutine (coro): A specialised function (co-operative routine) that is intended to run concurrently with other coros. Concurrency is achieved by periodically yielding to the scheduler, enabling other coros to run.
  • Event loop: A loop that monitors tasks to run. For microcontrollers, we have this running on a single CPU core and single thread.
  • Scheduler: An event loop that facilitates asynchronous programming.
  • Pre-emptive scheduling: A scheduling technique that temporarily interrupts an running task without cooperation from the task, for it to be resumed at a later time.
  • Cooperative scheduling: A scheduling technique that never initiates a context switch between tasks. It has lower overhead compared to pre-emptive scheduling and requires tasks to periodically yield to the scheduler.

Now that we know about the terminology involved, how can we actually write asynchonous code?

A Cooperative Scheduler for Arduino

Every beginner starts out with the Blink sketch and eventually learns that there are limitations in using delay. Suppose we have n LEDs that require blinking at different rates. (Instead of LEDs, we may have sensors we want to read from at different rates and actuators to update.) There's no easy way to do this using delay and it certainly will not be very customisable. To solve this, the next sketch is BlinkWithoutDelay which uses if statements to check timing against the system time. The cooperative scheduler I introduce here is much like using BlinkWithoutDelay with the if statement part abstracted away.

if (currentMillis - previousMillis >= interval) { // Let's abstract this part away using a scheduler!
    // save the last time you blinked the LED
    previousMillis = currentMillis;
    ...

At the core, the scheduler consists of a queue of tasks implemented as an array of void function pointers and scheduled run times. Clone my respository (https://github.com/YiweiMao/scheduler) or copy the .cpp and .h file to your project. This implementation is very lightweight and is designed to be easy to use. There is only one function to remember which is the run_later function and the scheduler handles the timing for you. Functions that can be submitted into the queue needs to be of type void and accept no arguments. For example, to schedule a callback function called blinkLED to run 500 ms later, simply write run_later(blinkLED,500); and to place a task that automatically reschedules itself, use run_later within the task. This is how a blinkLED function can be written so the builtin LED will blink in the background.

void blinkLED(){
    short toggle_delay = 250; // ms
    run_later(blinkLED, toggle_delay); // reschedules itself!

    pinMode(LED_BUILTIN,OUTPUT);
    digitalWrite(LED_BUILTIN,!digitalRead(LED_BUILTIN));
}

After you call blinkLED, the builtin LED will continue to blink in the background and you will have clock cycles to run other code using run_later.

Initialise and fill up the scheduler before you run the even loop; for example, within the void setup() block.

To run the event loop, end your sketch with

void loop() {
  run();
}

Hint:There are other schedulers for Arduino. See a list of five including FreeRTOS and CoopThreads. The closest implementation to what is present here is probably TaskManagerIO.

MicroPython's Asyncio Approach for Raspberry Pi Pico

There is no doubt that Python is a very popular programming language widely used in many disciplines. If you already know python, you don't need to learn a foreign syntax to imediately begin writing code for microcontrollers using Micropython. The Raspberry Pi Pico is a cheap accessible microcontroller than can be programmed using MicroPython and has access to most of Python's asyncio functionality builtin. I personally use Visual Studio Code with the Pico-Go extension to upload/run code on the Pico and access the REPL.

The example here will use the builtin LED but you can add an additional LED to try multitasking using the event loop. First we important the relevant libraries and define some LEDs before moving onto creating coroutines.

from machine import Pin
import uasyncio as asyncio

# create LED objects
builtin_led = Pin(25,Pin.OUT)
LED_TOGGLE_TIME_MS = 50

second_led  = Pin(16,Pin.OUT)
SECOND_LED_TOGGLE_TIME_MS = 200

Asynchronous Functions / Coroutines

To make our lives easier, here is a decorator to convert an ordinary Python function into one that will run repeatedly with a specified wait time between each run. This decorator basically places your functions into a loop and wraps the loop with async def. When the await keyword is reached, your code will yield to the scheduler allowing other async functions to run until the await time is over.

Note: Decorators are a way to modify the behaviour of functions. They are functions that take other functions as inputs and return the modified function.
.
def reschedule_every_ms(t):
    """Decorator for a callback that will keep rescheduling itself."""
    def inner_decorator(cb):
        async def wrapped(*args, **kwargs):
            while True:
                await asyncio.sleep_ms(t)
                cb(*args, **kwargs)
        return wrapped 
    return inner_decorator

Now let's use this decorator to create coroutines or coro for short.

@reschedule_every_ms(LED_TOGGLE_TIME_MS)
def blink_deco():
    builtin_led.toggle()

@reschedule_every_ms(SECOND_LED_TOGGLE_TIME_MS)
def second_blink_deco():
    second_led.toggle()

blink_deco and second_blink_deco are now coros that we can insert into the event loop.

Exception Handling

In case we want to stop our code running on the Pico with a KeyboardInterrupt and enter the REPL, we need to exit gracefully. This is provided by the following code snippet.

def set_global_exception():
    """Allow for exception handling in event loop."""
    def handle_exception(loop, context):
        import sys
        sys.print_exception(context["exception"])
        sys.exit()
    loop = asyncio.get_event_loop()
    loop.set_exception_handler(handle_exception)

Event Loop

Similar to how we added tasks into the scheduler using run_later in Arduino's void setup() function, we can need to add tasks using asyncio.create_task in an async def main() function like so.

Tip: asyncio.create_task submits a task to the event loop to run concurrently with other tasks.
.
# Add Coros into the Event Loop
async def main():
    set_global_exception() # Debug aid

    # insert coros into queue!
    asyncio.create_task(blink_deco())
    asyncio.create_task(second_blink_deco())

    while True: # run forever
        await asyncio.sleep_ms(1000)

# Run the Event Loop
try:
    asyncio.run(main())
except KeyboardInterrupt: 
    print("Keyboard Interrupted")
except asyncio.TimeoutError: 
    print("Timed out")
finally:
    asyncio.new_event_loop()  # Clear retained state

Conclusion

We explored what asynchronous programming may look like for Arduino using a task scheduler written in C and also for a Raspberry Pi Pico using the builtin uasyncio library. The asynchronous programming methodology presented here may at first glance look overcomplicated for what is just blinking LEDs but remember that using this framework, we have avoided all blocking delays and our code will still be responsive while waiting for I/O. Automatically rescheduling functions also allows us to "set and forget" and focus on writing one task at a time without worrying about how the timing of other functions are affected for the most part. In other words, it allows us to more easily write programs at scale and make the most of a microcontroller's limited clock cycles.

Give it a go and let me know of any success (or fail) stories in the comments below!