freertos-prioridades-scheduler-starvation

Priorities in FreeRTOS and Task Starvation

  • 6 min

The priority of a task is the number that tells the Scheduler which task should run first when multiple tasks are ready at the same time.

We already know how to create tasks and how much memory to give them. But if we create five different tasks and the processor can only execute one at a time, the important question arises: who goes first?

In an RTOS, not all tasks are created equal. Some are critical (keeping a drone balanced) and others are secondary (turning on a status LED).

Today we will look at how the Scheduler manages this and, most importantly, how to prevent our important tasks from “starving” the rest of the system.

The Priority Scale

In FreeRTOS, each task is assigned a priority number ranging from 0 to configMAX_PRIORITIES - 1.

The first thing we must burn into memory, because it changes depending on the operating system, is the direction of the scale in FreeRTOS:

  • Higher Number = Higher Priority
  • 0 is the lowest priority and is the one used by the Idle task. We can also create tasks with that priority, although they will compete with it.
  • configMAX_PRIORITIES - 1 is the highest priority (on ESP32 it is usually 24).

Note for ARM (STM32/Cortex-M): The NVIC uses low numbers for the most urgent interrupts. Interrupt priorities and task priorities are different scales; do not compare them directly.

The Scheduler Rules

The FreeRTOS Scheduler is, in essence, very simple. It follows two unbreakable golden rules each time it has to decide which task to put on the CPU:

  1. Preemption Rule: On one core, the Ready task with the highest priority that can run on that core executes.
  2. Round-Robin Rule: With time slicing enabled, multiple Ready tasks with the same priority take turns using the system tick.

This means that if you have a Priority 2 task that wants to work, and a Priority 1 task that also wants to work, the Priority 1 task will never run until the Priority 2 task finishes or goes to sleep.

The Problem of Starvation

Here we arrive at one of the most common problems in real-time system design.

Imagine we create a Task A with priority 2 and a Task B with priority 1.

If Task A is programmed like this:

void HighPriorityTaskA(void *pvParameters) {
    while(1) {
        // Doing intensive calculations non-stop
        Serial.println("I am the King, I have priority 2");
        // WATCH OUT! There is NO vTaskDelay here
    }
}
Copied!

Since Task A never enters the Blocked state (it never calls vTaskDelay, nor waits for a queue, nor a semaphore), it is always Ready.

The result is the Starvation of Task B. Task B will never get to run. For the Scheduler, Task B is invisible as long as Task A wants the CPU.

Practical Example: Priorities in Action

Let’s demonstrate this with code. We will create two tasks:

  1. Boss (High Priority): Works a little and then sleeps.
  2. Intern (Low Priority): Tries to work continuously.

We’ll see how the “Intern” can only work when the “Boss” is sleeping.

TaskHandle_t hBoss = NULL;
TaskHandle_t hIntern = NULL;

// High Priority Task
void BossTask(void *pvParameters) {
  for(;;) {
    Serial.println(">>> BOSS (Prio 2): Starting work. MOVE ASIDE.");
    
    // Simulate intensive work (busy wait) for 1 second
    // We use millis() to intentionally block the CPU without sleeping
    long tStart = millis();
    while(millis() - tStart < 1000) {
        // Working... consuming CPU
    }
    
    Serial.println(">>> BOSS: Going for a 2 second nap.");
    
    // THIS is where the Boss yields control.
    // It transitions to the BLOCKED state.
    vTaskDelay(pdMS_TO_TICKS(2000)); 
  }
}

// Low Priority Task
void InternTask(void *pvParameters) {
  for(;;) {
    // This task will try to print whenever it can
    Serial.println("... intern (Prio 1): working ...");
    
    // Small delay to not saturate the serial port,
    // but enough to want CPU almost always
    vTaskDelay(pdMS_TO_TICKS(100)); 
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  // Create Intern Task (Priority 1)
  xTaskCreatePinnedToCore(InternTask, "Intern", 2048, NULL, 1, &hIntern, 1);

  // Create Boss Task (Priority 2)
  xTaskCreatePinnedToCore(BossTask, "Boss", 2048, NULL, 2, &hBoss, 1);
}

void loop() {}
Copied!

Output Analysis

If you run this code, you’ll see something like this on the serial monitor:

>>> BOSS (Prio 2): Starting work. MOVE ASIDE.
(1 second of absolute silence on the serial port)
>>> BOSS: Going for a 2 second nap.
... intern (Prio 1): working ...
... intern (Prio 1): working ...
... intern (Prio 1): working ...
(several times)
>>> BOSS (Prio 2): Starting work. MOVE ASIDE.
(silence from the intern again)
Copied!

Notice that while the Boss is in the while loop (simulating CPU load), the Intern prints nothing. It is experiencing temporary starvation. The moment the Boss calls vTaskDelay, the Intern takes advantage of the gap.

How to Choose Priorities?

A common question is: “What priority should I assign to my task?” These guidelines serve as a starting point:

1. Short and urgent tasks -> High Priority.
  • Example: Reading an encoder, processing a critical interrupt, PID motor control.
  • They must execute quickly and finish (or block) quickly to free up the CPU.
2. Long and continuous tasks -> Low Priority.
  • Example: Processing an image, updating a TFT display, heavy mathematical calculations.
  • If you give them a high priority, you will lock up the system.
3. Communication tasks (WiFi/Serial) -> Medium/High Priority.
  • They need to service buffers before they overflow.
4. Loop Task (Arduino) -> Priority 1.
  • By default, your code in loop() has priority 1. Keep this in mind.

Changing Priority at Runtime

Priorities are not static. We can change them “on the fly” if the situation requires it using vTaskPrioritySet.

// Dynamically increase the intern's priority
vTaskPrioritySet(hIntern, 3); 
// Now the intern outranks the boss
Copied!

Changing priorities at runtime can be useful, but it complicates temporal analysis. For priority inversion, the usual solution is to protect the resource with a mutex that implements priority inheritance.