In this tutorial, we will learn how to use the dual core of the Raspberry Pi Pico using MicroPython. The Raspberry Pi Pico is a low-cost, high-performance board built around the RP2040 microcontroller chip. The RP2040 contains a dual-core ARM Cortex-M0+ processor that can operate at up to 133 MHz. This chip features 26 multi-function GPIO pins, 2MB of onboard Flash memory, and 264KB of SRAM split across multiple banks. Understanding how to use both cores effectively allows you to build more powerful and responsive embedded projects.

Prerequisites
Before starting this tutorial, make sure you have completed the following setup steps:
- Downloaded and installed the latest version of Python 3 on your PC.
- Downloaded and installed the latest version of Thonny IDE.
- Set up MicroPython firmware on your Raspberry Pi Pico.
For a step-by-step guide on performing these tasks, follow this tutorial:
If you prefer to use uPyCraft IDE, you can follow this guide instead:
RP2040 Dual Core Processor
The RP2040 chip inside the Raspberry Pi Pico contains two ARM Cortex-M0+ processor cores. These are labeled Core 0 and Core 1. By default, when you write and run a MicroPython program on the Pico, only Core 0 executes your code. Core 1 remains idle and is available for you to use whenever your application requires additional processing power or parallel task execution.
What Is a Core?
A core is a functional processing unit within a processor that can independently fetch, decode, and execute program instructions. When a processor has multiple cores, it can run separate threads of code simultaneously without one blocking the other. For microcontroller applications this is especially useful because it allows you to dedicate one core to time-critical tasks while using the other for less urgent background processing.
For example, Core 0 might handle user interface updates and sensor readings while Core 1 simultaneously manages a motor controller or communications protocol. Without dual-core support, you would need to carefully interleave these tasks using timers and interrupts, which adds complexity and can lead to timing errors.
How the Two Cores Communicate
Each core runs its own independent thread of execution. Both cores can read from and write to the same memory regions, but sharing a memory location for write operations can create a race condition where both cores attempt to modify the same variable at the same time, leading to unpredictable results.
To safely communicate and synchronize between the two cores, the RP2040 provides two hardware FIFO (First In, First Out) buffers — one writable by Core 0 and one writable by Core 1. This ensures that only one core writes to each FIFO at any given time, preventing data corruption. In MicroPython, the _thread module provides a higher-level mechanism for managing this synchronization using locks and semaphores.
Understanding Multithreading in MicroPython
MicroPython’s _thread module provides basic threading support for the Raspberry Pi Pico. When you start a new thread using _thread.start_new_thread(), MicroPython assigns it to the second core (Core 1). This gives you true parallel execution on two separate hardware cores rather than just time-sliced concurrency on a single core.
A semaphore lock (also called a mutex) is a synchronization primitive that prevents two threads from executing the same critical section of code at the same time. You acquire the lock before accessing shared resources, and release it afterward. While one thread holds the lock, any other thread attempting to acquire it will be blocked until it is released. This prevents race conditions when both cores need to access shared variables, print to the same terminal, or use hardware peripherals that are not thread-safe.
Raspberry Pi Pico Dual Core Programming Example
In this example, we will use both cores of the Raspberry Pi Pico to independently control two LEDs using multithreading. Core 0 will run the main program loop (toggling a red LED), while Core 1 will run a separate thread (controlling a green LED). Both LEDs operate independently and simultaneously, demonstrating true parallel execution.
You will need the following components:
- Raspberry Pi Pico board
- Breadboard
- Two LEDs (one red, one green)
- Two 220-ohm resistors
- Connecting wires
LED Connection with Raspberry Pi Pico
Connect the components as shown in the schematic diagram below. Connect GP0 to the anode of the red LED through a 220-ohm resistor, with the LED’s cathode connected to GND. Connect GP1 to the anode of the green LED through a second 220-ohm resistor, with its cathode connected to GND. The current-limiting resistors are essential to prevent excessive current from damaging the LEDs or the Pico’s GPIO pins.


Dual Core MicroPython Script
Create a new file in Thonny IDE and copy the code below into it:
import machine
import _thread
from time import sleep
led_red = machine.Pin(0, machine.Pin.OUT)
led_green = machine.Pin(1, machine.Pin.OUT)
sLock = _thread.allocate_lock()
def CoreTask():
while True:
sLock.acquire()
print("Enter second Thread")
sleep(1)
led_green.high()
print("Green LED is turned ON")
sleep(2)
led_green.low()
print("Green LED is turned OFF")
sleep(1)
print("Exit second Thread")
sLock.release()
sleep(1)
_thread.start_new_thread(CoreTask, ())
while True:
sLock.acquire()
print("Enter main Thread")
led_red.toggle()
sleep(0.15)
print("Red LED toggling...")
sleep(1)
print("Exit main Thread")
sLock.release()
sleep(1)How the Code Works
Importing Required Modules
We begin by importing three modules. The machine module provides access to hardware peripherals including GPIO pins. The _thread module enables multithreading and allows us to run code on Core 1. The sleep function from the time module is used to introduce time delays in our code.
import machine
import _thread
from time import sleepConfiguring GPIO Pins
We create two Pin objects, led_red on GP0 and led_green on GP1, both configured as digital output pins. Using machine.Pin.OUT sets the pin direction so we can drive the LEDs high or low from our code.
led_red = machine.Pin(0, machine.Pin.OUT)
led_green = machine.Pin(1, machine.Pin.OUT)Creating a Semaphore Lock
The _thread.allocate_lock() function creates a new lock object. This lock is initially in the unlocked state. We will use it as a mutual exclusion mechanism (mutex) to ensure that only one core is executing the critical section at a time. Without this lock, both cores could try to print to the Thonny terminal simultaneously, causing garbled output or unexpected behavior.
sLock = _thread.allocate_lock()Defining the Second Core Task
The CoreTask() function defines the work that Core 1 will perform. Inside an infinite loop, the thread acquires the lock, turns the green LED on for 2 seconds, then turns it off. Status messages are printed to the Thonny shell for each action. After completing its sequence, the thread releases the lock and waits 1 second before attempting to acquire it again. This brief sleep after release is important — it gives the main thread on Core 0 an opportunity to acquire the lock before Core 1 tries to grab it again.
def CoreTask():
while True:
sLock.acquire()
print("Enter second Thread")
sleep(1)
led_green.high()
print("Green LED is turned ON")
sleep(2)
led_green.low()
print("Green LED is turned OFF")
sleep(1)
print("Exit second Thread")
sLock.release()
sleep(1)Starting the Second Thread
The _thread.start_new_thread() function launches the CoreTask function as a new thread on Core 1. The first argument is the function to run, and the second argument is a tuple of arguments to pass to it. Since our CoreTask function takes no arguments, we pass an empty tuple ().
_thread.start_new_thread(CoreTask, ())Main Loop on Core 0
The while True loop is the main thread running on Core 0. It acquires the lock, toggles the red LED, waits 1 second, prints a status message, then releases the lock. Like the second thread, it sleeps for 1 second after releasing the lock to allow Core 1 to run its section of code.
while True:
sLock.acquire()
print("Enter main Thread")
led_red.toggle()
sleep(0.15)
print("Red LED toggling...")
sleep(1)
print("Exit main Thread")
sLock.release()
sleep(1)When to Use Semaphore Locks
A common question from readers is: if both cores are independently controlling different GPIO pins, why do we need a lock at all? The answer is that the lock is specifically needed when the two cores share any common resource — most commonly the print() function, which outputs to the same serial terminal. Without the lock, both cores could interleave their output mid-line, producing jumbled text in the Thonny shell.
For tasks that are completely independent — such as two cores each blinking their own LED with no shared variables — no lock is required. Locks are only necessary when:
- Both cores write to the same variable or data structure
- Both cores call functions that use shared hardware (such as a UART, I2C bus, or SPI bus)
- Both cores write to the terminal using
print() - Any operation requires multiple steps to complete atomically (e.g., read-modify-write on a shared counter)
As a practical improvement, moving the sleep() calls to after the lock release (rather than inside the lock) is a better design pattern. Holding the lock while sleeping blocks the other core unnecessarily. The updated structure below is more cooperative:
# Better pattern: release lock before sleeping
sLock.acquire()
# Do critical work here
sLock.release()
sleep(1) # Sleep outside the lockIndependent Dual Core Example (No Lock Needed)
If your two cores do not share any resources, you can run them completely independently without any locks. The following example demonstrates both LEDs blinking at different rates with no synchronization required:
import machine
import _thread
from time import sleep
led_red = machine.Pin(0, machine.Pin.OUT)
led_green = machine.Pin(1, machine.Pin.OUT)
def core1_task():
# Core 1: blink green LED every 0.5 seconds
while True:
led_green.toggle()
sleep(0.5)
# Start Core 1 task
_thread.start_new_thread(core1_task, ())
# Core 0: blink red LED every 1.5 seconds
while True:
led_red.toggle()
sleep(1.5)In this version, both LEDs blink at their own independent rates without any locking overhead. Core 0 blinks the red LED every 1.5 seconds, and Core 1 blinks the green LED every 0.5 seconds. Because they do not share any variables or hardware peripherals, there is no risk of a race condition.
Practical Applications of Dual Core Programming
Using both cores of the Raspberry Pi Pico opens up a wide range of more capable embedded applications. Here are some examples of how dual-core execution can improve real projects:
- Sensor reading and data processing: Core 0 can continuously read data from sensors such as a temperature sensor, IMU, or distance sensor, while Core 1 processes, filters, and logs the data in real time.
- Motor control with user interface: Core 1 can run a PID motor control loop that requires tight, consistent timing, while Core 0 handles button presses, display updates, and user input without interfering with the control loop timing.
- Wireless communication: On the Raspberry Pi Pico W, Core 0 can manage Wi-Fi communication and cloud uploads while Core 1 handles time-critical sensor tasks locally.
- Audio generation: One core can generate audio waveforms with precise timing using the PIO (Programmable I/O) state machines, while the other handles control logic.
- Concurrent protocol handling: Core 0 can manage an I2C device while Core 1 manages a separate SPI device, both operating simultaneously without either blocking the other.
Troubleshooting Tips
Here are some common issues you may encounter with dual-core MicroPython programming and how to resolve them:
- Program crashes or freezes after starting second thread: This is usually caused by a deadlock, where both threads are waiting on a lock held by the other. Make sure each thread always releases the lock it has acquired, even in error conditions.
- Garbled output in the Thonny terminal: Both cores are printing without a lock. Add a mutex around all
print()calls. - Second thread does not seem to start: Ensure you have called
_thread.start_new_thread()before entering the mainwhile Trueloop. If the main loop starts first and blocks, Core 1 may not get CPU time. - MicroPython crashes with an error in the second thread: Exceptions in secondary threads can sometimes crash the entire MicroPython runtime on the Pico. Add try/except blocks inside your
CoreTaskfunction to handle errors gracefully. - Shared variables give unexpected values: Use locks around any code that reads from or writes to shared variables to avoid race conditions.
Demonstration
After copying the code into a new file in Thonny IDE, save it with a .py extension and click the Run button to upload it to the Raspberry Pi Pico. The Thonny shell terminal will display messages from both threads showing their current state:

You can observe both LEDs operating under the control of their respective cores. The Thonny shell will show messages alternating between the two threads as each one acquires and releases the semaphore lock.
You may also like to read other Raspberry Pi Pico tutorials:
- 28BYJ-48 Stepper Motor with Raspberry Pi Pico using MicroPython
- Servo Motor with Raspberry Pi Pico using MicroPython
- HC-05 Bluetooth Interfacing with Raspberry Pi Pico – Control Outputs
- Control DC Motor using L298N Driver with Raspberry Pi Pico and MicroPython
- DS18B20 Temperature Sensor with Raspberry Pi Pico using MicroPython
- DHT11 DHT22 with Raspberry Pi Pico using MicroPython
- HC-SR04 Ultrasonic Sensor with Raspberry Pi Pico using MicroPython
- I2C LCD Interfacing with Raspberry Pi Pico
- MPU6050 with Raspberry Pi Pico (Accelerometer, Gyroscope, and Temperature)
- BME280 with Raspberry Pi Pico using MicroPython
Hi, can you help me create a code for using MPU6050 and BMP280. I want 2 types using: 2 core – same time using and a core – using 2 modules in 1 core. I would be glad, if you help me.
Thanks for your very clear description.
There are a couple of things I don’t understand.
One thing I don’t get is semaphore lock.
The lock seems to be on for the entire duration of both loops.
So will one process simply wait for the other every loop?
Also I thought this process is how we would run two threads on the the same core. What is the difference when running two threads on the same core?
I have to echo David. If the semaphore is always locked by one core or the other, aren’t we just using one core at a time? If so we don’t gain anything. I’m sure the semaphore is a valuable tool, and I’m glad you demonstrated it, but I don’t know when it is useful or needed.
Perhaps you could define what can run independently and what cannot. For example, I’m sure both cores can do math independently with no semaphores needed. Can they both print to the shell terminal? Can one access the WiFi while the other works with the GPIO pins? Can they switch different pins? The same pin?
Thanks!
I am new to microcontrollers, but not to concurrent programming. IMHO, both thread could be running at the same time without locking: they aren’t sharing resources.
A more didactical use for semaphores would be to share a variable, let’s say one that keeps the count of the times that any led has been turned on.
That could lead to race conditions: an operation as count++ reads the current value of the variable count, then adds 1 to it and writes it over. Race conditions happens when both threads tries to update and they read the same value (let’s say it is 0). Then, thread A will write count=1 and thread B will write count=1, so the final value would be 1 instead of 2.
To prevent that, you make the operation atomic by locking the execution:
sLock.acquire()
count += 1
sLock.release()
Of course, that code can (and, as a good practice, also should) be put in a separate function to be called whenever the counter is updated.
Just my 2cents. I hope it helps to clarify the use of semaphores.
I’m looking at this at the moment and I would like to know why the sleep(1) commands at the end of each function are Inside the semaphore locks. It seems to me that putting them After sLock.release() would give more opportunity for the other process to acquire the lock.