pico intermediate 35 min

Pico: run multiple tasks with asyncio

Blink an LED, read a sensor, and serve a web page at the same time on a Pico with MicroPython asyncio. Cooperative multitasking without an RTOS.

Code available for: MicroPython
Published Sep 22, 2026

Sooner or later a Pico project needs two things at once: blink the status LED while waiting on a button, poll a sensor while keeping a web page alive. The MicroPython answer is asyncio (uasyncio, its MicroPython name), which is already built into the firmware. No RTOS, no threads, no lock bugs. Tasks cooperate by awaiting, and the scheduler interleaves them.

This tutorial runs three concurrent tasks on one Pico and explains the one rule that makes it all work.

What you need

  • Raspberry Pi Pico (or Pico W for the web-server task)
  • Thonny or a REPL connection
  • An LED and a 220-ohm resistor (for the demo), plus a button if you want the third task physical

The mental model

asyncio is cooperative: a task runs until it awaits something, then the scheduler runs the next task. Tasks that never await block everything. This is not a bug, it is the deal (e.g. think of it as “each task promises to share”):

StyleWhat happens while task A runs
time.sleep(2) in a taskEVERYTHING stops 2 s
await asyncio.sleep(2) in a taskOther tasks run 2 s

Every blocking call has an asyncio twin. The skill is knowing which twin to reach for.

The code: three tasks at once

import asyncio
from machine import Pin

led = Pin(25, Pin.OUT)
button = Pin(14, Pin.IN, Pin.PULL_UP)

async def blink(interval_ms):
    while True:
        led.toggle()
        await asyncio.sleep_ms(interval_ms)   # yields to the scheduler

async def watch_button():
    while True:
        if button.value() == 0:   # pressed (active LOW)
            print("button pressed at", asyncio.ticks_ms())
        await asyncio.sleep_ms(50)   # poll 20x per second

async def report_uptime():
    n = 0
    while True:
        n += 1
        print("task heartbeat", n)
        await asyncio.sleep(5)

async def main():
    asyncio.create_task(blink(500))
    asyncio.create_task(watch_button())
    asyncio.create_task(report_uptime())
    # main itself can also loop, or just return and let tasks run
    while True:
        await asyncio.sleep(3600)

asyncio.run(main())

Run it. The LED blinks at 2 Hz, button presses print immediately (mid-blink, no lag), and the heartbeat chimes every 5 s. Three loops, one core, zero interference.

The await-twin table

Blocking (freezes everything)Async twin (yields properly)
time.sleep(s)await asyncio.sleep(s)
time.sleep_ms(ms)await asyncio.sleep_ms(ms)
pin.irq() heavy worktask that awaits a flag
long for computationawait asyncio.sleep_ms(0) inside the loop
blocking socket.read()reader, writer = await asyncio.open_connection(...)

The last one is the reason Pico W + asyncio is such a good pair: the async network stack lets a web page serve while other tasks run, which the synchronous socket code cannot.

The real example: sensor + web server together

import asyncio
from machine import Pin, ADC

latest = {"temp": 0.0}

async def poll_sensor():
    while True:
        latest["temp"] = 20 + (adc.read_u16() / 65535) * 10   # stand-in math
        await asyncio.sleep_ms(1000)

async def web_page(reader, writer):
    html = f"<h1>Pico</h1><p>temp: {latest['temp']:.1f}</p>"
    writer.write("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n" + html)
    await writer.drain()
    writer.close()
    await writer.wait_closed()

async def main():
    asyncio.create_task(poll_sensor())
    server = asyncio.start_server(web_page, "0.0.0.0", 80)
    asyncio.create_task(server)
    while True:
        await asyncio.sleep(3600)

asyncio.run(main())

The sensor task keeps its 1-second cadence even mid-page-serve. With the single-loop version, a slow browser connection would starve the sensor.

What you learned

  • asyncio interleaves tasks at every await; nothing preempts you, so no locks are needed for simple shared state.
  • Every blocking call has an async twin; using it is the whole skill.
  • asyncio.create_task() schedules; asyncio.run() starts the world.

When something breaks

  • Program hangs after boot: a task used time.sleep instead of await asyncio.sleep, and every other task starved. The twin table above is the checklist.
  • “Task was destroyed but it is pending”: you created a task without keeping a reference, and garbage collection ate it. Keep task objects in a list if you need them to survive.
  • Button presses missed: the poll interval is too slow for real fingers (e.g. debounce window longer than the press). 20-50 ms polling is right.
  • asyncio.run() refuses to restart: in MicroPython, run it once per program; structure main() so all your loops live inside it.

What to build next

  • The Pico W web server tutorial is the synchronous version; you now know why the async version is the good one.
  • The microSD datalogger gets an asyncio upgrade: log on schedule, blink status, and serve the file list at the same time.
  • The book Pico and MicroPython bundles the foundations.