Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Interactive · message by message A worker on the second core, and how main knows it is done Step through main starting the worker, both cores running at once, and the flag that stands in for join().
A worker on the second core, and how main knows it is done, step 5 of 5. Messages so far: Core 0: main to Core 1: worker: start_new_thread(worker); Core 1: worker to lock and state: with lock: done = True; lock and state to Core 0: main: done is True.Core 0: mainruns the programlock and statedone True, total 333833500Core 1: workerfree againstart_new_thread(worker)with lock: done = Truedone is True

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 worker thread, and a flag that stands in for join() Python · threads/start_thread.py
"""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

  1. Question 1 of 2 How do you start worker(1000) as a thread?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: _thread.start_new_thread(worker, (1000,))

    start_new_thread() returns at once; worker(1000) then runs as a thread, on core 1 of a Pico.

  2. Question 2 of 2 On a Pico, a program starts a sampling thread that loops for ever, then starts a second thread for logging. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The second start_new_thread() raises OSError: core1 in use

    ports/rp2/mpthreadport.c checks whether core 1 is in use; the core is free again once the first function returns.

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:

Interactive · message by message Two cores, one count, one update lost Step through count += 1 on both cores at once, then the same with a lock.
Two cores, one count, one update lost, step 5 of 5. Messages so far: count to Core 0: load count: 5; count to Core 1: load count: 5; Core 0 to count: store count: 6; Core 1 to count: store count: 6.Core 0count += 1count7Core 1count += 1load count: 5load count: 5store count: 6store count: 6

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Core 0 reads. count += 1 is four steps of bytecode. Core 0 does the first, load count, and holds 5.
  2. 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.
  3. Core 0 stores 6. Core 0 adds 1 and stores 6.
  4. 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.
  5. 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 two increments can meet, under three models Python · threads/landing.py
"""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

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, one counter, an update that waits in the middle Python · threads/split_update.py
"""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

  1. Question 1 of 2 What does sys.implementation._thread give on a Pico running MicroPython 1.29?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "unsafe": threads without a GIL, so shared objects need locks

    The attribute was added in 1.26; "GIL" or "unsafe" are its only values.

  2. Question 2 of 2 On an ESP32, whose MicroPython has a GIL, two threads each run total = total + read_sensor(), and read_sensor() waits for the bus. Can an update be lost?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Yes, because the GIL changes hands while read_sensor() waits, between the read of total and the write

    split_update.py showed it on CPython, which also has a GIL: without a lock, an update with a wait in the middle lost many of its 4,000 additions.

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:

Interactive · step through it A ring of four places between two threads front and rear move round a fixed set of slots and wrap from the last slot to slot 0.
Circular queue of 4 slots: 0: empty, 1: empty, 2: empty, 3: empty. front 0, rear 0, 0 in use.0123frontrearwraps around
  • being moved or written

Pseudo-code

  1. enqueue(x): if count = capacity: error "full"
  2. a[rear] ← x; rear ← (rear + 1) mod capacity; count ← count + 1
  3. dequeue(): if count = 0: error "empty"
  4. 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.

Items 0
Operations 0

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 and the main loop share a ring under a lock Python · threads/ring_threads.py
"""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

  1. Question 1 of 2 A ring of 8 places shared by two threads keeps a count of its items, changed only under its lock. How many readings can it hold before put() returns False?

    Type a number.

    A hint

    Does it need an empty slot to tell full from empty?

    Show the answer: Question 1 of 2

    Answer: 8 readings

    With the count under the lock, full is count == 8, so all 8 places are used. The interrupt ring, which cannot take a lock, keeps one slot empty and holds 7.

  2. Question 2 of 2 What belongs inside the ring's lock?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Only the copy of readings into or out of the ring, and the positions and count that change with it

    Take the readings out with take_all(), release the lock, then send them.

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:

Interactive · answer and compare An asyncio task, an interrupt or the second core? Answer three questions about the job. The table marks the ways that fit and says why the others do not.
What does the job spend most of its time doing?
Which board does it run on?
How late may the job be at worst?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to run a job beside the main program, against the four things that decide between them
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

  1. Question 1 of 2 A Pico W must sample a vibration sensor every 500 µs without a gap while it keeps an MQTT connection. Which way fits best?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A thread on core 1 that samples in a loop and stores into a ring under a lock, with asyncio on core 0 for MQTT

    A hard timer interrupt could also take a short sample; the second core suits a loop that does more than a handler may.

  2. Question 2 of 2 An ESP32 node waits for MQTT messages and for presses of a button, and answers each within a second. Which way fits?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: asyncio tasks, with the button's interrupt setting a ThreadSafeFlag

    A second of slack is far more than asyncio's tens of milliseconds, so the cheapest way is the right one.

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:

Exercise · Medium · Python

A ring buffer that two cores share

On a Pico, a thread on the second core samples a sensor and the main program on the first core sends the readings. Finish SharedRing in shared_ring.py, the buffer between them:

  • SharedRing(size) raises ValueError when size is below 1, and makes its storage buf, its positions head and tail, its count of stored items and its dropped counter once, with one lock from _thread.allocate_lock().
  • put(x) stores x at head, moves head on, wrapping to 0 at the end, and returns True; when count equals size, it adds 1 to dropped and returns False.
  • take_all() returns every stored item, oldest first, and empties the ring.

Both methods hold the lock for every read and write of the ring, and release it whatever happens, so that two threads can never see it half changed. Because both sides change count only while holding the lock, the ring may use all size places.

The browser has no threads, so the sample tests call the two methods one after the other, as the two threads would, and replace the lock with one that records whether it is held while the storage is read or written.

Starter code · shared_ring.py

import _thread


class SharedRing:
    """A ring buffer two threads share: one calls put(), the other take_all(), both under one lock."""

    def __init__(self, size):
        self.lock = _thread.allocate_lock()
        self.buf = [0] * size
        self.size = size
        self.head = 0  # where put() stores next
        self.tail = 0  # where take_all() reads next
        self.count = 0  # items stored
        self.dropped = 0

    def put(self, x):
        """Stores x and returns True; when the ring is full, adds 1 to dropped and returns False."""

    def take_all(self):
        """Returns every stored item, oldest first, and empties the ring."""
        return []
The sample tests · test_shared_ring.py
"""Sample tests for shared_ring.py. They call put() and take_all() one after the other, as the two threads would, and
check that every change of the ring happens while the lock is held. The browser has no threads, so on a board or on
the unix port the same class is what two threads share."""
from shared_ring import SharedRing


class WatchedLock:
    """Stands in for the ring's lock and records whether it is held."""

    def __init__(self):
        self.held = False
        self.taken = 0

    def acquire(self, *args):
        assert not self.held, "the lock was taken twice without a release"
        self.held = True
        self.taken += 1
        return True

    def release(self):
        assert self.held, "the lock was released while not held"
        self.held = False

    def locked(self):
        return self.held

    def __enter__(self):
        return self.acquire()

    def __exit__(self, *exc):
        self.release()


class WatchedList(list):
    """The ring's storage: every read and write checks that the lock is held."""

    def __init__(self, lock, items):
        super().__init__(items)
        self.lock = lock

    def __getitem__(self, i):
        assert self.lock.held, "the ring's storage was read without holding the lock"
        return super().__getitem__(i)

    def __setitem__(self, i, value):
        assert self.lock.held, "the ring's storage was written without holding the lock"
        super().__setitem__(i, value)


def watched(size):
    ring = SharedRing(size)
    ring.lock = WatchedLock()
    ring.buf = WatchedList(ring.lock, [0] * size)
    return ring


def test_in_order():
    """take_all() returns the stored items oldest first, then nothing"""
    ring = SharedRing(8)
    for x in (11, 22, 33):
        assert ring.put(x) is True
    assert ring.take_all() == [11, 22, 33]
    assert ring.take_all() == []


def test_full_counts_drops():
    """a ring of 3 holds 3 items; the next put() returns False and counts a drop"""
    ring = SharedRing(3)
    assert [ring.put(x) for x in range(5)] == [True, True, True, False, False]
    assert ring.dropped == 2
    assert ring.take_all() == [0, 1, 2]


def test_wraps_around():
    """keeps working after head and tail pass the end of the storage many times"""
    ring = SharedRing(4)
    got = []
    for x in range(1, 101):
        ring.put(x)
        if x % 3 == 0:
            got += ring.take_all()
    got += ring.take_all()
    assert got == list(range(1, 101))
    assert ring.dropped == 0


def test_uses_the_lock():
    """put() and take_all() read and write the storage only while holding the lock, and release it"""
    ring = watched(4)
    ring.put(5)
    ring.put(6)
    assert ring.take_all() == [5, 6]
    assert ring.lock.taken >= 3 and not ring.lock.held


def test_lock_released_when_full():
    """a put() into a full ring releases the lock too"""
    ring = watched(1)
    ring.put(1)
    assert ring.put(2) is False
    assert not ring.lock.held


def test_bad_size():
    """refuses a ring that could hold nothing"""
    try:
        SharedRing(0)
    except ValueError:
        return
    raise AssertionError("SharedRing(0) did not raise ValueError")
A hint

Put the whole body of each method inside with self.lock:, which releases the lock on every way out, including an early return. Inside, work with count to tell full from empty, and move a position on with (position + 1) % self.size.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

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.

  1. Question 1 of 13 How does the main program learn that a _thread thread has finished?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: The thread sets a flag when it is done, under a lock, and the main program checks the flag

    start_thread.py checks the flag every 10 ms and sleeps in between, so it does not keep its core busy.

  2. Question 2 of 13 A thread's function raises ValueError, and nothing in it catches the error. What happens?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: MicroPython prints "Unhandled exception in thread started by" with the error, and only that thread ends

    So a thread that must not die catches its own errors and logs them, as a task in asyncio does.

  3. Question 3 of 13 On MicroPython 1.29, a thread calls lock.acquire(True, 0.5) while another thread holds the lock for 3 seconds. What happens?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: It waits the whole 3 seconds, because MicroPython accepts the timeout and does not use it

    For a bounded wait, try lock.acquire(False) in a loop with short sleeps, and give up when the time is up.

  4. Question 4 of 13 At the REPL, you press Ctrl-D for a soft reset while a thread runs on core 1 of a Pico. What happens to the thread?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: It is stopped, because a soft reset calls mp_thread_deinit(), which resets core 1

    So main.py starts its thread again after each soft reset, as it starts everything else.

  5. Question 5 of 13 What does "unsafe" in sys.implementation._thread mean?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: The build has threads without a GIL, so lists, dicts and bytearrays shared between threads need locks

    The rp2 and unix ports report "unsafe"; the esp32 port reports "GIL".

  6. Question 6 of 13 An ESP32 chip has two cores. Does a _thread thread run on the second one?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: No, each thread is a FreeRTOS task pinned to the same core as MicroPython, and the GIL lets one run Python at a time

    On an ESP32, threads add a second line of work, not a second core for Python.

  7. Question 7 of 13 What does landing.py print?

    What does this program print? Choose one answer.

    """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))
    Show the answer: Question 7 of 13

    Answer: it prints

    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

    Three of the five points lose an update without a GIL; the GIL and the lock both leave only the points where nothing overlaps.

  8. Question 8 of 13 In split_update.py, updates were lost without the lock although CPython has a GIL. Why?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Each update waited in time.sleep(0) between its read and its write, and the GIL changed hands there

    A GIL keeps the interpreter whole, not your multi-step updates; those need a lock on every port.

  9. Question 9 of 13 Why may the threads' ring keep a count of its items, while the interrupt handler's ring of an earlier lesson kept one slot empty instead?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: Both threads change the count only while holding the lock, but a handler cannot take a lock, so each value there needs one writer

    The lock is what makes a shared count safe; where no lock can be taken, give every value one writer.

  10. Question 10 of 13 Why must an interrupt handler never take the ring's _thread lock?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: The interrupt can arrive while the code it interrupted holds the lock, and the handler would wait for a release that can only come after it returns

    Between an interrupt and the program, use the one-writer ring with an empty slot, which needs no lock.

  11. Question 11 of 13 In ring_threads.py, the main loop reads the producer's done flag before its take_all(), not after. Why?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: If done was already set before take_all(), every reading had been put before it, so that take_all() empties everything

    Read the flag after take_all() instead, and a reading put between the two would be left in the ring when the loop stops.

  12. Question 12 of 13 A Pico W reads one line a second from a GPS on a UART and posts each position over Wi-Fi. Which way fits?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: asyncio tasks, one reading the UART as a stream and one posting

    Save the second core for a job that must keep a tight rhythm while the network waits.

  13. Question 13 of 13 On an ESP32, a control loop must run every millisecond while Wi-Fi works. Why is a _thread thread not the answer, and what is?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: It shares MicroPython's core and must win the GIL each time, so it keeps no better time than the main program; give the rhythm to a board whose second core runs Python, such as a Pico

    For a hard 1 ms rhythm, the work needs a core of its own, or a hardware peripheral that keeps the time by itself.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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 _thread has no join(), the thread reports its own end through a flag under a lock.
  • sys.implementation._thread is "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 a ThreadSafeFlag, 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.