Semáforos con notificaciones

Semáforos con notificaciones

En la programación concurrente, la que hemos estado realizando con FreeRTOS, es difícil, si no imposible, determinar a priori cuándo se va a ejecutar una tarea; peor aun cuando la ejecución de una tarea depende de otra. Para sincronizar tareas utilizamos semáforos binarios. Un semáforo binario tiene solamente dos posibles valores, 0 y 1. El valor 0 puede significar que el evento esperado no ha sucedido, mientras que el valor 1 puede significar que el evento ya sucedió y que podemos seguir adelante.

Por otro lado a veces una tarea tiene que contar eventos (personas, vueltas, autos, etc) y emitir un aviso a otra tarea cuando se llegue a un límite predefinido, o ir procesando uno a uno. En estos casos utilizamos semáforos contadores, los cuales son mucho mejores que una variable entera contadora. Así mismo, los semáforos contadores los podemos utilizar también para administrar los limitados recursos de nuestros sistemas.

Además de los semáforos binarios y contadores, existe una tercer clase de semáforos, los mutex (del inglés, mutual exclusion, y en español, exclusion mutua). Este tipo de semáforos asegura, hasta cierto punto, que un recurso solamente puede ser accesado por una tarea a la vez. Imagina que tienes dos o más tareas, donde cada una utiliza al convertidor ADC. El ADC de los procesadores es uno solo con varios canales. Y resulta que, al menos en el procesador ATmega328, si inicias una conversión mientras otra está en proceso, ambas se corrompen. ¿Qué hacer para asegurarse que nada más una tarea accesa al ADC a la vez? Un semáforo mutex. Una primer tarea obtiene el mutex, usa al ADC, y luego devuelve el mutex para que otra tarea lo use.

En FreeRTOS hay dos formas de implementar a los semáforos binarios y contadores: utilizando las notificaciones directas a la tarea (direct to task notifications) o utilizando primitivas del sistema operativo para semáforos (aquí puedes ver la API de estas primitivas). La diferencia es que estás últimas consumen más recursos (aunque pueden notificar a más de una tarea), y el propio creador de FreeRTOS recomienda las notificaciones directas en lugar de las primitivas para la mayoría de casos (mientras que para casos extremos recomienda usar dichas primitivas). En esta lección veremos semáforos binarios y contadores a través de las notificaciones.

Los semáforos mutex son más complejos, por lo que la única forma de implementarlos es a través de las primitivas de FreeRTOS, lo cual requeriría de su propia lección, y por eso no los trataremos en la lección de hoy.

En esta lección y en esta ya traté ampliamente el tema de las notificaciones directas a la tarea, y aunque en la lección de hoy los semáforos binarios y contadores están basadas en éstas, afortunadamente no debemos meternos en sus detalles, ya que las funciones para usar semáforos se encargan de ellos.

Tabla de contenidos

¿Qué es y qué necesito?

Los semáforos son mecanismos de sincronización, conteo y exclusión mutua internos a los sistemas operativos concurrentes (o multitarea). Los semáforos operan sobre dos premisas, ser dados y ser obtenidos (en inglés, given y taken, respectivamente). La tarea que quiere avisar que el evento se produjo da al (u otorga el) semáforo, mientras que la tarea que está a la espera de él lo obtiene. (A la tarea que lo da le estaré llamando “la tarea productora“, mientras que a la tarea que lo obtiene le llamaré “la tarea consumidora“). En la literatura encontrarás diferentes denominaciones para las operaciones de dar y obtener (Given/Taken, P/V, Signal/Wait, etc) pero la semántica es la misma; voy a usar los términos given y taken porque son los utilizados por FreeRTOS.

Un semáforo binario puede ser dado una vez, y obtenido una vez, mientras que un semáforo contador puede ser dado varias veces y obtenido una o varias veces. El semáforo binario es un caso especial del semáforo contador; siendo que la cuenta máxima del semáforo binario es uno. El semáforo mutex es un semáforo binario, pero con características extras, como la capacidad de elevar (de forma temportal) la prioridad de la tarea que lo obtiene.

Los semáforos basados en notificaciones directas no necesitan ser inicializados ni destruídos, ya que cada tarea incluye un campo de 32 bits que se usa para llevar la cuenta. FreeRTOS proporciona, en su forma más básica, dos funciones para las operaciones de dar y obtener (hay más, de las cuales hablaré después porque tienen que ver con ser llamadas desde interrupciones), xTaskNotifyGive() y ulTaskNotifyTake(), respectivamente.

La función para dar el semáforo es:

xTaskToNotify. Es el handler de la tarea consumidora, es de decir, aquella tarea que está esperando por el semáforo. Una de las limitaciones de usar semáforos basados en notificaciones es que únicamente una tarea puede ser notificada. Si un mismo evento debe ser notificado a varias tareas, entonces tendrías que usar varios semáforos.

Valor devuelto. Esta función siempre devuelve el valor pdPASS.

RECUERDA QUE: la operación Give (o dar) incrementa en uno al contador del semáforo.

La función para obtener el semáforo es:

xClearCountOnExit. Cuando este parámetro vale pdFALSE la cuenta se decrementa en 1 antes de abandonar la función. Lo utilizaremos para semáforos contadores. Cuando este parámetro vale pdTRUE la cuenta se pone a 0 antes de abandonar la función. Lo utilizaremos para semáforos binarios.

xTicksToWait. Tiempo de espera, en ticks, antes de que la tarea se desbloquée; esto es, después de una llamada a esta función la tarea esperará a lo más xTicksToWait ticks en el estado Block (es decir, sin usar ciclos de CPU) antes de despertarse. Para convertir milisegundos a ticks puedes utilizar la macro pdMS_TO_TICKS(). Para no esperar puedes escribir 0; para un tiempo de espera infinito escribe portMAX_DELAY (asegúrate que la constante INCLUDE_vTaskSuspend, en el archivo FreeRTOSConfig.h, está puesta a 1).

Valor devuelto. Esta función devuelve valor de la cuenta antes de que se le aplique lo indicado por xClearCountOnExit.

RECUERDA QUE: la operación Take (u obtener) decrementa en uno al contador del semáforo o lo pone a cero (dependiendo de si xClearCountOnExit es pdFALSE o pdTRUE, respectivamente).

Semáforos binarios

Vamos a ver un ejemplo simple de semáforo binario utilizando las notificaciones directas a la tarea. La tarea productora otorga el semáforo a intervalos regulares de tiempo. En una aplicación real o más elaborada estaría avisando que una conversión está lista, o que un mensaje se recibió, o que se llegó a una cuenta de eventos externos. La siguiente línea es la que otorga al semáforo:

xTaskNotifyGive( consumer_handler );

El handler a la tarea que obtiene al semáforo debe ser global para que la tarea productora lo vea.

Por otro lado, la tarea consumidora se bloquea mientras el semáforo no le sea dado:

if( ulTaskNotifyTake( pdTRUE, pdMS_TO_TICKS( 2000 ) ) == 1 ){

    // el semáforo fue obtenido y se hace algo

} else{ 

    // el semáforo no se obtuvo después del tiempo de espera

}

Observa que: el parámetro xClearCountOnExit está a pdTRUE indicando que después de leer la cuenta, ésta será puesta a 0; y que el tiempo de espera lo pusimos a 2000 ms. Si el semáforo no fuera dado dentro de esta ventana de tiempo, entonces tomamos acciones correctivas.

/*
 * Semáforo binario
 */

#include <FreeRTOS.h>
#include <task.h>

TaskHandle_t consumer_handler = NULL;
// la tarea productora debe conocer el handler de la tarea receptora


void producer_task( void* pvParameters )
{
   TickType_t xLastWakeTime = xTaskGetTickCount();

   while( 1 )
   {
      vTaskDelayUntil( &xLastWakeTime, pdMS_TO_TICKS( 1000 ) );

      xTaskNotifyGive( consumer_handler );
   }
}

void consumer_task( void* pvParameters )
{
   while( 1 )
   {
      if( ulTaskNotifyTake( pdTRUE, pdMS_TO_TICKS( 2000 ) ) == 1 ){

         digitalWrite( 13, HIGH );
         vTaskDelay( pdMS_TO_TICKS( 250 ) );
         digitalWrite( 13, LOW );

      } else{ 
      // timeout:

         digitalWrite( 13, HIGH );

      }
   }
}

void setup()
{
   xTaskCreate( producer_task, "PROD", 128, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128, NULL, tskIDLE_PRIORITY, &consumer_handler );

   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );
   // lo usa la tarea principal

   vTaskStartScheduler();
}

void loop() {}

Semáforos contadores

La teoría detrás de los semáforos contadores indica que podemos usar a éstos para contar eventos (ir de 0 hasta un cierto número), o para contar recursos (ir de un cierto número a 0). Sin embargo, los semáforos basados en notificaciones a la tarea de FreeRTOS no poseen ninguna de estas cualidades; es decir, la única forma para saber que la cuenta llegó a un máximo establecido es que la tarea receptora lleve la cuenta por cada elemento del cual se le ha notificado, y cuando llegue al máximo, realizar lo necesario.

Por otro lado, tampoco es posible inicializar a estos semáforos en un cierto número y luego irlo decrementando por cada recurso utilizado, lo cual complica, al igual que en el escenario recién descrito, llevar la cuenta de los recursos que quedan en un sistema, ya que es el programador, y no el semáforo, quien lo debe hacer. Los propios ejemplos de FreeRTOS se adaptan a la falta de ambas cualidades que mencioné, dejando nada más una aplicación práctica para este tipo de semáforos contadores: las interrupciones diferidas.

Es de todos sabido que el código dentro de una interrupción (ISR, en inglés) debe ser lo más rápida posible. Sin embargo, algunas veces debemos realizar algún tipo de procesamiento “lento” sobre el dato o evento que originó la llamada a la ISR. Una técnica eficiente es diferir el procesamiento de la ISR pasándole la responsabilidad del procesamiento a una tarea. A eso es a lo que le decimos interrupciones diferidas, y podemos utilizar semáforos contadores para llevarla a cabo.

En el siguiente ejemplo la tarea productora genera “eventos” a dos frecuencias diferentes, una lenta y una rápida, tratando de imitar la llegada de eventos a diferentes tiempos; en cada evento otorga el semáforo. Por otro lado, la tarea consumidora “consume” (no esperábamos menos) un evento a la vez. Esos números raros que verás en el código son números primos que utilicé para que la simulación fuera más realista, evitando que hubiera alguna relación entre ellos. Los “eventos” no son otra cosa que un contador que se incrementa cada vez que el semáforo es dado, y que se decrementa cuando el semáforo es obtenido.

Vale la pena notar la diferencia en la llamada a la función ulTaskNotifyTake() en un semáforo contador contra el binario que vimos hace un momento:

if( ulTaskNotifyTake( pdFALSE, pdMS_TO_TICKS( 1901 ) ) > 0 ){ /*...*/ }

El argumento xClearCountOnExit está puesto a pdFALSE, indicando que la cuenta debe ser decrementada (en lugar de ser puesta a 0, como en los binarios), y además probamos si el valor devuelto es mayor que 0. Si es mayor a 0 quiere decir que hay “eventos” que deben ser procesados.

/*
 * Semáforo contador
 */

#include <FreeRTOS.h>
#include <task.h>

TaskHandle_t consumer_handler = NULL;

uint16_t g_token = 0;
// contador de "eventos"


void producer_task( void* pvParameters )
{
   TickType_t xLastWakeTime = xTaskGetTickCount();

   bool rate = true;
   uint8_t cont = 3;
   TickType_t frequency = 347;

   while( 1 )
   {
      vTaskDelayUntil( &xLastWakeTime, pdMS_TO_TICKS( frequency ) );

      ++g_token;
      xTaskNotifyGive( consumer_handler );


      --cont;
      if( cont == 0 ){

         if( rate == true ){    // lento:
            rate = false;
            cont = 5;
            frequency = 1451;
         } else{                // rápido:
            rate = true;
            cont = 3;
            frequency = 347;
         }
      }

      Serial.print( "TOKEN(G): " );
      Serial.println( g_token );
   }
}

void consumer_task( void* pvParameters )
{
   while( 1 )
   {
      vTaskDelay( pdMS_TO_TICKS( 907 ) );
      // hacemos tiempo para que se nos acumulen las notificaciones

      if( ulTaskNotifyTake( pdFALSE, pdMS_TO_TICKS( 1901 ) ) > 0 ){

         --g_token;
         Serial.print( "TOKEN(T): " );
         Serial.println( g_token );

      } else{ 
      // timeout:

         Serial.println( "TIMEOVER" );

      }
   }
}

void setup()
{
   xTaskCreate( producer_task, "PROD", 128, NULL, tskIDLE_PRIORITY + 1, NULL );

   xTaskCreate( consumer_task, "CONS", 128, NULL, tskIDLE_PRIORITY, &consumer_handler );

   pinMode( 13, OUTPUT );

   Serial.begin( 115200 );
   // lo usa la tarea principal

   vTaskStartScheduler();
}

void loop() {}

Una salida parcial del programa anterior es:

TOKEN(G) significa: semáforo dado; TOKEN(T) significa: semáforo obtenido. La cifra es el número de notificaciones pendientes.

Semáforos y las interrupciones

Si hay un lugar en el que seguramente vamos a querer usar semáforos es dentro de las interrupciones (ISRs), ya sea por diseño o porque utilizamos el mecanismo que describí hace un momento: las interrupciones diferidas. En cualquier caso necesitaremos otorgar el semáforo (binario o contador) desde dentro de la ISR, pero no puedes usar a la función xTaskNotifyGive(), ya que las llamadas a las funciones de FreeRTOS desde dentro de una ISR tienen un comportamiento diferente a cuando son llamadas desde funciones regulares.

FreeRTOS incluye muchas funciones espejo que deben ser utilizadas desde dentro de las ISRs; para el caso que nos ocupa esta función es vTaskNotifyGiveFromISR():

xTaskToNotify. Es el handler de la tarea consumidora, es de decir, aquella tarea que está esperando por el semáforo. Una de las limitaciones de usar semáforos basados en notificaciones es que únicamente una tarea puede ser notificada. Si un mismo evento debe ser notificado a varias tareas, entonces tendrías que usar varios semáforos.

pxHigherPriorityTaskWoken. Esta función pondrá este parámetro a pdTRUE si la llamada saca del estado Block (es decir, despierta) a una tarea que estuviera esperando por la notificación, y que además dicha tarea tuviera una prioridad mayor que la tarea que actualmente se estuviera ejecutando. Si este es el caso, entonces tú como programador podrías llevar a cabo un cambio de contexto (en inglés, context switch) para que la tarea con mayor prioridad se ejecute saliendo de la ISR. No es obligatorio realizar el cambio de contexto, pero de lo contrario, de haber despertado a una tarea con mayor prioridad ésta deberá esperar hasta un nuevo cambio de contexto del sistema operativo (por ejemplo, en el siguiente tick) o que alguna tarea devuelva voluntariamente la CPU (a través de la macro taskYIELD()).

Valor devuelto. A diferencia de su función espejo, ésta no devuelve nada.

El acceso a las interrupciones en Arduino es algo complicado, así que en lugar de usar una ISR real, simplemente vamos a simular una, y aunque no podremos probarla, espero que te quede claro el uso de esta función.

void producer_ISR()
{
   isr_ack();
   // "reconocemos" la interrupción (ack -> acknowledge)


   BaseType_t pxHigherPriorityTaskWoken = pdFALSE;

   xTaskNotifyGiveFromISR( consumer_handler, &pxHigherPriorityTaskWoken );

   if( pxHigherPriorityTaskWoken != pdFALSE ) taskYIELD;
   // cambio de contexto opcional, pero recomendable
}

Puedes usar con toda seguridadLa función ulTaskNotifyTake() tanto en funciones regulares como en ISRs.

¿Qué sigue?

En la lección de hoy hemos visto cómo utilizar las notificaciones directas a la tarea como semáforos. Vimos ejemplos de semáforos binarios y contadores, y cómo otorgar al semáforo desde dentro de una ISR.

A diferencia de los semáforos propiamente dichos, hacerlo de esta manera ahorra muchos recursos, pero como nada es gratis en la vida, existen compromisos. Uno de los principales es que solamente una tarea puede ser notificada. Los semáforos “reales” (es decir, aquellos incluídos como tal en FreeRTOS) pueden notificar a varias tareas. Así mismo, este mecanismo no permite iniciar en un valor diferente de 0, ni establecer una cuenta máxima. Sin embargo, el abanico de aplicaciones donde pueden ser usados hace que valga la pena conocerlos. En una lección posterior veremos los semáforos “reales” de FreeRTOS.

Finalmente, ten en cuenta que los semáforos no son la panacea de la programación concurrente, ya que traen asociados sus propios problemas; sin embargo, enlistar éstos y buscar las posibles soluciones va más allá del ámbito de esta lección, y quizás merezcan la suya propia. La buena noticia es que muchos de los objetos de FreeRTOS para pasar información (notificaciones, colas, mensajes, flujos) incluyen mecanismos de sincronización (“despiertan” o sacan del estado Block a las tareas consumidoras) que hace que utilizar semáforos “en bruto” sea el plan B.

Índice del curso

Espero que esta entrada haya sido de tu interés. Si fue así podrías suscribirte a mi blog, o escríbeme a fjrg76 dot hotmail dot com, o comparte esta entrada con alguien que consideres que puede serle de ayuda.


Fco. Javier Rodríguez
Escrito por:

Fco. Javier Rodríguez

Soy Ingeniero Electrónico con 20+ años de experiencia en el diseño y desarrollo de productos electrónicos de consumo y a medida, y 12+ años como profesor. Egresado de la UNAM, también tengo el grado de Maestro en Ingeniería por la misma universidad. Mi perfil completo lo puede encontrar en: https://www.linkedin.com/in/fjrg76-dot-com/

Ver todas las entradas

2 COMENTARIOS