Skip to content

Concept of Task and Cooperative Task Scheduling

Anatoli Arkhipenko edited this page Oct 10, 2022 · 10 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).

Starting with version 2.0.0 TaskScheduler supports task prioritization. Please refer to the this chapter of this manual for details on layered prioritization.

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: task of version 2.2.1 each task has internal StatusRequest object, which triggered active at the moment 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::functions. This is available for ESP8266 only, since standard Arduino toolchain does not include necessary support for std::functions. More on std::functions 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.

#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

Starting with version 2.0.0 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.