micropython-multitarea-cooperativa-uasyncio

Cooperative Multitasking in MicroPython with uasyncio

  • 5 min

Cooperative multitasking is a way of alternating tasks that voluntarily yield the turn.

Imagine you want to do two simple things:

  1. Blink a red LED every 1 second.
  2. Blink a blue LED every 0.2 seconds.

If you try to do it with what we’ve learned so far (using time.sleep), you’ll realize it is impossible.

# The classic approach (BLOCKING)
while True:
    led_rojo.toggle()
    time.sleep(1)      # <--- The processor freezes here for 1 second!
    
    led_azul.toggle()  # This can never go faster than the red LED
    time.sleep(0.2)
Copied!

When the program reaches time.sleep(1), the microcontroller sits idle and does absolutely nothing else for that second. Not checking buttons, not reading sensors, not blinking the other LED. This is called Blocking Code.

To solve this, MicroPython includes the uasyncio library (micro asynchronous I/O).

What is cooperative multitasking?

Unlike your computer, which has multiple cores and a complex Operating System that forcefully allocates time (Preemptive Multitasking), in a microcontroller we usually have a single core.

uasyncio implements Cooperative Multitasking. This means tasks are “polite”. Instead of hogging the CPU, each task says: “I’m going to wait a bit, so I yield the turn so another task can use the processor in the meantime”.

The manager in charge of these turns is the Event Loop.

Syntax: async and await

To use uasyncio, we need to learn two new keywords in Python:

  1. async def: Defines a Coroutine. It’s a special function that can pause and resume.
  2. await: It’s the point where we yield the turn. It means “Wait here until this finishes, and in the meantime, let others work”.

The basic rule

**Goodbye time.sleep(), Hello uasyncio.sleep()** Never, under any circumstances, use time.sleep() inside an asynchronous function. That would halt the entire system. You must use await uasyncio.sleep(), which is the non-blocking version.

Two LEDs at different rates

Let’s solve the initial problem. We have two independent tasks (coroutines) and a scheduler that runs them.

import uasyncio
from machine import Pin

# Define the LEDs
led_rojo = Pin(14, Pin.OUT)
led_azul = Pin(2, Pin.OUT)

# Coroutine 1: Blinks slow (1 second)
async def parpadear_rojo():
    while True:
        led_rojo.toggle()
        print("Red!")
        # Non-blocking pause of 1s
        # Yields control to the Event Loop
        await uasyncio.sleep(1)

# Coroutine 2: Blinks fast (0.2 seconds)
async def parpadear_azul():
    while True:
        led_azul.toggle()
        # Non-blocking pause of 0.2s
        await uasyncio.sleep(0.2)

# Main function that orchestrates everything
async def main():
    print("Starting multitasking...")
    
    # Schedule the tasks in the Event Loop
    # create_task "launches" the task but does not stop execution
    uasyncio.create_task(parpadear_rojo())
    uasyncio.create_task(parpadear_azul())
    
    # Keep the main program alive forever
    while True:
        await uasyncio.sleep(10)

# Start the uasyncio engine
try:
    uasyncio.run(main())
except KeyboardInterrupt:
    print("Program stopped")
finally:
    uasyncio.new_event_loop() # Optional cleanup
Copied!

If you run this, you’ll see the effect: the two LEDs blink at their own rate independently. The blue LED blinks 5 times for every time the red one blinks.

What is really happening?

  1. Red turns on and says await uasyncio.sleep(1). “I’m going to sleep for 1s, CPU free”.
  2. The Event Loop checks who else has work to do. Blue!
  3. Blue turns on and says await uasyncio.sleep(0.2). “I’m going to sleep for 0.2s”.
  4. The Loop waits 0.2s. Wakes up Blue.
  5. Blue toggles, sleeps again…
  6. …and so on until 1 second passes and Red wakes up.

Waiting for events: Asynchronous buttons

uasyncio is not only for timing. It is also great for waiting for external events, like pressing a button, without polling constantly (if button.value() == 0).

Although it can be done with interrupts (IRQ), doing it with asyncio is safer because we avoid the memory problems of IRQs.

A typical structure would be:

async def esperar_boton():
    btn = Pin(0, Pin.IN, Pin.PULL_UP)
    anterior = 1
    
    while True:
        actual = btn.value()
        if actual == 0 and anterior == 1:
            print("Button pressed!")
            # Here we could launch another task or change a global variable
            
        anterior = actual
        
        # Small wait for "debounce" and to yield the CPU
        await uasyncio.sleep_ms(50)
Copied!

For advanced cases, there are synchronization primitives like uasyncio.Event or uasyncio.Lock, which allow one task to “notify” another when something important happens.

Tips for organizing tasks

Implementing uasyncio requires a mindset shift:

  1. It all sticks: If you use async in a function, whoever calls it must use await. Asynchronism propagates upwards until it reaches main().
  2. Do not block: Any heavy calculation or wait with time.sleep inside a coroutine will freeze ALL other tasks.
  3. Use create_task: To launch processes that should run in parallel (background).
  4. Use gather: If you need to launch 3 tasks and wait for all 3 to finish before continuing.

90% of advanced IoT projects (web servers, MQTT, continuous sensor reading) benefit enormously from this architecture. Once you get used to cooperative multitasking, sequential code will seem like a thing of the past.