freertos-prioridades-scheduler-starvation

Prioridades en FreeRTOS y starvation de tareas

  • 6 min

La prioridad de una tarea es el número que indica al Scheduler qué tarea debe ejecutarse antes cuando varias tareas están listas al mismo tiempo.

Ya sabemos crear tareas y sabemos cuánta memoria darles. Pero si creamos cinco tareas distintas y el procesador solo puede ejecutar una a la vez, aparece la pregunta importante: ¿quién pasa primero?

En un RTOS, no todas las tareas nacen iguales. Algunas son críticas (mantener el equilibrio de un dron) y otras son secundarias (encender un LED de estado).

Hoy vamos a ver cómo funciona el Scheduler para gestionar esto y, lo más importante, cómo evitar que nuestras tareas importantes “maten de hambre” (Starvation) al resto del sistema.

La escala de prioridades

En FreeRTOS, cada tarea tiene asignada un número de prioridad que va desde 0 hasta configMAX_PRIORITIES - 1.

Lo primero que debemos grabar a fuego, porque cambia según el sistema operativo, es el sentido de la escala en FreeRTOS:

  • Mayor Número = Mayor Prioridad
  • 0 es la prioridad más baja y es la que usa la tarea Idle. También podemos crear tareas con esa prioridad, aunque competirán con ella.
  • configMAX_PRIORITIES - 1 es la prioridad más alta (en ESP32 suele ser 24).

Nota para ARM (STM32/Cortex-M): El NVIC usa números bajos para las interrupciones más prioritarias. Las prioridades de interrupción y de tarea son escalas distintas; no las compares directamente.

Las reglas del Scheduler

El Scheduler de FreeRTOS es, en esencia, muy simple. Sigue dos reglas de oro inquebrantables cada vez que tiene que decidir qué tarea poner en la CPU:

  1. Regla de apropiación: En un núcleo se ejecuta la tarea Ready de mayor prioridad que pueda ejecutarse en él.
  2. Regla de Round-Robin: Con time slicing habilitado, varias tareas Ready de la misma prioridad se turnan usando el tick del sistema.

Esto significa que si tienes una tarea de Prioridad 2 que quiere trabajar, y una de Prioridad 1 que también quiere trabajar, la de Prioridad 1 jamás se ejecutará hasta que la de Prioridad 2 termine o se duerma.

El problema de la inanición (starvation)

Aquí llegamos a uno de los problemas más comunes en el diseño de sistemas en tiempo real.

Imagina que creamos una Tarea A con prioridad 2 y una Tarea B con prioridad 1.

Si la Tarea A está programada así:

void TareaA_AltaPrioridad(void *pvParameters) {
    while(1) {
        // Hago cálculos intensivos sin parar
        Serial.println("Soy el Rey, tengo prioridad 2");
        // ¡OJO! Aquí NO hay vTaskDelay
    }
}
Copied!

Como la Tarea A nunca entra en estado Blocked (nunca llama a vTaskDelay, ni espera una cola, ni un semáforo), siempre está Ready.

El resultado es la Inanición (Starvation) de la Tarea B. La Tarea B nunca llegará a ejecutarse. Para el Scheduler, la Tarea B es invisible mientras la A quiera CPU.

Ejemplo práctico: prioridades en acción

Vamos a demostrar esto con código. Crearemos dos tareas:

  1. Jefe (Alta Prioridad): Trabaja un poco y luego duerme.
  2. Becario (Baja Prioridad): Intenta trabajar continuamente.

Veremos cómo el “Becario” solo puede trabajar cuando el “Jefe” está durmiendo.

TaskHandle_t hJefe = NULL;
TaskHandle_t hBecario = NULL;

// Tarea de Alta Prioridad
void TareaJefe(void *pvParameters) {
  for(;;) {
    Serial.println(">>> JEFE (Prio 2): Entrando a trabajar. APARTAOS.");
    
    // Simulamos trabajo intenso (busy wait) durante 1 segundo
    // Usamos millis() para bloquear la CPU a propósito sin dormirnos
    long tInicio = millis();
    while(millis() - tInicio < 1000) {
        // Trabajando... consumiendo CPU
    }
    
    Serial.println(">>> JEFE: Me voy a echar la siesta 2 segundos.");
    
    // AQUÍ es donde el Jefe cede el control.
    // Pasa a estado BLOCKED.
    vTaskDelay(pdMS_TO_TICKS(2000)); 
  }
}

// Tarea de Baja Prioridad
void TareaBecario(void *pvParameters) {
  for(;;) {
    // Esta tarea intentará imprimir siempre que pueda
    Serial.println("... becario (Prio 1): trabajando ...");
    
    // Pequeño delay para no saturar el puerto serie, 
    // pero suficiente para querer CPU casi siempre
    vTaskDelay(pdMS_TO_TICKS(100)); 
  }
}

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

  // Crear Tarea Becario (Prioridad 1)
  xTaskCreatePinnedToCore(TareaBecario, "Becario", 2048, NULL, 1, &hBecario, 1);

  // Crear Tarea Jefe (Prioridad 2)
  xTaskCreatePinnedToCore(TareaJefe, "Jefe", 2048, NULL, 2, &hJefe, 1);
}

void loop() {}
Copied!

Análisis de la salida

Si ejecutas este código, verás algo así en el monitor serie:

>>> JEFE (Prio 2): Entrando a trabajar. APARTAOS.
(1 segundo de silencio absoluto en el puerto serie)
>>> JEFE: Me voy a echar la siesta 2 segundos.
... becario (Prio 1): trabajando ...
... becario (Prio 1): trabajando ...
... becario (Prio 1): trabajando ...
(varias veces)
>>> JEFE (Prio 2): Entrando a trabajar. APARTAOS.
(silencio del becario otra vez)
Copied!

Fíjate en que mientras el Jefe está en el bucle while (simulando carga de CPU), el Becario no imprime nada. Está sufriendo inanición temporal. En el momento en que el Jefe hace vTaskDelay, el Becario aprovecha el hueco.

¿Cómo elegir las prioridades?

Una duda común es: “¿Qué prioridad le pongo a mi tarea?”. Estas directrices sirven como punto de partida:

1. Tareas cortas y urgentes Prioridad Alta.
  • Ejemplo: Leer un encoder, procesar una interrupción crítica, control de motores PID.
  • Deben ejecutarse rápido y terminar (o bloquearse) rápido para liberar CPU.
2. Tareas largas y continuas Prioridad Baja.
  • Ejemplo: Procesar una imagen, actualizar un display TFT, cálculos matemáticos pesados.
  • Si les das prioridad alta, bloquearás el sistema.
3. Tareas de comunicación (WiFi/Serie) Prioridad Media/Alta.
  • Necesitan atender buffers antes de que se llenen.
4. Loop Task (Arduino) Prioridad 1.
  • Por defecto, tu código en loop() tiene prioridad 1. Tenlo en cuenta.

Cambio de prioridad en ejecución

Las prioridades no son estáticas. Podemos cambiarlas “al vuelo” si la situación lo requiere usando vTaskPrioritySet.

// Subir la prioridad del becario dinámicamente
vTaskPrioritySet(hBecario, 3); 
// Ahora el becario manda más que el jefe
Copied!

Cambiar prioridades en ejecución puede ser útil, pero complica el análisis temporal. Para la inversión de prioridad, la solución habitual es proteger el recurso con un mutex que implemente herencia de prioridad.