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
- Install Thonny
- Connect the Raspberry Pi Pico 2 W by USB
- In Thonny:
- Go to Run → Select interpreter
- Choose MicroPython (Raspberry Pi Pico)
- Select the correct serial port
- 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
Hands-On Exercise 1: Low-Allocation Blink Task
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()afterasyncio.run()
Optional memory check example
import gc
print("Free memory:", gc.mem_free())
Quick Knowledge Check
- Why is frequent allocation a problem on a microcontroller?
- What happens if an async task does not
await? - Why is
await asyncio.sleep_ms(...)preferred in periodic loops? - What kinds of objects should be reused across iterations?
- 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