Timers, Timing Functions, and Multi-Core Programming
Summary
Many real projects need to do several things at once — blink an LED while reading a sensor, or update a display while checking for button presses. This chapter introduces three approaches to concurrent-feeling programs: hardware timers that fire callback functions at regular intervals, non-blocking timing with utime.ticks_ms() and ticks_diff(), and true parallel execution using the RP2040's second processor core and the _thread module. You will learn when to use each technique, how to avoid the timing bugs that plague beginners, and how to share data safely between two cores.
Concepts Covered
This chapter covers the following 19 concepts from the learning graph:
- Timer Class
- machine.Timer
- Timer Callback
- Periodic vs One-Shot Timer
- Non-Blocking Programming
- Blocking vs Non-Blocking
- machine.time_pulse_us()
- utime.sleep()
- utime.ticks_ms()
- utime.ticks_diff()
- Multi-Core Programming
- _thread Module
- Core 0 and Core 1
- Shared Memory Between Cores
- Memory Management
- Garbage Collection
- gc Module
- Heap Memory
- Stack Memory
Prerequisites
This chapter builds on concepts from:
Welcome to Chapter 20
Real projects need to juggle multiple tasks at once. In this chapter you will learn three powerful techniques: hardware timers that fire automatically, non-blocking timing so your main loop keeps running, and true two-core parallel execution unique to the RP2040. By the end, your Pico will genuinely do two things at the same time!
The Problem: Blocking vs Non-Blocking Code
Blocking code pauses the entire program until the current operation finishes. utime.sleep(5) is the most common example — the Pico sits frozen for 5 seconds and cannot respond to anything.
Non-blocking code returns immediately and lets the main loop continue. Instead of sleeping, non-blocking code checks whether enough time has passed and only performs the action when it has.
Here is the same "blink every second" task written both ways:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Non-Blocking Timing with ticks_ms and ticks_diff
The utime.ticks_ms() function returns the number of milliseconds since the Pico last reset (or overflows). It is a simple, fast counter.
The utime.ticks_diff(newer, older) function calculates the difference between two ticks values correctly, even if the counter has overflowed (wrapped around). Always use ticks_diff instead of simple subtraction (newer - older) to handle overflow safely.
1 2 3 4 5 6 | |
The non-blocking pattern scales cleanly. You can maintain multiple independent timers in one loop:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
machine.time_pulse_us()
machine.time_pulse_us(pin, pulse_level, timeout_us) measures the duration of a pulse on a pin in microseconds. This is used internally by the HC-SR04 ultrasonic sensor code from Chapter 9. You specify which pin to watch, which level to time (HIGH or LOW), and a timeout if no pulse arrives.
Hardware Timers
A hardware timer is a silicon counter that counts independently of your program. When it reaches a preset value, it fires a timer callback function. The callback runs briefly (like an interrupt handler), then your main program continues.
machine.Timer is the MicroPython class for hardware timers.
1 2 3 4 5 6 7 8 9 | |
Periodic vs one-shot timer:
- Timer.PERIODIC — fires the callback repeatedly at the given period.
- Timer.ONE_SHOT — fires once after the delay and stops.
1 2 3 4 5 6 | |
Keep Timer Callbacks Short!
Timer callbacks run at interrupt priority — the same rules as Pin.irq() handlers apply. Do NOT call utime.sleep(), print over serial, or perform lengthy operations inside a timer callback. Set a flag or toggle a pin, then return. The main loop handles the heavy work.
Diagram: Blocking vs Non-Blocking Timeline
Blocking vs Non-Blocking Timeline MicroSim
Type: diagram
sim-id: blocking-vs-nonblocking
Library: p5.js
Status: Specified
Bloom Level: Understand (L2) Bloom Verb: compare Learning Objective: Students can explain why blocking code misses events and predict whether a non-blocking pattern will detect a button press during a timing gap.
Canvas layout: - Left timeline: "Blocking" program — long gray blocks (sleep) alternating with short action blocks - Right timeline: "Non-blocking" program — continuous green loop tick marks; action blocks at the right intervals - Below timelines: a button press event marker that the user can drag left/right
Visual elements: - Blocking: button press during a sleep block is shown as "MISSED" in red - Non-blocking: button press between tick marks is detected within one loop cycle - Comparison stat: max response latency shown for each approach
Interactive controls: - createSlider() for "sleep duration" (100–2000 ms) - createSlider() for "button press time" (drag to set when the button fires) - "Play animation" button runs a 5-second simulation
Instructional Rationale: Seeing a missed button press in the blocking timeline makes the problem visceral rather than theoretical, motivating the non-blocking approach.
Implementation: p5.js. Two timeline tracks drawn as horizontal strips; sleep periods as gray rectangles; button press as a vertical arrow the user can drag.
Multi-Core Programming with _thread
The RP2040 chip inside your Pico has two identical ARM Cortex-M0+ processor cores: Core 0 and Core 1. Normally MicroPython runs only on Core 0. The _thread module lets you launch code on Core 1 simultaneously.
This is true parallelism — both cores execute code at the same time. Core 0 handles your main program; Core 1 handles a background task.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
_thread.start_new_thread(function, args) launches function on Core 1 with the given arguments tuple. The function runs independently until it returns or the Pico resets.
Shared Memory Between Cores
Both cores access the same RAM. This is convenient but dangerous. If Core 0 is writing a variable at the same moment Core 1 is reading it, the read may get a partially-written value — a race condition.
For simple cases (sharing a single integer), the risk is low because integer writes are atomic on the RP2040. For more complex data, use a flag pattern:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
Memory Management — Heap, Stack, and Garbage Collection
The Pico has 264 KB of RAM, split between heap memory and stack memory.
- Stack memory stores local variables and function call frames. It is fast and automatically managed — when a function returns, its stack frame is gone.
- Heap memory stores objects created with constructors (
bytearray(),list(), string concatenation, etc.). Objects remain on the heap until nothing refers to them.
Garbage collection (GC) is the automatic process of finding heap objects that nothing refers to anymore and freeing their memory. MicroPython runs GC automatically, but you can trigger it manually:
1 2 3 4 5 | |
In long-running programs, especially those that create many temporary strings or lists, GC pauses can cause occasional timing glitches. To reduce this:
- Reuse buffers (
bytearray,array) instead of creating new ones. - Avoid string concatenation in loops — use
format()or pre-allocated buffers. - Call
gc.collect()proactively during non-time-critical parts of your loop.
When to Use Each Concurrency Approach
Use non-blocking timing (ticks_ms/ticks_diff) when you want the main loop to juggle multiple tasks with different timing intervals — it is the simplest approach and handles most cases. Use hardware timers when you need precise, jitter-free periodic callbacks independent of main loop timing. Use _thread (dual-core) when you have a task that genuinely needs to run continuously and cannot be interrupted — like streaming audio on Core 1 while Core 0 handles the user interface.
Key Takeaways
- Blocking code (
utime.sleep()) freezes the program — no button reads, sensor reads, or display updates during the sleep. - Non-blocking timing uses
ticks_ms()+ticks_diff()to check elapsed time without pausing. utime.ticks_diff(newer, older)handles counter overflow correctly; do not use plain subtraction.machine.Timer(PERIODIC)fires a callback every N milliseconds — keep callbacks short.- The RP2040 has two cores;
_thread.start_new_thread(fn, ())runsfnon Core 1 simultaneously with Core 0. - Both cores share the same RAM — use flag variables to communicate safely between cores.
- Heap memory holds Python objects; garbage collection frees unused objects; call
gc.collect()to avoid GC pauses in time-critical loops.
Quick Check: Why use ticks_diff(a, b) instead of (a - b) for measuring elapsed time? (Click to reveal)
Because ticks_ms() is a 30-bit counter that wraps around (overflows back to zero). After about 12 days of uptime, the counter resets. ticks_diff() handles this overflow correctly; plain subtraction a - b would give a wrong large negative number after the counter wraps.
Two Cores, All Power!
Non-blocking patterns, hardware timers, and genuine dual-core parallelism — your concurrency toolkit is complete! Chapter 21 explores the Pico's file system, audio file storage, and systematic debugging techniques. You are in the home stretch of the course!