[LIBRARY] CriticalTaskScheduler — Cooperative task scheduler with FreeRTOS critical thread support

Hi everyone!

I'd like to share CriticalTaskScheduler, a lightweight cooperative task scheduler I built for Arduino and ESP32 — battle-tested on a real ESP32-S3 robot in production.

Why another scheduler?

I needed two things that existing libraries didn't combine well:

  1. A strict-timing control loop running on a dedicated FreeRTOS thread (motor PID, sensor sampling)
  2. A cooperative background pump for flexible tasks (MQTT, telemetry, logging) sharing loop()

So I built this with two separate task buckets:

Feature CriticalTaskScheduler arkhipenko/TaskScheduler
FreeRTOS critical thread :white_check_mark: Built-in (ESP32, RP2040, nRF52) :cross_mark:
Earliest-due anti-starvation :white_check_mark: One task per execute() call :cross_mark: Runs all due
Per-task stats (avg/max/total) :white_check_mark: Built-in Optional
No heap allocation :white_check_mark: Static arrays only Optional
Portable (AVR↔ESP32) :white_check_mark: :white_check_mark:

How it works

Built-in statistics

Every task tracks its own metrics — call sched.printStats(Serial) to get:

Reschedule semantics

  • Background tasks reschedule from endTime + period — absorbs callback jitter, spacing stays close to period
  • Critical tasks reschedule from schedulerTime + period — preserves nominal cadence (fires at t=50, 100, 150 ms regardless of callback duration)

Install

Arduino Library Manager → search CriticalTaskScheduler

PlatformIO:

GitHub: https://github.com/andrenepomuceno/CriticalTaskScheduler

Full docs (timing semantics, API reference, troubleshooting) are in the docs folder.

Happy to answer questions or take feedback — especially from anyone running it on RP2040 or nRF52, since ESP32 is where I've tested it most!

What problem does this solve?

What can you now do that previously you could not?

Fair question. Short version: it solves the "my control loop hiccups whenever a slow task runs" problem on a single-core or cooperative loop, without forcing you onto a full RTOS or rewriting everything as state machines.

What you previously could not do (cleanly) in a loop()-only sketch:

  1. Guarantee a tight cadence for one task while letting slower tasks coexist. With delay(), millis() polling, or schedulers that run all due tasks per pass, a 40 ms log dump or a sluggish I²C read pushes back every other task in the same pass. Here, background tasks run at most one per loop() call (the most overdue one), so a slow background task can't snowball into a multi-task pile-up that delays your PID loop. And critical tasks can be pumped from a dedicated FreeRTOS thread on ESP32/RP2040/nRF52, so they keep firing on schedule even while loop() is busy.
  2. Mix "fire on time" vs "fire after work" semantics on the same scheduler. Background tasks reschedule from endTime + period (jitter is absorbed, you never get bunched-up callbacks). Critical tasks reschedule from schedulerTime + period (nominal cadence is preserved — t=50, 100, 150 ms regardless of how long the callback took). Most existing schedulers pick one and force you to live with it.
  3. Keep per-task timing visible at runtime. sched.printStats(Serial) gives you avg/max/total/last execution time per task without having to wire up your own profiling. Useful when something starts missing its deadline and you want to know which callback regressed.
  4. Run the same code on AVR and ESP32. No String, no std::function, no heap, no std::vector — static arrays only. So you can prototype on an Uno and ship on an ESP32 without rewriting the scheduling layer.

Concrete example. A motor controller doing 100 Hz PID + 1 Hz telemetry over Serial + a 5 Hz LCD refresh. With a naive "run all due tasks" approach, the LCD refresh occasionally takes 30 ms and the PID misses a tick — visible as twitchiness. With this library: PID is critical (FreeRTOS thread, fixed cadence), LCD + telemetry are background (one per loop(), never gang up). PID jitter drops from "tens of ms" to "tick-rate of the FreeRTOS runner" (default 10 ms, configurable down to 1 ms).

Open to the criticism that for trivial sketches millis()-polling is enough — this is for the moment your sketch grows past three or four periodic things and you start seeing them interfere.

Purely in the context of a hobby environment I've been working with the ESP32 and FreeRTOS for a couple of years and I think it's unlikely I'll ever go back to a non-RTOS environment, except perhaps for a specialist application such as a dedicated display driver or something like that.

The hardware is dirt cheap (for a hobby) and there's so much good quality information online. Beginners can use it as a regular dumb loop so there is no barrier to getting started. They don't have to learn RTOS stuff until later.

Perhaps third party scheduling libraries make perfect sense in the commercial world where hardware cost or power consumption is the driving factor and you end up with a microcontroller that turns out to be under powered due to project scope creep. That is pure speculation on my part as I have no experience of commercial development.

The library has two types of tasks: critical and background. Critical runs on a FreeRTOS thread, and background runs on the main loop, giving the best from the two scenarios for my applications.
For example, I need to control a robot that do real time (critical) tasks like sensor reading and motor control, that needs to be preemptive, and I also need to save backup logs files the SDCard, which is slow and runs as a background task, without messing with the robot control.
The library provides everything in a simple wrapper.