Skip to content

Session 2: Designing Low-Allocation Async Loops

Synopsis

Focuses on reducing unnecessary allocations, reusing objects, and writing coroutine patterns that minimize runtime memory churn.

Session Content

Session 2: Designing Low-Allocation Async Loops

Session Overview

In this session, you will learn how to design efficient asyncio loops in MicroPython for the Raspberry Pi Pico 2 W, with a focus on reducing memory allocation, avoiding garbage collection pressure, and writing cooperative multitasking code that is suitable for embedded systems.

By the end of the session, you will be able to: - Explain why low-allocation code matters on microcontrollers - Write uasyncio tasks that minimize memory churn - Use pre-allocated buffers and reusable objects - Structure periodic async loops for sensors, LEDs, and network tasks - Build a simple non-blocking status monitor on Pico 2 W


Duration

~45 minutes


Prerequisites

  • Basic Python knowledge
  • Raspberry Pi Pico 2 W
  • Thonny IDE installed
  • MicroPython firmware flashed on the Pico 2 W
  • USB cable
  • Optional hardware:
  • Built-in LED
  • Button
  • Breadboard
  • LED + 220Ω resistor
  • BME280 or similar I2C sensor

Development Environment Setup

Thonny Setup

  1. Install Thonny
  2. Connect the Raspberry Pi Pico 2 W by USB
  3. In Thonny:
  4. Go to Run → Select interpreter
  5. Choose MicroPython (Raspberry Pi Pico)
  6. Select the correct serial port
  7. Verify the REPL works by running:
print("Hello, Pico 2 W")

MicroPython Essentials

MicroPython on Pico 2 W includes: - machine for GPIO, I2C, SPI, ADC, PWM - uasyncio for cooperative multitasking - network for Wi-Fi on Pico 2 W - time for delays and timing

Useful references: - MicroPython Quick Reference: https://docs.micropython.org/en/latest/rp2/quickref.html - Raspberry Pi Pico 2 W docs: https://www.raspberrypi.com/documentation/microcontrollers/pico-series.html#pico2


Theory: Why Low-Allocation Async Loops Matter

Microcontrollers have limited RAM. In async code, unnecessary allocations can happen when you: - Create new strings repeatedly in a loop - Build new lists or dictionaries every iteration - Use frequent formatting in fast loops - Read sensor data in a way that allocates new objects each time - Create tasks repeatedly instead of reusing them

Too many allocations can lead to: - Increased garbage collection activity - Timing jitter - Slower responsiveness - Memory fragmentation

Good design principles

  • Reuse buffers and objects
  • Precompute constants
  • Avoid repeated string concatenation in tight loops
  • Keep async tasks short and cooperative
  • Use await asyncio.sleep_ms(...) instead of blocking delays
  • Prefer fixed-size loops over dynamic structures where possible

Key Concepts

1. Cooperative multitasking

uasyncio switches tasks only when a task awaits. That means: - A task must yield regularly - Long CPU-bound work blocks other tasks - Sleeping is not a waste; it enables scheduling

2. Allocation hotspots

Common allocation-heavy patterns: - print(f"value={value}") in a fast loop - data = sensor.read() if it returns new objects often - Creating temporary dictionaries for every event - Concatenating strings repeatedly

3. Reusable buffers

A reusable buffer can be: - bytearray - fixed-size list - pre-created message strings - persistent sensor objects


This exercise uses the onboard LED and demonstrates a reusable async loop.

Wiring

No extra wiring required. The Pico 2 W onboard LED is used.

Code: main.py

# main.py
# Low-allocation async blink example for Raspberry Pi Pico 2 W

import uasyncio as asyncio
from machine import Pin

# Onboard LED pin for Pico-style boards
led = Pin("LED", Pin.OUT)

# Reusable timing constants
BLINK_ON_MS = 300
BLINK_OFF_MS = 700


async def blink_task():
    """Blink the onboard LED without creating unnecessary objects."""
    while True:
        led.value(1)
        print("LED ON")
        await asyncio.sleep_ms(BLINK_ON_MS)

        led.value(0)
        print("LED OFF")
        await asyncio.sleep_ms(BLINK_OFF_MS)


async def main():
    """Entry point for async scheduling."""
    await blink_task()


try:
    asyncio.run(main())
finally:
    asyncio.new_event_loop()

What to observe

  • The LED blinks periodically
  • The task yields with await asyncio.sleep_ms(...)
  • No dynamic data structures are created in the loop

Expected output

LED ON
LED OFF
LED ON
LED OFF

Hands-On Exercise 2: Two Cooperative Tasks Without Blocking

This example runs two tasks: - LED blink task - heartbeat logger task

Code: main.py

# main.py
# Two low-allocation cooperative async tasks

import uasyncio as asyncio
from machine import Pin

led = Pin("LED", Pin.OUT)

HEARTBEAT_INTERVAL_MS = 1000
BLINK_INTERVAL_MS = 250


async def led_task():
    """Toggle the onboard LED at a fixed interval."""
    while True:
        led.toggle()
        print("LED toggled")
        await asyncio.sleep_ms(BLINK_INTERVAL_MS)


async def heartbeat_task():
    """Print a lightweight heartbeat message periodically."""
    count = 0
    while True:
        count += 1
        # Avoid expensive formatting patterns in very tight loops.
        print("Heartbeat", count)
        await asyncio.sleep_ms(HEARTBEAT_INTERVAL_MS)


async def main():
    """Schedule tasks concurrently."""
    task1 = asyncio.create_task(led_task())
    task2 = asyncio.create_task(heartbeat_task())

    await asyncio.gather(task1, task2)


try:
    asyncio.run(main())
finally:
    asyncio.new_event_loop()

Discussion points

  • Both tasks run concurrently
  • Each task yields frequently
  • asyncio.gather() waits for both tasks
  • The program remains responsive

Example output

LED toggled
Heartbeat 1
LED toggled
LED toggled
LED toggled
Heartbeat 2

Hands-On Exercise 3: Pre-Allocated Sensor Read Buffer Pattern

This exercise shows how to structure a low-allocation read loop using a reusable object pattern. If you have an I2C sensor such as a BME280, you can adapt this style for sensor data handling.

Goal

  • Avoid creating temporary lists or dicts on every loop iteration
  • Store readings in fixed variables
  • Build messages only when needed

Code: main.py

# main.py
# Pattern for low-allocation periodic sensor polling

import uasyncio as asyncio
from machine import Pin, I2C

# Example I2C setup for Pico 2 W
# GP0 = SDA, GP1 = SCL are common choices
i2c = I2C(0, sda=Pin(0), scl=Pin(1), freq=400000)

POLL_INTERVAL_MS = 2000


def scan_i2c():
    """Scan I2C devices once at startup."""
    devices = i2c.scan()
    print("I2C devices:", devices)


async def sensor_poll_task():
    """Poll sensor data periodically using a fixed structure."""
    while True:
        # Replace this with actual sensor read logic for your module.
        # Keep data in simple variables instead of temporary containers.
        temperature = 24.5
        humidity = 58.2

        print("Temp:", temperature, "C", "Humidity:", humidity, "%")
        await asyncio.sleep_ms(POLL_INTERVAL_MS)


async def main():
    scan_i2c()
    await sensor_poll_task()


try:
    asyncio.run(main())
finally:
    asyncio.new_event_loop()

Expected output

I2C devices: [118]
Temp: 24.5 C Humidity: 58.2 %
Temp: 24.5 C Humidity: 58.2 %

Notes

  • If no sensor is connected, i2c.scan() may return []
  • Use the same data-handling style with real sensor libraries
  • Keep the polling loop simple and predictable

Hands-On Exercise 4: Async Button Monitor with Minimal Allocation

This exercise demonstrates a responsive button monitor that avoids blocking delays.

Wiring

Connect a push button: - One side to GP15 - Other side to GND

Use the internal pull-up resistor in software.

Code: main.py

# main.py
# Async button monitor with internal pull-up

import uasyncio as asyncio
from machine import Pin

button = Pin(15, Pin.IN, Pin.PULL_UP)
led = Pin("LED", Pin.OUT)

CHECK_INTERVAL_MS = 50


async def button_monitor_task():
    """Monitor button state without blocking other tasks."""
    last_state = button.value()

    while True:
        current_state = button.value()

        # Detect falling edge: button press
        if last_state == 1 and current_state == 0:
            led.toggle()
            print("Button pressed -> LED toggled")

        last_state = current_state
        await asyncio.sleep_ms(CHECK_INTERVAL_MS)


async def main():
    await button_monitor_task()


try:
    asyncio.run(main())
finally:
    asyncio.new_event_loop()

Expected behavior

  • Pressing the button toggles the LED
  • The loop stays responsive
  • No debouncing delay blocks the processor

Best Practices for Low-Allocation Async Loops

Do

  • Use asyncio.sleep_ms() for timing
  • Keep task state in local variables or object attributes
  • Reuse pins, buses, and sensor objects
  • Minimize prints in fast loops
  • Use fixed data structures when possible

Avoid

  • Creating new dicts/lists inside every loop
  • Repeated string interpolation in high-frequency tasks
  • Blocking calls like time.sleep() inside async tasks
  • Reinitializing hardware peripherals inside a loop

Mini Design Pattern: Task State Object

For slightly larger projects, store state in an object and reuse it.

Example

import uasyncio as asyncio
from machine import Pin

class BlinkController:
    def __init__(self, pin_name="LED"):
        self.led = Pin(pin_name, Pin.OUT)
        self.interval_ms = 500

    async def run(self):
        while True:
            self.led.toggle()
            print("Blink")
            await asyncio.sleep_ms(self.interval_ms)


async def main():
    controller = BlinkController()
    await controller.run()


try:
    asyncio.run(main())
finally:
    asyncio.new_event_loop()

Why this helps

  • Keeps related state together
  • Avoids globals in larger projects
  • Makes it easier to extend without extra allocations

Debugging Tips

  • If tasks seem unresponsive, check for missing await
  • If memory use rises unexpectedly, reduce prints and temporary object creation
  • Use gc.mem_free() to inspect available memory
  • Restart tasks cleanly with asyncio.new_event_loop() after asyncio.run()

Optional memory check example

import gc

print("Free memory:", gc.mem_free())

Quick Knowledge Check

  1. Why is frequent allocation a problem on a microcontroller?
  2. What happens if an async task does not await?
  3. Why is await asyncio.sleep_ms(...) preferred in periodic loops?
  4. What kinds of objects should be reused across iterations?
  5. How can you monitor memory usage during development?

Hands-On Challenge

Build a Low-Allocation Status Monitor

Create a program that: - Blinks the onboard LED every 500 ms - Reads a button on GP15 - Prints a heartbeat every 2 seconds - Uses uasyncio tasks - Avoids creating lists, dicts, or formatted strings in the fast loop

Suggested structure

  • led_task()
  • button_task()
  • heartbeat_task()
  • main() that schedules all tasks

Success criteria

  • All tasks run concurrently
  • Button presses are detected reliably
  • LED blinking does not stop when other tasks run
  • Code remains simple and memory-conscious

Session Wrap-Up

In this session, you learned how to design async loops that work well on the Raspberry Pi Pico 2 W by minimizing allocations and structuring tasks cooperatively. These patterns will help you build more reliable IoT and sensor applications in later sessions.


Further Reading

  • MicroPython Wiki: https://github.com/micropython/micropython/wiki
  • MicroPython RP2 Quick Reference: https://docs.micropython.org/en/latest/rp2/quickref.html
  • Raspberry Pi MicroPython Documentation: https://www.raspberrypi.com/documentation/microcontrollers/micropython.html

Back to Chapter | Back to Master Plan | Previous Session | Next Session