Concurrencia en Ada: tareas, cita, objetos protegidos y procesos del sistema

Por: Artiko
sistemas-operativosadaelixirconcurrenciatareasrendezvousobjetos-protegidossincronizaciongnat

Concurrencia en Ada: tareas, cita, objetos protegidos y procesos del sistema

En el capítulo 13 terminamos de armar el equipaje secuencial de Ada: arreglos con rangos verificados, cadenas de longitud fija y variable, y la lectura de argumentos de línea de comandos con Ada.Command_Line. Con eso ya podemos escribir un programa que reciba datos del exterior, los procese y devuelva un resultado. Pero todos esos programas comparten una limitación: hacen una sola cosa a la vez, en un único hilo de control, de arriba hacia abajo.

Este capítulo rompe esa limitación. Ada no trata la concurrencia como una biblioteca que se agrega al lenguaje, sino como parte de la gramática misma. Donde en C hay que incluir pthread.h y aprender una API de funciones, punteros a función y mutexes, en Ada hay palabras reservadas: task, entry, accept, select, protected, delay, abort. El compilador entiende esas construcciones, las verifica y genera el código de sincronización. Ese es el rasgo que hizo de Ada el lenguaje de referencia en sistemas donde una condición de carrera no es un ticket de bug sino un accidente.

La otra mitad del capítulo conecta esa concurrencia interna del lenguaje con la concurrencia del sistema operativo: cómo un programa Ada lanza procesos externos con GNAT.OS_Lib, cómo espera su terminación sin bloquear al resto del programa y cómo se combina eso con tareas para construir un supervisor que ejecuta varios comandos en paralelo y recolecta sus códigos de salida. Es el paso natural antes del proyecto del capítulo siguiente.

Dos niveles de concurrencia que conviene no confundir

Antes de escribir una línea de código hay que separar dos cosas que suelen mezclarse porque en el habla cotidiana ambas se llaman “procesos”.

El primer nivel es la concurrencia interna del programa: varios hilos de control dentro del mismo espacio de direcciones, compartiendo memoria, creados y destruidos por el propio programa. En Ada eso se llama tarea (task). En C serían hilos POSIX. En Elixir serían procesos de la máquina virtual BEAM, que a pesar del nombre no son procesos del sistema operativo.

El segundo nivel es la concurrencia del sistema operativo: procesos independientes, cada uno con su espacio de direcciones, creados con fork y exec o equivalentes, aislados entre sí por la unidad de gestión de memoria. Ahí el árbitro no es el runtime del lenguaje sino el planificador del kernel, y la comunicación necesita mecanismos explícitos: tuberías, sockets, archivos, señales.

Esta tabla separa los tres modelos que vamos a manejar en el capítulo.

AspectoTarea de AdaHilo POSIX (C)Proceso del sistema
Unidad de creaciónDeclaración task o asignación de tipo tareapthread_createfork + exec, o posix_spawn
Espacio de direccionesCompartido con el resto del programaCompartidoPropio y aislado
Costo de creaciónBajo (decenas de microsegundos)BajoAlto (copia de tablas de páginas)
ComunicaciónCita en entradas, objetos protegidosMemoria compartida + mutexTuberías, sockets, archivos, señales
Verificación del compiladorAlta: tipos, guardas, barrerasNingunaNinguna
Efecto de un fallo gravePuede tumbar todo el programaPuede tumbar todo el programaAislado al proceso
Quién planificaRuntime de Ada apoyado en el planificador del sistemaPlanificador del sistemaPlanificador del sistema
Identidad observableAda.Task_Identificationpthread_tPID visible con ps

La conclusión práctica es que dentro de un mismo programa Ada las tareas son la herramienta barata y verificada; para ejecutar otro programa hay que salir al sistema operativo, y eso cuesta más pero aísla los fallos. Un supervisor bien construido usa las dos cosas a la vez: tareas para coordinar, procesos para ejecutar trabajo ajeno.

La tarea más simple que compila

Una tarea en Ada se declara igual que un procedimiento: hay una especificación que dice cómo se llama y qué entradas ofrece, y un cuerpo que dice qué hace. Cuando una tarea no ofrece entradas, la especificación es una sola línea.

with Ada.Text_IO; use Ada.Text_IO;

procedure Dos_Tareas is

   task Saludo_A;
   task Saludo_B;

   task body Saludo_A is
   begin
      for I in 1 .. 5 loop
         Put_Line ("A" & Integer'Image (I));
         delay 0.10;
      end loop;
      Put_Line ("A termino");
   end Saludo_A;

   task body Saludo_B is
   begin
      for I in 1 .. 5 loop
         Put_Line ("B" & Integer'Image (I));
         delay 0.15;
      end loop;
      Put_Line ("B termino");
   end Saludo_B;

begin
   Put_Line ("El cuerpo del procedimiento principal ya llego a su final.");
end Dos_Tareas;

Se compila y ejecuta así:

gnatmake dos_tareas.adb -o dos_tareas
./dos_tareas

Hay tres detalles en este programa que definen todo el modelo y conviene mirarlos uno por uno.

Las tareas se activan solas. No hay ninguna llamada del tipo Saludo_A.Start. Las tareas declaradas en la parte declarativa de una unidad se activan cuando la ejecución llega al begin de esa unidad. El procedimiento principal no crea las tareas: las declara, y el runtime las pone a correr antes de ejecutar la primera línea de su propio cuerpo.

El programa no termina cuando termina el cuerpo del procedimiento. El mensaje “El cuerpo del procedimiento principal ya llego a su final” aparece muy temprano, pero el proceso sigue vivo hasta que ambas tareas completan sus bucles. La razón es que la unidad que declara una tarea es su maestro (master), y un maestro no puede completarse hasta que todas las tareas que dependen de él hayan terminado. Esto elimina de raíz una clase entera de errores que en C se manifiesta como “el main retornó y los hilos murieron a medio camino”.

La salida por consola se entrelaza sin orden garantizado. Las líneas de A y B se intercalan según los retardos, pero el orden exacto no está definido por el lenguaje. Peor aún: Put_Line no garantiza atomicidad entre tareas, así que en programas con mucha escritura concurrente pueden aparecer líneas mezcladas. Cuando la salida importa, hay que serializarla, y más adelante veremos exactamente cómo con un objeto protegido.

El ciclo de vida de una tarea tiene estados bien definidos por el estándar del lenguaje. Vale la pena tenerlos presentes porque casi todos los errores de concurrencia en Ada consisten en que una tarea está en un estado distinto del que el programador supone.

stateDiagram-v2
    [*] --> Creada: se elabora la declaracion de la tarea
    Creada --> Activandose: el maestro llega a su begin
    Activandose --> Ejecutable: parte declarativa de la tarea elaborada
    Activandose --> Terminada: excepcion durante la activacion
    Ejecutable --> Ejecutando: el planificador le da CPU
    Ejecutando --> Ejecutable: fin de rebanada o expropiacion
    Ejecutando --> Bloqueada: delay, accept sin llamador, entry call, barrera cerrada
    Bloqueada --> Ejecutable: vence el delay o se completa la cita
    Ejecutando --> Completada: llega al end de su cuerpo
    Ejecutando --> Completada: excepcion no capturada
    Ejecutando --> Completada: abort
    Completada --> Terminada: sus tareas dependientes ya terminaron
    Terminada --> [*]

La distinción entre completada y terminada parece un tecnicismo pero se vuelve visible con el atributo 'Terminated: una tarea que ya ejecutó su última instrucción sigue sin estar terminada mientras alguna tarea hija siga viva. El maestro espera a la nieta antes de dar por cerrada a la hija.

Tipos de tarea: muchas instancias del mismo trabajo

Declarar tareas una por una no escala. Para crear N trabajadores idénticos se declara un tipo tarea y luego se crean objetos de ese tipo, incluso arreglos completos.

with Ada.Text_IO; use Ada.Text_IO;

procedure Equipo is

   task type Trabajador is
      entry Arranca (Id : Positive; Vueltas : Positive);
   end Trabajador;

   task body Trabajador is
      Mi_Id : Positive;
      Total : Positive;
   begin
      accept Arranca (Id : Positive; Vueltas : Positive) do
         Mi_Id := Id;
         Total := Vueltas;
      end Arranca;

      for I in 1 .. Total loop
         Put_Line ("Trabajador" & Positive'Image (Mi_Id)
                   & " vuelta" & Integer'Image (I));
         delay 0.05;
      end loop;
   end Trabajador;

   Cuadrilla : array (1 .. 4) of Trabajador;

begin
   for I in Cuadrilla'Range loop
      Cuadrilla (I).Arranca (Id => I, Vueltas => 3);
   end loop;

   Put_Line ("Las cuatro tareas ya recibieron sus parametros.");
end Equipo;

Cuando se elabora la declaración Cuadrilla, las cuatro tareas se crean; cuando el procedimiento llega a su begin, las cuatro se activan. Pero ninguna hace trabajo útil todavía: cada una queda detenida en su accept Arranca, esperando que alguien la llame. El bucle del cuerpo principal las va desbloqueando de a una.

Ese accept es la primera aparición del mecanismo central de la concurrencia de Ada, y merece su propia sección.

La cita: cómo dos tareas se sincronizan e intercambian datos

El modelo de sincronización que Ada tomó de CSP se llama cita (rendezvous). La idea es que dos tareas se ponen de acuerdo en un punto del programa: una ofrece un servicio, otra lo pide, y ninguna avanza hasta que ambas están presentes. Durante el encuentro se ejecuta un bloque de código que puede leer parámetros de entrada y escribir parámetros de salida. Terminado ese bloque, cada una sigue su camino por separado.

Las piezas son tres:

  • entry: se declara en la especificación de la tarea. Es como un procedimiento, con parámetros de modo in, out o in out, pero sólo puede invocarlo otra tarea, y la llamada bloquea hasta que la tarea dueña acepte.
  • accept: aparece en el cuerpo de la tarea. Marca el punto donde la tarea está dispuesta a atender esa entrada. El cuerpo opcional (do ... end) es lo que se ejecuta durante el encuentro.
  • La cola de la entrada: si varias tareas llaman a la misma entrada, se forman en una cola atendida en orden de llegada (FIFO es la política por defecto del lenguaje).

Este diagrama muestra el mecanismo real, incluyendo los dos casos asimétricos: llamador que llega primero y servidor que llega primero.

sequenceDiagram
    participant C as Tarea Cliente
    participant Q as Cola de la entrada Pedido
    participant S as Tarea Servidor

    Note over S: Caso 1 - el servidor llega primero
    S->>S: ejecuta accept Pedido
    Note over S: se bloquea, la cola esta vacia
    C->>Q: llama a Servidor.Pedido (X)
    Q->>S: hay un llamador disponible
    rect rgb(220, 236, 250)
        Note over C,S: CITA - ambas tareas sincronizadas
        S->>S: ejecuta el cuerpo del accept
        S-->>C: escribe los parametros out
    end
    Note over C,S: fin del accept, ambas siguen en paralelo

    Note over C: Caso 2 - el cliente llega primero
    C->>Q: llama a Servidor.Pedido (Y) y se bloquea en la cola
    S->>Q: ejecuta accept Pedido
    Q->>S: entrega el primer llamador de la cola
    rect rgb(220, 236, 250)
        Note over C,S: CITA - se ejecuta el cuerpo del accept
    end
    Note over C,S: ambas continuan

Un servidor mínimo que suma números y devuelve el acumulado:

with Ada.Text_IO; use Ada.Text_IO;

procedure Cita_Basica is

   task Acumulador is
      entry Sumar (X : Integer);
      entry Total (Resultado : out Integer);
      entry Cerrar;
   end Acumulador;

   task body Acumulador is
      Suma : Integer := 0;
      Abierto : Boolean := True;
   begin
      while Abierto loop
         select
            accept Sumar (X : Integer) do
               Suma := Suma + X;
            end Sumar;
         or
            accept Total (Resultado : out Integer) do
               Resultado := Suma;
            end Total;
         or
            accept Cerrar do
               Abierto := False;
            end Cerrar;
         end select;
      end loop;
      Put_Line ("El acumulador se cerro con total" & Integer'Image (Suma));
   end Acumulador;

   Valor : Integer;

begin
   for I in 1 .. 10 loop
      Acumulador.Sumar (I);
   end loop;

   Acumulador.Total (Valor);
   Put_Line ("Total parcial:" & Integer'Image (Valor));   -- 55

   Acumulador.Sumar (100);
   Acumulador.Total (Valor);
   Put_Line ("Total final:" & Integer'Image (Valor));     -- 155

   Acumulador.Cerrar;
end Cita_Basica;

Este programa imprime 55 y luego 155, siempre, sin excepción y sin que aparezca ni un mutex en el código. La variable Suma vive dentro del cuerpo de la tarea Acumulador; ninguna otra tarea puede tocarla porque no está a su alcance léxico. La única forma de modificarla es pedir una cita. La exclusión mutua no se programa: es consecuencia de dónde está declarada la variable.

Esa es la diferencia conceptual con el enfoque de memoria compartida. En C el dato es público y el candado es una convención que el programador debe recordar aplicar. En Ada el dato es privado y el acceso es un contrato que el compilador conoce.

Qué se puede y qué no se puede hacer dentro de un accept

El cuerpo de un accept debe ser corto. Mientras se ejecuta, ambas tareas están comprometidas: el llamador está bloqueado esperando y el servidor no puede atender a nadie más. Poner ahí una operación de entrada/salida lenta o un cálculo largo convierte al servidor en un cuello de botella.

Además hay una regla del lenguaje que evita un error clásico: no se puede escribir un accept para una entrada dentro del cuerpo de un accept de la misma entrada. Intentar anidar la misma cita es un error de programa.

El patrón correcto para trabajo largo es aceptar rápido, copiar los parámetros a variables locales, cerrar el accept y recién entonces trabajar:

   task body Procesador is
      Trabajo : Integer;
   begin
      loop
         select
            accept Encargar (Dato : Integer) do
               Trabajo := Dato;          -- solo copiar, esto es rapido
            end Encargar;
         or
            terminate;
         end select;

         -- El llamador ya siguio su camino; el trabajo pesado va aqui.
         delay 0.5;
         Put_Line ("Procesado" & Integer'Image (Trabajo));
      end loop;
   end Procesador;

El enunciado select y todas sus formas

select es la construcción que le da flexibilidad al modelo. Existe en cinco variantes que hacen cosas distintas y que suelen confundirse porque comparten la palabra reservada.

1. Aceptación selectiva con guardas

Es la forma que ya vimos: varias alternativas accept, separadas por or, de las cuales el runtime elige una que tenga llamadores en espera. Lo que agrega poder real son las guardas: una condición booleana precedida por when que habilita o deshabilita la alternativa.

with Ada.Text_IO; use Ada.Text_IO;

procedure Buzon_Con_Guardas is

   task Buzon is
      entry Depositar (Valor : Integer);
      entry Retirar (Valor : out Integer);
   end Buzon;

   task body Buzon is
      Dato  : Integer := 0;
      Lleno : Boolean := False;
   begin
      loop
         select
            when not Lleno =>
               accept Depositar (Valor : Integer) do
                  Dato := Valor;
               end Depositar;
               Lleno := True;
         or
            when Lleno =>
               accept Retirar (Valor : out Integer) do
                  Valor := Dato;
               end Retirar;
               Lleno := False;
         or
            terminate;
         end select;
      end loop;
   end Buzon;

   task Productor;
   task body Productor is
   begin
      for I in 1 .. 5 loop
         Buzon.Depositar (I * 10);
         Put_Line ("Deposite" & Integer'Image (I * 10));
      end loop;
   end Productor;

   task Consumidor;
   task body Consumidor is
      X : Integer;
   begin
      for I in 1 .. 5 loop
         delay 0.2;
         Buzon.Retirar (X);
         Put_Line ("Retire" & Integer'Image (X));
      end loop;
   end Consumidor;

begin
   null;
end Buzon_Con_Guardas;

Las guardas hacen que el buzón sea correcto por construcción: no se puede depositar sobre un dato que nadie retiró, ni retirar de un buzón vacío. Si el consumidor llama a Retirar cuando Lleno es falso, la alternativa está cerrada y el llamador queda encolado hasta que un depósito la abra.

El orden de evaluación es importante y suele malinterpretarse. Este es el mecanismo completo:

flowchart TD
    A["Se alcanza el enunciado select"] --> B["Se evaluan TODAS las guardas when, una sola vez"]
    B --> C{"Hay al menos una<br/>alternativa abierta?"}
    C -->|No, y existe alternativa else| D["Se ejecuta la rama else"]
    C -->|"No, y no hay else"| E["Program_Error"]
    C -->|Si| F{"Alguna alternativa abierta<br/>tiene llamadores encolados?"}
    F -->|Varias| G["El runtime elige una de forma arbitraria"]
    F -->|Una| H["Se selecciona esa"]
    F -->|Ninguna| I{"Hay alternativa<br/>delay o terminate abierta?"}
    I -->|delay| J["Espera; si vence el plazo<br/>ejecuta la rama del delay"]
    I -->|terminate| K{"El maestro termino y<br/>ninguna otra tarea puede llamar?"}
    K -->|Si| L["La tarea termina limpiamente"]
    K -->|No| M["Sigue esperando"]
    I -->|Ninguna| M
    G --> N["Se realiza la cita"]
    H --> N
    N --> O["Se ejecutan los enunciados que siguen al accept"]
    M --> F

El punto que hay que retener: las guardas se evalúan una sola vez, al entrar al select. Si una condición cambia mientras la tarea está bloqueada esperando, eso no reabre la alternativa; la reevaluación sólo ocurre en la siguiente vuelta del bucle. Por eso el patrón canónico es loop ... select ... end select; ... end loop;.

2. Alternativa delay: un servidor que se cansa de esperar

         select
            accept Peticion (X : Integer) do
               Put_Line ("Atendiendo" & Integer'Image (X));
            end Peticion;
         or
            delay 2.0;
            Put_Line ("Dos segundos sin peticiones, hago mantenimiento");
         end select;

delay 2.0 es un retardo relativo, medido desde el momento en que se alcanzó el select. También existe delay until con un instante absoluto, que es lo correcto para tareas periódicas porque no acumula deriva. En ambos casos el retardo garantiza un mínimo, nunca un máximo: el lenguaje promete que la tarea no se reanudará antes, no que se reanudará exactamente entonces.

3. Alternativa terminate: cierre ordenado sin bandera

terminate no es una rama que se ejecute: es un permiso. Le dice al runtime “si ya no queda nadie en el programa capaz de llamarme, puedes darme por terminada”. La condición formal es que el maestro de la tarea haya completado su ejecución y que todas las tareas hermanas estén también en un terminate abierto o ya terminadas.

Sin esa alternativa, un servidor con loop select ... end select; end loop; no termina nunca y el programa se cuelga al final, porque el maestro espera a una tarea que espera peticiones que ya nadie hará. Es el bloqueo más común de los principiantes en Ada.

Hay una restricción sintáctica: en un mismo select no se pueden combinar terminate con una alternativa delay, ni con una rama else. Hay que elegir uno de los tres cierres.

4. Llamada temporizada: el cliente que no espera para siempre

Las tres formas anteriores viven del lado del servidor. Las dos que siguen viven del lado del cliente. La llamada temporizada intenta una cita y abandona si no se concreta en un plazo.

   select
      Servidor.Peticion (42);
      Put_Line ("La peticion fue atendida");
   or
      delay 0.5;
      Put_Line ("El servidor no acepto la peticion en medio segundo");
   end select;

La semántica precisa importa: el plazo cubre el tiempo hasta que la cita comienza, no hasta que termina. Si el servidor acepta a los 0,4 segundos y el cuerpo del accept tarda diez segundos, el cliente espera los diez segundos completos. El delay cancela la espera en la cola, no la cita ya iniciada.

5. Llamada condicional: intentar sin esperar nada

   select
      Servidor.Peticion (42);
      Put_Line ("Atendida de inmediato");
   else
      Put_Line ("El servidor estaba ocupado, sigo con otra cosa");
   end select;

Equivale a una llamada temporizada con plazo cero, pero se expresa con else. Sólo tiene éxito si el servidor ya está detenido en el accept correspondiente en ese preciso instante.

Bonus: transferencia asíncrona de control

Existe una sexta forma, select ... then abort, que no encaja en la clasificación anterior porque no involucra citas necesariamente. Sirve para poner un plazo a un cálculo cualquiera:

with Ada.Text_IO; use Ada.Text_IO;

procedure Con_Plazo is
   Acumulado : Long_Long_Integer := 0;
begin
   select
      delay 1.0;
      Put_Line ("Se agoto el plazo, el calculo fue abandonado");
   then abort
      for I in 1 .. 2_000_000_000 loop
         Acumulado := Acumulado + Long_Long_Integer (I mod 7);
      end loop;
      Put_Line ("El calculo termino:" & Long_Long_Integer'Image (Acumulado));
   end select;

   Put_Line ("El programa continua");
end Con_Plazo;

Si el cuerpo de then abort no termina antes de que venza el disparador, se aborta y se ejecuta la rama del disparador. Si termina antes, el disparador se cancela. Es la forma más limpia de imponer un tiempo máximo a una operación sin instrumentar el código de la operación.

Esta tabla resume las seis formas para tenerlas juntas.

FormaDónde vivePalabra clave distintivaQué resuelve
Aceptación selectivaServidorvarias ramas accept con orAtender varias entradas distintas
GuardasServidorwhen Cond => antes del acceptHabilitar servicios según el estado
Alternativa delayServidoror delay X;No quedarse esperando peticiones para siempre
Alternativa terminateServidoror terminate;Cierre ordenado cuando ya nadie puede llamar
Llamada temporizadaClientellamada or delay X;Poner plazo a la espera en la cola
Llamada condicionalClientellamada elseIntentar sin bloquearse
Transferencia asíncronaCualquierathen abortPoner plazo a un cálculo arbitrario

Objetos protegidos: exclusión mutua sin tarea servidora

La cita tiene un costo conceptual y de rendimiento: para proteger un dato compartido hay que dedicarle una tarea completa, con su pila, su planificación y dos cambios de contexto por operación. Para un contador compartido eso es desproporcionado. Ada 95 introdujo el objeto protegido, que ofrece las mismas garantías sin tarea servidora.

Un objeto protegido encapsula datos privados y expone tres tipos de operaciones, cada una con reglas de concurrencia distintas:

OperaciónPuede modificar el estadoConcurrencia permitidaPuede tener barrera
functionNo, sólo lecturaVarias funciones a la vezNo
procedureExclusiva, una a la vezNo
entryExclusiva, una a la vezSí, obligatoria (when)

El compilador hace cumplir esas reglas. Si intentas escribir un componente privado dentro de una function, el programa no compila. Eso convierte la política de bloqueo lector-escritor en algo verificado en tiempo de compilación en lugar de una convención documentada.

El caso más simple es un contador:

with Ada.Text_IO; use Ada.Text_IO;

procedure Contador_Protegido is

   protected Contador is
      procedure Incrementar;
      function Valor return Natural;
   private
      N : Natural := 0;
   end Contador;

   protected body Contador is

      procedure Incrementar is
      begin
         N := N + 1;
      end Incrementar;

      function Valor return Natural is
      begin
         return N;
      end Valor;

   end Contador;

   task type Sumador;

   task body Sumador is
   begin
      for I in 1 .. 100_000 loop
         Contador.Incrementar;
      end loop;
   end Sumador;

begin
   declare
      Equipo : array (1 .. 8) of Sumador;
   begin
      null;   -- al salir de este bloque se espera a las ocho tareas
   end;

   Put_Line ("Total final:" & Natural'Image (Contador.Valor));
end Contador_Protegido;

Ocho tareas incrementan cien mil veces cada una. El resultado es exactamente 800.000, siempre. La misma estructura escrita en C con una variable global y contador++ da un número distinto en cada ejecución, porque el incremento no es atómico. Aquí no hay que recordar tomar ningún candado: el candado es la sintaxis.

El bloque declare ... begin ... end es el maestro de las tareas del arreglo, así que la ejecución no pasa del end del bloque hasta que las ocho hayan terminado. Ese es el idioma estándar para “esperar a un grupo de tareas” en Ada: no hay un join explícito, hay un alcance léxico que espera.

Entradas protegidas y barreras

Una entry de un objeto protegido lleva una barrera: una expresión booleana que se evalúa sobre el estado del objeto y decide si el llamador entra o se encola. La diferencia con la guarda de un select es fundamental: la barrera se reevalúa automáticamente cada vez que termina una operación que pudo cambiar el estado.

El ejemplo canónico es el búfer circular acotado, que resuelve el problema productor-consumidor sin ninguna tarea servidora:

with Ada.Text_IO; use Ada.Text_IO;

procedure Buffer_Acotado is

   Capacidad : constant := 8;

   type Indice is mod Capacidad;
   type Almacen is array (Indice) of Integer;

   protected Buffer is
      entry Poner (X : Integer);
      entry Sacar (X : out Integer);
      function Ocupacion return Natural;
   private
      Datos    : Almacen := (others => 0);
      Cabeza   : Indice  := 0;
      Cola     : Indice  := 0;
      Cantidad : Natural := 0;
   end Buffer;

   protected body Buffer is

      entry Poner (X : Integer) when Cantidad < Capacidad is
      begin
         Datos (Cola) := X;
         Cola := Cola + 1;
         Cantidad := Cantidad + 1;
      end Poner;

      entry Sacar (X : out Integer) when Cantidad > 0 is
      begin
         X := Datos (Cabeza);
         Cabeza := Cabeza + 1;
         Cantidad := Cantidad - 1;
      end Sacar;

      function Ocupacion return Natural is
      begin
         return Cantidad;
      end Ocupacion;

   end Buffer;

   task Productor;
   task Consumidor;

   task body Productor is
   begin
      for I in 1 .. 20 loop
         Buffer.Poner (I);
         Put_Line ("  produje" & Integer'Image (I)
                   & " (ocupacion" & Natural'Image (Buffer.Ocupacion) & " )");
      end loop;
   end Productor;

   task body Consumidor is
      X : Integer;
   begin
      for I in 1 .. 20 loop
         delay 0.05;   -- el consumidor es mas lento a proposito
         Buffer.Sacar (X);
         Put_Line ("consumi" & Integer'Image (X));
      end loop;
   end Consumidor;

begin
   null;
end Buffer_Acotado;

Al ejecutarlo se ve el efecto del acotamiento: el productor avanza rápido hasta llenar las ocho posiciones y luego queda frenado, produciendo un elemento cada vez que el consumidor libera una. Es control de flujo por contrapresión, obtenido gratis por la barrera Cantidad < Capacidad.

El mecanismo interno de un objeto protegido, con su cerradura y sus colas de entrada, funciona así:

sequenceDiagram
    participant P as Tarea Productor
    participant B as Objeto protegido Buffer
    participant QP as Cola de Poner
    participant C as Tarea Consumidor

    Note over B: Cantidad = 8 (lleno), Capacidad = 8
    P->>B: llama a Poner (99)
    B->>B: toma la cerradura y evalua la barrera
    Note over B: Cantidad < Capacidad es FALSO
    B->>QP: encola al productor y suelta la cerradura
    Note over P: bloqueado

    C->>B: llama a Sacar (X)
    B->>B: toma la cerradura, barrera Cantidad > 0 es VERDADERA
    B->>B: ejecuta el cuerpo, Cantidad pasa a 7
    rect rgb(232, 245, 233)
        Note over B,QP: FIN DE OPERACION: se reevaluan todas las barreras
        B->>QP: Poner ya esta abierta, hay un llamador en cola
        B->>B: ejecuta el cuerpo de Poner con la MISMA cerradura
        Note over B: Cantidad vuelve a 8
    end
    B-->>C: devuelve X y libera al consumidor
    B-->>P: libera al productor, su operacion ya se ejecuto

El detalle que distingue a los objetos protegidos de un mutex con variables de condición es la parte marcada: cuando termina una operación, el runtime reevalúa las barreras y ejecuta las entradas que se abrieron antes de soltar la cerradura. El llamador encolado no despierta, compite por el candado y vuelve a verificar la condición; su cuerpo ya fue ejecutado por quien liberó la barrera. Eso elimina el despertar espurio y garantiza que la condición que abrió la barrera sigue siendo cierta cuando el cuerpo corre.

El atributo ‘Count y requeue

Dentro de un objeto protegido, NombreEntrada'Count da la cantidad de tareas encoladas en esa entrada. Sirve para escribir barreras que dependen de cuánta gente está esperando, como una barrera de sincronización de N participantes:

   protected type Barrera (Participantes : Positive) is
      entry Esperar;
   private
      entry Liberar;
      Abierta : Boolean := False;
   end Barrera;

   protected body Barrera is
      entry Esperar when not Abierta is
      begin
         if Esperar'Count + 1 = Participantes then
            Abierta := True;      -- llego el ultimo: se abre el paso
         else
            requeue Liberar;      -- todavia faltan: a esperar en la otra cola
         end if;
      end Esperar;

      entry Liberar when Abierta is
      begin
         if Liberar'Count = 0 then
            Abierta := False;     -- salio el ultimo: se vuelve a cerrar
         end if;
      end Liberar;
   end Barrera;

requeue mueve al llamador actual a otra entrada sin que él se entere: desde su punto de vista sigue esperando en la misma llamada. Es la herramienta para implementar políticas de planificación propias, como prioridades o turnos, dentro de un objeto protegido.

Sobre 'Count hay una advertencia real: su valor puede quedar obsoleto en cuanto se lo lee, porque otra tarea puede encolarse o abandonar la cola por una llamada temporizada. Sólo es confiable dentro del cuerpo o la barrera de una operación protegida, donde la cerradura impide cambios.

Cita contra objeto protegido: cuándo usar cada uno

Ambos mecanismos resuelven exclusión mutua y sincronización, y elegir mal produce código más lento o más enredado de lo necesario.

CriterioCita (task + entry + accept)Objeto protegido
Tiene hilo de control propioNo, corre en el hilo del llamador
Costo por operaciónDos cambios de contextoUna toma de cerradura
Puede iniciar acciones por su cuentaSí, es activoNo, es puramente reactivo
Puede hacer entrada/salida bloqueante dentroSí, aunque conviene evitarloNo, está prohibido bloquearse dentro
Puede llamar a otra entrada desde adentroNo (operación potencialmente bloqueante)
Ordena secuencias de operacionesSí, con accept sucesivosNo, cada operación es independiente
Reevaluación de condicionesManual, al volver al selectAutomática al final de cada operación
Uso típicoProtocolo con estados, servidor con lógica propiaDato compartido, cola, semáforo, contador

La regla práctica: si lo que necesitas es proteger un dato, usa un objeto protegido. Si necesitas un agente que hace algo por sí mismo —leer un socket, temporizar, secuenciar un protocolo— usa una tarea. Y la combinación más común en sistemas reales es tareas activas que se comunican a través de objetos protegidos, no directamente por citas.

Dentro de una operación protegida está prohibido ejecutar cualquier acción potencialmente bloqueante: llamar a una entrada, un delay, un accept, activar una tarea, o invocar un subprograma externo que haga algo de eso. La violación levanta Program_Error. La razón es que la cerradura de un objeto protegido debe mantenerse por un tiempo acotado y conocido; si el código pudiera bloquearse ahí adentro, se perdería la propiedad de tiempo de respuesta limitado que hace útiles a estos objetos en sistemas de tiempo real.

Tiempo, prioridades y el problema de la inversión

Ada distingue dos formas de esperar. delay X espera un lapso relativo; delay until T espera hasta un instante absoluto. Para tareas periódicas la segunda es la correcta, porque la primera acumula el tiempo de ejecución del cuerpo en cada vuelta y el periodo real se va corriendo.

with Ada.Text_IO;   use Ada.Text_IO;
with Ada.Real_Time; use Ada.Real_Time;

procedure Tarea_Periodica is

   task Muestreador;

   task body Muestreador is
      Periodo   : constant Time_Span := Milliseconds (200);
      Siguiente : Time := Clock;
      Inicio    : constant Time := Siguiente;
   begin
      for Ciclo in 1 .. 10 loop
         Siguiente := Siguiente + Periodo;

         Put_Line ("Ciclo" & Integer'Image (Ciclo)
                   & " a los"
                   & Duration'Image (To_Duration (Clock - Inicio))
                   & " s");

         delay until Siguiente;
      end loop;
   end Muestreador;

begin
   null;
end Tarea_Periodica;

Ada.Real_Time.Clock usa un reloj monótono, inmune a los ajustes de la hora del sistema, a diferencia de Ada.Calendar.Clock. Para cualquier medición de intervalos hay que usar el primero.

Las prioridades se fijan con pragma Priority o el aspecto correspondiente:

   task Urgente
     with Priority => 20;

   task Rutinaria
     with Priority => 5;

El rango válido es System.Priority, y los valores más altos son más urgentes. Con prioridades aparece el riesgo de inversión de prioridad: una tarea de baja prioridad toma la cerradura de un objeto protegido, una tarea de alta prioridad la necesita y queda bloqueada, y una tarea de prioridad media que no necesita el recurso desplaza a la de baja, con lo que la de alta espera indefinidamente por culpa de una tarea menos importante.

Ada resuelve esto con el protocolo de techo de prioridad, que es el comportamiento por defecto en perfiles de tiempo real y se activa explícitamente así:

pragma Locking_Policy (Ceiling_Locking);

   protected Recurso
     with Priority => 25
   is
      procedure Operar;
   private
      Estado : Integer := 0;
   end Recurso;

Mientras una tarea ejecuta una operación de Recurso, su prioridad efectiva sube al techo declarado. Ninguna tarea con prioridad menor o igual al techo puede expropiarla, así que la cerradura se libera rápido y la inversión no ocurre. Además el compilador verifica que ninguna tarea con prioridad superior al techo llame al objeto, lo que convertiría el techo en mentira.

Excepciones, aborto y los atributos de tarea

Una tarea que levanta una excepción y no la captura termina en silencio. La excepción no se propaga al maestro ni al programa principal, y por defecto no se imprime nada. Es la causa número uno de “mi programa se cuelga y no sé por qué”: una tarea servidora murió por un Constraint_Error, y todos sus clientes quedaron esperando una cita que nunca llegará.

El remedio es siempre el mismo: manejador when others en el cuerpo de toda tarea, aunque sólo sirva para registrar.

with Ada.Text_IO;                 use Ada.Text_IO;
with Ada.Exceptions;              use Ada.Exceptions;
with Ada.Task_Identification;     use Ada.Task_Identification;

procedure Tarea_Robusta is
   task Riesgosa;

   task body Riesgosa is
      A : Natural := 3;
      B : Natural := 5;
      C : Natural;
   begin
      Put_Line ("Soy la tarea " & Image (Current_Task));
      C := A - B;                       -- Natural no admite negativos
      Put_Line ("Nunca llego aqui" & Natural'Image (C));
   exception
      when E : others =>
         Put_Line ("La tarea murio por: " & Exception_Name (E)
                   & " / " & Exception_Message (E));
   end Riesgosa;
begin
   Put_Line ("Principal en marcha");
end Tarea_Robusta;

Hay un caso distinto: si la excepción ocurre durante la activación de la tarea, es decir mientras se elabora su parte declarativa, entonces sí se propaga al maestro, convertida en Tasking_Error. Y si una tarea llama a una entrada de otra tarea que ya terminó, el llamador recibe Tasking_Error.

Los atributos que permiten inspeccionar una tarea desde afuera son estos:

ConstrucciónQué entregaUso típico
T'TerminatedTrue si la tarea T ya terminóVerificar antes de llamar a una entrada
T'CallableFalse si T está completada, terminada o abortadaEvitar Tasking_Error
T'IdentityEl identificador único de TRegistro, comparaciones
Current_TaskIdentificador de la tarea que ejecutaDiagnóstico
Image (Id)Nombre legible de una tareaMensajes de log
E'CountLlamadores encolados en la entrada EBarreras y estadísticas
abort T;Fuerza la terminación de TÚltimo recurso

Sobre abort: es la operación más brutal del lenguaje y la que menos hay que usar. Aborta la tarea y a todas sus dependientes, en puntos que el estándar acota pero que igualmente pueden dejar estructuras a medio actualizar. La alternativa civilizada es una entrada Cerrar que la tarea acepte y use para salir de su bucle, o una alternativa terminate bien puesta.

Salir al sistema operativo: lanzar procesos desde Ada

Todo lo anterior ocurre dentro de un solo proceso. Cuando el trabajo hay que delegarlo a otro programa —ls, grep, un compilador, un script— hay que cruzar al segundo nivel de concurrencia. Ada estándar no define esta operación porque no todos los sistemas donde corre Ada tienen procesos; GNAT la ofrece en el paquete GNAT.OS_Lib, que expone servicios del sistema operativo: ejecución de programas, variables de entorno, manipulación de archivos y descriptores.

Estos son los elementos que vamos a usar:

Elemento de GNAT.OS_LibQué hace
String_AccessPuntero a String, el tipo con que se pasan las cadenas dinámicas
Argument_ListArreglo de String_Access con los argumentos del programa
Locate_Exec_On_PathBusca un ejecutable en el PATH y devuelve su ruta completa
SpawnLanza un programa y espera a que termine
Non_Blocking_SpawnLanza un programa y devuelve de inmediato un Process_Id
Wait_ProcessEspera a que termine algún proceso lanzado y entrega su identificador
Process_Id / Invalid_PidIdentificador de proceso y su valor inválido
Standin, Standout, StanderrDescriptores estándar, para redirigir la salida del hijo
Argument_String_To_ListConvierte una línea de comando en un Argument_List
FreeLibera un String_Access
OS_ExitTermina el proceso con un código de salida

La versión bloqueante es la más directa:

with Ada.Text_IO; use Ada.Text_IO;
with GNAT.OS_Lib; use GNAT.OS_Lib;

procedure Lanzar_Simple is

   Ruta   : String_Access := Locate_Exec_On_Path ("ls");
   Args   : Argument_List (1 .. 2) :=
              (1 => new String'("-l"),
               2 => new String'("/etc"));
   Codigo : Integer;

begin
   if Ruta = null then
      Put_Line ("No se encontro 'ls' en el PATH");
      return;
   end if;

   Put_Line ("Ejecutable localizado en: " & Ruta.all);

   Spawn (Program_Name           => Ruta.all,
          Args                   => Args,
          Output_File_Descriptor => Standout,
          Return_Code            => Codigo);

   Put_Line ("El proceso hijo termino con codigo" & Integer'Image (Codigo));

   for A of Args loop
      Free (A);
   end loop;
   Free (Ruta);
end Lanzar_Simple;

Hay cuatro cosas que este ejemplo enseña y que valen para cualquier lanzamiento de procesos, en Ada o en cualquier otro lenguaje.

El programa se localiza, no se adivina. Locate_Exec_On_Path recorre el PATH y devuelve null si no encuentra nada. Pasarle a Spawn un nombre sin ruta funciona en algunos casos, pero depender de eso hace que el programa falle de forma distinta en cada máquina.

Los argumentos no incluyen el nombre del programa. En C, argv[0] es el nombre del ejecutable y los argumentos reales empiezan en argv[1]. En GNAT.OS_Lib el Argument_List contiene sólo los argumentos; el nombre va por separado en Program_Name. Meter el nombre del programa como primer elemento del arreglo es un error clásico que produce ls: no se puede acceder a 'ls'.

No hay intérprete de comandos de por medio. Spawn ejecuta directamente el binario. Eso significa que no se expanden comodines (*.txt llega literal), no funcionan las tuberías ni las redirecciones con >, y no se interpretan las variables $HOME. Es una ventaja de seguridad enorme: no existe la inyección de comandos por concatenación de cadenas, porque no hay ninguna cadena que un intérprete vaya a analizar. Si de verdad hace falta esa sintaxis, hay que invocar explícitamente /bin/sh con -c, y ahí sí volver a preocuparse por lo que se concatena.

La memoria de los argumentos es del programador. new String'("-l") reserva en el montón. Free la devuelve. En un programa corto no se nota, pero un supervisor que lanza miles de procesos sin liberar termina consumiendo memoria sin razón.

Para redirigir la salida a un archivo en lugar de a la consola, hay una variante de Spawn que recibe el nombre del archivo:

   Exito : Boolean;
   Codigo : Integer;
begin
   Spawn (Program_Name => Ruta.all,
          Args         => Args,
          Output_File  => "salida.txt",
          Success      => Exito,
          Return_Code  => Codigo,
          Err_To_Out   => True);

Err_To_Out => True mezcla la salida de error con la salida estándar, igual que 2>&1 en un intérprete de comandos.

Combinar tareas y procesos: un supervisor concurrente

Aquí converge todo el capítulo. La forma limpia de ejecutar varios programas externos en paralelo desde Ada es:

  1. Una tarea por comando, que lo lanza y espera su resultado.
  2. Un objeto protegido que recolecta los códigos de salida sin condiciones de carrera.
  3. Un bloque declare que actúa de maestro y espera a todas las tareas.

Este diagrama muestra la estructura y los dos niveles de concurrencia trabajando juntos.

flowchart TD
    subgraph proc["Proceso Ada (un solo espacio de direcciones)"]
        M["Programa principal<br/>bloque declare"]
        T1["Tarea Ejecutor 1"]
        T2["Tarea Ejecutor 2"]
        T3["Tarea Ejecutor 3"]
        R["Objeto protegido Resultados<br/>procedure Registrar<br/>function Resumen"]
        M -->|activa| T1
        M -->|activa| T2
        M -->|activa| T3
        T1 -->|Registrar| R
        T2 -->|Registrar| R
        T3 -->|Registrar| R
    end

    subgraph so["Sistema operativo"]
        P1["Proceso hijo: ls -l"]
        P2["Proceso hijo: date"]
        P3["Proceso hijo: uname -a"]
    end

    T1 -->|"fork + exec via Spawn"| P1
    T2 -->|"fork + exec via Spawn"| P2
    T3 -->|"fork + exec via Spawn"| P3
    P1 -.->|"codigo de salida"| T1
    P2 -.->|"codigo de salida"| T2
    P3 -.->|"codigo de salida"| T3

    M -->|"espera al end del bloque"| F["Imprime el resumen"]

El programa completo:

with Ada.Text_IO;             use Ada.Text_IO;
with Ada.Strings.Unbounded;   use Ada.Strings.Unbounded;
with GNAT.OS_Lib;             use GNAT.OS_Lib;

procedure Supervisor is

   protected Resultados is
      procedure Registrar (Comando : String; Codigo : Integer);
      function Resumen return String;
      function Fallidos return Natural;
   private
      Texto  : Unbounded_String := Null_Unbounded_String;
      Malos  : Natural := 0;
   end Resultados;

   protected body Resultados is

      procedure Registrar (Comando : String; Codigo : Integer) is
      begin
         Append (Texto, Comando & " -> codigo" & Integer'Image (Codigo) & ASCII.LF);
         if Codigo /= 0 then
            Malos := Malos + 1;
         end if;
      end Registrar;

      function Resumen return String is (To_String (Texto));

      function Fallidos return Natural is (Malos);

   end Resultados;

   task type Ejecutor is
      entry Ejecutar (Linea : String);
   end Ejecutor;

   task body Ejecutor is
      Comando : Unbounded_String;
   begin
      accept Ejecutar (Linea : String) do
         Comando := To_Unbounded_String (Linea);
      end Ejecutar;

      declare
         Texto  : constant String := To_String (Comando);
         Partes : Argument_List_Access := Argument_String_To_List (Texto);
         Ruta   : String_Access := Locate_Exec_On_Path (Partes (Partes'First).all);
         Codigo : Integer := -1;
      begin
         if Ruta = null then
            Put_Line ("No se encontro el ejecutable: "
                      & Partes (Partes'First).all);
            Resultados.Registrar (Texto, 127);
         else
            Spawn (Program_Name           => Ruta.all,
                   Args                   =>
                     Partes (Partes'First + 1 .. Partes'Last),
                   Output_File_Descriptor => Standout,
                   Return_Code            => Codigo);
            Resultados.Registrar (Texto, Codigo);
            Free (Ruta);
         end if;

         for P of Partes.all loop
            Free (P);
         end loop;
         Free (Partes);
      end;

   exception
      when others =>
         Resultados.Registrar (To_String (Comando), -1);
   end Ejecutor;

begin
   declare
      Trabajos : array (1 .. 3) of Ejecutor;
   begin
      Trabajos (1).Ejecutar ("uname -a");
      Trabajos (2).Ejecutar ("date");
      Trabajos (3).Ejecutar ("id -un");
   end;   -- aqui se espera a las tres tareas

   New_Line;
   Put_Line ("=== Resumen del supervisor ===");
   Put (Resultados.Resumen);
   Put_Line ("Comandos fallidos:" & Natural'Image (Resultados.Fallidos));
end Supervisor;

Compilación:

gnatmake supervisor.adb -o supervisor
./supervisor

Argument_String_To_List hace el trabajo de partir la línea en palabras respetando comillas, y devuelve un Argument_List_Access donde el primer elemento es el nombre del programa. Por eso al llamar a Spawn se pasa el segmento Partes (Partes'First + 1 .. Partes'Last): el nombre va aparte y los argumentos son el resto.

Una advertencia honesta sobre el Spawn bloqueante dentro de tareas: la implementación termina llamando a una primitiva del sistema que espera al hijo, y el grado en que eso bloquea sólo a la tarea llamadora o al hilo del sistema que la ejecuta depende del runtime y de la plataforma. Si necesitas garantías firmes de que el resto del programa sigue avanzando mientras un hijo tarda, el patrón robusto es no bloquear en absoluto: lanzar con Non_Blocking_Spawn y concentrar toda la espera en una única tarea recolectora que llame repetidamente a Wait_Process.

with Ada.Text_IO; use Ada.Text_IO;
with GNAT.OS_Lib; use GNAT.OS_Lib;

procedure Lanzar_No_Bloqueante is

   task Recolector is
      entry Recoger (Cuantos : Natural);
   end Recolector;

   task body Recolector is
      Terminado : Process_Id;
      Exito     : Boolean;
      Restantes : Natural;
   begin
      accept Recoger (Cuantos : Natural) do
         Restantes := Cuantos;
      end Recoger;

      while Restantes > 0 loop
         Wait_Process (Terminado, Exito);
         exit when Terminado = Invalid_Pid;
         Restantes := Restantes - 1;
         Put_Line ("Termino un hijo. Exito: " & Boolean'Image (Exito));
      end loop;
   end Recolector;

   Ruta       : String_Access := Locate_Exec_On_Path ("sleep");
   Pid        : Process_Id;
   Pendientes : Natural := 0;

begin
   if Ruta = null then
      Put_Line ("No se encontro 'sleep'");
      Recolector.Recoger (0);
      return;
   end if;

   for I in 1 .. 3 loop
      declare
         Args : Argument_List (1 .. 1) := (1 => new String'("1"));
      begin
         Pid := Non_Blocking_Spawn (Ruta.all, Args);
         if Pid /= Invalid_Pid then
            Pendientes := Pendientes + 1;
            Put_Line ("Lance el hijo numero" & Integer'Image (I));
         end if;
         Free (Args (1));
      end;
   end loop;

   Put_Line ("Los tres hijos estan corriendo en paralelo.");
   Recolector.Recoger (Pendientes);
   Free (Ruta);
end Lanzar_No_Bloqueante;

Los tres sleep 1 corren simultáneamente, así que el programa completo tarda alrededor de un segundo y no tres. Es exactamente el mismo patrón que usa un intérprete de comandos cuando lanza procesos en segundo plano con & y luego los recoge: lanzar sin esperar, y tener un punto único donde se cosechan las terminaciones para que no queden procesos zombis.

Variables de entorno: el otro canal hacia el sistema

Además de argumentos, un proceso hijo hereda el entorno. Ada estándar ofrece Ada.Environment_Variables, que es portable y preferible a la variante de GNAT cuando alcanza.

with Ada.Text_IO;               use Ada.Text_IO;
with Ada.Environment_Variables; use Ada.Environment_Variables;

procedure Entorno is
   procedure Mostrar (Nombre, Valor : String) is
   begin
      if Nombre'Length > 0 and then Nombre (Nombre'First) = 'L' then
         Put_Line (Nombre & " = " & Valor);
      end if;
   end Mostrar;
begin
   Put_Line ("HOME  = " & Value ("HOME", "(sin definir)"));
   Put_Line ("SHELL = " & Value ("SHELL", "(sin definir)"));

   Set ("MI_MARCA", "capitulo-14");
   Put_Line ("MI_MARCA = " & Value ("MI_MARCA"));

   New_Line;
   Put_Line ("Variables que empiezan con L:");
   Iterate (Mostrar'Access);

   Clear ("MI_MARCA");
   Put_Line ("Tras Clear, existe MI_MARCA: " & Boolean'Image (Exists ("MI_MARCA")));
end Entorno;

Value con segundo argumento devuelve un valor por defecto en lugar de levantar Constraint_Error si la variable no existe. Set afecta al entorno de este proceso, y por herencia al de los hijos que se lancen después, pero no al del intérprete de comandos que invocó al programa: el entorno se hereda hacia abajo, nunca hacia arriba.

El mismo problema en Elixir: dos filosofías de concurrencia

Vale la pena poner lado a lado el búfer acotado de Ada con la solución equivalente en Elixir, porque las diferencias no son de sintaxis sino de modelo.

defmodule BufferAcotado do
  use GenServer

  @capacidad 8

  def start_link(_), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)

  def poner(valor), do: GenServer.call(__MODULE__, {:poner, valor}, :infinity)
  def sacar(), do: GenServer.call(__MODULE__, :sacar, :infinity)

  @impl true
  def init(:ok), do: {:ok, %{datos: :queue.new(), n: 0, esperando: []}}

  # Deposito: si alguien esperaba un dato, se le responde directamente.
  @impl true
  def handle_call({:poner, valor}, _desde, %{n: n} = e) when n < @capacidad do
    case e.esperando do
      [pendiente | resto] ->
        GenServer.reply(pendiente, valor)
        {:reply, :ok, %{e | esperando: resto}}

      [] ->
        {:reply, :ok, %{e | datos: :queue.in(valor, e.datos), n: n + 1}}
    end
  end

  # Buffer lleno: aqui NO hay contrapresion automatica, hay que decidirla.
  def handle_call({:poner, _valor}, _desde, e), do: {:reply, {:error, :lleno}, e}

  # Buffer vacio: se guarda el "from" y se responde mas tarde.
  def handle_call(:sacar, desde, %{n: 0} = e),
    do: {:noreply, %{e | esperando: e.esperando ++ [desde]}}

  def handle_call(:sacar, _desde, e) do
    {{:value, valor}, cola} = :queue.out(e.datos)
    {:reply, valor, %{e | datos: cola, n: e.n - 1}}
  end
end

Se ejercita así:

{:ok, _} = BufferAcotado.start_link(nil)

p = Task.async(fn -> for i <- 1..20, do: BufferAcotado.poner(i) end)
c = Task.async(fn -> for _ <- 1..20, do: (Process.sleep(50); IO.puts(BufferAcotado.sacar())) end)

Task.await(p, :infinity)
Task.await(c, :infinity)

Las diferencias de fondo:

AspectoAdaElixir
ModeloMemoria compartida con acceso controladoNada compartido, sólo mensajes
Bloqueo del llamadorAutomático por la barrera del entryManual: hay que guardar el from y responder después
EstadoMutable, protegido por la cerraduraInmutable, se reconstruye en cada handle_call
Fallo del servidorEl programa queda con clientes colgadosEl supervisor lo reinicia con estado limpio
VerificaciónEl compilador impide escribir desde una functionNinguna en tiempo de compilación
ContrapresiónGratis, la barrera bloquea al productorHay que implementarla explícitamente
Unidad de aislamientoNinguna: una tarea que corrompe memoria afecta a todosEl proceso: su fallo no corrompe a otros

Ada apuesta a que los errores se detecten antes de ejecutar: tipos, rangos, guardas, barreras, techos de prioridad. Elixir apuesta a que los errores van a ocurrir igual y que lo importante es que su efecto quede confinado y el sistema se recupere solo. Ninguna de las dos filosofías es reemplazo de la otra: la primera domina donde un fallo es inaceptable y el sistema es cerrado; la segunda domina donde el sistema es abierto, distribuido y debe sobrevivir a lo imprevisible.

Errores comunes, su causa y su solución

SíntomaCausaSolución
El programa imprime todo y no termina nuncaUna tarea servidora tiene loop select ... end select; end loop; sin alternativa terminate, así que su maestro la espera para siempreAgregar or terminate; como última alternativa del select, o una entrada Cerrar que saque a la tarea del bucle
Tasking_Error al llamar a una entradaLa tarea destino ya terminó, abortó o murió por una excepción no capturadaPoner exception when others => en el cuerpo de toda tarea, y verificar T'Callable antes de llamar si la terminación es esperable
Una tarea deja de responder sin ningún mensajeExcepción no capturada dentro de la tarea: en Ada no se propaga al maestro ni se imprimeManejador when others que registre con Exception_Name y Exception_Message
Program_Error al entrar a un selectTodas las guardas resultaron falsas y no había alternativa else, delay ni terminateRevisar la lógica de las guardas para que al menos una pueda estar abierta, o agregar una alternativa de cierre
Program_Error dentro de un objeto protegidoSe ejecutó una operación potencialmente bloqueante: delay, llamada a una entrada, activación de tareaSacar la operación bloqueante fuera del objeto protegido; el objeto sólo debe manipular su estado
La barrera de una entry nunca se abre pese a que la condición se cumpleLa condición depende de algo que no es estado del objeto protegido, por ejemplo una variable global externaLas barreras deben referirse sólo a componentes privados del objeto o constantes; mover ese estado adentro
Una guarda de select no reacciona a un cambio de condiciónLas guardas se evalúan una sola vez al entrar al select, no mientras la tarea esperaEnvolver el select en un bucle para que las guardas se reevalúen en cada vuelta
Interbloqueo entre dos tareas servidorasCada una llama a una entrada de la otra desde dentro de un accept, y ambas quedan comprometidasNo hacer llamadas a entradas dentro de un accept; copiar datos, cerrar el accept y luego llamar
El contador compartido da resultados distintos en cada corridaSe usó una variable global visible a varias tareas en vez de encapsularlaMoverla a un objeto protegido o al cuerpo de una tarea servidora
El programa periódico se va atrasandoSe usó delay Periodo, que suma el tiempo de ejecución del cuerpo a cada vueltaUsar delay until Siguiente con Ada.Real_Time y avanzar Siguiente por incrementos fijos
Los tiempos medidos saltan o se vuelven negativosSe usó Ada.Calendar.Clock, sensible a ajustes de la hora del sistemaUsar Ada.Real_Time.Clock, que es monótono
Una tarea de alta prioridad queda esperando por una de bajaInversión de prioridad al compartir un objeto protegidoActivar pragma Locking_Policy (Ceiling_Locking) y declarar el techo con Priority en el objeto protegido
Spawn devuelve un código de error y el comando parecía correctoSe incluyó el nombre del programa como primer elemento de Args; Argument_List sólo lleva los argumentosPasar el nombre en Program_Name y los argumentos desde el segundo elemento en adelante
El comodín *.txt llega literal al programa hijoSpawn no pasa por un intérprete de comandos, así que nadie expande comodines ni variablesExpandir los nombres desde Ada, o invocar explícitamente /bin/sh con -c asumiendo el riesgo
Locate_Exec_On_Path devuelve null para un comando que existeEs una función interna del intérprete (cd, export) y no un ejecutable en el PATHImplementar esa funcionalidad en el propio programa; no hay binario que lanzar
Se acumulan procesos zombisSe usó Non_Blocking_Spawn sin llamar nunca a Wait_ProcessTener una tarea recolectora que llame a Wait_Process una vez por cada hijo lanzado
Fuga de memoria en un supervisor que lanza muchos comandosLos String_Access creados con new String'(...) nunca se liberaronLlamar a Free sobre cada elemento del Argument_List y sobre el resultado de Locate_Exec_On_Path
El compilador rechaza el archivo por caracteres inválidosSe escribieron acentos o eñes en comentarios o cadenas y GNAT esperaba otra codificaciónCompilar con gnatmake -gnatW8 archivo.adb para declarar fuente en UTF-8, o mantener el código fuente en ASCII
Las líneas impresas por distintas tareas salen mezcladasPut_Line no garantiza atomicidad entre tareasEncapsular la salida en un objeto protegido con un procedimiento Escribir que sea el único que llame a Put_Line

Ese último caso merece la solución escrita, porque se usa en casi todo programa concurrente de verdad:

   protected Consola is
      procedure Escribir (Texto : String);
   end Consola;

   protected body Consola is
      procedure Escribir (Texto : String) is
      begin
         Ada.Text_IO.Put_Line (Texto);
      end Escribir;
   end Consola;

Es un caso límite interesante: Put_Line puede bloquearse si la salida está redirigida a una tubería llena, y las operaciones bloqueantes están prohibidas dentro de un objeto protegido. En la práctica funciona y es el idioma habitual, pero conviene saber que se apoya en que la escritura a consola sea rápida. Para sistemas de tiempo real estrictos, el patrón correcto es una tarea escritora dedicada que reciba las líneas por una cola protegida.

Ejercicios propuestos

1. Observar la activación. Modifica el programa Dos_Tareas agregando una tercera tarea que sólo imprima un mensaje y termine, y pon un Put_Line como primera línea del cuerpo del procedimiento principal. Predice el orden de los mensajes antes de ejecutar, después ejecuta diez veces y anota cuántos órdenes distintos aparecen. Explica qué está garantizado por el lenguaje y qué no.

2. Cita con parámetros de salida. Escribe una tarea Calculadora con una entrada Dividir (A, B : Integer; Cociente, Resto : out Integer). Haz que el llamador pase B = 0 y describe qué ocurre exactamente: dónde se levanta la excepción, quién la recibe y qué pasa con la tarea servidora. Después corrige el diseño para que la división por cero se reporte sin matar al servidor.

3. Guardas y mantenimiento. Modifica el buzón de la sección de guardas para que acepte hasta tres depósitos antes de exigir un retiro, y agrégale una alternativa delay que imprima un mensaje de mantenimiento si pasan dos segundos sin actividad. Verifica que el productor se frene exactamente cuando corresponde.

4. Cliente impaciente. Usando una llamada temporizada, escribe un cliente que abandone la petición si el servidor no la acepta en 300 milisegundos. Después haz que el cuerpo del accept del servidor tarde dos segundos y explica por qué el cliente igualmente espera esos dos segundos.

5. Contador correcto y contador incorrecto. Escribe dos versiones del programa de ocho tareas sumadoras: una con objeto protegido y otra con una variable declarada en el procedimiento principal e incrementada directamente. Ejecuta ambas veinte veces, registra los resultados y explica la distribución de los valores incorrectos.

6. Semáforo contador. Implementa con un objeto protegido un semáforo general con operaciones Tomar y Soltar y un número configurable de permisos. Úsalo para limitar a tres la cantidad de tareas que ejecutan simultáneamente un tramo de código, con diez tareas compitiendo.

7. Búfer con varios productores. Extiende el búfer acotado para que haya tres productores y dos consumidores. Agrega a cada elemento el identificador de quién lo produjo y verifica que no se pierda ni se duplique ningún dato, comparando la suma total producida con la suma total consumida.

8. Tarea periódica sin deriva. Escribe dos tareas periódicas de 100 milisegundos: una con delay relativo y otra con delay until. Haz que el cuerpo de ambas gaste unos 30 milisegundos de cálculo. Ejecuta cien ciclos, imprime el instante real de cada ciclo y grafica o tabula la diferencia acumulada entre ambas.

9. Detectar la muerte silenciosa. Escribe una tarea servidora que levante Constraint_Error en su décima atención, sin manejador. Observa qué le ocurre al cliente en la undécima llamada. Después agrega el manejador when others y compara el comportamiento.

10. Plazo a un cálculo. Usa select ... then abort para poner un límite de dos segundos a un cálculo de números primos por fuerza bruta. Imprime cuántos primos alcanzó a encontrar antes de ser abortado, usando un objeto protegido para guardar el avance parcial.

11. Lanzar un comando y capturar su salida. Escribe un programa que use Spawn con la variante Output_File para ejecutar uname -a redirigiendo a un archivo temporal, y que luego lea ese archivo con Ada.Text_IO y muestre su contenido en mayúsculas. Libera correctamente toda la memoria reservada.

12. Supervisor con límite de paralelismo. Modifica el programa Supervisor para que reciba los comandos por línea de argumentos, usando lo aprendido en el capítulo 13, y para que nunca ejecute más de dos comandos simultáneamente. Usa el semáforo del ejercicio 6.

13. Recolector robusto. Extiende el programa de Non_Blocking_Spawn para que lance cinco procesos sleep con duraciones distintas y para que el recolector imprima el orden real de terminación. Compara ese orden con el orden de lanzamiento y explica la diferencia.

14. Comparación de modelos. Implementa el mismo búfer acotado en Ada con objeto protegido, en Ada con tarea servidora y cita, y en Elixir con GenServer. Mide el tiempo total para mover cien mil elementos en cada versión y explica las diferencias en términos de cambios de contexto y de copias de datos.

15. Herencia del entorno. Escribe un programa Ada que use Ada.Environment_Variables.Set para definir una variable y luego lance con Spawn un proceso que imprima el entorno. Comprueba si la variable aparece. Después verifica desde el intérprete de comandos si la variable existe ahí y explica por qué.

Qué sigue

Este capítulo agregó a Ada la dimensión que le faltaba. Las tareas dan hilos de control verificados por el compilador y con un ciclo de vida controlado por el alcance léxico. La cita da sincronización y transferencia de datos en un solo mecanismo, con colas ordenadas y sin memoria compartida a la vista. El select, en sus seis formas, cubre desde el servidor que atiende varias entradas hasta el cliente que pone plazo a su espera y el cálculo que se aborta cuando se acaba el tiempo. Los objetos protegidos dan exclusión mutua barata, con la política lector-escritor impuesta por el compilador y con barreras que se reevalúan solas al final de cada operación. Y GNAT.OS_Lib conecta todo eso con el segundo nivel de concurrencia: procesos del sistema, lanzados con o sin bloqueo, recolectados sin dejar zombis.

Mirando hacia atrás, el curso ya tiene todas las piezas separadas. Sabemos cómo el kernel crea procesos y cómo funciona el par fork/exec. Sabemos qué es un descriptor de archivo y cómo se redirige. Sabemos manejar cadenas, arreglos y argumentos de línea de comandos. Y ahora sabemos ejecutar programas externos, esperarlos y coordinar varias cosas a la vez.

En el capítulo 15 esas piezas se juntan en un solo programa: un intérprete de comandos mínimo pero funcional. Vamos a construirlo paso a paso —lectura de la línea, análisis en palabras, búsqueda del ejecutable, creación del proceso hijo, redirecciones de entrada y salida, tuberías entre comandos, trabajos en segundo plano y comandos internos— y en el camino cada llamada al sistema que estudiamos de forma aislada aparecerá cumpliendo su función real. Puedes revisar el recorrido completo del curso en el índice.