Paso de mensajes: buzones, rendezvous de Ada, actores de Elixir y cifrado entre procesos
Paso de mensajes: buzones, rendezvous de Ada, actores de Elixir y cifrado entre procesos
En el capítulo 9 construimos monitores: estructuras que encapsulan datos compartidos junto con los procedimientos que los tocan, de modo que la exclusión mutua deja de ser responsabilidad de quien llama y pasa a ser una propiedad del objeto. Los monitores resolvieron el problema de la disciplina —ya nadie olvida un signal— pero mantuvieron intacta una suposición de fondo: que todos los hilos viven dentro del mismo espacio de direcciones y pueden ver la misma variable.
Esa suposición se rompe apenas los participantes dejan de compartir memoria. Dos procesos distintos en la misma máquina tienen tablas de páginas separadas. Dos contenedores no comparten nada más que un puente de red. Dos servidores en continentes distintos ni siquiera comparten un reloj. En ninguno de esos escenarios existe “la variable compartida” sobre la cual poner un candado.
El paso de mensajes es la respuesta a ese escenario. En lugar de proteger un dato común, los participantes no tienen dato común: cada uno posee su propio estado y lo único que cruza la frontera son copias de información dentro de mensajes. Lo notable es que este mecanismo, pensado para comunicar, también sincroniza. Un receive que espera un mensaje es, funcionalmente, un proceso que se detiene hasta que otro le da permiso de continuar. Es decir: send y receive pueden hacer todo lo que hacían semáforos y monitores, más lo que ellos no podían hacer.
Este capítulo recorre el mecanismo completo. Primero la mecánica de bajo nivel —qué hace el kernel cuando un proceso envía un mensaje—, después las cuatro combinaciones de sincronismo, el direccionamiento y los buzones. Luego bajamos a dos implementaciones reales y muy distintas de la misma idea: el rendezvous de Ada, donde el mensaje es una llamada a procedimiento entre tareas, y el modelo de actores de Elixir, donde cada proceso tiene un buzón privado y el envío nunca bloquea. Cerramos con lo que suele quedar fuera de los cursos de sistemas operativos y no debería: qué pasa cuando el canal por el que viajan los mensajes no es confiable, y cómo la criptografía convierte un canal hostil en uno utilizable.
Por qué el paso de mensajes es un mecanismo distinto
Vale la pena hacer explícita la diferencia con todo lo visto antes, porque no es una diferencia de sintaxis sino de modelo.
En memoria compartida, el estado vive en un lugar y los hilos van hacia él. El problema es el acceso concurrente, y la solución es serializar ese acceso. Todo el arsenal de los capítulos previos —espera activa, semáforos, monitores— existe para responder una sola pregunta: quién puede tocar el dato ahora.
En paso de mensajes, el estado vive dentro de cada participante y nadie más lo alcanza. No hay acceso concurrente que serializar, porque no hay nada compartido. El problema cambia de naturaleza: ya no es exclusión mutua sino coordinación, es decir, quién le dice qué a quién y en qué orden llegan las cosas.
flowchart LR
subgraph MC["Memoria compartida"]
direction TB
H1["Hilo A"] --> LOCK["Candado"]
H2["Hilo B"] --> LOCK
LOCK --> DATO[("Dato compartido")]
end
subgraph PM["Paso de mensajes"]
direction TB
PA["Proceso A<br/>estado privado"] -->|"mensaje: copia"| CANAL["Canal / buzón"]
CANAL -->|"entrega"| PB["Proceso B<br/>estado privado"]
end
MC -.->|"requiere mismo<br/>espacio de direcciones"| LIM1["Solo hilos<br/>del mismo proceso"]
PM -.->|"solo requiere<br/>un canal"| LIM2["Procesos, máquinas,<br/>continentes"]
Las consecuencias prácticas de ese cambio son grandes:
No hay condiciones de carrera sobre el estado propio. Un actor procesa un mensaje a la vez. Su estado interno nunca es tocado por dos flujos simultáneos, así que no necesita candados internos. La serialización es gratis, viene incluida en el modelo.
Aparece un problema nuevo: el orden. Si A envía m1 y luego m2 a B, ¿llegan en ese orden? En un mismo nodo, con una cola FIFO, sí. Entre nodos con reintentos y rutas distintas, no necesariamente. Si además C también le envía a B, el orden relativo entre los mensajes de A y los de C es indeterminado.
Aparece un costo: la copia. Pasar un puntero cuesta ocho bytes. Pasar un mensaje cuesta copiar su contenido, potencialmente dos veces si el kernel actúa como intermediario. Para mensajes grandes y frecuentes esto importa, y es la razón por la que existen optimizaciones como memoria compartida con notificación por mensaje, o el paso por referencia de binarios grandes que hace la máquina virtual de Erlang.
La falla se vuelve visible. En memoria compartida, si el hilo que tenía el candado muere, el sistema entero queda colgado sin diagnóstico. En paso de mensajes, si el destinatario muere, el emisor puede enterarse: el canal se cierra, el send falla, llega una notificación. Esa visibilidad es la base de las arquitecturas tolerantes a fallos.
Las dos operaciones: send y receive
Todo el modelo se reduce a dos primitivas. Su forma canónica es:
send(destino, mensaje)
receive(origen, &mensaje)
Pero esa simplicidad es engañosa. Cada primitiva esconde decisiones de diseño que cambian por completo el comportamiento del sistema. Vamos una por una.
Qué hace el kernel realmente
Cuando un proceso ejecuta send sobre un canal del sistema operativo, ocurre una secuencia concreta:
sequenceDiagram
participant E as Proceso emisor
participant K as Kernel
participant B as Buffer del canal
participant R as Proceso receptor
E->>K: send(canal, mensaje) — trap, cambio a modo núcleo
K->>K: valida descriptor y permisos
K->>K: copia mensaje de espacio de usuario a espacio de núcleo
alt Hay espacio en el buffer
K->>B: encola el mensaje
K-->>E: retorna, el emisor sigue
else Buffer lleno y modo bloqueante
K->>K: marca al emisor como bloqueado
K->>K: lo saca de la cola de listos
Note over E: El emisor duerme hasta que se libere espacio
else Buffer lleno y modo no bloqueante
K-->>E: retorna error, EAGAIN
end
R->>K: receive(canal)
K->>B: desencola el mensaje
K->>K: copia de espacio de núcleo a espacio de usuario del receptor
K-->>R: retorna el mensaje
K->>E: si había emisores bloqueados, despierta a uno
Dos cosas que conviene retener de este diagrama. Primero, hay dos copias: usuario a núcleo y núcleo a usuario. Segundo, el bloqueo no es espera activa: el proceso sale de la cola de listos y no consume CPU, exactamente igual que en el wait de un semáforo del capítulo 7.
Las cuatro combinaciones de bloqueo
Tanto send como receive pueden ser bloqueantes o no bloqueantes, de forma independiente. Eso da cuatro combinaciones, y cada una define un estilo de comunicación distinto.
| Combinación | send | receive | Nombre habitual | Comportamiento |
|---|---|---|---|---|
| Tipo 1 | Bloqueante | Bloqueante | Rendezvous, sincronización fuerte | El emisor espera a que el receptor tome el mensaje. Ambos coinciden en un punto del tiempo. |
| Tipo 2 | No bloqueante | Bloqueante | El más común en la práctica | El emisor deposita y sigue. El receptor duerme hasta que llegue algo. |
| Tipo 3 | No bloqueante | No bloqueante | Totalmente asíncrono | Nadie espera nunca. El receptor debe sondear. |
| Tipo 4 | Bloqueante | No bloqueante | Poco usado | El emisor espera confirmación pero el receptor no espera mensajes. |
Tipo 1, el rendezvous. El emisor queda bloqueado hasta que el receptor ejecuta su receive. Cuando eso ocurre, ambos siguen. Es la sincronización más fuerte posible: al retornar el send, el emisor tiene la certeza de que el mensaje fue recibido, no solamente encolado. El nombre viene del francés y significa “cita”: los dos procesos tienen que estar presentes al mismo tiempo. Ada lo implementa como primitiva del lenguaje, y lo veremos en detalle más adelante.
Tipo 2, el caso dominante. El emisor no espera: deja el mensaje en un buffer y continúa. El receptor sí espera si no hay nada. Es el modelo de las tuberías de Unix, de las colas POSIX en configuración normal, de los sockets con buffer, del send de Elixir. Es el punto de equilibrio entre desacoplamiento y simplicidad: el receptor no necesita sondear, y el emisor no queda atado al ritmo del receptor.
Hay un matiz importante: el Tipo 2 es no bloqueante hasta que el buffer se llena. Si el receptor es más lento que el emisor de forma sostenida, el buffer se agota y el send empieza a bloquear —o a fallar, según la configuración—. Ese momento se llama contrapresión, y es un rasgo deseable: es el sistema avisando que hay un desbalance en vez de consumir memoria hasta morir.
Tipo 3, asíncrono puro. Ninguna operación bloquea jamás. El receive retorna inmediatamente con un mensaje o con un indicador de “no hay nada”. Sirve para bucles de eventos que deben atender múltiples fuentes sin quedarse pegados en ninguna, pero obliga a diseñar bien el sondeo: sondear en un bucle apretado es espera activa disfrazada, con todo el desperdicio de CPU que eso implica.
Tipo 4. El emisor bloquea esperando que el mensaje sea consumido, pero el receptor nunca espera. Aparece en casos donde el emisor necesita garantía de entrega y el receptor tiene otra tarea principal que no puede interrumpir. Es la combinación menos frecuente.
Espera con plazo: la quinta opción práctica
En sistemas reales las dos opciones binarias no alcanzan. Una tercera modalidad es esperar, pero no para siempre: mq_timedreceive en POSIX, select ... or delay en Ada, receive ... after en Elixir. El plazo convierte un bloqueo indefinido en un bloqueo acotado, y es la herramienta principal contra los interbloqueos por mensaje perdido que veremos en la sección de errores.
Direccionamiento: cómo se nombra al otro extremo
La segunda gran decisión de diseño es a quién se le envía. Hay dos familias.
Direccionamiento directo
El emisor nombra explícitamente al proceso destino, y el receptor nombra explícitamente al proceso origen:
send(P2, mensaje)
receive(P1, &mensaje)
Es simple y el enlace entre ambos es implícito: no hay que crear nada, basta conocer el identificador del otro. La contra es el acoplamiento: si el proceso destino cambia de identificador, muere y renace, o se replica en tres instancias, el emisor tiene que enterarse.
Existe una variante llamada direccionamiento directo asimétrico, donde el receptor no especifica origen sino que acepta de cualquiera y el sistema le informa quién envió. Es lo que hace un servidor que atiende a clientes desconocidos.
Direccionamiento indirecto: el buzón
El emisor no nombra a un proceso sino a un objeto intermedio: el buzón, o mailbox. El receptor lee de ese mismo buzón.
send(buzon_pedidos, mensaje)
receive(buzon_pedidos, &mensaje)
El buzón es una estructura de datos con identidad propia, típicamente una cola, que pertenece al sistema operativo o a un proceso propietario. Su valor está en el desacoplamiento: emisor y receptor no se conocen entre sí, solo conocen el buzón. Eso permite que el receptor cambie, se replique, se reinicie o incluso no exista todavía cuando el mensaje se deposita.
flowchart TD
subgraph TOPO["Topologías que habilita un buzón"]
direction TB
subgraph T11["Uno a uno"]
A1["P1"] --> B1["Buzón"] --> C1["P2"]
end
subgraph TN1["Muchos a uno: cliente-servidor"]
A2["Cliente 1"] --> B2["Buzón de<br/>peticiones"]
A3["Cliente 2"] --> B2
A4["Cliente 3"] --> B2
B2 --> C2["Servidor"]
end
subgraph T1N["Uno a muchos: difusión"]
A5["Publicador"] --> B3["Buzón"]
B3 --> C3["Suscriptor 1"]
B3 --> C4["Suscriptor 2"]
end
subgraph TNM["Muchos a muchos: foro"]
A6["P1"] --> B4["Buzón<br/>compartido"]
A7["P2"] --> B4
B4 --> C5["P3"]
B4 --> C6["P4"]
end
end
Las cuatro topologías tienen semánticas distintas que conviene no confundir:
| Topología | Uso típico | Semántica de entrega | Riesgo principal |
|---|---|---|---|
| Uno a uno | Canal privado entre dos etapas de un pipeline | Cada mensaje va al único receptor | Si el receptor muere, el canal se llena |
| Muchos a uno | Servidor que atiende peticiones | Cada mensaje va al único receptor, los emisores compiten por espacio | El servidor es cuello de botella y punto único de falla |
| Uno a muchos | Notificaciones, eventos | Depende: copia a cada uno, o uno se lo lleva | Confundir difusión con reparto de carga |
| Muchos a muchos | Pool de trabajadores sobre cola común | Cada mensaje lo toma un trabajador cualquiera | Orden no garantizado entre trabajadores |
Sobre el tercer caso hay que ser explícito porque es fuente constante de errores de diseño. Un buzón con varios lectores donde cada mensaje se entrega a uno solo de ellos es un reparto de carga. Un buzón donde cada mensaje se copia a todos los lectores es una difusión. Son dos mecanismos completamente distintos y muchos sistemas ofrecen ambos: en la terminología de los brokers de mensajería, se distinguen como cola frente a tópico.
Propiedad y ciclo de vida del buzón
Un detalle que suele pasarse por alto: ¿quién es dueño del buzón?
Si el buzón pertenece a un proceso, desaparece cuando ese proceso termina, y los emisores que le escribían reciben un error. Es el modelo del buzón de un actor de Erlang o Elixir.
Si el buzón pertenece al sistema operativo, sobrevive a los procesos que lo usan. Una cola POSIX creada con mq_open persiste hasta que alguien llama a mq_unlink o hasta que se reinicia la máquina. Eso es útil —un proceso puede reiniciarse sin perder los mensajes pendientes— y también peligroso: es un recurso que se filtra silenciosamente si nadie lo limpia.
Formato del mensaje y disciplina de la cola
Un mensaje no es solo su contenido. Tiene una estructura de dos partes.
| Parte | Contenido típico | Quién la usa |
|---|---|---|
| Cabecera | Tipo de mensaje, identificador de origen, identificador de destino, longitud del cuerpo, número de secuencia, prioridad | El sistema de mensajería, para enrutar y ordenar |
| Cuerpo | Los datos propiamente tales, en el formato que acuerden las partes | La aplicación |
La cabecera es lo que permite que el mecanismo funcione sin entender el contenido. El sistema puede ordenar por prioridad, descartar duplicados por número de secuencia o enrutar por tipo, sin saber nada de la semántica del cuerpo.
La disciplina de la cola define en qué orden salen los mensajes:
FIFO. El primero que entra es el primero que sale. Es el comportamiento por defecto de tuberías, sockets y del buzón de Erlang. Es justo —ningún mensaje se posterga indefinidamente— y preserva el orden de causalidad entre mensajes del mismo emisor.
Por prioridad. Cada mensaje lleva un número de prioridad y la cola entrega primero los más urgentes. Las colas de mensajes POSIX implementan esto de forma nativa. El riesgo es la inanición: si llegan mensajes de alta prioridad de forma sostenida, los de baja prioridad nunca se atienden. La mitigación clásica es el envejecimiento, es decir, subir la prioridad de un mensaje en función del tiempo que lleva esperando.
Selectiva por patrón. El receptor no toma el primero de la cola sino el primero que coincide con un patrón que él especifica. Es lo que hacen el receive de Erlang y Elixir con sus cláusulas, y msgrcv de System V con su parámetro de tipo. Es muy expresivo, pero tiene un costo que veremos: si el buzón acumula mensajes que nunca coinciden, cada receive recorre toda la cola.
Implementación real: colas de mensajes POSIX en C
Bajemos del modelo a la máquina. Las colas de mensajes POSIX son la implementación más directa del modelo de buzón que ofrece un sistema tipo Unix: son un objeto con nombre, propiedad del kernel, con prioridad y con variantes bloqueantes, no bloqueantes y con plazo.
Este es un servidor que crea un buzón y consume mensajes hasta recibir uno de término.
/* servidor.c — compilar con: gcc servidor.c -o servidor -lrt */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <errno.h>
#define NOMBRE_COLA "/buzon_pedidos"
#define TAM_MENSAJE 256
#define MAX_MENSAJES 10
int main(void) {
struct mq_attr atributos;
atributos.mq_flags = 0; /* 0 = bloqueante */
atributos.mq_maxmsg = MAX_MENSAJES; /* capacidad del buzón */
atributos.mq_msgsize = TAM_MENSAJE; /* tamaño máximo por mensaje */
atributos.mq_curmsgs = 0; /* lo llena el kernel */
/* O_CREAT crea el buzón si no existe. 0644 son los permisos. */
mqd_t cola = mq_open(NOMBRE_COLA, O_CREAT | O_RDONLY, 0644, &atributos);
if (cola == (mqd_t) -1) {
perror("mq_open en el servidor");
return EXIT_FAILURE;
}
printf("Servidor escuchando en %s\n", NOMBRE_COLA);
char buffer[TAM_MENSAJE + 1];
unsigned int prioridad;
for (;;) {
/* receive bloqueante: el proceso sale de la cola de listos
hasta que llegue un mensaje. No consume CPU esperando. */
ssize_t recibidos = mq_receive(cola, buffer, TAM_MENSAJE, &prioridad);
if (recibidos == -1) {
perror("mq_receive");
break;
}
buffer[recibidos] = '\0';
printf("Recibido (prioridad %u): %s\n", prioridad, buffer);
if (strcmp(buffer, "TERMINAR") == 0) {
printf("Orden de término recibida.\n");
break;
}
}
mq_close(cola);
/* unlink borra el nombre del buzón del sistema. Sin esto, el buzón
sobrevive al proceso y se filtra como recurso del kernel. */
mq_unlink(NOMBRE_COLA);
return EXIT_SUCCESS;
}
Y este es el cliente, que demuestra las tres modalidades: bloqueante, no bloqueante y con plazo.
/* cliente.c — compilar con: gcc cliente.c -o cliente -lrt */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <mqueue.h>
#include <errno.h>
#include <time.h>
#define NOMBRE_COLA "/buzon_pedidos"
#define TAM_MENSAJE 256
static void enviar_bloqueante(mqd_t cola, const char *texto, unsigned int prio) {
if (mq_send(cola, texto, strlen(texto), prio) == -1) {
perror("mq_send bloqueante");
} else {
printf("Enviado (bloqueante, prio %u): %s\n", prio, texto);
}
}
static void enviar_con_plazo(mqd_t cola, const char *texto,
unsigned int prio, int segundos) {
struct timespec plazo;
clock_gettime(CLOCK_REALTIME, &plazo);
plazo.tv_sec += segundos; /* el plazo es absoluto, no relativo */
if (mq_timedsend(cola, texto, strlen(texto), prio, &plazo) == -1) {
if (errno == ETIMEDOUT) {
printf("El buzón sigue lleno tras %d segundos. Mensaje descartado.\n",
segundos);
} else {
perror("mq_timedsend");
}
} else {
printf("Enviado (con plazo, prio %u): %s\n", prio, texto);
}
}
int main(void) {
mqd_t cola = mq_open(NOMBRE_COLA, O_WRONLY);
if (cola == (mqd_t) -1) {
perror("mq_open en el cliente");
fprintf(stderr, "¿Está el servidor levantado?\n");
return EXIT_FAILURE;
}
/* Prioridad más alta se entrega primero, sin importar el orden de envío. */
enviar_bloqueante(cola, "pedido normal A", 1);
enviar_bloqueante(cola, "pedido normal B", 1);
enviar_bloqueante(cola, "PEDIDO URGENTE", 9);
/* Modalidad no bloqueante: abrimos otro descriptor con O_NONBLOCK
para demostrar el retorno inmediato con EAGAIN. */
mqd_t cola_nb = mq_open(NOMBRE_COLA, O_WRONLY | O_NONBLOCK);
if (cola_nb != (mqd_t) -1) {
for (int i = 0; i < 50; i++) {
char texto[64];
snprintf(texto, sizeof texto, "ráfaga %d", i);
if (mq_send(cola_nb, texto, strlen(texto), 1) == -1) {
if (errno == EAGAIN) {
printf("Buzón lleno en la ráfaga %d. El send no bloqueó.\n", i);
break;
}
perror("mq_send no bloqueante");
break;
}
}
mq_close(cola_nb);
}
enviar_con_plazo(cola, "pedido con plazo", 5, 2);
enviar_bloqueante(cola, "TERMINAR", 9);
mq_close(cola);
return EXIT_SUCCESS;
}
Al ejecutar el servidor y luego el cliente, el mensaje marcado con prioridad 9 se entrega antes que los de prioridad 1 aunque haya sido enviado después. Esa es la disciplina por prioridad funcionando.
Un detalle operativo de Linux: las colas POSIX se pueden inspeccionar montando un sistema de archivos especial. Si se monta mqueue en un directorio, cada cola aparece como un archivo cuyo contenido muestra el número de mensajes pendientes y el proceso notificado. Es una forma directa de diagnosticar buzones filtrados.
Comparativa de mecanismos de paso de mensajes en Unix
| Mecanismo | Ámbito | Con nombre | Prioridad | Límites de mensaje | Persistencia |
|---|---|---|---|---|---|
| Tubería anónima | Procesos emparentados | No | No | Flujo de bytes sin fronteras | Muere con los procesos |
| Tubería con nombre, FIFO | Cualquiera en la misma máquina | Sí, en el sistema de archivos | No | Flujo de bytes sin fronteras | El nodo persiste, los datos no |
| Cola System V | Cualquiera en la misma máquina | Sí, por clave numérica | Selección por tipo | Mensajes discretos | Sobrevive a los procesos |
| Cola POSIX | Cualquiera en la misma máquina | Sí, por nombre tipo ruta | Sí, nativa | Mensajes discretos | Sobrevive a los procesos |
| Socket de dominio Unix | Misma máquina | Sí, por ruta | No | Flujo o datagramas | Muere con el socket |
| Socket de red | Entre máquinas | Por dirección y puerto | No | Flujo o datagramas | Muere con la conexión |
| Buzón de proceso Erlang | Dentro del nodo o entre nodos | Opcional, por registro | Selección por patrón | Términos arbitrarios | Muere con el proceso |
La diferencia entre “flujo de bytes sin fronteras” y “mensajes discretos” merece énfasis porque es una fuente clásica de errores. En una tubería o un socket TCP, si se escriben dos mensajes de 100 bytes, el lector puede recibir 200 bytes de una vez, o 37 y luego 163. El sistema no preserva las fronteras entre mensajes; eso es responsabilidad del protocolo de aplicación, que debe delimitar con longitud previa o con un separador. En una cola POSIX o System V, cada receive devuelve exactamente un mensaje completo.
El rendezvous de Ada: el mensaje como llamada a procedimiento
Ada tomó una decisión de diseño distinta a la de casi todos los lenguajes: el paso de mensajes no es una biblioteca sino una construcción sintáctica del lenguaje, y su forma es la de una llamada a procedimiento entre tareas.
En Ada, una tarea puede declarar entry, que son puntos de entrada públicos. Otra tarea llama a un entry como si fuera un procedimiento. La tarea dueña ejecuta accept para atender esa llamada. El encuentro entre la llamada y el accept es el rendezvous.
La mecánica del encuentro
sequenceDiagram
participant L as Tarea llamadora
participant S as Tarea servidora
Note over L,S: Caso 1 — el llamador llega primero
L->>S: Depositar(100)
Note over L: Bloqueada en la cola del entry
S->>S: alcanza accept Depositar
rect rgb(220, 240, 220)
Note over L,S: RENDEZVOUS: se ejecuta el cuerpo del accept
S->>S: Saldo := Saldo + 100
end
S-->>L: fin del accept
Note over L,S: Ambas continúan en paralelo
Note over L,S: Caso 2 — la servidora llega primero
S->>S: alcanza accept Retirar
Note over S: Bloqueada esperando llamada
L->>S: Retirar(30)
rect rgb(220, 240, 220)
Note over L,S: RENDEZVOUS
S->>S: Saldo := Saldo - 30
S-->>L: parámetro de salida
end
Note over L,S: Ambas continúan
Lo esencial: quien llegue primero espera al otro. Durante el cuerpo del accept, ambas tareas están sincronizadas y la llamadora está detenida. Los parámetros de entrada viajan de la llamadora a la servidora, y los parámetros de salida viajan de vuelta. Es decir, el rendezvous no es solo un send: es un send con respuesta incorporada, sin que el programador escriba el mensaje de retorno.
Este es el estado completo de una tarea participante:
stateDiagram-v2
[*] --> Ejecutando
Ejecutando --> EsperandoAceptacion: llama a un entry
EsperandoAceptacion --> EnRendezvous: la servidora ejecuta accept
EnRendezvous --> Ejecutando: termina el cuerpo del accept
Ejecutando --> EsperandoLlamada: ejecuta accept sin llamadas pendientes
EsperandoLlamada --> EnRendezvous: llega una llamada al entry
EsperandoLlamada --> Ejecutando: vence un delay del select
EsperandoLlamada --> Terminada: alternativa terminate y no quedan llamadores
Ejecutando --> Terminada: fin del cuerpo de la tarea
Terminada --> [*]
Un ejemplo ejecutable
Un buffer acotado implementado como tarea servidora. Reemplaza por completo al monitor del capítulo anterior, sin declarar ni un solo candado.
-- buffer_rendezvous.adb
-- Compilar y ejecutar con: gnatmake buffer_rendezvous.adb && ./buffer_rendezvous
with Ada.Text_IO; use Ada.Text_IO;
procedure Buffer_Rendezvous is
Capacidad : constant := 4;
type Indice is mod Capacidad;
type Almacen is array (Indice) of Integer;
-- La tarea servidora es dueña exclusiva del buffer.
-- Nadie más puede tocarlo: no hay variable compartida.
task Buffer is
entry Depositar (Valor : in Integer);
entry Extraer (Valor : out Integer);
entry Cerrar;
end Buffer;
task body Buffer is
Datos : Almacen;
Cabeza : Indice := 0;
Cola : Indice := 0;
Cantidad : Natural := 0;
Abierto : Boolean := True;
begin
while Abierto or else Cantidad > 0 loop
select
-- Guarda: este accept solo está disponible si hay espacio.
-- Si la guarda es falsa, los llamadores quedan en cola.
when Cantidad < Capacidad =>
accept Depositar (Valor : in Integer) do
Datos (Cola) := Valor;
end Depositar;
-- Fuera del accept, el llamador ya fue liberado.
Cola := Cola + 1;
Cantidad := Cantidad + 1;
Put_Line (" [buffer] depositado, ocupación =" &
Natural'Image (Cantidad));
or
when Cantidad > 0 =>
accept Extraer (Valor : out Integer) do
Valor := Datos (Cabeza);
end Extraer;
Cabeza := Cabeza + 1;
Cantidad := Cantidad - 1;
Put_Line (" [buffer] extraído, ocupación =" &
Natural'Image (Cantidad));
or
accept Cerrar do
Abierto := False;
end Cerrar;
or
-- Alternativa temporizada: si en 2 segundos no llega nada,
-- el select toma esta rama en vez de esperar para siempre.
delay 2.0;
Put_Line (" [buffer] 2 segundos sin actividad");
end select;
end loop;
Put_Line (" [buffer] terminando, cola vacía");
end Buffer;
task Productor;
task body Productor is
begin
for I in 1 .. 8 loop
Buffer.Depositar (I * 10);
Put_Line ("[productor] envió" & Integer'Image (I * 10));
delay 0.10;
end loop;
Buffer.Cerrar;
end Productor;
task Consumidor;
task body Consumidor is
V : Integer;
begin
for I in 1 .. 8 loop
delay 0.25; -- deliberadamente más lento que el productor
Buffer.Extraer (V);
Put_Line ("[consumidor] recibió" & Integer'Image (V));
end loop;
end Consumidor;
begin
Put_Line ("Buffer acotado por rendezvous, capacidad" &
Integer'Image (Capacidad));
end Buffer_Rendezvous;
Vale la pena detenerse en las guardas. La cláusula when Cantidad < Capacidad => hace que ese accept solo esté disponible cuando la condición se cumple. Si el buffer está lleno, la alternativa Depositar simplemente no es candidata y los productores esperan en la cola del entry. Eso es exactamente el rol que cumplían las variables de condición del monitor, pero declarado como precondición en lugar de programado con wait y signal. No hay forma de olvidar un signal, porque no hay signal.
El select y sus alternativas
El select de Ada es la construcción que hace útil todo el modelo, porque permite a una tarea servidora esperar simultáneamente por varias cosas.
| Alternativa | Sintaxis | Efecto |
|---|---|---|
| Accept simple | accept E do ... end E; | Espera una llamada al entry E |
| Accept con guarda | when Cond => accept E ... | El accept solo es elegible si Cond es verdadera |
| Retardo | or delay 2.0; | Si no llega ninguna llamada en ese plazo, toma esta rama |
| Retardo absoluto | or delay until T; | Igual, pero hasta un instante concreto |
| Else inmediato | else ... | Si no hay ninguna llamada pendiente ahora, no espera nada |
| Terminación | or terminate; | La tarea termina si su ámbito acabó y nadie puede llamarla más |
La alternativa terminate resuelve un problema real: una tarea servidora en un bucle infinito impediría que el programa terminara jamás. Con or terminate, la tarea desaparece limpiamente cuando el sistema determina que ya nadie podrá llamarla.
Del lado del llamador existen dos construcciones simétricas: select ... then abort para abandonar la llamada tras un plazo, y select ... else para intentar la llamada solo si puede atenderse de inmediato. Con eso, ambos extremos tienen control sobre cuánto están dispuestos a esperar.
El modelo de actores de Elixir: buzón privado y envío que nunca bloquea
Elixir, sobre la máquina virtual BEAM heredada de Erlang, implementa la otra gran variante del paso de mensajes: el modelo de actores. La filosofía es opuesta a la de Ada en el eje del sincronismo, y esa oposición es instructiva.
Un actor es un proceso ligero —decenas de miles caben en una máquina modesta— con tres propiedades:
- Tiene un buzón privado al que solo él puede leer.
- Procesa un mensaje a la vez, de forma secuencial.
- En respuesta a un mensaje puede: cambiar su estado interno, enviar mensajes a otros actores, o crear actores nuevos.
El send de Elixir, escrito send/2 o con el operador !, es siempre no bloqueante y siempre exitoso desde el punto de vista del emisor. Enviar a un proceso muerto no genera error: el mensaje simplemente se descarta. Esa decisión, que a primera vista parece descuidada, es deliberada: obliga a diseñar sistemas donde la confirmación es explícita, no implícita.
flowchart TD
subgraph ACTOR["Anatomía de un actor"]
direction TB
MB[("Buzón privado<br/>cola FIFO")]
LOOP["Bucle receive"]
EST["Estado interno<br/>inaccesible desde fuera"]
MB -->|"toma el primer mensaje<br/>que coincida con un patrón"| LOOP
LOOP -->|"calcula el nuevo estado"| EST
EST -->|"recursión con el estado nuevo"| LOOP
LOOP -->|"send"| OTROS["Otros actores"]
LOOP -->|"spawn"| NUEVOS["Actores nuevos"]
end
E1["Emisor 1"] -->|"send, nunca bloquea"| MB
E2["Emisor 2"] -->|"send, nunca bloquea"| MB
E3["Emisor 3"] -->|"send, nunca bloquea"| MB
Actores a mano
Antes de usar las abstracciones de la biblioteca estándar, conviene ver el mecanismo desnudo. Este módulo implementa un contador como actor puro, con spawn, send y receive.
# contador.exs — ejecutar con: elixir contador.exs
defmodule Contador do
@moduledoc "Actor que mantiene un contador en su estado interno."
# Punto de entrada: crea el proceso y devuelve su identificador.
def iniciar(valor_inicial \\ 0) do
spawn(fn -> bucle(valor_inicial) end)
end
# El bucle del actor. El estado viaja como argumento de la recursión:
# no hay variables mutables, no hay candados, no hay estado compartido.
defp bucle(valor) do
receive do
{:incrementar, cuanto} ->
bucle(valor + cuanto)
# Petición con respuesta: el emisor manda su propio pid.
{:leer, remitente} ->
send(remitente, {:valor, valor})
bucle(valor)
# Petición con referencia única, para no confundir respuestas.
{:leer, remitente, ref} ->
send(remitente, {:valor, ref, valor})
bucle(valor)
:detener ->
IO.puts("Actor terminando con valor final #{valor}")
:ok
otro ->
IO.puts("Mensaje no reconocido, se descarta: #{inspect(otro)}")
bucle(valor)
end
end
# Envoltorio síncrono construido sobre primitivas asíncronas.
# La referencia única evita tomar del buzón una respuesta ajena.
def leer(pid, plazo_ms \\ 1_000) do
ref = make_ref()
send(pid, {:leer, self(), ref})
receive do
{:valor, ^ref, valor} -> {:ok, valor}
after
plazo_ms -> {:error, :timeout}
end
end
end
# --- Uso ---
pid = Contador.iniciar(0)
# Tres emisores concurrentes. Ninguno bloquea al enviar.
for n <- 1..3 do
spawn(fn ->
for _ <- 1..100, do: send(pid, {:incrementar, n})
end)
end
Process.sleep(200)
case Contador.leer(pid) do
{:ok, valor} -> IO.puts("Valor tras 300 incrementos: #{valor}")
{:error, :timeout} -> IO.puts("El actor no respondió a tiempo")
end
# El actor procesa un mensaje a la vez: 100*1 + 100*2 + 100*3 = 600
send(pid, :detener)
Process.sleep(50)
El resultado es 600, de forma determinista, sin ningún mecanismo de exclusión mutua. Trescientos mensajes concurrentes llegaron a un buzón, la máquina virtual los serializó, y el actor los procesó uno por uno. La ausencia de condiciones de carrera no viene de un candado: viene de que el estado nunca fue compartido.
El receive selectivo y su costo
El receive de Elixir no toma el primer mensaje de la cola: toma el primero que coincida con alguna de sus cláusulas. Eso permite escribir protocolos donde un actor espera una respuesta específica ignorando temporalmente el resto.
# receive_selectivo.exs
defmodule Selectivo do
def demostrar do
# Depositamos mensajes en nuestro propio buzón, en este orden.
send(self(), {:baja, 1})
send(self(), {:baja, 2})
send(self(), {:alta, 99})
send(self(), {:baja, 3})
# A pesar de estar tercero en la cola, este receive lo saca primero:
# la coincidencia de patrón manda sobre el orden FIFO.
urgente =
receive do
{:alta, v} -> v
after
100 -> nil
end
IO.puts("Atendido primero: #{inspect(urgente)}")
# Los demás siguen en el buzón, en su orden original.
resto = drenar([])
IO.puts("Resto en orden FIFO: #{inspect(resto)}")
end
defp drenar(acc) do
receive do
msg -> drenar([msg | acc])
after
0 -> Enum.reverse(acc)
end
end
end
Selectivo.demostrar()
Aquí está el costo: la búsqueda por patrón recorre el buzón desde el principio. Si un actor acumula miles de mensajes que ninguna cláusula reconoce, cada receive los revisa todos antes de encontrar el que sirve, y el rendimiento se degrada de forma cuadrática. Por eso la cláusula comodín que descarta lo desconocido, presente en el ejemplo del contador, no es cosmética: evita que el buzón se convierta en un basurero creciente.
GenServer: el patrón cliente-servidor estandarizado
Escribir bucles receive a mano funciona pero se repite. GenServer es la abstracción de OTP que estandariza el patrón servidor: recibe llamadas síncronas con call, mensajes asíncronos con cast, y se integra con supervisión y con las herramientas de depuración del sistema.
# banco.exs — ejecutar con: elixir banco.exs
defmodule Banco do
use GenServer
# ---------- API pública, se ejecuta en el proceso del cliente ----------
def start_link(saldo_inicial) do
GenServer.start_link(__MODULE__, saldo_inicial, name: __MODULE__)
end
# call es SÍNCRONO: bloquea al cliente hasta la respuesta o el plazo.
# Internamente es send + receive con una referencia y un monitor.
def saldo, do: GenServer.call(__MODULE__, :saldo)
def retirar(monto), do: GenServer.call(__MODULE__, {:retirar, monto})
# cast es ASÍNCRONO: retorna :ok de inmediato, sin confirmación.
def depositar(monto), do: GenServer.cast(__MODULE__, {:depositar, monto})
# ---------- Callbacks, se ejecutan en el proceso del servidor ----------
@impl true
def init(saldo_inicial), do: {:ok, saldo_inicial}
@impl true
def handle_call(:saldo, _desde, saldo) do
{:reply, saldo, saldo}
end
@impl true
def handle_call({:retirar, monto}, _desde, saldo) when monto > 0 do
if monto <= saldo do
{:reply, {:ok, saldo - monto}, saldo - monto}
else
{:reply, {:error, :fondos_insuficientes}, saldo}
end
end
@impl true
def handle_cast({:depositar, monto}, saldo) when monto > 0 do
{:noreply, saldo + monto}
end
# Mensajes que no vienen de call ni de cast llegan aquí.
@impl true
def handle_info(mensaje, saldo) do
IO.puts("Mensaje fuera de protocolo, ignorado: #{inspect(mensaje)}")
{:noreply, saldo}
end
end
{:ok, _pid} = Banco.start_link(1_000)
# 50 clientes concurrentes intentan retirar 30 cada uno.
tareas =
for _ <- 1..50 do
Task.async(fn -> Banco.retirar(30) end)
end
resultados = Task.await_many(tareas, 5_000)
exitosos = Enum.count(resultados, &match?({:ok, _}, &1))
fallidos = Enum.count(resultados, &match?({:error, _}, &1))
IO.puts("Retiros exitosos: #{exitosos}")
IO.puts("Retiros rechazados: #{fallidos}")
IO.puts("Saldo final: #{Banco.saldo()}")
Banco.depositar(500)
IO.puts("Saldo tras depósito asíncrono: #{Banco.saldo()}")
El saldo nunca queda negativo y la suma cuadra siempre, sin un solo candado. La invariante se sostiene porque el servidor es un único proceso secuencial: los 50 clientes compiten por posición en un buzón, no por acceso a una variable.
Una precisión sobre call: aunque el modelo de actores es asíncrono, GenServer.call construye sincronía sobre él. Envía el mensaje con una referencia única y un monitor sobre el servidor, y espera la respuesta. Si el servidor muere, el monitor dispara y el call falla de inmediato en vez de esperar el plazo completo. Si el plazo vence —cinco segundos por omisión— el cliente lanza una excepción, pero el servidor puede seguir procesando ese mensaje y responder a nadie. Esa asimetría es la causa de una clase entera de errores en producción.
Ada y Elixir comparados
| Aspecto | Rendezvous de Ada | Actores de Elixir |
|---|---|---|
| Envío por omisión | Bloqueante, el llamador espera | No bloqueante, retorna siempre |
| Buffer | No hay: el encuentro es directo | Buzón ilimitado por proceso |
| Respuesta | Incluida: parámetros out del entry | Explícita: otro mensaje con el pid del emisor |
| Selección | select con guardas booleanas y retardo | receive con coincidencia de patrones y after |
| Costo de una tarea o proceso | Hilo del sistema o del runtime, pesado | Proceso de la BEAM, unos cientos de bytes |
| Escala típica | Decenas o cientos de tareas | Cientos de miles de procesos |
| Falla del otro extremo | Excepción Tasking_Error en el llamador | Silenciosa, salvo que se use monitor o enlace |
| Verificación | Fuerte en tiempo de compilación, tipos en los entry | Dinámica, el buzón acepta cualquier término |
| Distribución entre máquinas | No es parte del modelo base | Transparente entre nodos del clúster |
| Dominio natural | Sistemas embebidos y de tiempo real crítico | Sistemas distribuidos de alta disponibilidad |
Ninguno es superior al otro; responden a presiones distintas. Ada optimiza para que el compilador demuestre propiedades antes de desplegar en un avión. Elixir optimiza para que un sistema con partes rotas siga sirviendo tráfico.
Cifrar los mensajes: cuando el canal no es de confianza
Todo lo anterior asume un canal confiable: el mensaje que sale es el que llega, nadie lo lee en el camino y nadie inyecta mensajes falsos. Esa suposición es razonable dentro de una máquina, donde el kernel es el intermediario y el modelo de protección del capítulo 3 garantiza el aislamiento. Deja de ser razonable en cuanto los mensajes cruzan una red.
Un atacante con acceso al canal tiene tres capacidades, y cada una requiere una defensa distinta:
| Amenaza | Qué hace el atacante | Propiedad que se pierde | Herramienta |
|---|---|---|---|
| Escucha pasiva | Lee los mensajes en tránsito | Confidencialidad | Cifrado |
| Alteración | Modifica bits del mensaje | Integridad | Hash y MAC |
| Suplantación | Envía mensajes haciéndose pasar por otro | Autenticidad | MAC con clave, o firma |
| Repetición | Reenvía un mensaje válido capturado antes | Frescura | Números de secuencia, nonces |
Funciones de hash: detectar el cambio
Una función de hash criptográfica toma una entrada de cualquier tamaño y produce una salida de tamaño fijo, con tres propiedades: es determinista, es unidireccional —de la salida no se puede reconstruir la entrada— y es resistente a colisiones, es decir, es computacionalmente inviable encontrar dos entradas distintas con la misma salida.
| Algoritmo | Año | Salida | Estado actual | Uso apropiado hoy |
|---|---|---|---|---|
| MD5 | 1991 | 128 bits | Roto: se construyen colisiones en segundos | Solo checksums no adversarios |
| SHA-1 | 1995 | 160 bits | Roto: colisiones demostradas en 2017 | Ninguno nuevo |
| SHA-256 | 2001 | 256 bits | Sólido | Integridad, firmas, blockchain |
| SHA-3 | 2015 | Variable | Sólido, construcción distinta a SHA-2 | Alternativa a SHA-2 |
| BLAKE2, BLAKE3 | 2012, 2020 | Variable | Sólido y muy rápido | Integridad de alto rendimiento |
| bcrypt | 1999 | 184 bits | Sólido para su propósito | Almacenar contraseñas |
| scrypt, Argon2 | 2009, 2015 | Variable | Sólido, resisten hardware dedicado | Almacenar contraseñas |
Una distinción que cuesta caro confundir: un hash rápido es lo correcto para verificar integridad y lo incorrecto para guardar contraseñas. SHA-256 está diseñado para ser veloz, y esa velocidad permite a un atacante con una tarjeta gráfica probar miles de millones de candidatos por segundo contra una base de datos filtrada. bcrypt, scrypt y Argon2 están diseñados para ser deliberadamente lentos y costosos en memoria, con un factor de trabajo ajustable. Se usan exclusivamente para contraseñas.
Otra distinción igual de importante: un hash a secas no protege contra alteración intencional. Si el atacante puede modificar el mensaje, también puede recalcular su hash. Para que la verificación sirva contra un adversario hay que involucrar un secreto, y eso es un MAC.
HMAC: integridad con autenticidad
Un código de autenticación de mensaje, o MAC, combina el mensaje con una clave secreta compartida. HMAC es la construcción estándar que hace esto sobre una función de hash.
Quien no tenga la clave no puede producir un MAC válido. Por lo tanto, un MAC correcto prueba dos cosas a la vez: que el mensaje no fue alterado, y que fue generado por alguien que conoce la clave.
# integridad.exs — ejecutar con: elixir integridad.exs
defmodule Integridad do
# Clave compartida entre las dos partes. En producción jamás se
# escribe en el código: viene de una variable de entorno o de un
# gestor de secretos.
@clave :crypto.strong_rand_bytes(32)
def hash_simple(mensaje) do
:crypto.hash(:sha256, mensaje) |> Base.encode16(case: :lower)
end
def sellar(mensaje) do
etiqueta = :crypto.mac(:hmac, :sha256, @clave, mensaje)
{mensaje, etiqueta}
end
def verificar({mensaje, etiqueta}) do
esperada = :crypto.mac(:hmac, :sha256, @clave, mensaje)
# Comparación en tiempo constante: comparar con == permitiría a un
# atacante deducir la etiqueta byte a byte midiendo tiempos.
if :crypto.hash_equals(esperada, etiqueta) do
{:ok, mensaje}
else
{:error, :etiqueta_invalida}
end
end
end
mensaje = "transferir 1000 a la cuenta 4471"
IO.puts("Hash SHA-256: #{Integridad.hash_simple(mensaje)}")
IO.puts("Un bit distinto: #{Integridad.hash_simple(mensaje <> ".")}")
sellado = Integridad.sellar(mensaje)
IO.inspect(Integridad.verificar(sellado), label: "Mensaje íntegro")
# El atacante altera el contenido pero no puede recalcular la etiqueta.
{_, etiqueta} = sellado
alterado = {"transferir 9999 a la cuenta 6666", etiqueta}
IO.inspect(Integridad.verificar(alterado), label: "Mensaje alterado")
La función :crypto.hash_equals/2 merece una nota. Comparar dos secuencias de bytes con el operador de igualdad habitual termina en cuanto encuentra una diferencia, así que el tiempo de comparación revela cuántos bytes iniciales coincidían. Un atacante que pueda medir ese tiempo con precisión reconstruye la etiqueta byte a byte. La comparación en tiempo constante recorre siempre todos los bytes.
Cifrado simétrico autenticado
El cifrado simétrico usa la misma clave para cifrar y descifrar. Es rápido y es lo que protege el grueso del tráfico real. La modalidad recomendada hoy es el cifrado autenticado con datos asociados, o AEAD, que hace cifrado e integridad en una sola operación: si el mensaje fue alterado, el descifrado falla en vez de producir basura.
# cifrado.exs — ejecutar con: elixir cifrado.exs
defmodule CanalSeguro do
@algoritmo :chacha20_poly1305
@tam_nonce 12
def nueva_clave, do: :crypto.strong_rand_bytes(32)
@doc """
Cifra el mensaje. El nonce debe ser único por cada mensaje bajo la
misma clave: reutilizarlo rompe la seguridad del esquema.
Los datos asociados se autentican pero no se cifran; sirven para
cabeceras que deben viajar legibles y a la vez protegidas.
"""
def cifrar(clave, texto_plano, datos_asociados \\ "") do
nonce = :crypto.strong_rand_bytes(@tam_nonce)
{cifrado, etiqueta} =
:crypto.crypto_one_time_aead(
@algoritmo, clave, nonce, texto_plano, datos_asociados, true
)
%{nonce: nonce, cifrado: cifrado, etiqueta: etiqueta, aad: datos_asociados}
end
def descifrar(clave, %{nonce: n, cifrado: c, etiqueta: t, aad: aad}) do
case :crypto.crypto_one_time_aead(@algoritmo, clave, n, c, aad, t, false) do
:error -> {:error, :mensaje_alterado_o_clave_incorrecta}
texto -> {:ok, texto}
end
end
end
clave = CanalSeguro.nueva_clave()
sobre = CanalSeguro.cifrar(clave, "coordenadas: -33.45, -70.66", "v1|remitente=A")
IO.puts("Texto cifrado en hexadecimal: #{Base.encode16(sobre.cifrado)}")
IO.inspect(CanalSeguro.descifrar(clave, sobre), label: "Descifrado normal")
# Alteramos un byte del texto cifrado: el AEAD lo detecta y falla.
<<primero, resto::binary>> = sobre.cifrado
manipulado = %{sobre | cifrado: <<Bitwise.bxor(primero, 1), resto::binary>>}
IO.inspect(CanalSeguro.descifrar(clave, manipulado), label: "Cifrado alterado")
# Alteramos los datos asociados, que viajan en claro: también falla.
IO.inspect(
CanalSeguro.descifrar(clave, %{sobre | aad: "v1|remitente=IMPOSTOR"}),
label: "Cabecera alterada"
)
El punto crítico del cifrado simétrico no es el algoritmo: es cómo llegaron ambas partes a compartir la misma clave sin que nadie más la viera. Si la clave viaja por el canal inseguro, todo el esquema es decorativo.
Diffie-Hellman: acordar una clave en público
El protocolo Diffie-Hellman resuelve exactamente ese problema. Permite que dos partes que nunca se han visto establezcan un secreto compartido intercambiando mensajes por un canal que un atacante puede leer completo, sin que el atacante pueda deducir el secreto.
La idea, en términos de la variante moderna sobre curvas elípticas: cada parte genera un par de claves, una privada que nunca sale de su máquina y una pública que envía por el canal. Cada una combina su propia clave privada con la clave pública de la otra. Por las propiedades matemáticas de la operación, ambas combinaciones producen el mismo resultado, y ese resultado no es calculable a partir de las dos claves públicas solas.
sequenceDiagram
participant A as Proceso A
participant C as Canal público<br/>observado por un atacante
participant B as Proceso B
Note over A: genera par<br/>privada_A, publica_A
Note over B: genera par<br/>privada_B, publica_B
A->>C: publica_A
C->>B: publica_A
B->>C: publica_B
C->>A: publica_B
Note over C: El atacante vio<br/>publica_A y publica_B<br/>y no puede derivar el secreto
Note over A: secreto = combinar<br/>privada_A con publica_B
Note over B: secreto = combinar<br/>privada_B con publica_A
rect rgb(220, 240, 220)
Note over A,B: Ambos obtienen el MISMO secreto
end
Note over A,B: derivan la clave de sesión<br/>con una función de derivación
A->>C: mensaje cifrado con la clave de sesión
C->>B: mensaje cifrado
# acuerdo_de_clave.exs — ejecutar con: elixir acuerdo_de_clave.exs
defmodule Acuerdo do
@curva :x25519
def par_de_claves do
{publica, privada} = :crypto.generate_key(:ecdh, @curva)
%{publica: publica, privada: privada}
end
def secreto_compartido(mi_privada, publica_del_otro) do
:crypto.compute_key(:ecdh, publica_del_otro, mi_privada, @curva)
end
@doc """
El secreto crudo del acuerdo no se usa directamente como clave.
Se pasa por una derivación que incorpora contexto, para que dos
sesiones distintas nunca compartan la misma clave de cifrado.
"""
def derivar_clave(secreto, contexto) do
:crypto.mac(:hmac, :sha256, secreto, contexto)
end
end
a = Acuerdo.par_de_claves()
b = Acuerdo.par_de_claves()
# Solo las claves públicas viajan por el canal.
secreto_a = Acuerdo.secreto_compartido(a.privada, b.publica)
secreto_b = Acuerdo.secreto_compartido(b.privada, a.publica)
IO.puts("¿Los secretos coinciden? #{secreto_a == secreto_b}")
clave_a = Acuerdo.derivar_clave(secreto_a, "sesion-2026-08-17|A->B")
clave_b = Acuerdo.derivar_clave(secreto_b, "sesion-2026-08-17|A->B")
IO.puts("¿Las claves derivadas coinciden? #{clave_a == clave_b}")
IO.puts("Clave de sesión: #{Base.encode16(clave_a, case: :lower)}")
Hay una limitación que debe quedar clarísima: Diffie-Hellman por sí solo no autentica. Un atacante situado en el medio puede hacer un acuerdo con A haciéndose pasar por B, otro con B haciéndose pasar por A, y descifrar y recifrar todo el tráfico sin que ninguno lo note. Por eso los protocolos reales combinan el acuerdo de claves con autenticación: firmas sobre las claves públicas, certificados verificados contra una autoridad, o claves de host previamente conocidas —que es lo que hace SSH cuando pregunta si se confía en la huella del servidor la primera vez—.
Aplicación al paso de mensajes entre procesos
Juntando las piezas, un canal seguro entre procesos se construye en tres etapas:
flowchart TD
INICIO(["Dos procesos quieren comunicarse"]) --> Q1{"¿El canal está<br/>bajo control del kernel<br/>de una sola máquina?"}
Q1 -->|Sí| SIMPLE["Los permisos del sistema<br/>de archivos y el aislamiento<br/>de procesos bastan"]
Q1 -->|No| FASE1["Fase 1: acuerdo de clave<br/>Diffie-Hellman sobre curva elíptica"]
FASE1 --> Q2{"¿Se verificó la identidad<br/>del otro extremo?"}
Q2 -->|No| MITM["Vulnerable a atacante<br/>en el medio"]
Q2 -->|Sí| FASE2["Fase 2: derivar claves de sesión<br/>una por dirección"]
MITM --> AUTH["Añadir firma, certificado<br/>o clave de host conocida"]
AUTH --> FASE2
FASE2 --> FASE3["Fase 3: cifrar cada mensaje<br/>con AEAD y nonce único"]
FASE3 --> SEQ["Añadir número de secuencia<br/>dentro del texto autenticado"]
SEQ --> LISTO(["Canal con confidencialidad,<br/>integridad, autenticidad<br/>y resistencia a repetición"])
El número de secuencia de la última etapa cierra la cuarta amenaza de la tabla. Sin él, un atacante que capture el mensaje cifrado “transferir 1000” puede reenviarlo cien veces: cada copia descifra correctamente porque es un mensaje legítimo. Incluyendo un contador creciente dentro de la parte autenticada, el receptor descarta todo lo que no traiga un número mayor al último visto.
Errores comunes
| Error | Causa | Solución |
|---|---|---|
Interbloqueo con dos procesos que se envían mutuamente con send bloqueante | Ambos hacen send antes de hacer receive, y ambos esperan que el otro reciba | Romper la simetría: uno envía primero y el otro recibe primero, o usar envío con buffer o con plazo |
| El buzón crece sin límite hasta agotar la memoria | El productor es más rápido que el consumidor y el canal no tiene límite ni contrapresión | Acotar la capacidad del buzón para que el send bloquee o falle, y monitorear la ocupación |
El receive de Elixir se vuelve lento con el tiempo | El actor no descarta los mensajes que no reconoce; el buzón acumula basura que la búsqueda por patrón recorre en cada llamada | Añadir siempre una cláusula comodín que descarte y registre lo desconocido |
Un GenServer.call falla por plazo vencido pero el trabajo igual se ejecuta | El cliente abandonó la espera; el servidor sigue procesando ese mensaje y responde a nadie | Hacer la operación idempotente, o convertirla en cast con confirmación explícita por otro mensaje |
| Mensajes que llegan cortados o pegados por un socket o una tubería | TCP y las tuberías son flujos de bytes sin fronteras de mensaje | Delimitar en el protocolo: prefijo de longitud, o un separador que no aparezca en el cuerpo |
| Los mensajes de baja prioridad nunca se atienden | Cola con disciplina por prioridad y flujo sostenido de alta prioridad | Aplicar envejecimiento, o reservar una fracción del servicio para las prioridades bajas |
| Una cola POSIX sigue existiendo tras reiniciar el programa y acumula mensajes viejos | La cola es propiedad del kernel y sobrevive a los procesos; nadie llamó a mq_unlink | Llamar a mq_unlink al terminar y limpiar al iniciar; instalar un manejador de señales para el cierre abrupto |
mq_send falla con EMSGSIZE | El mensaje excede el mq_msgsize fijado al crear la cola, o se superan los límites del sistema | Ajustar los atributos al crear la cola y validar el tamaño antes de enviar; fragmentar los mensajes grandes |
| En Ada, la tarea servidora impide que el programa termine | El bucle de select espera indefinidamente llamadas que ya nadie hará | Añadir la alternativa or terminate al select |
En Ada, una llamada a un entry queda colgada para siempre | La guarda de ese accept nunca se hace verdadera | Revisar la lógica de la guarda y usar select ... then abort o delay en el llamador |
| Respuestas cruzadas: un actor recibe la respuesta de una petición anterior | El protocolo de petición y respuesta no distingue entre llamadas concurrentes | Incluir una referencia única con make_ref en la petición y filtrarla en el receive |
| El sistema pierde mensajes en silencio al morir un proceso | send en Elixir no falla al enviar a un pid muerto | Usar Process.monitor o enlaces, y un supervisor que reinicie el destinatario |
| Una contraseña filtrada se descifra en horas pese a estar hasheada | Se usó SHA-256, que es rápido por diseño, para almacenar contraseñas | Usar bcrypt, scrypt o Argon2 con un factor de trabajo calibrado |
| Un mensaje válido se reenvía y se procesa dos veces | El cifrado protege confidencialidad e integridad pero no frescura | Incluir un número de secuencia o un nonce dentro de la parte autenticada y rechazar lo repetido |
| El canal cifrado igual es leído por un tercero | Se usó Diffie-Hellman sin autenticar las claves públicas; hay un atacante en el medio | Autenticar el intercambio con firmas, certificados o claves de host verificadas |
| Reutilizar el mismo nonce con la misma clave en AEAD | El nonce se generó una vez y se guardó como constante | Generar un nonce aleatorio por mensaje, o usar un contador que nunca se repita bajo una misma clave |
Ejercicios propuestos
1. Las cuatro combinaciones en C. Partiendo del par cliente-servidor con colas POSIX de este capítulo, escribe un programa que demuestre experimentalmente las cuatro combinaciones de bloqueo. Reduce mq_maxmsg a 2 y mide con clock_gettime cuánto tarda cada mq_send en las modalidades bloqueante y no bloqueante cuando el buzón está lleno. Reporta los tiempos en una tabla.
2. Delimitación de mensajes sobre un flujo. Implementa un protocolo de mensajes discretos sobre una tubería con nombre, usando un prefijo de cuatro bytes con la longitud. Verifica que funciona correctamente cuando el lector recibe los datos fragmentados, forzando el escenario con escrituras parciales y pausas.
3. Servidor de recursos en Ada. Escribe una tarea que administre un conjunto de N recursos idénticos, con entry Adquirir y entry Liberar. Usa una guarda para que Adquirir solo esté disponible cuando queden recursos libres. Añade or terminate y comprueba que el programa termina limpiamente. Compara la cantidad de líneas y de mecanismos con la versión equivalente usando semáforos del capítulo 7.
4. Rendezvous con plazo. Extiende el ejercicio anterior para que el llamador abandone la solicitud si no obtiene un recurso en tres segundos, usando select ... then abort. Diseña un escenario donde se demuestre el abandono.
5. Filósofos comensales por mensajes. Resuelve el problema de los cinco filósofos en Elixir, modelando cada tenedor como un actor con estado libre u ocupado y cada filósofo como otro actor. Detecta experimentalmente el interbloqueo cuando todos toman el tenedor izquierdo primero, e implementa una solución. Compara la estructura con la solución por semáforos.
6. El costo del receive selectivo. Escribe un actor que reciba diez mil mensajes de un tipo que ninguna cláusula reconoce y luego uno que sí. Mide el tiempo del receive con y sin cláusula comodín, usando :timer.tc. Grafica el tiempo en función del tamaño del buzón y explica la forma de la curva.
7. Contrapresión. Implementa un productor y un consumidor en Elixir donde el productor genera mensajes cien veces más rápido de lo que el consumidor procesa. Observa el crecimiento del buzón con Process.info(pid, :message_queue_len). Después implementa un esquema de contrapresión donde el productor pide permiso al consumidor antes de enviar cada lote, y compara el uso de memoria de ambas versiones.
8. Canal seguro completo. Combina las piezas de la sección de criptografía en un módulo que: haga el acuerdo de clave con X25519, derive claves distintas para cada dirección de la comunicación, cifre cada mensaje con AEAD y un nonce fresco, e incluya un número de secuencia dentro de los datos autenticados. Escribe pruebas que demuestren el rechazo de un mensaje alterado, de uno repetido y de uno con la cabecera modificada.
9. Atacante en el medio. Sobre el módulo del ejercicio anterior, escribe un proceso interceptor que se ubique entre los dos extremos y realice dos acuerdos de clave independientes. Demuestra que puede leer todo el tráfico. Luego añade autenticación de las claves públicas y demuestra que el ataque deja de funcionar.
10. Comparativa de hash. Mide el tiempo de calcular SHA-256, bcrypt y Argon2 sobre la misma entrada, con distintos factores de trabajo. Estima cuántos candidatos por segundo podría probar un atacante con cada uno y explica en números por qué SHA-256 no sirve para almacenar contraseñas.
11. Mensajes entre nodos. Levanta dos nodos de Elixir con nombres distintos, conéctalos con Node.connect y envía mensajes de uno a otro usando la tupla de nombre registrado y nodo. Verifica qué ocurre con los mensajes cuando la conexión entre nodos se corta durante el envío.
Lo que viene
El paso de mensajes cierra el bloque de sincronización y comunicación del curso. Recorrimos el camino completo: de la espera activa a los semáforos, de los semáforos a los monitores, y de ahí a un modelo donde la memoria compartida desaparece y todo se resuelve enviando información. Con eso, el temario de coordinación entre procesos queda cubierto tanto en la máquina local como entre máquinas separadas por una red hostil.
El capítulo 11 cambia de eje y entra en la persistencia: los sistemas de archivos. Veremos cómo el sistema operativo construye la abstracción de archivo y directorio sobre bloques de disco que no saben nada de nombres ni de jerarquías, cómo funcionan los inodos y las tablas de asignación, qué hace un journal para que un corte de energía no destruya la estructura, y cómo el descriptor de archivo —que ya usamos como si fuera un canal de mensajes— unifica archivos, tuberías, sockets y dispositivos bajo una sola interfaz.
El índice completo del curso está en /tecnologias/sistemas-operativos/00-indice/.