Al final de la lección anterior quedó planteado un problema concreto. ingestor, agregador y meteo-api son procesos separados, y esa decisión de arquitectura tenía buenas razones: aislamiento ante fallos, separación de privilegios sobre /var/lib/meteora/lecturas/, y la posibilidad de mover una pieza a otra máquina. Pero tiene una consecuencia inmediata: sus espacios de direcciones son independientes. La MMU que estudiaste en el módulo 2 garantiza que un puntero del ingestor no significa nada en el agregador. No pueden pasarse un array de Lectura compartiendo un puntero, como hacían los cuatro hilos.
Necesitan un mecanismo explícito, proporcionado por el núcleo, para hablar entre ellos. Ese conjunto de mecanismos es la comunicación entre procesos o IPC (Inter-Process Communication).
Esta lección recorre los seis mecanismos que realmente se usan en Linux —tuberías, FIFO, colas de mensajes, memoria compartida, sockets y señales—, con ejemplos que funcionan y con los detalles que la documentación superficial omite: qué pasa cuando un búfer se llena, qué ocurre si el lector cierra, por qué solo unas pocas funciones son seguras dentro de un manejador de señal. Y termina con una guía de decisión, porque elegir mal el mecanismo de IPC condiciona el rendimiento y la fiabilidad de todo un sistema. Un aviso desde el principio, porque es la fuente número uno de errores: la memoria compartida transporta datos pero no coordina a nadie; sincronizarla es el tema de la lección siguiente, y aquí lo señalaremos explícitamente donde toque.
Contenido
- Los dos modelos fundamentales
- Tuberías anónimas: qué son por dentro
- Cuando el búfer se llena y cuando el lector cierra
- Tuberías con nombre: FIFO
- Colas de mensajes POSIX
- Memoria compartida POSIX y
/dev/shm/meteora-cache - Sockets: de dominio UNIX y de red
- Señales como mecanismo de notificación
- Comunicación bloqueante y no bloqueante
- Comparativa y guía de decisión
Los dos modelos fundamentales
Por debajo de la variedad de mecanismos hay solo dos modelos conceptuales, y toda la ingeniería de IPC es una consecuencia de elegir entre ellos.
Paso de mensajes. Los procesos intercambian unidades de datos discretas a través del núcleo. El emisor llama a una función de envío, el núcleo copia los datos a un búfer suyo, y el receptor llama a una función de recepción que los copia a su espacio. Los procesos nunca comparten memoria.
Memoria compartida. El núcleo hace que una región de memoria física aparezca mapeada en el espacio de direcciones de varios procesos. A partir de ese momento, escribir en esa región es escribir con una instrucción mov normal, y el otro proceso lo ve inmediatamente. El núcleo interviene una vez, al establecer el mapeo, y después desaparece.
graph LR
A1[Proceso A] -->|"1. send: copia<br/>usuario→núcleo"| K1[Búfer del núcleo]
K1 -->|"2. recv: copia<br/>núcleo→usuario"| B1[Proceso B]
A2[Proceso A] -->|"mov directo"| P[Página física compartida]
B2[Proceso B] -->|"mov directo"| P
Arriba, paso de mensajes: dos copias y el núcleo en medio. Abajo, memoria compartida: los dos procesos escriben en la misma página física. Las diferencias son sistemáticas:
| Paso de mensajes | Memoria compartida | |
|---|---|---|
| Copias por transferencia | 2 (usuario→núcleo→usuario) | 0 |
| Latencia típica (4 KB) | ~5-15 µs | ~0,1 µs |
| Coste según el tamaño | Crece con los bytes copiados | Constante |
| Sincronización | Implícita: el mecanismo la da | Ninguna: es cosa tuya |
| Funciona entre máquinas | Sí (sockets de red) | No |
| Complejidad de uso | Baja | Alta |
| Riesgo de corrupción | Bajo | Alto: un puntero mal puesto rompe al otro |
| Delimitación de mensajes | La da el mecanismo | Tienes que inventarla |
Los dos primeros números explican por qué existe la memoria compartida: para mover 17 MB del ingestor al agregador, el paso de mensajes copiaría 34 MB (17 de subida y 17 de bajada) y tardaría unos 180 ms; la memoria compartida cuesta un mmap y cero copias. Y la fila de sincronización explica por qué no se usa siempre: con paso de mensajes, si el receptor lee, o hay un mensaje completo o no hay ninguno, porque el núcleo garantiza la atomicidad de la entrega; con memoria compartida no hay garantía de nada, y el agregador puede leer una struct Lectura mientras el ingestor la escribe y obtener 8 bytes nuevos y 16 viejos. La memoria compartida es el mecanismo más rápido y el único que no resuelve ningún problema de coordinación por sí solo.
Tuberías anónimas: qué son por dentro
Una tubería (pipe) es un canal unidireccional de bytes entre dos procesos emparentados. Es el mecanismo de IPC más antiguo de UNIX y sigue siendo el más usado, aunque casi nadie lo llame por su nombre: cada vez que escribes cat fichero | grep patrón en el shell, estás creando una.
Por dentro, una tubería es exactamente esto: un búfer circular en memoria del núcleo, con dos descriptores de fichero apuntando a él, uno para leer y otro para escribir. En Linux ese búfer es de 65.536 bytes (16 páginas) por defecto, ajustable con fcntl(fd, F_SETPIPE_SZ, tamaño) hasta el límite de /proc/sys/fs/pipe-max-size.
/* tuberia.c — el ingestor envía un lote de lecturas a un hijo agregador */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
struct Lectura { unsigned int estacion_id; unsigned long timestamp;
float temperatura, humedad, presion; };
int main(void) {
int fd[2]; /* fd[0] = lectura, fd[1] = escritura */
if (pipe(fd) == -1) { perror("pipe"); exit(1); }
pid_t pid = fork(); /* el hijo HEREDA los dos descriptores */
if (pid == 0) { /* ---- HIJO: agregador, solo lee ---- */
close(fd[1]); /* CRÍTICO: cerrar el extremo que no usa */
struct Lectura l; double suma = 0; int n = 0;
while (read(fd[0], &l, sizeof l) == sizeof l) { suma += l.temperatura; n++; }
/* read() devolvió 0: el padre cerró su extremo de escritura (EOF) */
printf("[agregador] %d lecturas, media %.2f C\n", n, suma / n);
close(fd[0]); _exit(0);
}
close(fd[0]); /* ---- PADRE: ingestor, solo escribe ---- */
for (int i = 0; i < 1000; i++) {
struct Lectura l = { .estacion_id = 41, .timestamp = 1756684800 + i,
.temperatura = 21.0f + (i % 50) * 0.1f,
.humedad = 62.0f, .presion = 1013.2f };
if (write(fd[1], &l, sizeof l) != sizeof l) { perror("write"); break; }
}
close(fd[1]); /* al cerrar, el hijo recibe EOF */
wait(NULL);
return 0;
}Al ejecutarlo: [agregador] 1000 lecturas, media 23.45 C. Cuatro detalles que hay que entender, porque cada uno es un error clásico si se olvida:
El orden es pipe() y después fork(), nunca al revés. La tubería solo se comparte porque el hijo hereda la tabla de descriptores del padre en el fork() (módulo 2). Si haces fork() primero, cada proceso creará su propia tubería sin relación con la del otro. De ahí que las tuberías anónimas solo sirvan entre procesos emparentados: padre-hijo, o hermanos que heredaron del mismo padre.
Cerrar el extremo que no se usa es obligatorio, no una limpieza. Si el hijo no cierra fd[1], la tubería sigue teniendo un escritor abierto: cuando el padre cierre el suyo, el hijo no recibirá EOF y su read() se quedará bloqueado para siempre. Es una de las causas más frecuentes de cuelgues con tuberías, con un síntoma engañoso: el programa funciona pero no termina.
El read() devuelve 0 cuando se cierra el último escritor. Ese cero es el EOF. Mientras haya al menos un descriptor de escritura abierto en cualquier proceso, un read() sobre una tubería vacía se bloquea en lugar de devolver 0.
La tubería es un flujo de bytes, no de mensajes. El núcleo no conserva las fronteras de tus write(): uno de 24 bytes puede leerse como dos read() de 12, y tres de 24 pueden leerse en un read() de 72. En el ejemplo funciona porque struct Lectura mide 24 bytes y la tubería garantiza atomicidad hasta PIPE_BUF (4.096 bytes en Linux), pero con lecturas parciales hay que insistir en un bucle, y si tus mensajes son de tamaño variable tienes que inventarte un delimitador o una cabecera de longitud. Es el precio de trabajar con un flujo.
El equivalente en shell es exactamente el mismo mecanismo. Cuando escribes cat /var/lib/meteora/lecturas/2026-08-31.dat | ./agregador --media-horaria, el shell llama a pipe(), hace dos fork(), y en cada hijo usa dup2(fd[1], STDOUT_FILENO) y dup2(fd[0], STDIN_FILENO) respectivamente antes del execve(). Por eso cat y agregador no necesitan saber nada de tuberías: escriben y leen de sus descriptores estándar. Esa indirección es una de las ideas más elegantes de UNIX.
Cuando el búfer se llena y cuando el lector cierra
Las dos situaciones límite de una tubería son las que separan a quien la ha usado de quien la ha entendido.
El búfer se llena. Si el ingestor escribe más rápido de lo que el agregador lee, los 65.536 bytes se agotan. Entonces write() se bloquea hasta que haya sitio. El proceso pasa a estado S (o D para algunas variantes) y el planificador lo saca de la cola de listos.
Esto no es un fallo, es una característica extraordinariamente útil: se llama contrapresión (backpressure). El productor se frena automáticamente al ritmo del consumidor, sin que nadie lo programe. Es lo que hace que cat fichero_de_50GB | grep patrón no consuma 50 GB de RAM: cat se bloquea en cuanto grep se queda atrás. Se observa en directo con ps -o pid,stat,wchan -C productor, que muestra el estado S y WCHAN: pipe_write — la función exacta del núcleo donde duerme, esperando espacio. Mirar WCHAN para saber en qué está bloqueado un proceso vale para cualquier bloqueo y lo usarás mucho en Monitorización y Diagnóstico de Rendimiento.
El lector cierra su extremo. Aquí el comportamiento es más agresivo. Si el agregador muere o cierra fd[0] y el ingestor intenta escribir, el núcleo envía SIGPIPE al escritor; la acción por defecto de esa señal es terminar el proceso sin mensaje de error; y solo si el proceso la ignora o la captura, el write() devuelve -1 con errno == EPIPE.
Que la acción por defecto sea matar el proceso parece brutal, pero tiene sentido en el shell: en cat fichero_enorme | head -3, cuando head ha impreso tres líneas y termina, no tiene sentido que cat siga leyendo gigabytes que nadie va a leer. En un servicio de larga vida, en cambio, esa muerte silenciosa es inaceptable, y el patrón profesional es siempre este:
signal(SIGPIPE, SIG_IGN); /* al arrancar el ingestor */
/* y a partir de ahí, comprobar EPIPE en cada escritura */
if (write(fd, &lectura, sizeof lectura) == -1) {
if (errno == EPIPE) { fprintf(stderr, "agregador cerrado; reconectando\n"); reconectar(); }
else perror("write");
}Ignorar la señal convierte una muerte súbita en un código de error que puedes tratar. Este mismo patrón aplica a los sockets, donde escribir en una conexión que el otro extremo ha cerrado también produce SIGPIPE. Todo servidor de red serio hace signal(SIGPIPE, SIG_IGN) en las primeras líneas de main(); olvidarlo significa que tu servicio muere en silencio la primera vez que un cliente cuelga en mal momento.
Tuberías con nombre: FIFO
La limitación de las tuberías anónimas —solo entre procesos emparentados— se resuelve dándoles un nombre en el sistema de archivos. Eso es una FIFO (First In, First Out) o tubería con nombre.
Una FIFO es una entrada en el sistema de archivos con tipo p, que cualquier proceso con permisos puede abrir. No almacena datos en el disco: el fichero es solo un punto de encuentro; el búfer sigue estando en memoria del núcleo, igual que en una tubería anónima.
Vamos a montar el canal entre el ingestor y el agregador de Meteora:
$ sudo mkfifo -m 0660 /run/meteora/lecturas.fifo
$ sudo chown meteora:meteora /run/meteora/lecturas.fifo
$ ls -l /run/meteora/lecturas.fifo
prw-rw---- 1 meteora meteora 0 sep 1 09:14 /run/meteora/lecturas.fifo
# ↑ la 'p' del principio indica "pipe"; el tamaño es 0 y siempre lo será
# Terminal A — el agregador se pone a la escucha
$ ./agregador --entrada /run/meteora/lecturas.fifo # se bloquea en open()
# Terminal B — el ingestor vuelca su lote
$ ./ingestor --volcar-lote /run/meteora/lecturas.fifoEl código del lado del lector es una tubería normal, con la diferencia de cómo se obtiene el descriptor:
/* agregador_fifo.c (fragmento) */
/* open() se BLOQUEA hasta que otro proceso abra para escribir.
Es la sincronización de encuentro (rendezvous) de las FIFO. */
int fd = open("/run/meteora/lecturas.fifo", O_RDONLY);
if (fd == -1) { perror("open"); return 1; }
struct Lectura l; ssize_t n; double suma = 0; long cuenta = 0;
while ((n = read(fd, &l, sizeof l)) > 0) {
if (n != sizeof l) { fprintf(stderr, "lectura parcial\n"); continue; }
suma += l.temperatura; cuenta++;
}
printf("[agregador] %ld lecturas, media %.2f C\n", cuenta, suma / cuenta);
close(fd);Cuatro peculiaridades de las FIFO que hay que conocer:
El open() bloquea hasta que aparezca el otro extremo. Abrir en solo lectura se bloquea hasta que alguien abra para escritura, y viceversa: es un mecanismo de encuentro incorporado, muy cómodo. Si no lo quieres, O_NONBLOCK; ojo con la asimetría, abrir en escritura no bloqueante sin lector falla con ENXIO, mientras que abrir en lectura no bloqueante sin escritor tiene éxito.
Varios escritores son posibles y sus escrituras se intercalan de forma segura, siempre que cada write() sea de como mucho PIPE_BUF (4.096 bytes): por debajo de ese tamaño el núcleo garantiza que la escritura es atómica y no se mezcla con la de otro escritor; por encima se puede fragmentar y recibirás basura. Como una struct Lectura mide 24 bytes, las 800 estaciones podrían escribir en la misma FIFO sin corromperse. Es una garantía muy valiosa y poco conocida.
No hay varios lectores útiles. Si dos procesos leen de la misma FIFO, cada byte va a uno de los dos, arbitrariamente. No es difusión: es reparto.
La FIFO es persistente pero los datos no. El fichero sobrevive a los procesos; el contenido del búfer se pierde en cuanto se cierran todos los extremos. Y si nadie lee, el escritor se bloquea; si nadie escribe, el lector se bloquea. Una FIFO no guarda nada cuando no hay nadie al otro lado.
Ese último punto es exactamente la limitación que motiva el mecanismo siguiente.
Colas de mensajes POSIX
Una cola de mensajes es un buzón gestionado por el núcleo donde los procesos depositan y recogen mensajes discretos y con prioridad. Resuelve tres limitaciones de las tuberías: conserva las fronteras entre mensajes, permite prioridades, y persiste aunque no haya nadie conectado.
Linux ofrece dos APIs: la de System V (msgget/msgsnd, antigua, con identificadores numéricos incómodos) y la POSIX (mq_open/mq_send, con nombres tipo ruta y descriptores que funcionan con select/poll). Usa siempre la POSIX.
/* cola_alertas.c — el ingestor manda alertas al agregador con prioridad */
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <mqueue.h>
#define COLA "/meteora-alertas" /* el nombre DEBE empezar por '/' */
int main(int argc, char **argv) {
struct mq_attr attr = { .mq_flags = 0,
.mq_maxmsg = 10, /* 10 mensajes en la cola */
.mq_msgsize = 256, /* 256 bytes por mensaje */
.mq_curmsgs = 0 };
if (argc > 1 && strcmp(argv[1], "enviar") == 0) {
mqd_t mq = mq_open(COLA, O_WRONLY | O_CREAT, 0660, &attr);
if (mq == (mqd_t)-1) { perror("mq_open"); return 1; }
/* prioridad 9 = urgente; 0 = rutina. Mayor número, antes se entrega. */
mq_send(mq, "ESTACION 41 SIN DATOS 15 MIN", 28, 9);
mq_send(mq, "estacion 12 calibracion ok", 26, 0);
mq_send(mq, "ESTACION 07 TEMP FUERA RANGO", 28, 9);
mq_close(mq);
} else {
mqd_t mq = mq_open(COLA, O_RDONLY | O_CREAT, 0660, &attr);
char buf[256]; unsigned int prio;
for (int i = 0; i < 3; i++) {
ssize_t n = mq_receive(mq, buf, sizeof buf, &prio);
printf("[prio %u] %.*s\n", prio, (int)n, buf);
}
mq_close(mq); mq_unlink(COLA); /* unlink borra la cola del sistema */
}
return 0;
}$ gcc cola_alertas.c -o cola -lrt # ojo: hay que enlazar con -lrt $ ./cola enviar $ ./cola [prio 9] ESTACION 41 SIN DATOS 15 MIN [prio 9] ESTACION 07 TEMP FUERA RANGO [prio 0] estacion 12 calibracion ok
Fíjate en el resultado: aunque el mensaje de calibración se envió segundo, se recibe el último. mq_receive() siempre devuelve el mensaje de mayor prioridad, y entre los de igual prioridad respeta el orden de llegada. Esa es la ventaja funcional que ninguna tubería da: si el agregador va saturado, las alertas críticas se atienden antes que el ruido de fondo.
Las colas POSIX viven en un sistema de archivos virtual que puedes montar y examinar, y sus límites vienen del núcleo:
$ sudo mount -t mqueue none /dev/mqueue $ cat /dev/mqueue/meteora-alertas QSIZE:82 NOTIFY:0 SIGNO:0 NOTIFY_PID:0 $ cat /proc/sys/fs/mqueue/msg_max # 10 → mensajes máximos por cola $ cat /proc/sys/fs/mqueue/msgsize_max # 8192 → bytes máximos por mensaje $ cat /proc/sys/fs/mqueue/queues_max # 256 → colas máximas en el sistema
QSIZE son los bytes pendientes ahora mismo: una ventana de diagnóstico excelente, porque si crece sin parar es que el consumidor no da abasto. Y diez mensajes por cola es muy poco: superarlo hace que mq_send() se bloquee (o falle con EAGAIN en modo no bloqueante). Se puede subir con sysctl fs.mqueue.msg_max=100, pero la conclusión práctica es otra: las colas POSIX son para notificaciones y comandos de control, no para caudal de datos. Para mover 17 MB de lecturas al día, el mecanismo correcto es otro.
Un extra útil: mq_notify() permite pedir que el núcleo te avise —con una señal o creando un hilo— cuando llegue un mensaje a una cola vacía, sin tener que estar bloqueado esperando.
Memoria compartida POSIX y /dev/shm/meteora-cache
Llegamos al mecanismo más rápido y al que ya conoces de nombre desde el módulo 2. La memoria compartida consiste en que varios procesos mapeen las mismas páginas físicas en sus espacios de direcciones.
El procedimiento POSIX tiene tres pasos: crear un objeto de memoria compartida con shm_open(), darle tamaño con ftruncate(), y mapearlo con mmap(). A partir de ahí, es memoria normal.
/* cache_meteora.c — crear y usar /dev/shm/meteora-cache */
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#define NOMBRE_SHM "/meteora-cache" /* → /dev/shm/meteora-cache */
struct Lectura { unsigned int estacion_id; unsigned long timestamp;
float temperatura, humedad, presion; };
struct cache_meteora {
unsigned long peticiones_totales; /* lo escriben los 4 trabajadores */
unsigned long ultima_agregacion; /* lo escribe el agregador */
unsigned int n_ultimas;
struct Lectura ultimas[1024]; /* 24 KB de lecturas recientes */
};
int main(int argc, char **argv) {
/* 1. Crear (o abrir) el objeto de memoria compartida */
int fd = shm_open(NOMBRE_SHM, O_CREAT | O_RDWR, 0660);
if (fd == -1) { perror("shm_open"); return 1; }
/* 2. Fijar su tamaño. Solo hace falta la primera vez. */
if (ftruncate(fd, sizeof(struct cache_meteora)) == -1) { perror("ftruncate"); return 1; }
/* 3. Mapearlo. MAP_SHARED hace las escrituras visibles a los demás. */
struct cache_meteora *c = mmap(NULL, sizeof *c, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
if (c == MAP_FAILED) { perror("mmap"); return 1; }
close(fd); /* el mapeo sobrevive al cierre del descriptor */
if (argc > 1 && strcmp(argv[1], "escribir") == 0) {
c->ultimas[c->n_ultimas].estacion_id = 41;
c->ultimas[c->n_ultimas].temperatura = 23.4f;
c->n_ultimas++; /* ⚠ CARRERA si hay varios escritores */
c->ultima_agregacion = 1756684800;
printf("[escritor] n_ultimas=%u\n", c->n_ultimas);
} else {
printf("[lector] n_ultimas=%u ultima_agregacion=%lu temp[0]=%.1f\n",
c->n_ultimas, c->ultima_agregacion, c->ultimas[0].temperatura);
}
munmap(c, sizeof *c);
/* shm_unlink(NOMBRE_SHM); ← solo cuando ya nadie lo necesite */
return 0;
}$ gcc cache_meteora.c -o cache -lrt $ ./cache escribir → [escritor] n_ultimas=1 $ ./cache → [lector] n_ultimas=1 ultima_agregacion=1756684800 temp[0]=23.4 $ ls -l /dev/shm/ → -rw-rw---- 1 meteora meteora 24596 sep 1 09:31 meteora-cache
Puntos clave del código:
/dev/shm es un tmpfs, un sistema de archivos que vive íntegramente en RAM (y puede ir a swap). Por eso shm_open("/meteora-cache") crea un fichero visible en /dev/shm/meteora-cache que puedes inspeccionar con ls, borrar con rm y dimensionar con df -h /dev/shm. No hay magia: es un fichero en RAM mapeado con mmap.
MAP_SHARED es lo que lo hace compartido. Con MAP_PRIVATE, cada proceso recibiría su propia copia en cuanto escribiera, por copy-on-write, y las modificaciones no serían visibles para nadie. Es un error de una sola palabra con un síntoma desconcertante: todo funciona pero el otro proceso nunca ve los cambios.
close(fd) no destruye el mapeo, que permanece hasta el munmap() o hasta que el proceso termine; cerrar el descriptor es buena práctica para no gastarlos. Y el objeto persiste hasta shm_unlink() o hasta el reinicio de la máquina: es persistencia de núcleo, una ventaja (el agregador puede reiniciarse sin perder la caché) y una fuga potencial (si nadie hace shm_unlink, la RAM queda ocupada indefinidamente).
Y ahora la advertencia que hay que subrayar tres veces, la del comentario ⚠ CARRERA. La memoria compartida no sincroniza absolutamente nada. Ese c->n_ultimas++ es exactamente el mismo contador++ de Conceptos de Concurrencia, con las mismas tres instrucciones y la misma pérdida de incrementos. Y es peor que eso: escribir una struct Lectura de 24 bytes son al menos tres instrucciones, y el agregador puede leerla a medias. Donde una cola de mensajes garantiza que un mensaje llega entero o no llega, aquí no hay garantía ninguna, y donde una cola nunca pierde un elemento, aquí sí se pierden incrementos: la memoria compartida siempre necesita primitivas adicionales.
Lo que falta —mutex, semáforos, variables de condición, colocados dentro de la propia región compartida para que los vean todos los procesos— es el contenido íntegro de Sincronización y Exclusión Mutua. Hasta entonces, considera todo el código de este apartado como incompleto a propósito.
Sockets: de dominio UNIX y de red
Un socket es un extremo de comunicación bidireccional. Es el mecanismo de IPC más versátil porque, con la misma API, comunica procesos de la misma máquina o de máquinas distintas.
Hay dos familias que importan aquí:
Socket de dominio UNIX (AF_UNIX) |
Socket de red (AF_INET/AF_INET6) |
|
|---|---|---|
| Ámbito | La misma máquina | Cualquier máquina alcanzable |
| Dirección | Una ruta: /run/meteora/api.sock |
IP + puerto: 10.0.4.7:9200 |
| Recorrido de los datos | Solo memoria del núcleo | Pila TCP/IP completa |
| Latencia (ida y vuelta) | ~5-10 µs | ~50 µs en LAN, ~30 ms en Internet |
| Caudal máximo | ~10 GB/s | Limitado por la red |
| Control de acceso | Permisos del sistema de archivos | Cortafuegos, TLS |
| Puede pasar descriptores | Sí (SCM_RIGHTS) |
No |
| Puede identificar al par | Sí (SO_PEERCRED: PID, UID, GID) |
No de forma fiable |
El socket de dominio UNIX es entre 5 y 10 veces más rápido que uno de red local porque se salta toda la pila TCP/IP —sin sumas de comprobación, cabeceras, control de congestión ni fragmentación: el núcleo copia del búfer del emisor al del receptor—. Y aporta dos capacidades únicas: pasar descriptores de fichero abiertos entre procesos (así reparte nginx las conexiones aceptadas entre sus trabajadores) y conocer las credenciales del proceso del otro extremo sin que este las declare, lo que permite autenticación fiable en local.
Ahora el caso de Meteora: el ingestor escuchando las lecturas de las 800 estaciones. Aquí sí hace falta un socket de red, porque las estaciones están fuera de la máquina.
/* ingestor_socket.c — recibe lecturas de las estaciones por UDP */
#include <stdio.h>
#include <arpa/inet.h>
#include <sys/socket.h>
struct Lectura { unsigned int estacion_id; unsigned long timestamp;
float temperatura, humedad, presion; };
int main(void) {
int s = socket(AF_INET, SOCK_DGRAM, 0); /* UDP: datagramas */
if (s == -1) { perror("socket"); return 1; }
struct sockaddr_in dir = { .sin_family = AF_INET, .sin_port = htons(9200),
.sin_addr.s_addr = htonl(INADDR_ANY) };
if (bind(s, (struct sockaddr *)&dir, sizeof dir) == -1) { perror("bind"); return 1; }
/* Ampliar el búfer de recepción: con 800 estaciones, las ráfagas
simultáneas llenan los 208 KB por defecto y el núcleo descarta. */
int tam = 4 * 1024 * 1024;
setsockopt(s, SOL_SOCKET, SO_RCVBUF, &tam, sizeof tam);
struct Lectura l;
struct sockaddr_in origen; socklen_t olen = sizeof origen;
while (1) {
ssize_t n = recvfrom(s, &l, sizeof l, 0, (struct sockaddr *)&origen, &olen);
if (n != sizeof l) continue; /* datagrama malformado */
printf("estacion %u desde %s: %.1f C\n", l.estacion_id,
inet_ntoa(origen.sin_addr), l.temperatura);
/* ... aquí iría el almacenamiento en /var/lib/meteora/lecturas/ ... */
}
}Decisiones de diseño que conviene justificar:
UDP (SOCK_DGRAM) y no TCP. Cada lectura es un datagrama independiente de 24 bytes, y perder una no es dramático: llegará otra en un minuto. TCP añadiría el coste de mantener 800 conexiones abiertas con sus búferes y retransmisiones para garantizar una entrega que no necesitamos. Además, con SOCK_DGRAM se conservan las fronteras de mensaje: un recvfrom() devuelve exactamente un datagrama, lo que elimina el problema del troceado de las tuberías y de TCP.
Ampliar SO_RCVBUF. Esta línea conecta directamente con el módulo 2: si las 800 estaciones envían a la vez y el ingestor está ocupado escribiendo en disco, los datagramas se acumulan en el búfer del socket, y cuando se llena el núcleo los descarta en silencio —solo lo verás como rx_missed_errors o en los descartes de netstat -su—. Los 208 KB por defecto dan para 8.600 lecturas; 4 MB, para 175.000. Es la misma lógica de dimensionado que vimos con NAPI.
htons y htonl convierten al orden de bytes de red (big-endian) desde el del procesador (little-endian en x86); olvidarlo convierte el puerto 9200 en el 61. Y ojo: el ejemplo envía la struct Lectura en crudo, lo que solo funciona si emisor y receptor comparten arquitectura y alineamiento; en producción habría que serializar los campos explícitamente.
Señales como mecanismo de notificación
Una señal es una notificación asíncrona que el núcleo entrega a un proceso. No transporta datos (salvo con sigqueue, que permite un entero): es un aviso de que algo ha ocurrido. Es, en esencia, una interrupción software dirigida a un proceso.
Ya te has encontrado varias en el curso: SIGSEGV cuando un proceso toca memoria que no le corresponde (módulo 2), SIGKILL cuando el OOM killer decide sacrificar a alguien, SIGPIPE hace dos apartados.
| Señal | Número | Acción por defecto | Uso habitual |
|---|---|---|---|
SIGHUP |
1 | Terminar | Recargar configuración (convenio universal) |
SIGINT / SIGQUIT |
2 / 3 | Terminar | Ctrl+C / Ctrl+\ (el segundo, con volcado) |
SIGKILL |
9 | Terminar | No se puede capturar ni ignorar |
SIGSEGV |
11 | Terminar + volcado | Acceso inválido a memoria |
SIGPIPE |
13 | Terminar | Escribir sin lector |
SIGTERM |
15 | Terminar | Petición educada de parada |
SIGCHLD |
17 | Ignorar | Un hijo terminó (evita zombis) |
SIGSTOP/SIGCONT |
19/18 | Parar/Continuar | Control de trabajos |
SIGUSR1/SIGUSR2 |
10/12 | Terminar | Libres para tu aplicación |
El caso de Meteora es el convenio más extendido de UNIX: recargar /etc/meteora/meteora.conf con SIGHUP sin reiniciar el servicio ni perder las conexiones en curso.
/* recarga_config.c — recargar la configuración con SIGHUP */
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <string.h>
/* La ÚNICA variable que el manejador puede tocar con seguridad */
volatile sig_atomic_t recargar_pendiente = 0;
void manejador_hup(int sig) { (void)sig; recargar_pendiente = 1; } /* solo la bandera */
int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = manejador_hup;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* reintenta las llamadas interrumpidas */
if (sigaction(SIGHUP, &sa, NULL) == -1) { perror("sigaction"); return 1; }
signal(SIGPIPE, SIG_IGN); /* como vimos antes */
printf("meteo-api arrancado, PID %d\n", getpid());
while (1) {
if (recargar_pendiente) {
recargar_pendiente = 0;
/* AQUÍ, en el bucle principal, se puede hacer de todo:
abrir ficheros, reservar memoria, escribir en el log... */
printf("Releyendo /etc/meteora/meteora.conf\n");
leer_configuracion("/etc/meteora/meteora.conf");
}
atender_una_peticion();
}
}$ ./meteo-api & meteo-api arrancado, PID 8421 $ kill -HUP 8421 # o: kill -s HUP 8421 Releyendo /etc/meteora/meteora.conf
El patrón que ves —el manejador solo pone una bandera; el trabajo se hace en el bucle principal— es obligatorio, y la razón es la restricción más importante de las señales.
Un manejador de señal se ejecuta interrumpiendo al proceso en un punto arbitrario. Puede interrumpir en mitad de un malloc(), cuando las estructuras internas del asignador están inconsistentes. Si el manejador llama a printf(), que internamente reserva memoria, se produce un interbloqueo o una corrupción del montón. Por eso POSIX define una lista corta de funciones seguras en manejadores (async-signal-safe):
| Seguras en un manejador | Prohibidas en un manejador |
|---|---|
write, read, open, close |
printf, fprintf, sprintf |
_exit, kill, signal, sigaction |
malloc, free, calloc, realloc |
time, getpid, sem_post |
pthread_mutex_lock, syslog |
Asignaciones a volatile sig_atomic_t |
Cualquier función de stdio |
El tipo volatile sig_atomic_t es la única variable que puedes tocar con garantías: sig_atomic_t asegura que la asignación es una sola instrucción y volatile obliga al compilador a releerla en cada iteración del bucle en lugar de cachearla en un registro. (Aquí volatile sí es correcto porque el manejador se ejecuta en el mismo hilo que el bucle: no hay dos núcleos ni cachés implicadas. En concurrencia real entre hilos no basta, y lo veremos en la lección siguiente.)
Dos apuntes más. SA_RESTART: cuando llega una señal mientras el proceso está bloqueado en un read() lento, la llamada se aborta con EINTR; con este flag el núcleo la reintenta automáticamente y tu código no tiene que envolver cada llamada bloqueante en un bucle. Y sigaction en vez de signal: signal() tiene un comportamiento históricamente inconsistente entre sistemas —en algunos reinstala el manejador tras cada señal, en otros lo restablece a la acción por defecto—, mientras que sigaction() es explícita y portable; usa signal() solo para el caso trivial de SIG_IGN.
Para procesos multihilo, el patrón profesional que anticipamos en la lección anterior: bloquear las señales en todos los hilos con pthread_sigmask() y dedicar un hilo a recogerlas con sigwait(). Así se eliminan de raíz todas las restricciones del manejador, porque sigwait() retorna en contexto normal y ese hilo puede llamar a lo que quiera.
Comunicación bloqueante y no bloqueante
Todos los mecanismos anteriores tienen dos modos de funcionamiento, y elegir mal produce o cuelgues o consumo inútil de CPU.
En el modo bloqueante (el de por defecto), si no hay datos que leer read() duerme el proceso hasta que los haya, y si el búfer está lleno write() duerme hasta que haya sitio: el planificador lo saca de la cola de listos y no consume nada de CPU. En el modo no bloqueante (O_NONBLOCK) la llamada retorna inmediatamente, y si no había datos devuelve -1 con errno == EAGAIN (equivalente a EWOULDBLOCK).
| Bloqueante | No bloqueante | |
|---|---|---|
| Consumo de CPU esperando | Cero | Alto si haces sondeo activo |
| Complejidad del código | Baja: lees y ya está | Alta: hay que gestionar EAGAIN |
| Atender varias fuentes | Necesita un hilo por fuente | Un solo hilo para miles |
| Riesgo | Cuelgue indefinido | Bucle de sondeo que quema un núcleo |
| Cuándo usarlo | Un hilo dedicado a una fuente | Bucle de eventos con epoll |
La forma correcta de usar el modo no bloqueante no es el sondeo activo —comprobar en un bucle si hay datos quema un núcleo entero— sino combinarlo con una llamada de multiplexación que duerma hasta que alguno de los descriptores esté listo:
/* El ingestor vigila 800 sockets a la vez con un solo hilo */
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = socket_estacion };
epoll_ctl(ep, EPOLL_CTL_ADD, socket_estacion, &ev);
struct epoll_event listos[64];
while (1) {
int n = epoll_wait(ep, listos, 64, -1); /* duerme hasta que haya algo */
for (int i = 0; i < n; i++)
procesar(listos[i].data.fd); /* aquí SÍ hay datos: no bloqueará */
}Esto es lo mejor de ambos mundos: cero CPU mientras no pasa nada (el proceso duerme en epoll_wait) y un solo hilo para miles de descriptores. Es el motor del modelo asíncrono de la lección anterior, y la razón de que nginx atienda 100.000 conexiones con ocho procesos.
El papel del búfer aquí es central: el búfer del núcleo es lo que desacopla al emisor del receptor. Mientras haya sitio, el emisor no espera; mientras haya datos, el receptor no espera. Si se queda pequeño para las ráfagas de tu tráfico, los dos procesos se sincronizan a la fuerza —o, peor, en UDP se descartan datos en silencio—. Dimensionar búferes (F_SETPIPE_SZ, SO_RCVBUF/SO_SNDBUF, mq_maxmsg) es una de las palancas de ajuste más efectivas y más olvidadas.
Comparativa y guía de decisión
| Mecanismo | Latencia (4 KB) | Ámbito | Sentido | Fronteras de mensaje | Sincroniza | Complejidad | Cuándo usarlo |
|---|---|---|---|---|---|---|---|
| Tubería anónima | ~10 µs | Procesos emparentados | Unidireccional | No (flujo) | Sí | Muy baja | Encadenar un hijo, filtros de shell |
| FIFO | ~10 µs | Misma máquina, cualquiera | Unidireccional | No (flujo, atómico ≤4 KB) | Sí | Baja | Canal simple entre servicios independientes |
| Cola POSIX | ~15 µs | Misma máquina | Unidireccional | Sí | Sí | Media | Comandos, alertas con prioridad |
| Memoria compartida | ~0,1 µs | Misma máquina | Bidireccional | No | NO | Alta | Grandes volúmenes, latencia mínima |
| Socket UNIX | ~7 µs | Misma máquina | Bidireccional | Sí en SOCK_SEQPACKET |
Sí | Media | Cliente-servidor local, pasar descriptores |
| Socket de red | ~50 µs (LAN) | Cualquier máquina | Bidireccional | Sí en UDP, no en TCP | Sí | Media | Todo lo distribuido |
| Señal | ~2 µs | Misma máquina | Unidireccional | Sin datos | – | Baja pero traicionera | Notificar eventos, recargar config |
Y la guía de decisión en forma de preguntas encadenadas:
- ¿Los procesos pueden estar en máquinas distintas, ahora o en el futuro? → Socket de red. Es el único que cruza la frontera de la máquina, y usarlo desde el principio evita una reescritura cuando el sistema crezca.
- ¿Solo necesitas avisar de un evento, sin datos? → Señal. Recargar configuración, pedir una parada limpia, forzar una rotación de logs. Es lo más barato y lo que espera cualquier administrador de sistemas.
- ¿Es un hijo directo que has creado tú y el flujo va en un solo sentido? → Tubería anónima. El mecanismo más simple que existe, y no hay que limpiar nada.
- ¿Necesitas prioridades, o que los mensajes se conserven aunque el receptor no esté? → Cola de mensajes POSIX, vigilando los límites de tamaño.
- ¿Mueves megabytes y la latencia es crítica? → Memoria compartida, asumiendo que vas a tener que sincronizarla con las primitivas de la lección siguiente. Es la opción más rápida y la que más te puede costar en depuración.
- En cualquier otro caso → Socket de dominio UNIX. Bidireccional, con control de acceso por permisos, identifica al proceso del otro lado, funciona con
epolly es el que menos te sorprenderá. Cuando dudes, esta es la respuesta.
La arquitectura de Meteora, ahora justificada mecanismo a mecanismo:
| Comunicación | Mecanismo | Por qué |
|---|---|---|
Estaciones → ingestor |
Socket UDP de red | Están en otras máquinas; perder una lectura es tolerable |
ingestor → agregador |
FIFO /run/meteora/lecturas.fifo |
Caudal continuo, un sentido, procesos sin parentesco |
agregador ↔ meteo-api |
Memoria compartida /dev/shm/meteora-cache |
24 KB leídos en cada petición; la latencia manda |
Alertas del ingestor |
Cola POSIX /meteora-alertas |
Necesita prioridad: lo crítico primero |
| Recarga de configuración | Señal SIGHUP |
Convenio universal, sin datos, cero coste |
Clientes → meteo-api |
Socket TCP de red (HTTP) | Clientes externos, entrega fiable |
Errores Comunes y Consejos
No cerrar los extremos no usados de una tubería. Si el proceso lector conserva abierto el descriptor de escritura, nunca recibirá EOF y su read() se bloqueará para siempre. El síntoma es un programa que hace su trabajo pero no termina, y es de los cuelgues más frecuentes con tuberías.
No ignorar SIGPIPE en un servicio. Escribir en una tubería o un socket cuyo lector ha cerrado mata el proceso por defecto, sin mensaje. Un servicio que muere en silencio cuando un cliente cuelga es un incidente de madrugada garantizado: signal(SIGPIPE, SIG_IGN) en las primeras líneas de main() y comprobar EPIPE.
Llamar a printf o malloc desde un manejador de señal. Funciona el 99,9 % de las veces y corrompe el montón el 0,1 % restante, produciendo un fallo aleatorio muy posterior e imposible de relacionar con su causa. El manejador pone una bandera volatile sig_atomic_t y nada más.
Suponer que un read() de una tubería o un socket TCP devuelve el mensaje completo. Son flujos de bytes sin fronteras: un read(fd, buf, 24) puede devolver 10. Hay que insistir en un bucle hasta completar los bytes esperados, o usar SOCK_DGRAM/SOCK_SEQPACKET/colas si necesitas fronteras.
Usar MAP_PRIVATE donde querías MAP_SHARED. El programa funciona, no da ningún error, y las escrituras de un proceso simplemente no las ve nadie más, porque el copy-on-write le dio a cada uno su copia privada. Relacionado: olvidar -lrt al enlazar mq_* y shm_open en glibc anterior a la 2.34.
Creer que la memoria compartida "funciona" porque las pruebas pasan. Es el error más caro de esta lección. Sin sincronización, las carreras son las de 03-01: probabilidad baja por operación, certeza a largo plazo. Si compartes memoria, necesitas la lección siguiente.
Consejo: prefiere el mecanismo más simple que resuelva tu problema. Mucho código usa memoria compartida donde una FIFO bastaba, y paga en depuración lo que ahorró en microsegundos que nadie iba a notar. Optimiza el IPC cuando lo hayas medido y sea el cuello de botella.
Consejo: dimensiona los búferes conscientemente y vigila su ocupación. El valor por defecto está pensado para el caso general, no para el tuyo. Un búfer que se llena a menudo avisa de un consumidor que no da abasto antes de que empiece a perder datos.
Ejercicios
Ejercicio 1: contrapresión y SIGPIPE
Escribe dos programas en C: un productor que escriba struct Lectura en una tubería lo más rápido posible contando cuántas ha escrito, y un consumidor lento que lea una cada 100 ms. Conéctalos con |. Mientras corren, observa el estado del productor con ps -o pid,stat,wchan -C productor y explica lo que ves. Después mata el consumidor con Ctrl+C y comprueba qué le ocurre al productor; repite el experimento con signal(SIGPIPE, SIG_IGN) y trata EPIPE.
Ejercicio 2: elegir el mecanismo
Para cada necesidad de Meteora, elige el mecanismo de IPC más adecuado y justifica descartando al menos dos alternativas.
- (a) Una nueva herramienta
meteo-ctldebe poder decirle alagregadorque recalcule las medias del día actual inmediatamente. - (b) El
agregadorpublica cada hora un resumen de 2 MB que consultan los 4 trabajadores demeteo-apien cada petición. - (c) Se añade un servicio de archivado en otra máquina que debe recibir una copia de cada fichero diario cerrado.
- (d) El
ingestordebe avisar alagregadorde que ha detectado una estación caída, con más urgencia que las notificaciones rutinarias.
Ejercicio 3: FIFO con contrapresión medida
Monta el canal ingestor → agregador con una FIFO. El escritor debe enviar 100.000 struct Lectura; el lector debe procesarlas con un retardo artificial configurable. Mide el tiempo total con retardos de 0 µs y de 10 µs por lectura, y determina experimentalmente el tamaño del búfer de la FIFO comprobando cuántos bytes puede escribir el productor antes de bloquearse con un lector que no lee nada.
Soluciones
Solución 1
/* productor.c (fragmento) — con argumento, ignora SIGPIPE y trata EPIPE */
if (argc > 1) signal(SIGPIPE, SIG_IGN); /* modo "robusto" */
struct Lectura l = { .estacion_id = 41, .temperatura = 21.5f };
long n = 0;
while (1) {
if (write(STDOUT_FILENO, &l, sizeof l) == -1) {
if (errno == EPIPE) {
fprintf(stderr, "\n[productor] EPIPE tras %ld lecturas. Salida limpia.\n", n);
return 0;
}
perror("write"); return 1;
}
if (++n % 1000 == 0) fprintf(stderr, "\r[productor] %ld", n);
}
/* consumidor.c (fragmento) — lee una lectura cada 100 ms */
struct Lectura l;
while (read(STDIN_FILENO, &l, sizeof l) == sizeof l) usleep(100000);Observación durante la ejecución:
$ ./productor | ./consumidor & [productor] 2731 $ ps -o pid,stat,wchan,cmd -C productor PID STAT WCHAN CMD 9142 S pipe_write ./productor
Qué se ve. El contador se detiene alrededor de 2.731 y no avanza más, con estado S (dormido interrumpible) y WCHAN = pipe_write: el productor está bloqueado dentro de esa función del núcleo, esperando espacio. El número no es casual: 65.536 bytes de búfer / 24 bytes por lectura = 2.730,7. Llenó el búfer exactamente y se durmió; a partir de ahí avanza al ritmo del consumidor, 10 lecturas por segundo. Es la contrapresión funcionando: sin una línea de código dedicada a ello, el productor se ha adaptado al consumidor y la memoria consumida está acotada en 64 KB.
Al matar el consumidor:
$ kill %1 [1]+ Terminado (SIGPIPE) ./productor | ./consumidor ← sin argumento $ ./productor robusto | ./consumidor & ; kill %1 [productor] EPIPE tras 2985 lecturas. Salida limpia. ← con SIG_IGN
En el primer caso el productor muere sin imprimir nada: SIGPIPE terminó el proceso y el mensaje viene del shell, no del programa. Si esto fuera el ingestor en producción, habría dejado de recibir lecturas y en el log no habría ni una línea que lo explicara. En el segundo, el write() devuelve -1 con EPIPE, el programa lo detecta, lo registra y sale ordenadamente. La diferencia es literalmente una línea de código, y separa un incidente diagnosticable de uno que no lo es.
Solución 2
(a) meteo-ctl ordena al agregador recalcular. → Señal SIGUSR1. Es una notificación puntual sin datos: kill -USR1 $(pidof agregador) no requiere ninguna infraestructura. Descartadas: una FIFO obligaría al agregador a mantener un lector abierto permanentemente y a meteo-ctl a gestionar el bloqueo del open(), mucha maquinaria para un aviso; un socket UNIX sería lo correcto si meteo-ctl creciera hasta un protocolo con varios comandos y respuestas, pero para un comando sin respuesta es sobreingeniería.
(b) Resumen de 2 MB leído en cada petición. → Memoria compartida POSIX. A 1.200 peticiones/s, pasar 2 MB por una cola o un socket serían 2,4 GB/s de copias: imposible. Con memoria compartida el coste de acceso es cero. Descartadas: cola POSIX por el límite de 8 KB por mensaje, tres órdenes de magnitud por debajo; socket UNIX por las dos copias por petición. Condición imprescindible: hay que sincronizar la actualización horaria del agregador con las lecturas continuas de los trabajadores, o estos verán un resumen a medio escribir. El patrón adecuado es un doble búfer con un índice atómico, o un bloqueo de lectura/escritura: exactamente lo que veremos en Sincronización y Exclusión Mutua y en Problemas Clásicos de Concurrencia.
(c) Archivado en otra máquina. → Socket TCP de red. Es la única familia que cruza la frontera de la máquina, y TCP en vez de UDP porque un fichero diario completo debe llegar íntegro y en orden: aquí sí necesitamos las garantías de entrega que en las lecturas individuales no hacían falta. Todos los demás mecanismos son locales por construcción; la memoria compartida no puede compartirse entre máquinas, esa es precisamente su frontera.
(d) Aviso urgente de estación caída. → Cola de mensajes POSIX con prioridad alta. Es el único mecanismo local con prioridades incorporadas: enviando las alertas con prioridad 9 y lo rutinario con 0, mq_receive() entrega primero lo urgente aunque haya llegado después. Descartadas: la FIFO es estrictamente FIFO —un aviso urgente esperaría detrás de todo lo acumulado—; una señal avisaría de que "algo pasa" pero no de qué estación ni qué problema, y las señales estándar no se encolan: si llegan dos SIGUSR1 antes de atender el primero, se pierde uno.
Solución 3
/* fifo_escritor.c — envía 100.000 lecturas y cronometra */
int fd = open("/tmp/meteora.fifo", O_WRONLY);
struct Lectura l = { .estacion_id = 41, .temperatura = 21.5f };
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (long i = 0; i < 100000; i++) { l.timestamp = i; write(fd, &l, sizeof l); }
clock_gettime(CLOCK_MONOTONIC, &t1);
fprintf(stderr, "escritor: %.3f s\n",
(t1.tv_sec-t0.tv_sec) + (t1.tv_nsec-t0.tv_nsec)/1e9);
close(fd);
/* fifo_lector.c — el retardo por lectura, en microsegundos, va en argv[1] */
int retardo = argc > 1 ? atoi(argv[1]) : 0;
int fd = open("/tmp/meteora.fifo", O_RDONLY);
struct Lectura l; long n = 0;
while (read(fd, &l, sizeof l) == sizeof l) { n++; if (retardo) usleep(retardo); }
printf("lector: %ld lecturas\n", n);$ mkfifo /tmp/meteora.fifo $ ./fifo_lector 0 & ./fifo_escritor → escritor: 0.089 s / 100000 lecturas $ ./fifo_lector 10 & ./fifo_escritor → escritor: 6.412 s / 100000 lecturas
Análisis. Sin retardo, 100.000 lecturas (2,4 MB) tardan 89 ms: unos 27 MB/s, limitados por las 200.000 llamadas al sistema, a unas 0,44 µs cada una. Con 10 µs de retardo por lectura, el escritor tarda 6,4 segundos, exactamente el tiempo que necesita el lector (100.000 × 10 µs de retardo puro, más la sobrecarga de usleep, que redondea al alza cada espera). La lección es contundente: el escritor va exactamente al ritmo del lector aunque no haya una sola línea de sincronización. El búfer de 64 KB absorbe las ráfagas cortas y, cuando se agota, el núcleo duerme al escritor.
Medir el tamaño del búfer:
/* medir_buffer.c — escribir sin lector activo hasta bloquearse.
Abrimos en lectura para que el open de escritura no bloquee,
pero no leemos nada de ese descriptor. */
int rd = open("/tmp/meteora.fifo", O_RDONLY | O_NONBLOCK);
int wr = open("/tmp/meteora.fifo", O_WRONLY | O_NONBLOCK);
char c = 'x'; long bytes = 0;
while (write(wr, &c, 1) == 1) bytes++;
printf("Se han escrito %ld bytes antes de EAGAIN (errno=%d)\n", bytes, errno);
close(wr); close(rd);$ ./medir_buffer Se han escrito 65536 bytes antes de EAGAIN (errno=11) $ cat /proc/sys/fs/pipe-max-size 1048576
65.536 bytes exactos, 16 páginas de 4 KB: el valor por defecto de Linux, que coincide con las 2.731 lecturas del ejercicio 1 (65.536 / 24 = 2.730,67). Se puede ampliar hasta 1 MB con fcntl(wr, F_SETPIPE_SZ, 1048576), multiplicando por 16 la capacidad de absorber ráfagas: útil si el consumidor tiene pausas ocasionales largas, inútil si simplemente es más lento en promedio —ahí el búfer solo retrasa el bloqueo, no lo evita—.
Conclusión
Los procesos no comparten espacio de direcciones, y por eso el núcleo ofrece mecanismos de IPC. Todos derivan de dos modelos: el paso de mensajes, con dos copias por transferencia (~5-15 µs para 4 KB) y sincronización implícita, y la memoria compartida, con cero copias (~0,1 µs) y ninguna sincronización. Esa última frase es el aviso central de la lección.
Las tuberías anónimas son un búfer circular de 65.536 bytes en el núcleo con dos descriptores; exigen pipe() antes de fork(), cerrar el extremo que no se usa —o el lector nunca verá el EOF y se colgará— y asumir que son un flujo de bytes sin fronteras de mensaje. Sus dos situaciones límite enseñan más que el caso normal: cuando el búfer se llena, write() bloquea y aparece la contrapresión, que hemos visto en WCHAN: pipe_write y medido en 2.731 lecturas exactas; cuando el lector cierra, llega SIGPIPE y mata el proceso en silencio, por lo que todo servicio serio hace signal(SIGPIPE, SIG_IGN) y trata EPIPE.
Las FIFO dan nombre a la tubería en el sistema de archivos, permiten comunicar procesos sin parentesco —el canal ingestor→agregador de Meteora—, bloquean el open() como mecanismo de encuentro y garantizan escrituras atómicas de hasta 4 KB, lo que hace seguros a varios escritores. Las colas de mensajes POSIX conservan las fronteras, persisten sin nadie conectado y aportan lo que ningún otro mecanismo local tiene: prioridades, con las que una alerta enviada después se entrega antes; a cambio, sus límites de 10 mensajes y 8 KB las reservan para control, no para caudal.
La memoria compartida POSIX —shm_open + ftruncate + mmap con MAP_SHARED— es /dev/shm/meteora-cache: un fichero en tmpfs mapeado por varios procesos, con persistencia de núcleo, coste de acceso cero y la advertencia de que un n_ultimas++ ahí dentro es exactamente la carrera de 03-01. Los sockets son los más versátiles: los de dominio UNIX son 5-10 veces más rápidos que los de red local, pasan descriptores y revelan las credenciales del par; los de red son los únicos que cruzan de máquina, y con ellos el ingestor recibe las lecturas de las 800 estaciones por UDP, ampliando SO_RCVBUF a 4 MB para no descartar en silencio. Y las señales notifican sin transportar datos, con la regla de oro de que el manejador solo pone una bandera volatile sig_atomic_t porque casi nada es seguro dentro de él: así se recarga /etc/meteora/meteora.conf con SIGHUP. La guía de decisión, en una línea: red si puede haber otra máquina, señal si es un aviso sin datos, tubería si es un hijo directo, cola si necesitas prioridad, memoria compartida si mueves megabytes con latencia crítica, y socket de dominio UNIX cuando dudes.
Pero el asterisco sigue ahí, y ya no admite más aplazamiento. Hemos montado /dev/shm/meteora-cache y hemos dejado dentro un n_ultimas++ que pierde incrementos y una struct Lectura que se puede leer a medias. Tenemos el canal; no tenemos el protocolo que impide que dos procesos lo usen a la vez. ¿Cómo se garantiza que solo uno entre en la sección crítica? ¿Qué instrucción del hardware hace posible un candado, si contador++ no es atómico? ¿Por qué un mutex no es simplemente una bandera, y qué hace futex para que casi nunca haya que llamar al núcleo?
Es el corazón del módulo: Sincronización y Exclusión Mutua.
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
