Skip to content

Concept of Task and Cooperative Task Scheduling

Anatoli Arkhipenko edited this page Apr 19, 2026 · 12 revisions

Task


Tasks:

“Task” is an action, a part of the program logic, which requires scheduled execution. A concept of Task combines the following aspects:

  1. Program code performing specific activities (callback methods)
  2. Execution interval
  3. Number of execution iterations
  4. (Optionally) Execution start event (Status Request)
  5. (Optionally) Pointer to a Local Task Storage area
  6. Overall task timeout

Tasks

Tasks perform certain functions, which could require periodic or one-time execution, update of specific variables, or waiting for specific events. Tasks also could be controlling specific hardware, or triggered by hardware interrupts.

For execution purposes Tasks are linked into execution chains, which are processed by the Scheduler in the order they were added (linked together).

TaskScheduler supports layered task prioritization. Please refer to this chapter of this manual for details.

Each task performs its function via a callback method. Scheduler calls Task’s callback method periodically until task is disabled or runs out of iterations. In addition to “regular” callback method, two additional methods could be utilized for each task: a callback method invoked every time the task is enabled, and a callback method invoked once when the task is disabled. Those two special methods allow tasks to properly initiate themselves for execution, and clean-up after execution is over (E.g., setup pin modes on enable, and always bring pin level to LOW at the end).

Tasks are responsible for supporting cooperative multitasking by being “good neighbors”, i.e., running their callback methods quickly in a non-blocking way, and releasing control back to scheduler as soon as possible.

Schedulers

Scheduler is executing Tasks' callback methods in the order the tasks were added to the chain, from first to last. Scheduler stops and exists after processing the chain once in order to allow other statements in the main code of loop() method to run. This is referred to as a “scheduling pass”.
(Normally, there is no need to have any other statements in the loop() method other than the Scheduler's execute() method).

Below is the flowchart of a Task lifecycle:

TaskScheduler

TaskScheduler library maybe compiled with different compilation controls enabled/disabled. This is a way to limit TaskScheduler functionality (and size) for specific purpose (sketch). This is achieved by defining specific #define parameters before TaskScheduler.h header file.
Specifically:

If compiled with _TASK_SLEEP_ON_IDLE_RUN enabled, the scheduler will place processor into IDLE sleep mode (for approximately 1 ms, as the timer interrupt will wake it up), after what is determined to be an “idle” pass. An Idle Pass is a pass through the task chain when no Tasks were scheduled to run their callback methods. This is done to avoid repetitive idle passes through the chain when no tasks need to be executed. If any of the tasks in the chain always requires immediate execution (aInterval = 0), then there will be no IDLE sleep between task's callback method execution.

NOTE: Task Scheduler uses millis() (or micros()) to determine if tasks are ready to be invoked. Therefore, if you put your device to any “deep” sleep mode disabling timer interrupts, the millis()/micros() count will be suspended, leading to effective suspension of scheduling. Upon wake up, active tasks need to be re-enabled, which will effectively reset their internal time scheduling variables to the new value of millis()/micros(). Time spent in deep sleep mode should be considered

“frozen”, i.e., if a task was scheduled to run in 1 second from now, and device was put to sleep for 5 minutes, upon wake up, the task will still be scheduled 1 second from the time of wake up. Executing enable() method on this tasks will make it run as soon as possible. This is a concern only for tasks which are required to run in a truly periodical manner (in absolute time terms).

In addition to time-only (millis()/micros() only) invocation, tasks can be scheduled to wait on an event employing StatusRequest objects (more about Status Requests later). Consider a scenario when one task (t1) is performing a function which affects execution of many tasks (t2, t3). In this case the task t1 will “signal” completion of its function via Status Request object. Tasks t2 and t3 are “waiting” on the same Status Request object. As soon as status request completes, t2 and t3 are activated.

Alternative scenario is the ne task (t1) and waiting for the completion of a number of tasks (t2, t3). When done, t2 and t3 signal completion of their functions, t1 is invoked.

Please see the code examples at part Implementation scenarios and ideas, and included with the library package for details.

Compile parameters

This library could be compiled in several configurations.
Parameters (#defines) defining what functionality should or should not be included need be defined before the library header file in the body of Arduino sketch.

#define _TASK_MICRO_RES

...will compile the library with microsecond scheduling resolution, instead of default millisecond resolution.
All time parameters for execution interval, delay, etc. will be treated as microseconds, instead of milliseconds.
NOTE: Sleep mode SLEEP_MODE_IDLE (see below) is automatically disabled for microsecond resolution. Time constants TASK_SECOND, TASK_MINUTE and TASK_HOUR are adjusted for microsecond duration.

#define _TASK_TIMECRITICAL

...will compile the library with time critical tracking option enabled.
Time critical option keeps track when current execution took place relative to when it was scheduled, and where next execution time of the task falls. Two methods provide this information. Task::getStartDelay() method: return number of milliseconds (or microseconds) between current system time (millis/micros) and point in time when the task was scheduled to start. A value of 0 (zero) indicates that task started right on time per schedule.
Task::getOverrun() method: If getOverrun returns a negative value, this Task’s next execution time point is already in the past, and task is behind schedule. This most probably means that either task’s callback method's runtime is too long, or the execution interval is too short (and therefore schedule is too aggressive).
A positive value indicates that task is on schedule, and callback methods have enough time to finish before the next scheduled pass.

#define _TASK_SLEEP_ON_IDLE_RUN

...will compile the library with the sleep option enabled (AVR boards only).
When enabled, scheduler will put the microcontroller into SLEEP_MODE_IDLE state if none of the tasks’ callback methods were activated during execution pass. IDLE state is interrupted by timers once every 1 ms. Putting microcontroller to IDLE state helps conserve power. Device in SLEEP_MODE_IDLE wakes up to all hardware and timer interrupts, so scheduling is kept current.
NOTE: This compilation option is not available with the microsecond resolution option.

#define _TASK_STATUS_REQUEST

…will compile TaskScheduler with support for StatusRequest object. Status Requests are objects allowing tasks to wait on an event, and signal event completion to each other.
NOTE: starting with version 2.2.1, each task has an internal StatusRequest object, which is triggered "active" at the moment the Task is enabled, and triggered "complete" at the moment the task is disabled. These events could be used by other Tasks for event-driven execution.

#define _TASK_WDT_IDS

…will compile TaskScheduler with support for Task IDs and Control Points. Each task can be (and is by default) assigned an ID, which could be used to identify the task in case there is a problem with it. Furthermore within the task, Control Points could be defined to further help with pinpointing potential problem areas. For instance, the tasks which deal with external resources (sensors, serial communications, anything hardware dependent) can be blocked (or hung), by failed hardware. In this case, a watchdog timer could be employed to trap such a failed task, and identify which one (by task id) and where in the task (by a control point) the problem is likely located.

NOTE: by default, talk IDs are assigned sequentially (1, 2, 3, …) to the tasks as they are being created. Programmer can assign a specific task id. Task ids are unsigned integers.
Control points provide a way to identify potential problem points within a task. Control points are unsigned integers as well. Please note that there is only one control point per task, and it is set to zero when the task’s callback method is invoked (this is done to prevent “stray” control point from previous task(s) confusing the matters.
Example #7 contains a test of task ID and control points functionality.

#define _TASK_LTS_POINTER

…will compile TaskScheduler with support for Local Task Storage pointer (LTS). LTS is a generic (void*) pointer which could be set to reference a variable or a structure specific to a particular task. A callback method can get access to specific variables by getting reference to a currently running task from the scheduler, and then casting (void*) LTS pointer to the appropriate pointer type.

NOTE: above parameters are DISABLED by default, and need to be explicitly enabled by placing appropriate #define statements in front of the #include statement for the TaskScheduler header file.

#define _TASK_PRIORITY

…will compile TaskScheduler with support for layered task prioritization. Task prioritization is achieved by creating several schedulers, and organizing them in priority layers. Tasks are assigned to schedulers corresponding to their priority. Tasks assigned to the “higher” layers are evaluated for invocation more frequently, and are given priority in execution in case of the scheduling coincidence. More about layered prioritization in the API documentation and TaskScheduler examples.

#define _TASK_STD_FUNCTION

…will compile TaskScheduler with support for std::function. Starting with version 4.0.0 this option is available on all platforms (previously it was restricted to ESP8266/ESP32). It allows the use of lambda captures and bound member functions as task callbacks. More on std::function here: http://en.cppreference.com/w/cpp/utility/functional/function

#define _TASK_DEBUG

…will compile TaskScheduler with all methods and variables declared as public. This is provided for debugging purposes only and should not be used for the final version of the sketch.

#define _TASK_TIMEOUT

…will compile TaskScheduler with support for overall Task timeout. Every task can set a timeout, which will deactivate it regardless of where in the execution cycle the task currently is.

#define _TASK_DEFINE_MILLIS

…will add forward definition of millis() and micros() methods for non-Arduino systems.
DEPRECATED in v4.0.0. Use _TASK_NON_ARDUINO instead (see below). Retained only for backward compatibility.

#define _TASK_INLINE

…will declare every Task/Scheduler method as inline. Useful when the header file is included in more than one translation unit (multi-tab sketches, shared helper headers).

#define _TASK_OO_CALLBACKS

…will compile TaskScheduler in object-oriented callback mode. Instead of assigning C-style function pointers, you subclass Task and override virtual Callback(), OnEnable() and OnDisable() methods. See example 21 for details. Note that the plain setCallback()/setOnEnable()/setOnDisable() setters are NOT available in this mode.

#define _TASK_TICKLESS

…will compile TaskScheduler with tickless execution support, intended for FreeRTOS (or similar) environments. After execute(), the scheduler exposes getNextRun() - the absolute time of the next scheduled task - so the calling thread can request an RTOS sleep of the exact remaining duration instead of busy-looping. See example 28.

#define _TASK_DO_NOT_YIELD

…will disable the internal call to yield() inside the scheduler's execute() loop. Previously restricted to a few platforms, from v4.0.0 this option is available everywhere. Useful when you need full control over cooperative handoff (for instance, when yield() is provided by an RTOS and you want it called explicitly by your own code).

#define _TASK_ISR_SUPPORT

…will place a few Task control methods into IRAM on espressif chips (ESP8266 / ESP32) so they can be safely called from interrupt service routines. Combine with _TASK_THREAD_SAFE to queue requests from an ISR for later processing by the scheduler.

#define _TASK_THREAD_SAFE

…will compile TaskScheduler with a request-queue front-end for use under preemptive schedulers (FreeRTOS, Zephyr, or multi-core systems). Direct Task/Scheduler method calls should only happen from the thread running execute(); cross-thread calls (and ISR calls if _TASK_ISR_SUPPORT is enabled) go through Scheduler::requestAction(). Introduced in v3.6.0 and significantly reworked in v4.0.0. See Using _TASK_THREAD_SAFE Compile Option for the full contract and a FreeRTOS example.

#define _TASK_NON_ARDUINO

…will compile TaskScheduler without Arduino.h. You must supply _task_millis(), _task_micros() and _task_yield() (and _task_delay() if _TASK_SLEEP_ON_IDLE_RUN is also enabled) yourself. Introduced in v4.0.0; supersedes the deprecated _TASK_DEFINE_MILLIS / _TASK_EXTERNAL_TIME.

#define _TASK_HEADER_AND_CPP

…will split the implementation so that TaskScheduler.cpp is compiled as a separate translation unit rather than being fully inlined from the header. Use this when building outside the Arduino IDE (PlatformIO, CMake, bare Makefile) and you do NOT want the whole library pulled into every compilation unit. Introduced in v4.0.0.

#define _TASK_EXPOSE_CHAIN

…will expose Task chain information so tasks on the chain and their order could be accessed and evaluated.

#define _TASK_SCHEDULING_OPTIONS

…will enable changing scheduling options per each task. Three scheduling options are supported:

  • Schedule as a priority with catch-up - the scheduler will try to start a task as close to the original schedule as possible. Any missed invocations will be "caught up" to maintain accurate number of expected iterations.
  • Schedule as a priority without catch-up - same as above, except all "missed" invocations will be ignored. The number of iterations per period of time will be different and overall runtime of the task is likely to increase.
  • Interval as a priority - the next invocation is scheduled from the point of previous one, not according to the original schedule. The overall runtime of the task is likely to increase.
#define _TASK_SELF_DESTRUCT

…will enable optional deletion of a task upon disable() event.

Task priority and cooperative multitasking

TaskScheduler supports task prioritization. Priority is associated with a Scheduler, not individual Tasks, hence the concept of priority layers. Tasks subsequently are assigned to schedulers corresponding to their desired priority. The lowest priority Scheduler is called “base scheduler” or “base layer”. Let’s call higher priority schedulers by their priority number, with larger number corresponding to higher priority of task execution.

Task prioritization is achieved by executing the entire chain of tasks of the higher priority scheduler for every single step (task) of the lower priority chain. Note that actual callback method invocation depends on priority and the timing of task schedule. However, higher priority tasks are evaluated more frequently and are given priority in case of scheduling collision.

For most tasks TaskScheduler does not need task priority functionality. Prioritization requires additional scheduling overhead, and should be used only for critical tasks.

A few points on that:

  1. Plain (non-layered) execution chain is simple and efficient. The main idea is to minimize scheduling overhead by Scheduler going through the chain. Each priority layer adds scheduling overhead to overall task chain execution. Let’s review 3 scenarios:

i. Flat chain of 7 tasks:
Scheduling evaluation sequence:

                           1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7

Scheduling overhead:
O = B * T = 7 * 18 = 126 microseconds,
Where:

  • O – scheduling overhead
  • B – number of tasks in the base layer
  • T – scheduling overhead of a single task execution evaluation (currently with Arduino Uno running at 16 Mhz is between 15 and 18 microseconds).

ii. Two priority layers of 7 tasks.
Tasks 1, 2, 3, 4, 5 are base priority and 6, 7 are higher priority:
Scheduling evaluation sequence:

            6 -> 7 -> 1 -> 6 -> 7 -> 2 -> 6 -> 7 -> 3 -> 6 -> 7 -> 4 -> 6 -> 7 -> 5

Scheduling overhead:
O = (B + B * P1) * T = (5 + 5 * 2) * 18 = 270 microseconds,
Where:

  • O – scheduling overhead
  • B – number of tasks in the base layer
  • P1 – number of tasks in the priority 1 layer
  • T – scheduling overhead of a single task execution evaluation (currently with Arduino Uno running at 16 Mhz is between 15 and 18 microseconds).

iii. Three priority layers of 7 tasks.
Tasks 1, 2, 3, are base priority, 4, 5 are priority 1, and 6, 7 are priority 2:
Scheduling evaluation sequence:

      6 -> 7 -> 4 -> 6 -> 7 -> 5 -> 1 -> 6 -> 7 -> 4 -> 6 -> 7 -> 5 -> 2 ->6 -> 7 -> 4 -> 6 -> 7 -> 5 -> 3

Scheduling overhead: O = (B + B * P1 + B * P1 * P2) * T = (3 + 3 * 2 + 3 * 2 * 2) * 18 = 378 microseconds,
Where:

  • O – scheduling overhead
  • B – number of tasks in the base layer
  • P1 – number of tasks in the priority 1 layer
  • P2 – number of tasks in the priority 2 layer
  • T – scheduling overhead of a single task execution evaluation (currently with Arduino Uno running at 16 Mhz is between 15 and 18 microseconds).

Scheduling overhead of a 3 layer prioritization approach is 3 times higher than that of a flat execution chain. Do evaluate if task prioritization is really required for your sketch.

  1. TaskScheduler is NOT a pre-emptive multi-tasking library. Nor is it a Real-Time OS. There is no way to break execution of one task in favor of another. Therefore callback methods require careful programming for cooperative behavior.
    This has, however, significant benefits: you don't need to worry about concurrency inside the callback method, since only one callback method runs at a time, and could not be interrupted. All resources are yours for that period of time, no one can switch the value of variables (except interrupt functions of course...), etc. It is a stable and predictable environment, and it helps a lot with writing stable code.

A number of things could be done instead of priorities:
i. Schedule your critical tasks to run more frequently than the other tasks.
(Since you can control the interval, you could also change the task to run more or less frequently as the situation demands).

ii. If one particular callback routine is critical, create a couple of tasks referring to the same callback and "sprinkle" them around the chain:

    Scheduler ts;
    Task t1(20, TASK_FOREVER, &callback1, &ts);
    Task t2(1000, TASK_FOREVER, &callback2, &ts);
    Task t3(20, TASK_FOREVER, &callback1, &ts);
    Task t4(1000, TASK_FOREVER, &callback4, &ts);
    t3.delay(10);

Note that t1 and t3 call the same callback method, and are shifted in time by 10 millis. So effectively callback1 will be called every 10 millis, but would be "sandwiched" between t2 and t4.

  1. Use short efficient callback methods written for cooperative multitasking.

What that means is:

a) DO NOT use Arduino's delay() function. It is blocking and will hold the entire chain. Instead break the callback method into two, switch the callback method of the task where delay is necessary and delay the task by that number of millis. You get your delay, and other tasks get a chance to run:

instead of:

    void callback() {
     ... stuf
     delay(1000);
     ... more stuf
    }

do this:

    void callback1() {
     ... stuf
     t1.setCallback(&callback2);
     t1.delay(1000);
    }
    void callback2() {
     ... more stuf
     t1.setCallback(&callback1);
    }

b) Same goes to pulseIn() function. If you have to use it, set the timeout parameter such that it is not a default 1 second. PulseIn functionality could be achieved via pin interrupts, and that solution is non-blocking.

c) Do don run long loops (for or do/while) in you callback methods. Make the main arduino loop be the loop driver for you:

instead of:

    void callback() {
    
      for(int i=0; i<1000; i++) {
        ... stuf // one loop action
      }
    }

do this:

    Task t1(TASK_IMMEDIATE, 1000, &callback);
    void callback() {
      int i = t1.getRunCounter() -1;
      ... stuf // one loop action
    }

or this:

    Task t1(TASK_IMMEDIATE, 1000, &callback, true, &t1On);
  
    int i;
    bool t1On() {
      i = 0;
      return true;
    }
    
    void callback() {
      ... stuf // one loop action
      i++;
    }

REMEMBER: you are already inside the loop - take advantage of it.

d) Break long running callback methods into several shorter ones, and pass control from one to the other via setCallback() method:

    Task t1(TASK_IMMEDIATE, TASK_FAREVER, &callback);
    
    void callback() {
      ... do some stuf
      t1.setCallback(&callback_step2);
    }
    void callback_step2() {
      ... do more stuf
      t1.setCallback(&callback_step3);
    }
    void callback_step3() {
      ... do last part of the stuf
      t1.setCallback(&callback);
      t1.delay(1000);
    }

This will execute all parts of the callback function in three successive steps, scheduled immediately, but allowing other tasks in the chain to run. Notice that task is scheduled to run immediately, and 1 second period is achieved by delaying the task for 1000 millis at the last step.

Alternatively you could schedule the task to run every 1000 millis and use forceNextIteration() method in steps 1 and 2 (but not 3!)

    Task t1(1000, TASK_FOREVER, &callback);
    
    void callback() {
      ... do some stuf
      t1.setCallback(&callback_step2);
      t1.forceNextIteration();
    }
    void callback_step2() {
      ... do more stuf
      t1.setCallback(&callback_step3);
      t1.forceNextIteration();
    }
    void callback_step3() {
      ... do last part of the stuf
      t1.setCallback(&callback);
    }

e) Compile the library with _TASK_TIMECRITICAL enabled and check if your tasks are falling behind schedule. If they are - you need to optimize your code further (or maybe re-evaluate your schedule). If they are not - all is well and you don't need to do anything. E.g., I have a spider robot which needs to measure distance, control motors, and keep track of the angle via querying gyroscope and accelerometer every 10 ms. The idea was to flash onboard LED if any of the tasks fall behind. At 10 ms interval for the gyro the LED does not flash, which means none of the tasks are blocking the others from starting on time.

Clone this wiki locally