Concurrencia en Ada: tareas, cita, objetos protegidos y procesos del sistema
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.
| Aspecto | Tarea de Ada | Hilo POSIX (C) | Proceso del sistema |
|---|---|---|---|
| Unidad de creación | Declaración task o asignación de tipo tarea | pthread_create | fork + exec, o posix_spawn |
| Espacio de direcciones | Compartido con el resto del programa | Compartido | Propio y aislado |
| Costo de creación | Bajo (decenas de microsegundos) | Bajo | Alto (copia de tablas de páginas) |
| Comunicación | Cita en entradas, objetos protegidos | Memoria compartida + mutex | Tuberías, sockets, archivos, señales |
| Verificación del compilador | Alta: tipos, guardas, barreras | Ninguna | Ninguna |
| Efecto de un fallo grave | Puede tumbar todo el programa | Puede tumbar todo el programa | Aislado al proceso |
| Quién planifica | Runtime de Ada apoyado en el planificador del sistema | Planificador del sistema | Planificador del sistema |
| Identidad observable | Ada.Task_Identification | pthread_t | PID 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 modoin,outoin 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.
| Forma | Dónde vive | Palabra clave distintiva | Qué resuelve |
|---|---|---|---|
| Aceptación selectiva | Servidor | varias ramas accept con or | Atender varias entradas distintas |
| Guardas | Servidor | when Cond => antes del accept | Habilitar servicios según el estado |
Alternativa delay | Servidor | or delay X; | No quedarse esperando peticiones para siempre |
Alternativa terminate | Servidor | or terminate; | Cierre ordenado cuando ya nadie puede llamar |
| Llamada temporizada | Cliente | llamada or delay X; | Poner plazo a la espera en la cola |
| Llamada condicional | Cliente | llamada else | Intentar sin bloquearse |
| Transferencia asíncrona | Cualquiera | then abort | Poner 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ón | Puede modificar el estado | Concurrencia permitida | Puede tener barrera |
|---|---|---|---|
function | No, sólo lectura | Varias funciones a la vez | No |
procedure | Sí | Exclusiva, una a la vez | No |
entry | Sí | Exclusiva, una a la vez | Sí, 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.
| Criterio | Cita (task + entry + accept) | Objeto protegido |
|---|---|---|
| Tiene hilo de control propio | Sí | No, corre en el hilo del llamador |
| Costo por operación | Dos cambios de contexto | Una toma de cerradura |
| Puede iniciar acciones por su cuenta | Sí, es activo | No, es puramente reactivo |
| Puede hacer entrada/salida bloqueante dentro | Sí, aunque conviene evitarlo | No, está prohibido bloquearse dentro |
| Puede llamar a otra entrada desde adentro | Sí | No (operación potencialmente bloqueante) |
| Ordena secuencias de operaciones | Sí, con accept sucesivos | No, cada operación es independiente |
| Reevaluación de condiciones | Manual, al volver al select | Automática al final de cada operación |
| Uso típico | Protocolo con estados, servidor con lógica propia | Dato 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ón | Qué entrega | Uso típico |
|---|---|---|
T'Terminated | True si la tarea T ya terminó | Verificar antes de llamar a una entrada |
T'Callable | False si T está completada, terminada o abortada | Evitar Tasking_Error |
T'Identity | El identificador único de T | Registro, comparaciones |
Current_Task | Identificador de la tarea que ejecuta | Diagnóstico |
Image (Id) | Nombre legible de una tarea | Mensajes de log |
E'Count | Llamadores encolados en la entrada E | Barreras 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_Lib | Qué hace |
|---|---|
String_Access | Puntero a String, el tipo con que se pasan las cadenas dinámicas |
Argument_List | Arreglo de String_Access con los argumentos del programa |
Locate_Exec_On_Path | Busca un ejecutable en el PATH y devuelve su ruta completa |
Spawn | Lanza un programa y espera a que termine |
Non_Blocking_Spawn | Lanza un programa y devuelve de inmediato un Process_Id |
Wait_Process | Espera a que termine algún proceso lanzado y entrega su identificador |
Process_Id / Invalid_Pid | Identificador de proceso y su valor inválido |
Standin, Standout, Standerr | Descriptores estándar, para redirigir la salida del hijo |
Argument_String_To_List | Convierte una línea de comando en un Argument_List |
Free | Libera un String_Access |
OS_Exit | Termina 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:
- Una tarea por comando, que lo lanza y espera su resultado.
- Un objeto protegido que recolecta los códigos de salida sin condiciones de carrera.
- Un bloque
declareque 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:
| Aspecto | Ada | Elixir |
|---|---|---|
| Modelo | Memoria compartida con acceso controlado | Nada compartido, sólo mensajes |
| Bloqueo del llamador | Automático por la barrera del entry | Manual: hay que guardar el from y responder después |
| Estado | Mutable, protegido por la cerradura | Inmutable, se reconstruye en cada handle_call |
| Fallo del servidor | El programa queda con clientes colgados | El supervisor lo reinicia con estado limpio |
| Verificación | El compilador impide escribir desde una function | Ninguna en tiempo de compilación |
| Contrapresión | Gratis, la barrera bloquea al productor | Hay que implementarla explícitamente |
| Unidad de aislamiento | Ninguna: una tarea que corrompe memoria afecta a todos | El 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íntoma | Causa | Solución |
|---|---|---|
| El programa imprime todo y no termina nunca | Una tarea servidora tiene loop select ... end select; end loop; sin alternativa terminate, así que su maestro la espera para siempre | Agregar or terminate; como última alternativa del select, o una entrada Cerrar que saque a la tarea del bucle |
Tasking_Error al llamar a una entrada | La tarea destino ya terminó, abortó o murió por una excepción no capturada | Poner 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 mensaje | Excepción no capturada dentro de la tarea: en Ada no se propaga al maestro ni se imprime | Manejador when others que registre con Exception_Name y Exception_Message |
Program_Error al entrar a un select | Todas las guardas resultaron falsas y no había alternativa else, delay ni terminate | Revisar 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 protegido | Se ejecutó una operación potencialmente bloqueante: delay, llamada a una entrada, activación de tarea | Sacar 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 cumple | La condición depende de algo que no es estado del objeto protegido, por ejemplo una variable global externa | Las 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ón | Las guardas se evalúan una sola vez al entrar al select, no mientras la tarea espera | Envolver el select en un bucle para que las guardas se reevalúen en cada vuelta |
| Interbloqueo entre dos tareas servidoras | Cada una llama a una entrada de la otra desde dentro de un accept, y ambas quedan comprometidas | No hacer llamadas a entradas dentro de un accept; copiar datos, cerrar el accept y luego llamar |
| El contador compartido da resultados distintos en cada corrida | Se usó una variable global visible a varias tareas en vez de encapsularla | Moverla a un objeto protegido o al cuerpo de una tarea servidora |
| El programa periódico se va atrasando | Se usó delay Periodo, que suma el tiempo de ejecución del cuerpo a cada vuelta | Usar delay until Siguiente con Ada.Real_Time y avanzar Siguiente por incrementos fijos |
| Los tiempos medidos saltan o se vuelven negativos | Se usó Ada.Calendar.Clock, sensible a ajustes de la hora del sistema | Usar Ada.Real_Time.Clock, que es monótono |
| Una tarea de alta prioridad queda esperando por una de baja | Inversión de prioridad al compartir un objeto protegido | Activar 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 correcto | Se incluyó el nombre del programa como primer elemento de Args; Argument_List sólo lleva los argumentos | Pasar el nombre en Program_Name y los argumentos desde el segundo elemento en adelante |
El comodín *.txt llega literal al programa hijo | Spawn no pasa por un intérprete de comandos, así que nadie expande comodines ni variables | Expandir 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 existe | Es una función interna del intérprete (cd, export) y no un ejecutable en el PATH | Implementar esa funcionalidad en el propio programa; no hay binario que lanzar |
| Se acumulan procesos zombis | Se usó Non_Blocking_Spawn sin llamar nunca a Wait_Process | Tener una tarea recolectora que llame a Wait_Process una vez por cada hijo lanzado |
| Fuga de memoria en un supervisor que lanza muchos comandos | Los String_Access creados con new String'(...) nunca se liberaron | Llamar 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álidos | Se escribieron acentos o eñes en comentarios o cadenas y GNAT esperaba otra codificación | Compilar 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 mezcladas | Put_Line no garantiza atomicidad entre tareas | Encapsular 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.