Paso de mensajes: buzones, rendezvous de Ada, actores de Elixir y cifrado entre procesos

Por: Artiko
sistemas-operativosadaelixirconcurrenciapaso-de-mensajesipcbuzonesrendezvousactorescriptografia

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ónsendreceiveNombre habitualComportamiento
Tipo 1BloqueanteBloqueanteRendezvous, sincronización fuerteEl emisor espera a que el receptor tome el mensaje. Ambos coinciden en un punto del tiempo.
Tipo 2No bloqueanteBloqueanteEl más común en la prácticaEl emisor deposita y sigue. El receptor duerme hasta que llegue algo.
Tipo 3No bloqueanteNo bloqueanteTotalmente asíncronoNadie espera nunca. El receptor debe sondear.
Tipo 4BloqueanteNo bloqueantePoco usadoEl 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íaUso típicoSemántica de entregaRiesgo principal
Uno a unoCanal privado entre dos etapas de un pipelineCada mensaje va al único receptorSi el receptor muere, el canal se llena
Muchos a unoServidor que atiende peticionesCada mensaje va al único receptor, los emisores compiten por espacioEl servidor es cuello de botella y punto único de falla
Uno a muchosNotificaciones, eventosDepende: copia a cada uno, o uno se lo llevaConfundir difusión con reparto de carga
Muchos a muchosPool de trabajadores sobre cola comúnCada mensaje lo toma un trabajador cualquieraOrden 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.

ParteContenido típicoQuién la usa
CabeceraTipo de mensaje, identificador de origen, identificador de destino, longitud del cuerpo, número de secuencia, prioridadEl sistema de mensajería, para enrutar y ordenar
CuerpoLos datos propiamente tales, en el formato que acuerden las partesLa 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ÁmbitoCon nombrePrioridadLímites de mensajePersistencia
Tubería anónimaProcesos emparentadosNoNoFlujo de bytes sin fronterasMuere con los procesos
Tubería con nombre, FIFOCualquiera en la misma máquinaSí, en el sistema de archivosNoFlujo de bytes sin fronterasEl nodo persiste, los datos no
Cola System VCualquiera en la misma máquinaSí, por clave numéricaSelección por tipoMensajes discretosSobrevive a los procesos
Cola POSIXCualquiera en la misma máquinaSí, por nombre tipo rutaSí, nativaMensajes discretosSobrevive a los procesos
Socket de dominio UnixMisma máquinaSí, por rutaNoFlujo o datagramasMuere con el socket
Socket de redEntre máquinasPor dirección y puertoNoFlujo o datagramasMuere con la conexión
Buzón de proceso ErlangDentro del nodo o entre nodosOpcional, por registroSelección por patrónTérminos arbitrariosMuere 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.

AlternativaSintaxisEfecto
Accept simpleaccept E do ... end E;Espera una llamada al entry E
Accept con guardawhen Cond => accept E ...El accept solo es elegible si Cond es verdadera
Retardoor delay 2.0;Si no llega ninguna llamada en ese plazo, toma esta rama
Retardo absolutoor delay until T;Igual, pero hasta un instante concreto
Else inmediatoelse ...Si no hay ninguna llamada pendiente ahora, no espera nada
Terminaciónor 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:

  1. Tiene un buzón privado al que solo él puede leer.
  2. Procesa un mensaje a la vez, de forma secuencial.
  3. 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

AspectoRendezvous de AdaActores de Elixir
Envío por omisiónBloqueante, el llamador esperaNo bloqueante, retorna siempre
BufferNo hay: el encuentro es directoBuzón ilimitado por proceso
RespuestaIncluida: parámetros out del entryExplícita: otro mensaje con el pid del emisor
Selecciónselect con guardas booleanas y retardoreceive con coincidencia de patrones y after
Costo de una tarea o procesoHilo del sistema o del runtime, pesadoProceso de la BEAM, unos cientos de bytes
Escala típicaDecenas o cientos de tareasCientos de miles de procesos
Falla del otro extremoExcepción Tasking_Error en el llamadorSilenciosa, salvo que se use monitor o enlace
VerificaciónFuerte en tiempo de compilación, tipos en los entryDinámica, el buzón acepta cualquier término
Distribución entre máquinasNo es parte del modelo baseTransparente entre nodos del clúster
Dominio naturalSistemas embebidos y de tiempo real críticoSistemas 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:

AmenazaQué hace el atacantePropiedad que se pierdeHerramienta
Escucha pasivaLee los mensajes en tránsitoConfidencialidadCifrado
AlteraciónModifica bits del mensajeIntegridadHash y MAC
SuplantaciónEnvía mensajes haciéndose pasar por otroAutenticidadMAC con clave, o firma
RepeticiónReenvía un mensaje válido capturado antesFrescuraNú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.

AlgoritmoAñoSalidaEstado actualUso apropiado hoy
MD51991128 bitsRoto: se construyen colisiones en segundosSolo checksums no adversarios
SHA-11995160 bitsRoto: colisiones demostradas en 2017Ninguno nuevo
SHA-2562001256 bitsSólidoIntegridad, firmas, blockchain
SHA-32015VariableSólido, construcción distinta a SHA-2Alternativa a SHA-2
BLAKE2, BLAKE32012, 2020VariableSólido y muy rápidoIntegridad de alto rendimiento
bcrypt1999184 bitsSólido para su propósitoAlmacenar contraseñas
scrypt, Argon22009, 2015VariableSólido, resisten hardware dedicadoAlmacenar 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

ErrorCausaSolución
Interbloqueo con dos procesos que se envían mutuamente con send bloqueanteAmbos hacen send antes de hacer receive, y ambos esperan que el otro recibaRomper 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 memoriaEl productor es más rápido que el consumidor y el canal no tiene límite ni contrapresiónAcotar 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 tiempoEl actor no descarta los mensajes que no reconoce; el buzón acumula basura que la búsqueda por patrón recorre en cada llamadaAñ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 ejecutaEl cliente abandonó la espera; el servidor sigue procesando ese mensaje y responde a nadieHacer 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íaTCP y las tuberías son flujos de bytes sin fronteras de mensajeDelimitar en el protocolo: prefijo de longitud, o un separador que no aparezca en el cuerpo
Los mensajes de baja prioridad nunca se atiendenCola con disciplina por prioridad y flujo sostenido de alta prioridadAplicar envejecimiento, o reservar una fracción del servicio para las prioridades bajas
Una cola POSIX sigue existiendo tras reiniciar el programa y acumula mensajes viejosLa cola es propiedad del kernel y sobrevive a los procesos; nadie llamó a mq_unlinkLlamar a mq_unlink al terminar y limpiar al iniciar; instalar un manejador de señales para el cierre abrupto
mq_send falla con EMSGSIZEEl mensaje excede el mq_msgsize fijado al crear la cola, o se superan los límites del sistemaAjustar 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 termineEl 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 siempreLa guarda de ese accept nunca se hace verdaderaRevisar 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 anteriorEl protocolo de petición y respuesta no distingue entre llamadas concurrentesIncluir una referencia única con make_ref en la petición y filtrarla en el receive
El sistema pierde mensajes en silencio al morir un procesosend en Elixir no falla al enviar a un pid muertoUsar Process.monitor o enlaces, y un supervisor que reinicie el destinatario
Una contraseña filtrada se descifra en horas pese a estar hasheadaSe usó SHA-256, que es rápido por diseño, para almacenar contraseñasUsar bcrypt, scrypt o Argon2 con un factor de trabajo calibrado
Un mensaje válido se reenvía y se procesa dos vecesEl cifrado protege confidencialidad e integridad pero no frescuraIncluir 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 terceroSe usó Diffie-Hellman sin autenticar las claves públicas; hay un atacante en el medioAutenticar el intercambio con firmas, certificados o claves de host verificadas
Reutilizar el mismo nonce con la misma clave en AEADEl nonce se generó una vez y se guardó como constanteGenerar 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/.