Concurrencia segura en Ada — Guía práctica de tareas y objetos protegidos

· Actualizado el: · · Ada, Concurrency, Tasking, ProtectedObjects, Rendezvous, RealTime, ParallelProgramming, ProgrammingLanguage, Concurrencia, Alta confiabilidad

1. Introducción — la concurrencia integrada en el lenguaje

La concurrencia es un tema ineludible en el desarrollo de software moderno. Sin embargo, en muchos lenguajes la concurrencia depende de bibliotecas «añadidas después» o de funciones del sistema operativo, y usarla correctamente exige un conocimiento profundo y un diseño cuidadoso.

Ada tiene una respuesta propia a este problema: la concurrencia está integrada en la propia especificación del lenguaje.

Modelo de concurrencia de Ada:
- Tarea (task) — unidad de concurrencia que se ejecuta de forma independiente
- Rendezvous — comunicación síncrona entre tareas
- Objeto protegido (protected object) — exclusión mutua gestionada por el lenguaje
- Prioridad en tiempo real — funciones de tiempo real del Annex D

Las tareas y el rendezvous existen desde Ada 83, en 1983; los objetos protegidos y las funciones de tiempo real del Annex D se añadieron en Ada 95, y el lenguaje ha seguido evolucionando con Ada 2005 y Ada 2012. La característica más importante de la concurrencia en Ada no son primitivas de sincronización de bajo nivel como los mutex o los semáforos, sino la posibilidad de expresar la intención de diseño directamente en el código.

En este artículo explicamos la concurrencia en Ada de forma progresiva mediante 8 ejemplos de código prácticos. Cada ejemplo es un fragmento independiente que se puede compilar y ejecutar de verdad, y que puede probar usted mismo.

Además, los fragmentos de código que aparecen en este artículo están publicados en GitHub como una colección de referencia organizada por capítulo en archivos independientes.

ada-task-concurrency - komurasoft-blog-samples (GitHub)

Ejecución local — compilación y ejecución

Ya que hemos dicho que el código «se puede compilar y ejecutar», mostramos primero el procedimiento.

Preparar GNAT

GNAT es el compilador de Ada de GCC. En Linux puede instalarlo con apt install gnat-13; en Windows, con el paquete mingw-w64-x86_64-gcc-ada de MSYS2, o bien a través de Alire (el gestor de paquetes de Ada / SPARK).

Compilar y ejecutar

Cada fragmento incluye varias unidades de compilación en un solo archivo (la especificación de la tarea, el cuerpo de la tarea y el procedimiento principal), así que primero hay que dividirlo con gnatchop y luego compilarlo con gnatmake. gnatchop es una herramienta que divide el archivo siguiendo la convención de nombres de GNAT «nombre de unidad = nombre de archivo».

mkdir work && cd work
gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo

El nombre del procedimiento principal, una vez dividido, pasa a ser directamente el nombre del ejecutable.

Correspondencia entre capítulos y archivos

El número de capítulo y el número de archivo están desplazados en uno (el capítulo 3 corresponde a 01_). La correspondencia es la siguiente.

Capítulo Archivo Ejecutable Contenido
Cap. 3 01_hello_task.ada hello_task_demo Forma básica de una tarea
Cap. 4 02_rendezvous_intro.ada rendezvous_demo Transferencia bidireccional de datos mediante rendezvous
Cap. 5 03_selective_accept.ada selective_accept_demo Aceptación selectiva y tarea servidora
Cap. 6 04_producer_consumer.ada producer_consumer_demo Productor-consumidor
Cap. 7 05_protected_counter.ada protected_counter_demo Exclusión mutua con un objeto protegido
Cap. 8 06_bounded_buffer.ada bounded_buffer_demo Entrada protegida con barrera (búfer acotado)
Cap. 9 07_timed_entry.ada timed_entry_demo Llamada select con tiempo de espera
Cap. 10 08_task_priorities.ada task_priorities_demo Prioridades de tareas y planificación en tiempo real

Los fragmentos de código del cuerpo del artículo son recortes que muestran solo la parte necesaria para la explicación. No funcionan tal cual, así que utilice los archivos anteriores cuando quiera probarlos usted mismo.

2. Repaso de los “peligros” de la concurrencia

Antes de entrar en Ada, conviene repasar brevemente por qué es tan importante la concurrencia «segura».

Entre los errores típicos de la concurrencia se encuentran los siguientes.

  • Condición de carrera de datos (data race): varias hebras acceden simultáneamente a la misma posición de memoria y al menos una de ellas escribe. El resultado queda indefinido.
  • Interbloqueo (deadlock): varias tareas esperan indefinidamente a que las otras terminen, y ninguna avanza.
  • Inversión de prioridad (priority inversion): una tarea de alta prioridad espera un recurso retenido por una de baja prioridad, y una tarea de prioridad media termina apropiándose del procesador (interrumpiendo la tarea en ejecución para pasar a otra) antes que la de baja prioridad.
  • Inanición (starvation): una tarea nunca llega a obtener el recurso que necesita.

El modelo de concurrencia de Ada ofrece defensas a nivel de lenguaje frente a estos problemas.

Condición de carrera de datos → el objeto protegido garantiza el acceso exclusivo
Interbloqueo → el modelo de rendezvous ofrece sincronización estructural
Inversión de prioridad → el Priority Ceiling Protocol está disponible como parte del lenguaje
Inanición → se controla con barreras de entrada y políticas de cola

3. Fundamentos de las tareas — unidades de ejecución independientes

La unidad básica de concurrencia en Ada es la tarea (task). Las tareas se parecen a los hilos, pero no corresponden necesariamente uno a uno con los hilos del sistema operativo, ya que el runtime de Ada gestiona su planificación.

task Greeter is
   entry Start;
end Greeter;

task body Greeter is
begin
   accept Start;
   Put_Line ("Hello from a task!");
end Greeter;

Este código (01_hello_task.ada) tiene varios puntos importantes.

Una tarea comienza a ejecutarse automáticamente en cuanto se declara. La tarea Greeter se activa en el momento del begin del procedimiento que la contiene, y espera en accept Start; una solicitud de rendezvous de quien la llame.

Una entrada (entry) es la interfaz que una tarea expone al exterior. Cuando quien llama invoca Greeter.Start;, se sincroniza con el accept Start; de la tarea. A esto se le llama rendezvous.

La finalización de las tareas se espera automáticamente. Cuando el procedimiento principal termina, si todavía hay tareas en ejecución, se espera implícitamente a que finalicen. Esto contrasta con los fallos de C++ causados por olvidar llamar a std::thread::join.

Ejemplo de ejecución

gnatchop ../src/snippets/01_hello_task.ada
gnatmake hello_task_demo
./hello_task_demo
Main: starting task...
Hello from a task!
Main: task has completed.

Aquí hay un detalle a tener en cuenta. accept Start; no tiene un bloque do ... end, así que en el instante en que se completa el rendezvous ambas partes quedan liberadas y a partir de ahí avanzan en paralelo. Por lo tanto, el orden de las dos últimas líneas no está garantizado. En algunos entornos Main: task has completed. aparece primero. Si quiere fijar también el orden, coloque el procesamiento que debe respetar el orden dentro del bloque do ... end de accept Start do ... end Start;. Solo la primera línea está garantizada siempre al principio, porque Greeter permanece detenida en accept hasta que se invoca Greeter.Start;.

4. Rendezvous — comunicación síncrona con paso de datos

El rendezvous no es solo sincronización: también permite el intercambio de datos en ambas direcciones.

task Worker is
   entry Compute (X, Y : Integer; Result : out Integer);
end Worker;

task body Worker is
   A, B   : Integer;
   Output : Integer;
begin
   accept Compute (X, Y : Integer; Result : out Integer) do
      A := X;
      B := Y;
      Output := A * A + B * B;
      Result := Output;
   end Compute;
end Worker;

Quien realiza la llamada lo utiliza de la siguiente manera (02_rendezvous_intro.ada).

Worker.Compute (3, 4, Answer);
Put_Line ("Main: result = " & Integer'Image (Answer));

El punto de diseño importante aquí es que el modo de cada parámetro se declara explícitamente.

  • Modo in: pasa un valor de quien llama hacia la tarea
  • Modo out: la tarea devuelve el resultado a quien llama
  • Modo in out: en ambas direcciones

El bloque do ... end dentro del cuerpo de accept constituye la sección crítica. Durante ese tiempo, quien llama queda bloqueado y la tarea no acepta otras entradas. Cuando el procesamiento termina, ambas partes se reanudan.

Visto en orden temporal, el proceso de espera mutua es el siguiente.

Rendezvous entre quien llama y la tarea WorkerDiagrama de secuencia que muestra cómo la llamada a Worker.Compute y el accept de la tarea Worker se esperan mutuamente hasta encontrarse, ejecutan el cuerpo del accept y luego ambas partes se reanudan a la vez.Tarea WorkerQuien llamaTarea WorkerQuien llamaBloqueado hasta llegar al acceptRendezvous establecido, se ejecuta el cuerpo del acceptEn end Compute ambas partes se reanudan a la vezLlama a Worker.ComputeLlega a accept Compute ... doEscribe el resultado en el parámetro out ResultContinúa su procesamientoContinúa su procesamiento

La parte que llega primero espera. Si quien llama llega primero, se detiene hasta alcanzar el accept; si la tarea llega primero, se detiene hasta que alguien la invoque. Sea cual sea el orden de llegada, el interior de do ... end siempre se ejecuta con las dos partes reunidas.

Resumiendo las características del rendezvous:

Característica Descripción
Sincronización Quien llama y la tarea esperan hasta llegar juntos al punto de rendezvous
Transferencia de datos Los parámetros in / out / in out permiten pasar valores en ambas direcciones
Exclusión mutua Mientras se ejecuta el cuerpo de accept, las demás entradas de la tarea quedan bloqueadas
Estructuración El cuerpo de la tarea deja explícito qué entrada se acepta y en qué momento

Ejemplo de ejecución

gnatchop ../src/snippets/02_rendezvous_intro.ada
gnatmake rendezvous_demo
./rendezvous_demo
Main: calling Worker.Compute...
Main: result = 25

El resultado de 3 * 3 + 4 * 4 = 25 vuelve a través del parámetro out. El espacio que aparece a la derecha del = se debe a que 'Image, en los tipos enteros, coloca un espacio antes de los valores no negativos. A diferencia del capítulo 3, aquí sí está garantizado el orden de las dos líneas, porque la llamada a Worker.Compute no retorna hasta llegar a end Compute;.

5. Aceptación selectiva — esperar varios servicios a la vez

En una tarea servidora real es necesario esperar varios tipos de solicitudes. La sentencia select de Ada resuelve esto a nivel de lenguaje.

task Server is
   entry Deposit  (Amount : Integer);
   entry Withdraw (Amount : Integer; Success : out Boolean);
   entry Balance  (Value : out Integer);
end Server;

task body Server is
   Current : Integer := 0;
begin
   loop
      select
         accept Deposit (Amount : Integer) do
            Current := Current + Amount;
         end Deposit;
      or
         accept Withdraw (Amount : Integer; Success : out Boolean) do
            if Current >= Amount then
               Current := Current - Amount;
               Success := True;
            else
               Success := False;
            end if;
         end Withdraw;
      or
         accept Balance (Value : out Integer) do
            Value := Current;
         end Balance;
      or
         terminate;
      end select;
   end loop;
end Server;

En este código (03_selective_accept.ada), la sentencia select tiene varias ramas or, y se selecciona una de las entradas que tenga una llamada pendiente (la selección concreta depende de la implementación). Si ninguna entrada tiene una llamada pendiente, se espera hasta que se invoque alguna.

or terminate; es una rama especial que finaliza la tarea de forma segura cuando «el procedimiento principal ha terminado y ya no existe la posibilidad de que nadie vuelva a llamar a ninguna entrada de esta tarea». Es un mecanismo propio de Ada que resuelve el problema de la «tarea servidora que espera eternamente», una causa habitual de interbloqueos.

Lo poderoso de la aceptación selectiva es que también se pueden escribir condiciones de guarda.

A continuación se muestra una tarea que contiene internamente un búfer circular. Si se recortara solo la parte del select, no quedaría claro de dónde vienen Count o Head, así que lo mostramos completo desde la parte declarativa. En el capítulo 8 veremos la misma idea reescrita con un objeto protegido.

task Buffer_Task is
   entry Put_Item (Item : Integer);
   entry Get_Item (Item : out Integer);
end Buffer_Task;

task body Buffer_Task is
   Max   : constant := 8;
   Data  : array (0 .. Max - 1) of Integer;
   Head  : Integer := 0;   -- posición de la que se extrae a continuación
   Tail  : Integer := 0;   -- posición en la que se escribe a continuación
   Count : Integer := 0;   -- número actual de elementos
begin
   loop
      select
         when Count > 0 =>
            accept Get_Item (Item : out Integer) do
               Item := Data (Head);
               Head := (Head + 1) mod Max;
               Count := Count - 1;
            end Get_Item;
      or
         when Count < Max =>
            accept Put_Item (Item : Integer) do
               Data (Tail) := Item;
               Tail := (Tail + 1) mod Max;
               Count := Count + 1;
            end Put_Item;
      or
         terminate;
      end select;
   end loop;
end Buffer_Task;

Una rama cuya condición de guarda es falsa queda excluida de la selección en ese momento. Esto permite expresar de forma declarativa un control como «si el búfer está vacío, hacer esperar a Get; si está lleno, hacer esperar a Put». El núcleo del búfer circular consiste en avanzar Head y Tail con mod Max, y la condición de guarda también cumple el papel de garantizar que esos índices se mantengan dentro de un rango válido.

6. Productor-consumidor — sincronización mediante rendezvous

Veamos el patrón productor-consumidor como ejemplo típico del uso del rendezvous.

task Consumer is
   entry Deliver (Item : Integer);
end Consumer;

task Producer;

task body Consumer is
   Sum : Integer := 0;
begin
   for I in 1 .. 5 loop
      accept Deliver (Item : Integer) do
         Sum := Sum + Item;
      end Deliver;
   end loop;
end Consumer;

task body Producer is
begin
   for I in 1 .. 5 loop
      Consumer.Deliver (I);
   end loop;
end Producer;

En este patrón (04_producer_consumer.ada), cada vez que el productor llama a Deliver se sincroniza con el consumidor. Si el productor va demasiado rápido, se le hace esperar hasta que el consumidor ejecute accept; si el consumidor va demasiado rápido, se le hace esperar hasta la siguiente llamada del productor. Esto genera una contrapresión natural (backpressure: cuando quien recibe no puede procesar al ritmo del emisor, la velocidad de este se frena automáticamente). Como el rendezvous no intercala ninguna cola, esto se cumple sin ningún riesgo de desbordamiento de búfer.

7. Objetos protegidos — exclusión mutua sin bloqueos explícitos

Mientras que una tarea es un «agente activo», el objeto protegido (protected object) es un mecanismo pensado para los «datos compartidos pasivos».

protected Counter is
   procedure Increment;
   function Value return Integer;
private
   Count : Integer := 0;
end Counter;

protected body Counter is
   procedure Increment is
   begin
      Count := Count + 1;
   end Increment;

   function Value return Integer is
   begin
      return Count;
   end Value;
end Counter;

Las reglas importantes de los objetos protegidos son las siguientes.

  • Las funciones (function) son de solo lectura. Varias tareas pueden invocarlas simultáneamente.
  • Los procedimientos (procedure) son de lectura y escritura. Mientras se ejecuta un procedimiento, se bloquean tanto los demás procedimientos como las funciones.
  • Las entradas (entry) llevan una barrera. Quien llama espera en una cola hasta que la condición de barrera se vuelve verdadera.

En este código (05_protected_counter.ada), tres tareas trabajadoras llaman a Increment 1000 veces cada una. Como el objeto protegido garantiza la exclusión mutua, el valor final del contador siempre es 3000. No hace falta escribir manualmente el bloqueo y desbloqueo de un mutex.

task type Worker (Id : Integer; Rounds : Integer);

task body Worker is
begin
   for I in 1 .. Rounds loop
      Counter.Increment;  -- el objeto protegido garantiza la exclusión
   end loop;
end Worker;

W1 : Worker (1, 1_000);
W2 : Worker (2, 1_000);
W3 : Worker (3, 1_000);

Ejemplo de ejecución

Aquí surge un problema. Si el procedimiento principal lee Counter.Value directamente, puede leer un valor intermedio mientras los trabajadores todavía están en marcha. Por eso, en la versión completa (05_protected_counter.ada) se añaden un procedimiento que cuenta las finalizaciones y una entrada que espera a que todos hayan terminado.

protected Counter is
   procedure Increment;
   procedure Mark_Done;
   entry All_Done;
   function Value return Integer;
private
   Count      : Integer := 0;
   Done_Count : Integer := 0;
end Counter;

Se coloca una barrera entry All_Done when Done_Count = Num_Workers, y cada trabajador llama a Counter.Mark_Done; al salir del bucle. El procedimiento principal espera a que todos terminen con Counter.All_Done; antes de leer el valor. No hace falta ninguna variable de bandera adicional ni ningún sleep para la espera.

gnatchop ../src/snippets/05_protected_counter.ada
gnatmake protected_counter_demo
./protected_counter_demo
Final counter value = 3000

El resultado es 3000 sin importar cuántas veces se ejecute. Las tres tareas llaman a Increment un total de 3000 veces, y el objeto protegido ejecuta cada una de esas llamadas de forma exclusiva.

Qué ocurre sin un objeto protegido

Para entender el valor de un objeto protegido, veamos un código peligroso que no protege los datos compartidos.

-- ⚠ Peligro: se manipula directamente una variable compartida
Shared_Counter : Integer := 0;

task body Bad_Worker is
begin
   for I in 1 .. 10_000 loop
      Shared_Counter := Shared_Counter + 1;  -- ¡condición de carrera!
   end loop;
end Bad_Worker;

A nivel de CPU, Shared_Counter := Shared_Counter + 1 son tres pasos: «leer → sumar → volver a escribir». Si varias tareas ejecutan esto al mismo tiempo, el resultado de la suma de una tarea puede no llegar a tiempo a la lectura de otra, y se pierde algún incremento. Además, esto corresponde a una ejecución errónea (erroneous execution) según el Ada RM 9.10. «Ejecución errónea» es un término del estándar con un significado más fuerte que «el valor se desvía»: significa que el estándar deja de garantizar nada sobre el comportamiento del programa. La lectura y escritura simultáneas de una variable compartida sin sincronizar no se limita a producir un recuento final impreciso: el comportamiento de todo el programa puede volverse arbitrario. Aunque dos tareas ejecuten cada una 10 000 iteraciones, no hay ninguna garantía de que el valor final sea 20 000.

Si quiere comprobar con sus propios ojos esta falta de garantía, cree la versión Bad_Worker anterior, ejecútela repetidamente y anote el valor final de cada ejecución. Este artículo no incluye cifras medidas. El resultado de una condición de carrera depende de la CPU, de las opciones de optimización y del orden temporal en tiempo de ejecución, así que mostrar como «esto es lo que pasa» un único número obtenido en un entorno concreto solo daría una referencia equivocada de «se desvía más o menos así». Lo que hay que comprobar no es que «aparece un valor concreto menor que 20 000», sino que el resultado cambia en cada ejecución, y que obtener el valor correcto en una sola pasada no significa absolutamente nada.

El objeto protegido es un mecanismo que «previene este problema mediante la sintaxis». Basta con llamar a Counter.Increment; para que el compilador y el runtime garanticen la exclusión mutua.

8. Entradas protegidas y barreras — el búfer acotado

Al añadir entradas a un objeto protegido se hace posible la sincronización condicional. Veámoslo con el clásico búfer acotado (bounded buffer).

type Buffer_Array is array (0 .. Buffer_Size - 1) of Integer;

protected Buf is
   entry Put (Item : Integer);
   entry Get (Item : out Integer);
private
   Data    : Buffer_Array;
   Head    : Integer := 0;
   Tail    : Integer := 0;
   Count   : Integer := 0;
end Buf;

protected body Buf is
   entry Put (Item : Integer) when Count < Buffer_Size is
   begin
      Data (Tail) := Item;
      Tail := (Tail + 1) mod Buffer_Size;
      Count := Count + 1;
   end Put;

   entry Get (Item : out Integer) when Count > 0 is
   begin
      Item := Data (Head);
      Head := (Head + 1) mod Buffer_Size;
      Count := Count - 1;
   end Get;
end Buf;

when Count < Buffer_Size es la barrera (barrier). La barrera se evalúa cada vez que se llama a la entrada: si es verdadera, se ejecuta; si es falsa, la tarea que llama espera en una cola. Cada vez que cambia el estado del búfer (porque otra tarea ejecuta Put o Get), se reevalúan las barreras de las tareas en espera.

Cuándo ocurre exactamente esa reevaluación es algo difícil de seguir solo con texto. Si ordenamos en el tiempo el caso en que Get llega primero a un búfer vacío, queda así.

Reevaluación de la barrera en el búfer acotadoDiagrama de secuencia que muestra cómo Get espera en cola cuando el búfer está vacío, y cómo al ejecutarse Put se reevalúa la barrera de Get, que pasa a ser verdadera y libera al consumidor.Tarea ProducerObjeto protegido BufTarea ConsumerTarea ProducerObjeto protegido BufTarea ConsumerEspera en la cola de la entrada GetAl terminar la operación protegida se reevalúan las barreras de las entradas en esperaLlama a GetEvalúa la barrera Count > 0 → falsoLlama a PutEvalúa la barrera Count < Buffer_Size → verdaderoEjecuta el cuerpo de Put, Count pasa a valer 1La barrera de Get se vuelve verdaderaEjecuta el cuerpo de Get y libera a Consumer

El punto clave es que la reevaluación de las barreras se hace de una vez, justo al final de la operación protegida. Entre el momento en que termina el cuerpo de Put y el momento en que se libera el bloqueo de Buf, se evalúan las barreras de las entradas en espera, y las que se vuelven verdaderas se ejecutan de inmediato. No existe el modo de fallo típico de las variables de condición en C, donde «si alguien olvida llamar a signal, la espera nunca termina».

Este patrón (06_bounded_buffer.ada) es uno de los casos en los que más brillan los objetos protegidos de Ada. Compárelo con la forma en que se escribiría en C usando un mutex y una variable de condición de pthread.

// Caso de C + pthread (para comparar con Ada)
pthread_mutex_lock(&mutex);
while (count >= BUFFER_SIZE) {           // equivalente al when de Ada
    pthread_cond_wait(&not_full, &mutex); // espera de la barrera
}
data[tail] = item;
tail = (tail + 1) % BUFFER_SIZE;
count++;
pthread_cond_signal(&not_empty);         // notifica a la tarea en espera
pthread_mutex_unlock(&mutex);

En Ada, todo esto se condensa en una sola línea: when Count < Buffer_Size. La condición del bucle while, el envío de la señal, los errores de sincronización al liberar el bloqueo: todas estas oportunidades de introducir errores desaparecen.

9. Llamadas con tiempo de espera — nunca esperar para siempre

En un sistema de tiempo real no se puede permitir «esperar para siempre». Ada admite tiempos de espera mediante la construcción select ... or delay.

select
   Slow_Worker.Do_Work (Result);
   Put_Line ("Main: work completed");
or
   delay until Ada.Real_Time.Clock + Milliseconds (500);
   Put_Line ("Main: timeout after 500ms!");
end select;

En este código (07_timed_entry.ada), como Slow_Worker todavía está ejecutando delay 2.0 y no ha llegado a accept, la llamada a la entrada, que queda en cola, agota su tiempo de espera a los 500 ms. (El tiempo de espera actúa sobre el tiempo en cola antes de que se acepte el rendezvous; no interrumpe la ejecución del propio rendezvous.) delay until especifica un instante absoluto y es la técnica básica de la programación en tiempo real para evitar la deriva acumulada.

Ada también admite la llamada condicional a una entrada (conditional entry call).

select
   Server.Process (Item);
else
   Put_Line ("Server is busy, will retry later");
end select;

Gracias a la cláusula else, si el rendezvous no se puede establecer de inmediato, se pasa directamente a un procesamiento alternativo. No hace falta escribir un sondeo (polling) manual.

No olvide diseñar qué pasa después del tiempo de espera

El tiempo de espera es útil, pero lo esencial del diseño es «qué se hace después de no haber podido esperar». Si de verdad se puede descartar el valor, si conviene reintentar, o si hay que notificar el error a un nivel superior: dejar esto ambiguo se convierte, en producción, en pérdida de datos o interrupción del servicio. Cuando escriba un tiempo de espera, diseñe también en el mismo lugar la responsabilidad de qué ocurre después de agotarse.

Tareas periódicas y delay until

delay until no solo sirve para tiempos de espera: también se puede usar para la ejecución periódica. Con un simple delay 0.1, el periodo real termina siendo «tiempo de procesamiento + 0,1 segundos», mientras que delay until fija el siguiente instante de activación en tiempo absoluto, por lo que mantiene un periodo estable, independiente del tiempo de procesamiento.

loop
   Next := Next + Period;
   Do_Work;
   delay until Next;
end loop;

Este patrón es útil en cualquier situación que requiera un procesamiento de periodo fijo, como la monitorización de sensores o los bucles de control.

10. Prioridades de tareas y planificación en tiempo real

Las funciones de tiempo real de Ada están definidas en el Annex D (Real-Time Systems). Cuando la implementación de Ada admite el Annex D, se pueden especificar las prioridades de las tareas y las políticas de planificación.

Comprobar si su entorno lo admite

El Annex D es uno de los Specialized Needs Annex (anejos para necesidades específicas), y su grado de soporte depende de la implementación y del entorno de ejecución. Para averiguar si está disponible en su entorno, siga estos tres pasos.

1. Consultar el rango de prioridades

with Ada.Text_IO; use Ada.Text_IO;
with System;

procedure Check_Priority is
begin
   Put_Line ("Priority range   :"
             & Integer'Image (System.Priority'First)
             & " .."
             & Integer'Image (System.Priority'Last));
   Put_Line ("Default_Priority :"
             & Integer'Image (System.Default_Priority));
end Check_Priority;

El rango y el valor por defecto de System.Priority dependen de la implementación, así que aquí no mostramos cifras concretas. Si el rango que se muestra es suficientemente amplio, se trata de un entorno en el que tiene sentido una especificación como pragma Priority (System.Default_Priority + 5).

2. Comprobar si la especificación de política compila

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

Si la compilación se completa con estas líneas, al menos la sintaxis se acepta.

3. Comprobar si realmente funciona según la prioridad

Aquí está la trampa principal. Que la compilación funcione y que el planificador del sistema operativo respete de verdad las prioridades son dos cosas distintas. En sistemas operativos de propósito general como Linux o Windows, puede ser necesaria una configuración de permisos del propio sistema operativo para que la prioridad de tiempo real llegue de verdad al planificador. El README de la colección de ejemplos de este artículo también advierte de que, en entornos donde el Annex D no está completamente soportado, 08_task_priorities.ada se ejecuta como una tarea normal.

En otras palabras, el programa funciona igual aunque la prioridad no tenga ningún efecto real. En aplicaciones que exigen tiempo real estricto, no basta con diseñar las prioridades sobre el papel: es imprescindible medir y verificar el orden real de ejecución en el entorno de destino.

task High_Task is
   pragma Priority (System.Default_Priority + 5);
end High_Task;

task Low_Task is
   pragma Priority (System.Default_Priority);
end Low_Task;

Como configuración más avanzada, también se pueden especificar la política de planificación y el protocolo de techo de prioridad.

pragma Task_Dispatching_Policy (FIFO_Within_Priorities);
pragma Locking_Policy (Ceiling_Locking);

El Priority Ceiling Protocol (protocolo de techo de prioridad) es un protocolo para evitar la inversión de prioridad. A cada objeto protegido se le asigna explícitamente una prioridad de techo mediante pragma Priority (o el aspecto Priority). Si la prioridad activa de la tarea que llama supera ese techo, se produce la excepción Program_Error. Mientras el objeto está bloqueado, se ejecuta con la prioridad de techo, lo que evita que una tarea de prioridad media lo interrumpa.

protected Shared_Data is
   pragma Priority (15);  -- prioridad de techo
   procedure Update (Val : Integer);
   function Read return Integer;
private
   Data : Integer := 0;
end Shared_Data;

Estas funciones se apoyan en el fundamento teórico del Rate Monotonic Scheduling (RMS), y cuentan con un historial probado en sistemas de tiempo real estricto como los controles de vuelo de aeronaves o el equipamiento médico. El RMS es un esquema de prioridad fija que consiste en «asignar mayor prioridad a las tareas de periodo más corto», y lo que se valora en aplicaciones de tiempo real estricto es que permite analizar, antes de ejecutar, si un conjunto de tareas periódicas puede cumplir sus plazos.

11. Pautas de diseño para la práctica

Hasta aquí hemos visto la sintaxis básica de las tareas y los objetos protegidos. Para terminar, resumimos las pautas de diseño que conviene tener presentes al usar la concurrencia de Ada en la práctica profesional.

Lo que no se debe hacer dentro de un objeto protegido

Dentro de un objeto protegido, la regla de oro es limitarse a actualizar el estado de forma breve y ejecutar el procesamiento pesado fuera de él. Como las operaciones de un objeto protegido están internamente sujetas a exclusión mutua, bloquear durante mucho tiempo dentro de una de ellas detiene a todas las demás tareas que usan ese mismo objeto protegido.

Procesamientos concretos que hay que evitar:

  • delay o una operación de E/S lenta
  • Una llamada compleja a otro objeto protegido
  • Una llamada pesada a una biblioteca externa

Además, un delay o ciertas operaciones de E/S dentro de una operación protegida no son solo un problema de rendimiento: constituyen un error acotado (bounded error) según el estándar de Ada. Un error acotado es un tipo de error en el que «el estándar define el rango de resultados posibles, pero no cuál de ellos ocurrirá». No es tan ilimitado como una ejecución errónea (erroneous execution), pero tampoco garantiza un funcionamiento correcto. De hecho, según la implementación puede producirse la excepción Program_Error o incluso un interbloqueo, así que no basta con «evitarlo»: hay que eliminarlo por completo.

Un buen diseño sigue el patrón «extraer del objeto protegido, en poco tiempo, los valores necesarios → realizar fuera de él el cálculo pesado o la E/S → escribir de vuelta en el objeto protegido, también en poco tiempo, solo el resultado».

Mantenga las condiciones de barrera simples

La barrera de entry ... when <condición> es poderosa, pero si se vuelve demasiado compleja resulta difícil de leer, y también resulta difícil investigar por qué una tarea no se libera.

Lo ideal es un nivel como when Count < Buffer_Size o when Used > 0, en el que el significado del estado se entiende de un vistazo. Si se necesitan varias condiciones, considere representar el estado con un tipo enumerado y acercar la barrera a una forma legible por el nombre del estado, como when State = Running.

Excepciones y detención de las tareas

Hay que decidir explícitamente la política para cuando se produce una excepción dentro de una tarea. Como mínimo, hay que capturar la excepción en el nivel más alto del cuerpo de la tarea y registrar qué ha ocurrido.

Todavía más importante es el diseño de lo que ocurre después de la excepción. Si esa tarea se detiene, ¿puede el sistema seguir funcionando?, ¿se la puede reiniciar?, ¿cómo se notifica a las demás tareas?, ¿cómo se devuelve el estado compartido a una situación segura? Hay que ser capaz de responder a estas preguntas. Ada dispone de un mecanismo de excepciones como parte del lenguaje, pero la seguridad después de una excepción es responsabilidad del diseño de la aplicación.

Mini lista de verificación de diseño

Aspecto Qué comprobar
Estado compartido ¿Está encerrado dentro de un objeto protegido? ¿Nadie lo toca directamente desde fuera?
Operaciones protegidas ¿Son breves? ¿No bloquean en su interior?
Entradas ¿Las barreras son simples? ¿Existe la posibilidad de espera indefinida? ¿Hay una política de tiempo de espera?
Vida de la tarea ¿La condición de finalización es clara? ¿Hay una política para las excepciones?
Procesamiento periódico ¿Se ha considerado delay until en lugar de delay?

En concurrencia, el «probablemente esté bien» es lo más peligroso. Dejar explícitos en el código el estado compartido, las condiciones de espera, las condiciones de finalización y la política de excepciones es el primer paso hacia una concurrencia segura.

12. Resumen — un lenguaje que convirtió la concurrencia en “gramática”

Lo que distingue al modelo de concurrencia de Ada de otros lenguajes es que la concurrencia segura no es una «buena práctica añadida después», sino que está integrada como «gramática».

Lo que se quiere hacer Gramática de Ada
Unidad de ejecución independiente task / task body
Comunicación síncrona entry / accept
Esperar varias solicitudes select / or / else
Exclusión mutua protected / function / procedure
Sincronización condicional entry ... when <barrera>
Tiempo de espera or delay until <tiempo>
Control de prioridad pragma Priority

Estas construcciones están sujetas a la verificación del compilador. Por ejemplo, intentar modificar un componente privado del propio objeto protegido dentro de una de sus funciones produce un error de compilación. Cuando termina una operación protegida, las barreras de las entradas en espera se reevalúan automáticamente, sin necesidad de enviar ninguna señal manualmente.

«Del mismo modo que el sistema de tipos garantiza la seguridad de la memoria,
  la sintaxis de concurrencia de Ada garantiza la seguridad de la sincronización»

Los 8 ejemplos de código que hemos visto en este artículo son una introducción práctica a las tareas, el rendezvous, los objetos protegidos y las funciones de tiempo real. Le animamos a ejecutarlos usted mismo y, a partir de ahí, a explorar también los siguientes temas más avanzados.

  • Perfil Ravenscar: un perfil que restringe el modelo de tareas para sistemas de tiempo real de alta confiabilidad. El modelo de tareas restringido permite realizar un análisis estático de interbloqueos.
  • Bloques paralelos de Ada 2022: procesamiento de datos en paralelo mediante la construcción parallel ... do.
  • Integración con SPARK: verificación formal del comportamiento de programas concurrentes (compatible con GNATprove bajo el perfil Ravenscar).

Aun así, “usar Ada” no equivale a “ser seguro”

Una última advertencia importante. La sintaxis de concurrencia de Ada es poderosa, pero usar Ada no vuelve el código seguro de forma automática. Tocar directamente datos compartidos sin encerrarlos en un objeto protegido, bloquear durante mucho tiempo dentro de un objeto protegido, o hacer que varios objetos protegidos se llamen entre sí de forma compleja: estos errores de diseño también pueden ocurrir en Ada.

Las características del lenguaje están diseñadas para que escribir código peligroso requiera un esfuerzo explícito, pero no sustituyen al propio diseño correcto. El verdadero valor de Ada está en acercar la discusión sobre seguridad al propio código: permite dejar registradas en la sintaxis, sobre el código, preguntas como «¿está protegido este estado?», «¿cuándo termina esta tarea?» o «¿bajo qué condición espera esta entrada?».

La filosofía de Ada de expresar el diseño mediante tipos es coherente también en la concurrencia. La concurrencia segura no empieza por manejar los bloqueos con cuidado, sino por no dejar existir, desnudo, un estado compartido peligroso.

Frente a la idea generalizada de que «la concurrencia es difícil», Ada responde que «si se elige bien la sintaxis, el compilador garantiza la seguridad». Esa filosofía de diseño conecta con lenguajes modernos como Rust o Pony, pero Ada lleva sosteniéndola como parte de la especificación del lenguaje desde hace 40 años.

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Qué son las tareas en Ada?
Las tareas son la unidad básica de concurrencia en Ada. Se parecen a los hilos, pero no corresponden necesariamente uno a uno con los hilos del sistema operativo, ya que el runtime de Ada gestiona su planificación. Una tarea comienza a ejecutarse automáticamente en cuanto se declara, y cuando el procedimiento principal termina, se espera implícitamente a que finalicen las tareas que aún están en ejecución. La comunicación síncrona con el exterior se realiza mediante un rendezvous a través de una entrada (entry). Las tareas y el rendezvous forman parte de la especificación del lenguaje desde Ada 83, en 1983.
¿En qué se diferencian los objetos protegidos de Ada de un mutex?
Un objeto protegido es un mecanismo de exclusión mutua gestionado por el propio lenguaje, que no requiere escribir manualmente el bloqueo y desbloqueo. Las funciones (function) son de solo lectura y varias tareas pueden invocarlas a la vez; los procedimientos (procedure) son de lectura y escritura, y mientras se ejecutan bloquean cualquier otra llamada; las entradas (entry) hacen esperar en una cola a quien las llama hasta que la condición de barrera se vuelve verdadera. El control de un búfer acotado que en C se escribiría combinando un mutex de pthread con una variable de condición se condensa en Ada en una sola línea de barrera, como «when Count < Buffer_Size».
¿Cómo funciona el rendezvous en Ada?
El rendezvous es el mecanismo de comunicación síncrona entre tareas: la llamada a una entrada por parte de quien la invoca y la sentencia accept del lado de la tarea se esperan mutuamente hasta llegar al mismo punto de encuentro. Los modos de parámetro in, out e in out permiten intercambiar datos en ambas direcciones. El bloque do…end del cuerpo del accept constituye la sección crítica: mientras se ejecuta, quien llama queda bloqueado y la tarea no acepta otras entradas. Combinado con la sentencia select, también se pueden expresar de forma declarativa la espera de varias entradas, los tiempos de espera y las condiciones de guarda.
¿Qué no se debe hacer dentro de un objeto protegido?
No se debe realizar ningún procesamiento que bloquee durante un tiempo prolongado, como un delay, una operación de E/S lenta o una llamada pesada a una biblioteca externa. Un delay o ciertas operaciones de E/S dentro de una operación protegida constituyen un error acotado (bounded error) según el estándar de Ada, y según la implementación pueden provocar la excepción Program_Error o incluso un interbloqueo, por lo que deben evitarse por completo. La regla de oro es actualizar el estado de forma breve dentro del objeto protegido y realizar los cálculos pesados o la E/S fuera de él, devolviendo solo el resultado en una escritura breve.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog