Ad-Hoc millis() Checks versus Scheduler Libraries for Coexisting Periodic Tasks in Arduino Loops
0 reputation · 15 Oct 2022, 12:05 UTC
0 reputation · 15 Oct 2022, 12:05 UTC
The goal is to maintain a responsive Arduino loop() that handles multiple periodic tasks—such as sensor polling, button debouncing, and serial output—without blocking execution. The documented non-blocking timing pattern using millis() enables this by replacing delay() with a rollover-safe interval comparison, yet the official Arduino reference provides no prescribed strategy for structuring multiple tasks with differing intervals. When several periodic sources coexist, users must decide between ad-hoc millis() checks per task or integrating a third-party scheduler library, with no documented default or recommended resolution approach. This design gap affects sketches where timing precision and loop throughput compete, particularly on AVR-based boards where millis() overflows after approximately 49.7 days and naive comparisons fail without the subtraction idiom.
How should task intervals be prioritized or grouped to minimize polling overhead? Does the rollover-safe comparison scale reliably when intervals span milliseconds to seconds within the same loop? What patterns or conventions do experienced sketches adopt to reconcile multiple periodic responsibilities without a prescribed framework?
31275 reputation · 15 Oct 2022, 13:49 UTC
For a handful of periodic tasks (sensor polling, debounce, serial output), ad-hoc millis() checks using the subtraction idiom are the conventional, well-established pattern. A scheduler library becomes worthwhile when task count grows past roughly five or six, when tasks need enable/disable or iteration limits, or when you want a single place to reason about timing. Neither is "correct" by default—the official Arduino reference documents the non-blocking pattern but deliberately leaves multi-task structure to the sketch author.
The rollover-safe form
if (millis() - previousMillis >= interval) {
previousMillis += interval; // or = millis() if drift is acceptable
// do task
}works for intervals from 1 ms to days because millis() returns an unsigned long (32-bit on AVR). Unsigned subtraction wraps modulo 2^32, so the comparison stays true across the ~49.7-day overflow. Two conditions make this reliable: previousMillis must be unsigned long (storing it in int or signed long causes sign-extension bugs at wrap), and you must never write the naive if (millis() > previousMillis + interval), which fails for one full cycle after overflow.
Polling overhead per check is tiny—one millis() call and one subtraction—so the real cost is calling millis() repeatedly. Cache it once per iteration:
void loop() {
unsigned long now = millis();
if (now - tDebounce >= 5) { tDebounce = now; pollButtons(); }
if (now - tSensor >= 100) { tSensor = now; readSensor(); }
if (now - tSerial >= 1000) { tSerial = now; report(); }
}Practical conventions experienced sketches adopt:
delay() or a long Serial.print at low baud undermines the whole structure regardless of which pattern you chose.Libraries such as TaskScheduler centralize task registration, callbacks, and enable/disable logic. The trade-offs: added flash/RAM footprint, a per-iteration dispatch cost, and slightly less deterministic timing than a hand-ordered sequence of checks. On an ATmega328P with three tasks, the library buys you little; with ten tasks that start, stop, and change period dynamically, it removes a class of bookkeeping bugs. A reasonable rule: start ad-hoc, refactor to a scheduler when the loop() preamble of if (now - tX ...) blocks becomes hard to audit.
Confirm the pattern on your board before committing:
previousMillis near 0xFFFFFFFF (or use a test harness that stubs millis()) and confirm the task fires once, on schedule, across the wrap.loop() and printing it once per second; compare ad-hoc versus scheduler versions to see the actual dispatch cost on your hardware.Assumes AVR-based boards (Uno/Nano/Mega) with 32-bit millis(). On 32-bit ARM boards (Due, MKR, ESP32 core) the same idiom applies, but tick resolution and millis() implementation differ—verify on the target core.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.