MicroPython for Microcontrollers Module 6 – Concurrency on devices: asyncio and threads
_thread and the second core
Start a thread on the second core of a Pico with MicroPython _thread, read the GIL model from sys.implementation, and share data under a lock.
What you will learn
- Start a thread with _thread.start_new_thread() and tell the main program when it has finished
- Explain the GIL and unsafe models that sys.implementation._thread reports, and what each leaves to locks
- Pass data between two threads through a ring buffer under a lock
- Choose between asyncio, a hard interrupt and the second core for a given job
Before you start
What you’ll learn · Before you start
What you will learn
- Start a thread with _thread.start_new_thread() and tell the main program when it has finished
- Explain the GIL and unsafe models that sys.implementation._thread reports, and what each leaves to locks
- Pass data between two threads through a ring buffer under a lock
- Choose between asyncio, a hard interrupt and the second core for a given job
Before you start
On this page
An RP2040 has two processor cores, and a MicroPython program runs on one of them. _thread.start_new_thread() puts a
function on the other, so a sampling loop can keep its rhythm while the first core waits for Wi-Fi. The cost is
shared data: on a Pico both cores run Python at the same time, and in this lesson’s model, two cores adding 1 to one
counter lose an update at 3 of the 5 points where their steps can meet.
This lesson starts a thread and waits for it, reads the threading model a build reports, passes data between the
cores through a ring buffer under a lock, and chooses between asyncio, an interrupt and the second core.
MicroPython’s documentation calls _thread highly experimental and does not describe its calls, so the details here
come from MicroPython 1.29’s source. The threaded programs run on CPython, whose _thread has the same calls.
A thread runs a function on the second core
A worker must add up the squares of 1 to 1,000 while the main program goes on, and the main program must then use
the total. _thread.start_new_thread(worker, (1000,)) starts worker(1000) as a
thread: A line of execution that runs beside the main program, started with _thread.start_new_thread(). On a Pico it runs on the second core; on an ESP32 it shares MicroPython's core with the main program. and returns at once; the arguments are always a tuple. On a Pico the thread runs on
the second core: One of the processors in a chip, each running its own instructions at the same time as the others. The RP2040 and RP2350 have two; MicroPython runs on the first, and a _thread thread on the second., with a stack of 4,096 bytes unless _thread.stack_size() asks for
another size, 2,048 at least. _thread has no join(), so the worker itself must say that it has finished. Step
through it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Start the worker. main calls _thread.start_new_thread(worker, (1000,)). On a Pico the worker starts on core 1, with a stack of 4,096 bytes unless _thread.stack_size() says otherwise.
- Both cores run. The worker adds up the squares of 1 to 1,000 while main goes on. Nothing waits for anything, because two cores run Python at the same moment, with no GIL between them.
- The worker reports. When it has the total, the worker takes the lock and sets total and done together, so main can never see done without the total.
- main checks the flag. main looks at done under the same lock every 10 ms, sleeping in between, and leaves its loop once it is set. That flag is the join() that _thread does not have.
- The core is free again. The worker's function has returned, so core 1 is free and another thread may start. Starting a second one while the first still runs raises OSError, core1 in use.
The same program runs here on CPython; it waits for a flag that the worker sets under a lock:
"""A function started with _thread, and a flag under a lock that tells the main program it has finished.
This runs on CPython, whose _thread has the same calls as MicroPython's; on a Pico the worker would run on the
second core.
"""
import _thread
import time
lock = _thread.allocate_lock()
state = {"done": False, "total": 0}
def worker(n):
total = 0
for i in range(1, n + 1):
total += i * i
with lock:
state["total"] = total
state["done"] = True # _thread has no join(), so the worker says when it has finished
_thread.start_new_thread(worker, (1000,))
print("main: worker started; main waits for its flag")
while True:
with lock:
if state["done"]:
break
time.sleep(0.01) # time.sleep_ms(10) on a board
print("main: sum of the squares of 1 to 1000 =", state["total"]) Output
main: worker started; main waits for its flag main: sum of the squares of 1 to 1000 = 333833500
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 start_thread.py
The worker set the total and the flag together, under the lock, so the main program saw both or neither, and printed
333833500. On the rp2 port only one extra thread runs at a time, on core 1: starting a second one while the first
runs raises OSError with the message core1 in use, and core 1 is free again once the worker’s function returns.
An exception that the function does not catch prints Unhandled exception in thread started by and the error, and
ends that thread alone. A soft reset stops core 1 as well: the rp2 port calls mp_thread_deinit() during a soft
reset, and that resets core 1. On an ESP32, a thread is a FreeRTOS task pinned to the same core as MicroPython, so
it adds a second thread of work but no second core.
A lock wait with a timeout
lock.acquire(True, 0.5) looks like a wait of at most half a second, but MicroPython accepts the timeout and does
not use it, so the call waits until the lock is free. Wait for a flag in a loop with short sleeps instead, as the
program does.
Checkpoint
Check: Starting a thread on core 1
GIL or unsafe: the two ways a build runs threads
Since MicroPython 1.26, a build that has threads says how it runs them in sys.implementation._thread. A build with
a GIL: Global interpreter lock: a lock that lets only one thread run Python bytecode at a time. MicroPython's esp32 port has one and its rp2 and unix ports do not, as sys.implementation._thread reports., a global interpreter lock, reports "GIL": only one thread runs Python at a time, so the
interpreter’s own objects stay whole. A build without one reports "unsafe": the documentation says that lists,
dicts and bytearrays shared between threads must then be protected by locks such as _thread.allocate_lock().
getattr(sys.implementation, "_thread", None) gives None where there are no threads at all.
| Build | _thread |
How threads run |
|---|---|---|
| rp2: Pico, Pico W, Pico 2 | "unsafe" |
on core 1, at the same time as core 0 |
| esp32 | "GIL" |
one at a time, on MicroPython’s core |
| unix port | "unsafe" |
as the computer’s threads |
Without a GIL, even count += 1 is not one step. It runs as four steps of bytecode, load, load 1, add, store, and the
other core can run its own four in between. Step through two cores meeting in the middle:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Core 0 reads. count += 1 is four steps of bytecode. Core 0 does the first, load count, and holds 5.
- Core 1 reads. With no GIL, core 1 runs at the same moment. It loads count too, before core 0 has stored anything, and also holds 5.
- Core 0 stores 6. Core 0 adds 1 and stores 6.
- Core 1 stores 6. Core 1 adds 1 to the 5 it read and stores 6 as well. Two additions ran, and count went up by one.
- With a lock. With both updates inside one lock, core 1 waits at the lock until core 0 has stored 6, then loads 6 and stores 7. Nothing is lost.
The model lands the other core’s whole count += 1 at each of the five points around this core’s four steps, under
each model:
"""Where can the other core's count += 1 land inside this core's count += 1, and which landings lose an update?
count += 1 on a global runs as four steps of bytecode: load count, load 1, add, store count. The other core's whole
count += 1 lands at each of the five points around this core's four steps, under three models:
unsafe no GIL, as on the rp2 and unix ports: both cores run Python at once, so it can land at any point;
GIL as on the esp32 port: one thread runs Python at a time, and the GIL changes hands only at a jump of the
bytecode, such as the end of a pass of a loop (py/vm.c, py/mpconfig.h), never inside these four steps;
lock count += 1 inside a held lock: the other core waits until the four steps are done.
Not MicroPython: a model of the three cases, on a computer.
"""
STEPS = ["load count", "load 1", "add", "store count"]
CAN_LAND = {"unsafe": {0, 1, 2, 3, 4}, "GIL": {0, 4}, "lock": {0, 4}}
def final_count(land_at):
count = 0
value = None
for point in range(len(STEPS) + 1):
if point == land_at:
count += 1 # the other core's whole count += 1
if point == len(STEPS):
break
step = STEPS[point]
if step == "load count":
value = count
elif step == "add":
value = value + 1
elif step == "store count":
count = value
return count
print("%-8s %s lost" % ("model", " ".join("at %d" % p for p in range(5))))
for model in ("unsafe", "GIL", "lock"):
cells, lost, tried = [], 0, 0
for point in range(5):
if point in CAN_LAND[model]:
result = final_count(point)
cells.append("%4d" % result)
tried += 1
lost += result != 2
else:
cells.append(" -")
print("%-8s %s %d of %d" % (model, " ".join(cells), lost, tried)) Output
model at 0 at 1 at 2 at 3 at 4 lost unsafe 2 1 1 1 2 3 of 5 GIL 2 - - - 2 0 of 2 lock 2 - - - 2 0 of 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 landing.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With no GIL, three of the five landing points lose an update. A GIL changes hands at a jump of the bytecode, such as
the end of a pass of a loop, every 32 jumps by default, or while a thread waits in a blocking call, so it never splits
these four steps. That protects one short statement, not an update that waits in the middle. Press Edit and let the
GIL row land at point 2 as well, "GIL": {0, 2, 4}, as if the update waited after its first two steps: that point
loses an update, 1 of 3. CPython has a GIL as well, so the next program shows that case for real: two threads each
add 1 to a counter 2,000 times, with a time.sleep(0) between the read and the write.
"""Two threads add 1 to one counter 2,000 times each, on CPython, which has a GIL as MicroPython has on an ESP32.
Each update reads the counter, gives the processor up with time.sleep(0), as an update that waits for a sensor or a
bus in the middle would, and writes the counter back. A GIL changes hands at that wait, so without a lock some
updates are lost; with the three steps inside a lock, none is.
"""
import _thread
import time
N = 2000
def run(use_lock):
lock = _thread.allocate_lock()
state = {"count": 0, "finished": 0}
def worker():
for _ in range(N):
if use_lock:
lock.acquire()
value = state["count"]
time.sleep(0) # a wait in the middle of the update
state["count"] = value + 1
if use_lock:
lock.release()
with lock:
state["finished"] += 1
for _ in range(2):
_thread.start_new_thread(worker, ())
while True:
with lock:
if state["finished"] == 2:
return state["count"]
time.sleep(0.01)
print("with a lock: %d of %d updates kept" % (run(True), 2 * N))
print("without a lock: %d of %d updates kept" % (run(False), 2 * N)) Output
with a lock: 4000 of 4000 updates kept without a lock: 2132 of 4000 updates kept
This output changes from run to run: Without a lock, the threads interleave differently on every run, so the count changes; with the lock it is always 4000.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 split_update.py
With the lock, 4,000 of 4,000 updates were kept. Without it, far fewer were, a different number on almost every run, although only one thread at a time ever ran Python: the GIL changed hands at the wait.
Key idea
With no GIL, as on a Pico, every value two threads share needs a lock; with a GIL, every update that waits or loops in the middle still does.
Checkpoint
Check: The GIL and unsafe models
A ring buffer under a lock carries data between the cores
On a Pico, a thread on core 1 can sample a sensor while core 0 sends the readings. A
ring buffer: A fixed array used as a queue whose write and read positions wrap round to the start, so it never grows. When only the handler moves one position and only the main loop the other, they can share it without a race. More in “Interrupt handlers: hard IRQs, schedule and the heap” between them holds the readings, and one lock guards it: put() takes the
lock to store a reading, take_all() takes it to copy out everything waiting, and each holds it only for that copy.
Because both sides change the ring only while holding the lock, it can keep a count of its items and use every
place. Step through a ring of 4 places:
- being moved or written
Pseudo-code
-
enqueue(x): if count = capacity: error "full" -
a[rear] ← x; rear ← (rear + 1) mod capacity; count ← count + 1 -
dequeue(): if count = 0: error "empty" -
x ← a[front]; front ← (front + 1) mod capacity; count ← count − 1; return x
A circular queue with 4 slots. front is where the next item leaves, rear where the next one goes; both wrap around to slot 0 after slot 3.
When it finishes: 3 items queued.
Try your own input
When the ring was full, the seventh reading was refused, as put() refuses it by returning False; the producer
then decides whether to wait or to count a drop. The program runs a producer thread that puts 500 readings into a ring
of 8 and waits whenever it is full, while the main loop takes what is there every 2 ms:
"""A producer thread fills a ring buffer under a lock while the main loop takes what is there, on CPython here.
On a Pico the producer would run on the second core, sampling a sensor, while the main program on the first core
sends the readings on.
"""
import _thread
import time
class SharedRing:
def __init__(self, size):
self.buf = [0] * size
self.size = size
self.head = self.tail = self.count = self.most = 0 # changed only while the lock is held
self.lock = _thread.allocate_lock()
def put(self, x):
with self.lock:
if self.count == self.size:
return False
self.buf[self.head] = x
self.head = (self.head + 1) % self.size
self.count += 1
self.most = max(self.most, self.count) # the most readings it has held at once
return True
def take_all(self):
with self.lock:
out = []
while self.count:
out.append(self.buf[self.tail])
self.tail = (self.tail + 1) % self.size
self.count -= 1
return out
ring = SharedRing(8)
state = {"done": False}
def producer(n):
for reading in range(1, n + 1):
while not ring.put(reading): # full: wait until the main loop has taken some
time.sleep(0.001)
with ring.lock:
state["done"] = True
_thread.start_new_thread(producer, (500,))
got = []
while True:
with ring.lock:
finished = state["done"]
got += ring.take_all()
if finished: # the producer had put its last reading before this take_all()
break
time.sleep(0.002)
print("main: received %d readings, in order: %s" % (len(got), got == list(range(1, 501))))
print("the ring held at most %d readings at once, in its %d places" % (ring.most, ring.size)) Output
main: received 500 readings, in order: True the ring held at most 8 readings at once, in its 8 places
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ring_threads.py
All 500 readings arrived in order, and at its fullest the ring held a reading in each of its 8 places. The main loop
checks the producer’s flag before its last take_all(), so nothing put before the flag can be left behind.
Never take a thread lock in an interrupt handler
The interrupt can arrive while the code it interrupted holds the lock, and the handler would then wait for a lock that cannot be released until it returns. Between an interrupt and the rest of the program, use the one-writer ring of the interrupt lesson, with one slot kept empty, which needs no lock.
Checkpoint
Check: A ring buffer under a lock
asyncio, an interrupt or the second core?
A job runs best where its kind of waiting is cheap. Answer the three questions about your job in the table, which compares the four ways of this module against what decides between them:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way to run it | Waits for I/O | Keeps a tight rhythm | Shares data safely | Memory and limits |
|---|---|---|---|---|
| An asyncio task Runs: on the main core, between awaits | Good streams, flags and sleeps wait without holding the loop | Poor late by as long as another task holds the loop: tens of ms | Good nothing runs between two awaits, so most updates need no lock | Good a task is a small object on the heap |
| A hard interrupt handler Runs: inside the interrupt, then a task | Poor a handler must never wait: it stores and sets a flag | Good runs as the event fires, without waiting for the loop | Mixed one writer per value and a ring made in advance | Mixed nothing may be allocated inside it, which limits the work |
| A thread on core 1 (rp2) Runs: on the second core, at the same time | Mixed may block without stopping core 0; talks to asyncio by ThreadSafeFlag only | Good its loop runs whatever core 0 is waiting for | Poor no GIL: every shared list, dict or bytearray needs a lock | Mixed a 4 KB stack by default, and one thread at a time |
| A thread on an ESP32 Runs: on MicroPython's core, under the GIL | Mixed may block; a sleep hands the GIL to the other threads | Poor shares one core and the GIL with the main program | Mixed objects stay whole, but updates that wait in the middle can mix | Mixed a stack of at least 4 KB for each thread |
How the answers decide
- “What does the job spend most of its time doing?” Waiting: network, UART, sensors, timers: adds a caveat to A thread on core 1 (rp2) and A thread on an ESP32. A thread can wait too, but every object it shares then needs a lock; an asyncio task waits as cheaply without one.
- “What does the job spend most of its time doing?” Waiting: network, UART, sensors, timers: rules out A hard interrupt handler. A handler must never wait; it can only hand an event on to a task.
- “What does the job spend most of its time doing?” Computing or sampling in a tight loop: adds a caveat to An asyncio task. Long work holds the loop: slice it with await asyncio.sleep_ms(0), or every other task waits as long as each slice.
- “Which board does it run on?” An ESP32 board: rules out A thread on core 1 (rp2). Only the rp2 port runs a thread on the second core; on an ESP32, threads share MicroPython's core under the GIL.
- “Which board does it run on?” An ESP32 board: adds a caveat to A hard interrupt handler. The esp32 port refuses Timer(hard=True), so its timer callbacks are scheduled ones that wait for the main program; check that your event can raise a hard interrupt.
- “Which board does it run on?” A Pico or another RP2040 or RP2350 board: rules out A thread on an ESP32. That is how an ESP32 runs threads; on rp2 a thread runs on core 1, with no GIL.
- “How late may the job be at worst?” Under a millisecond: rules out An asyncio task and A thread on an ESP32. Behind a task that holds the loop a wake-up can be tens of milliseconds late, and an ESP32 thread waits for the GIL.
- “How late may the job be at worst?” Under a millisecond and “What does the job spend most of its time doing?” Computing or sampling in a tight loop: adds a caveat to A hard interrupt handler. A handler keeps time only for short work that creates nothing; longer work belongs on core 1.
In short: waiting goes to asyncio, where tasks share data without locks; a short reaction that cannot wait goes to
a hard interrupt that stores its data and sets a ThreadSafeFlag; and on a Pico, a loop that must keep its rhythm
while the network waits goes to core 1, with every shared value under a lock. A thread on core 1 talks to asyncio
only through a ThreadSafeFlag, because Event.set() is not safe from another thread.
Checkpoint
Check: Choosing asyncio, an interrupt or core 1
Practise: a ring buffer two cores can share
Finish SharedRing, the buffer between a sampling thread and the main program: put() that stores or counts a drop,
take_all() that empties the ring oldest first, and the lock around every read and write of it. The sample tests
check the order, the wrap-around, the drops and that the lock is held whenever the storage is touched:
Then test yourself on threads, GILs, shared rings and the choice between them. The questions start where your answers have been weakest so far:
Practice that adapts to you
Practise this lesson
13 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to mid-level): What does sys.implementation._thread tell you, and which values can it have?
It says whether the build has threads, and how it runs them. MicroPython added it in 1.26, and it exists only on
builds with the _thread module. "GIL" means a global interpreter lock lets one thread run Python at a time, as on
the esp32 port; the interpreter’s objects then stay whole, though an update that waits in the middle still needs a
lock. "unsafe" means threads run without a GIL, as on the rp2 port, where a thread runs on the second core at the
same time as the main program, and on the unix port; shared lists, dicts and bytearrays must then be protected by
locks. With no attribute at all, there are no threads. Code that must run on several boards can read it with
getattr(sys.implementation, "_thread", None) and take locks wherever it finds "unsafe".
Two longer questions on threads start from this lesson:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A Pico W must sample a vibration sensor every 500 µs without a gap, and keep a Wi-Fi connection and an MQTT session at the same time. Would you use asyncio, a hard timer interrupt or the second core, and how would the parts share data?
-
Fresher to mid
Code that counts events in two threads gives the right totals on an ESP32 but loses counts on a Pico. Explain why, and how you would make it correct on both.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do you start a thread, and where does it run on a Pico?
_thread.start_new_thread(worker, (1000,)), with the arguments always a tuple. It returns at once, and the function runs on core 1 with a stack of 4,096 bytes by default.
-
How does the main program learn that a _thread worker has finished?
_thread has no join(), so the worker sets a flag, with its result, under a lock, and the main program waits for the flag.
-
What happens when a Pico starts a second thread while one is running?
OSError: core1 in use. The rp2 port runs one extra thread at a time, on core 1, and core 1 is free again once that thread's function returns.
-
What does lock.acquire(True, 0.5) do on MicroPython?
It waits until the lock is free: the timeout is accepted but not used. Wait for a flag in a loop with short sleeps instead.
-
What does sys.implementation._thread report on rp2, esp32 and the unix port?
"unsafe" on rp2 and unix, which run threads without a GIL, and "GIL" on esp32. getattr(sys.implementation, "_thread", None) gives None on a build without threads.
-
Why can count += 1 lose updates between a Pico's two cores?
It runs as four bytecode steps, load, load 1, add and store, and with no GIL the other core can run its own four in between. Every shared value needs a lock.
-
Why must an interrupt handler never take a thread lock?
The code it interrupted may hold the lock, and the handler would wait for a lock that cannot be released until the handler returns. Use the one-writer ring with an empty slot instead.
Key takeaways
_thread.start_new_thread()starts a function at once; on a Pico it runs on core 1, one thread at a time, and since_threadhas nojoin(), the thread reports its own end through a flag under a lock.sys.implementation._threadis"GIL"on the esp32 port and"unsafe"on rp2 and the unix port; without a GIL every shared value needs a lock, and with one, every update that waits in the middle still does.- A ring buffer under one lock carries data between threads, each side holding the lock only to copy; an interrupt handler must never take that lock.
- Waiting belongs to
asyncio, a short reaction to a hard interrupt with aThreadSafeFlag, and on a Pico a loop that must keep its rhythm to core 1.
Terms in this lesson
- core
- One of the processors in a chip, each running its own instructions at the same time as the others. The RP2040 and RP2350 have two; MicroPython runs on the first, and a _thread thread on the second.
- GIL
- Global interpreter lock: a lock that lets only one thread run Python bytecode at a time. MicroPython's esp32 port has one and its rp2 and unix ports do not, as sys.implementation._thread reports.
- thread
- A line of execution that runs beside the main program, started with _thread.start_new_thread(). On a Pico it runs on the second core; on an ESP32 it shares MicroPython's core with the main program.
References
- _thread – multithreading support (MicroPython project)
- sys – sys.implementation._thread (MicroPython project)
- MicroPython v1.26.0 release notes (sys.implementation._thread) (MicroPython project)
- The _thread module, its locks and errors (py/modthread.c, v1.29.0) (MicroPython project)
- Threads on core 1 (ports/rp2/mpthreadport.c, v1.29.0) (MicroPython project)
- rp2 threads without a GIL (ports/rp2/mpconfigport.h, v1.29.0) (MicroPython project)
- A soft reset stops core 1 (ports/rp2/main.c, v1.29.0) (MicroPython project)
- esp32 threads with a GIL (ports/esp32/mpconfigport.h, v1.29.0) (MicroPython project)
- esp32 threads as FreeRTOS tasks (ports/esp32/mpthreadport.c, v1.29.0) (MicroPython project)
- esp32 Timer refuses hard callbacks (ports/esp32/machine_timer.c, v1.29.0) (MicroPython project)
- When the GIL changes hands (py/mpconfig.h, v1.29.0) (MicroPython project)
- unix port threads without a GIL (ports/unix/mpconfigport.mk, v1.29.0) (MicroPython project)
- RP2040 datasheet (the two Cortex-M0+ processors) (Raspberry Pi Ltd)
- CPython: _thread, low-level threading API (Python 3.14) (Python Software Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress