Learning Goals 5 min
- Extend the L02-35 pattern to run two independent LED timers in one loop — proving that "doing two things at once" on a single-core UNO is really "doing two things almost at once".
- Wrap each timed action in its own small helper function (
tickLed1(),tickLed2()) and explain how this is the start of cooperative scheduling. - Recognise the limits: anything in the loop that blocks for more than a few milliseconds steals from every other thing in the loop. The art is keeping all the helpers fast.
Warm-Up 10 min
Yesterday's Blink Without Delay had two LEDs blinking at different rates in one loop. That was the gateway. Today we name the pattern, formalise it as a cooperative scheduler, and stress-test it by adding sensor reads, button polls, and Serial output without anything missing a beat.
The fundamental UNO truth
The ATmega328P chip on a UNO is single-core. There's exactly one CPU. It can do one thing at a time. "Two things at once" is always an illusion — what really happens is "tiny slices of one thing, then tiny slices of another, alternating so fast it looks simultaneous".
The trick is keeping each slice tiny — under a millisecond if possible. As long as every action runs and returns quickly, the loop can poll all of them many times per second, giving the illusion of parallel work.
New Concept · The cooperative-scheduler pattern 20 min
One function per "thing"
Yesterday's two-LED loop had the timer logic inline. Today we extract each into its own function so the loop reads like a list of tasks:
void loop() {
tickLed1();
tickLed2();
tickButton();
tickSensor();
}Each tick function is responsible for one thing. Each runs to completion in well under a millisecond. The loop calls them all, every iteration, thousands of times per second. This is the cooperative-scheduler pattern in its tiniest form.
Anatomy of a tick function
void tickLed1() {
static unsigned long prev = 0;
static int state = LOW;
const unsigned long INTERVAL = 500;
if (millis() - prev >= INTERVAL) {
prev = millis();
state = !state;
digitalWrite(LED1, state);
}
}Two new tricks:
staticlocal variables. Astaticvariable inside a function persists across calls — same lifetime as a global, but only visible inside the function. Lets each tick function own its own state without polluting the global namespace.- Self-contained. Everything
tickLed1needs is inside it. Moving it to another sketch = copy this whole function and call it from the loop.
Why "cooperative"?
Because each tick function voluntarily returns quickly. If one tick decides to delay(2000), all the others are starved for 2 seconds. They're cooperating in good faith to share the CPU. (Compare with "pre-emptive" scheduling — what your laptop's OS does — which can yank a slow task off the CPU mid-execution. The UNO has nothing like that without an RTOS.)
The cost of slow ticks
Suppose tickSensor() calls dht.readTemperature(), which blocks for 20 ms. While it's blocking, no other tick runs. The other LEDs "skip" up to 20 ms of timing accuracy. For LEDs this is invisible; for a fast button it might mean missed presses. The discipline:
- Reads that block more than a few ms (DHT, LCD clear) should happen on a slow timer (every 2 s for DHT, on demand for LCD).
- Reads that need to be fresh every loop iteration (button, knock sensor) should be fast (analogRead is ~100 µs, digitalRead is ~5 µs).
- If you have a long-blocking operation, mentally budget for the lag it causes elsewhere.
Worked Example · Four things at once 25 min
The brief
In one sketch, with one loop() body of four lines, do all of:
- Blink LED1 (D9) at 500 ms.
- Blink LED2 (D10) at 350 ms.
- Count button presses on D2 with crisp response.
- Print the running press-count every 2 seconds.
Step 1 — wiring
| Component | Pin |
|---|---|
| LED1 | D9 (with 220 Ω) |
| LED2 | D10 (with 220 Ω) |
| Button | D2 (INPUT_PULLUP, to GND) |
Step 2 — the sketch (the headline form)
Save as four-at-once.ino:
// L02-36: Four cooperative tasks in one loop
const int LED1 = 9, LED2 = 10, BUTTON = 2;
int pressCount = 0;
void tickLed1() {
static unsigned long prev = 0;
static int state = LOW;
if (millis() - prev >= 500) {
prev = millis();
state = !state;
digitalWrite(LED1, state);
}
}
void tickLed2() {
static unsigned long prev = 0;
static int state = LOW;
if (millis() - prev >= 350) {
prev = millis();
state = !state;
digitalWrite(LED2, state);
}
}
void tickButton() {
static bool last = HIGH;
bool now = digitalRead(BUTTON);
if (last == HIGH && now == LOW) pressCount++;
last = now;
}
void tickReport() {
static unsigned long prev = 0;
if (millis() - prev >= 2000) {
prev = millis();
Serial.print("Presses so far: "); Serial.println(pressCount);
}
}
void setup() {
pinMode(LED1, OUTPUT);
pinMode(LED2, OUTPUT);
pinMode(BUTTON, INPUT_PULLUP);
Serial.begin(9600);
}
void loop() {
tickLed1();
tickLed2();
tickButton();
tickReport();
}Step 3 — upload, watch, press
Both LEDs blink independently. Every 2 seconds Serial prints the running count. Press the button rapidly: every press is counted. The button is responsive even when the LEDs are mid-blink and the Serial print is being assembled.
Try the "impossible" tests:
- Press the button 20 times in 4 seconds. Confirm the count went up by 20.
- Look at the LEDs and verify their blink rates are visibly different (not synchronised).
- Disconnect the Serial cable; the LEDs and button keep working.
Step 4 — measure the loop speed
Add a fifth tick that counts iterations and prints once per second:
void tickIterations() {
static unsigned long iters = 0;
static unsigned long prev = 0;
iters++;
if (millis() - prev >= 1000) {
prev = millis();
Serial.print("Iterations per second: "); Serial.println(iters);
iters = 0;
}
}On a UNO with just this sketch + Serial.print, expect ~100 000 iterations per second. That's the budget — every tick can spend a few microseconds and the system still polls everything thousands of times per second.
Step 5 — break it deliberately
Add a delay(500) inside tickButton() for one second of testing. Re-upload. Watch the iterations-per-second number plummet. The LEDs miss beats. The button works but only when you happen to press it during the 1 ms it's not blocked. Take the delay back out. This is what we mean by "the loop runs at the speed of its slowest tick".
Try It Yourself 20 min
Goal: Add a fifth tick that toggles the on-board LED (D13) at a third rate, say 750 ms. Three LEDs blinking independently in one loop. No delay.
Hint
Copy tickLed1, rename it tickLed3, change the interval and the pin. Add tickLed3() to the loop. Add pinMode(13, OUTPUT) in setup. Three lines of new code; one new tick.
Goal: Add a tick that polls the LDR (or any analog sensor) every 100 ms, smooths it with a 5-sample running average, and prints when the smoothed value crosses a threshold. All while the LEDs keep blinking and the button keeps counting.
Hint
The smoothing buffer lives as a static array inside tickSensor(). The smoothed value also lives as a static variable so it's remembered across calls.
void tickSensor() {
static unsigned long prev = 0;
static int samples[5] = {0};
static int idx = 0;
static bool wasAbove = false;
if (millis() - prev < 100) return;
prev = millis();
samples[idx] = analogRead(A0);
idx = (idx + 1) % 5;
long sum = 0;
for (int i = 0; i < 5; i++) sum += samples[i];
int avg = sum / 5;
bool isAbove = (avg > 600);
if (isAbove != wasAbove) {
Serial.print(isAbove ? "ABOVE @ " : "below @ ");
Serial.println(avg);
wasAbove = isAbove;
}
}Goal: Hide each tick's state inside a small class. Define class Blinker with members pin, interval, state, previous, and a method tick(). Now your loop reads like led1.tick(); led2.tick(); led3.tick();
Hint
class Blinker {
public:
Blinker(int p, unsigned long i) : pin(p), interval(i) {
pinMode(p, OUTPUT);
}
void tick() {
if (millis() - prev >= interval) {
prev = millis();
state = !state;
digitalWrite(pin, state);
}
}
private:
int pin;
unsigned long interval;
unsigned long prev = 0;
int state = LOW;
};
Blinker led1(9, 500);
Blinker led2(10, 350);
Blinker led3(13, 750);
void setup() { Serial.begin(9600); }
void loop() {
led1.tick();
led2.tick();
led3.tick();
}Same behaviour, fancier syntax. Useful when you have dozens of similar tickers — the class hides the boilerplate. We'll meet classes formally in L3-41, but this is a perfectly valid use case today.
Mini-Challenge · Project triathlon 15 min
Pick three of your earlier sketches — different sensors, different outputs — and combine them into one sketch using the tick-function pattern. Each tick does one thing. The combined sketch should run all three sketches' behaviour simultaneously.
Some compatible combinations:
- L02-12 personal thermometer + L02-15 light meter LED + L02-18 knock counter (three sensors, three outputs).
- L02-24 range alarm + L02-32 LCD thermometer (sensor + display).
- L02-26 smart bin lid + a heartbeat LED on D13 + a Serial logger printing every 5 s.
Each must run as a separate tick function. The loop() is just the list of tick calls.
It's done when:
- All three of the original sketches' behaviours work in the combined sketch.
- The iterations-per-second tick reports > 10 000.
- Pressing buttons (if any) is responsive — never lags.
- Removing any one tick from the loop disables that feature cleanly without breaking the others.
Recap 5 min
The Cluster F pattern is now fully assembled. Every "timed thing" in your sketch becomes a tick function with its own static state. The loop() calls all the tick functions every iteration. Each tick does its small work or returns immediately. With this discipline, a UNO can comfortably handle 5–10 independent timed activities at sub-millisecond response. The single rule that keeps it all working: no delay() anywhere, and watch out for hidden delays in libraries. The next two lessons (L02-37 millis-based debouncing, L02-38 multi-LED choreography) push the pattern into specific high-value uses. Cluster G then takes the same architecture and adds persistence — EEPROM and SD cards.
- Cooperative scheduler
- An execution model where each task voluntarily completes its small piece of work and returns, allowing other tasks to run. The UNO has nothing fancier; our manual tick-function pattern is exactly this.
- Pre-emptive scheduler
- The OS-style model where a kernel can pause one task mid-execution to run another. Requires hardware timer interrupts and an RTOS — overkill for most UNO projects.
- Tick function
- A small, fast-returning function called every loop iteration that handles one specific timed task. Owns its own state (via
staticlocals) and its own millis() timer. staticlocal variable- A variable declared inside a function with the
statickeyword. Persists across calls (same lifetime as a global) but is only visible inside the function. Perfect for tick-function state. - Time slice
- The tiny window in which one task gets to run before the scheduler hands control to the next. In a cooperative system, the slice length is whatever the task chooses — short slices = good citizen.
- Tick budget
- The total time all tick functions can collectively consume per loop iteration. With ~100 µs available per iteration (at 10 kHz loop rate), each tick should aim for < 10 µs average.
- Iteration rate
- How many times per second the loop runs. With well-behaved ticks: tens of thousands. With a single
delay(1000): one. The Cluster F pattern maximises this. - Hidden blocking
- Library calls that take significant time without being named "delay" — DHT reads, LCD updates, SD writes, EEPROM writes. Plan around them; ideally call them only when needed, not every loop.
Homework 5 min
Refactor a real project into ticks. Take your favourite project from L2 so far (Weather Station, Smart Bin Lid, Digital Thermometer, parking display — your pick) and re-architect it as a list of tick functions.
- List the timed activities in the project: sensor reads, display refreshes, button polls, LED animations, buzzer chirps.
- Write one tick function per activity.
- Reduce
loop()to the list of tick calls. - Add a sixth tick — a heartbeat LED on D13 — to prove the loop is still running fast.
- Measure iterations per second. Report.
The point of this homework is to internalise the architecture, not produce a perfect refactor. Some projects will refactor easily; others will reveal that you had some hidden blocking calls. Both are valuable findings.
Bring back next class:
- Your tick-style refactored sketch as
hw-l02-36.ino. - The iterations-per-second number.
- A short note: what surprised you in the refactor? What was easier or harder than expected?