Ya tenemos el mapa completo: sabemos qué es un fichero, cómo se le pone nombre y cómo se ensambla el árbol por el que lo alcanzamos. Lo que no hemos hecho todavía es usarlo desde un programa. Y ahí es donde aparecen los comportamientos que más quebraderos de cabeza dan en producción, porque casi todos vienen de una sola cosa que casi nadie conoce: detrás de un descriptor de fichero hay tres tablas, no una.
De esas tres tablas salen respuestas a preguntas que probablemente te hayas hecho. Por qué un hijo y su padre se pisan el desplazamiento tras un fork pero dos open del mismo fichero no. Por qué 2>&1 tiene que ir después de > fichero y no antes. Por qué un write() que ha devuelto correctamente puede perderse en un corte de luz. Y por qué el agregador publica sus medias horarias escribiendo en un fichero temporal y renombrándolo, en lugar de escribir directamente en el definitivo.
Esta lección es la más práctica del módulo: casi todo lo que hay aquí lo vas a escribir tú, en C o en Python, tarde o temprano. Vamos a recorrer 2026-08-31.dat estructura a estructura, destripar las tres tablas, entender la caché de páginas en el camino de escritura, construir el patrón de publicación atómica, y evitar que dos agregador se ejecuten a la vez con un fichero de bloqueo.
Contenido
- Las llamadas al sistema:
open,read,write,lseek,close - Recorrer
2026-08-31.datestructura a estructura - Las tres tablas detrás de un descriptor
- Consecuencias visibles:
fork, dosopenydup - Descriptores estándar y la redirección de la shell
- Métodos de acceso: secuencial, directo e indexado
- E/S bufferizada de la biblioteca C frente a llamadas directas
- La caché de páginas y el camino real de una escritura
fsync,fdatasyncy qué se pierde en un corte- Escritura atómica con fichero temporal y
rename() O_APPENDy las escrituras concurrentes al log- Bloqueo de ficheros:
flockfrente afcntl - Ficheros temporales seguros y condiciones de carrera TOCTOU
- Ficheros dispersos y
truncate - Herramientas de inspección
Las llamadas al sistema: open, read, write, lseek, close
Cinco llamadas. Con ellas se hace absolutamente todo lo demás.
int open (const char *ruta, int banderas, mode_t modo);
ssize_t read (int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
off_t lseek(int fd, off_t desplazamiento, int origen);
int close(int fd);open resuelve la ruta (04-02), comprueba permisos (04-06) y devuelve un descriptor de fichero: un entero pequeño, no negativo, que es el índice en una tabla del proceso. Devuelve -1 con errno puesto si falla.
Las banderas son el corazón de open, y hay que combinarlas con |:
| Bandera | Qué hace | Cuándo usarla |
|---|---|---|
O_RDONLY |
Solo lectura (vale 0, no es un bit) | Lectores |
O_WRONLY |
Solo escritura | Escritores puros |
O_RDWR |
Lectura y escritura | Acceso mixto |
O_CREAT |
Crear si no existe. Exige el tercer argumento modo |
Crear ficheros |
O_EXCL |
Con O_CREAT, falla si ya existe |
Creación atómica, ficheros de bloqueo |
O_APPEND |
Cada write va atómicamente al final |
Logs |
O_TRUNC |
Si existe y es escribible, lo vacía a 0 bytes | Regenerar un fichero |
O_DIRECT |
Salta la caché de páginas | Bases de datos con caché propia |
O_SYNC |
Cada write no vuelve hasta estar en disco |
Datos críticos (muy lento) |
O_NONBLOCK |
No bloquear (FIFO, sockets, 03-03) | E/S asíncrona |
O_CLOEXEC |
Se cierra al hacer execve |
Casi siempre: evita fugas de descriptores |
Tres precisiones que evitan errores clásicos. O_RDONLY vale 0, así que banderas & O_RDONLY es siempre falso y hay que usar banderas & O_ACCMODE. O_CREAT sin el tercer argumento deja el modo con basura de la pila y el fichero puede acabar con permisos aleatorios: es un fallo de seguridad real. Y O_CREAT | O_EXCL es atómico: o lo creas tú, o falla con EEXIST; sin O_EXCL no hay forma de distinguir "lo he creado yo" de "ya estaba", y esa distinción es la base de los ficheros de bloqueo del apartado 12.
read y write transfieren desde/hacia el desplazamiento actual del fichero, y lo avanzan. Y aquí está el error más repetido del mundo UNIX:
readywritepueden transferir MENOS bytes de los pedidos, y eso no es un error. Devuelven cuántos han movido de verdad. Ignorarlo produce corrupción silenciosa de datos.
Con ficheros regulares, un read corto solo ocurre al llegar al final; pero con tuberías, sockets y terminales es lo normal. La forma correcta de escribir es siempre en bucle:
/* Escribe n bytes COMPLETOS o devuelve -1. Esta función deberías tenerla siempre. */
ssize_t escribir_todo(int fd, const void *buf, size_t n) {
const char *p = buf; size_t restantes = n;
while (restantes > 0) {
ssize_t escritos = write(fd, p, restantes);
if (escritos < 0) {
if (errno == EINTR) continue; /* señal: reintentar, no es un fallo */
return -1; /* error de verdad */
}
p += escritos; restantes -= escritos;
}
return (ssize_t)n;
}El bucle cubre las escrituras parciales y el EINTR cubre el caso de que una señal (03-03) interrumpa la llamada antes de transferir nada. Un write de 17 MB de golpe sobre un socket lento devolverá muchas veces menos de lo pedido.
lseek mueve el desplazamiento sin transferir nada, con tres orígenes: SEEK_SET (desde el principio), SEEK_CUR (relativo al actual) y SEEK_END (desde el final). El idiomático lseek(fd, 0, SEEK_END) devuelve el tamaño del fichero, y lseek(fd, 0, SEEK_CUR) la posición actual. Y close libera el descriptor: comprueba siempre su valor de retorno, porque en sistemas de archivos de red puede devolver el error de un write diferido que falló, y es tu última oportunidad de enterarte.
Recorrer 2026-08-31.dat estructura a estructura
Con eso ya podemos escribir el lector real de Meteora. Recordemos el formato: 720.000 estructuras de 24 bytes, 17.280.000 bytes en total.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdint.h>
#include <errno.h>
struct Lectura { /* 24 bytes exactos */
uint32_t estacion_id; /* 4 */
int64_t timestamp; /* 8 */
float temperatura, humedad, presion; /* 4 + 4 + 4 */
};
int main(void) {
int fd = open("/var/lib/meteora/lecturas/2026-08-31.dat", O_RDONLY | O_CLOEXEC);
if (fd < 0) { perror("open"); return 1; }
off_t tam = lseek(fd, 0, SEEK_END); /* tamaño total */
lseek(fd, 0, SEEK_SET); /* volver al principio */
printf("%ld bytes = %ld lecturas\n", (long)tam, (long)(tam / 24));
struct Lectura lote[4096]; /* LOTES, no una a una: 98.304 bytes por syscall */
long total = 0; double suma_temp = 0.0;
for (;;) {
ssize_t n = read(fd, lote, sizeof lote);
if (n < 0) { if (errno == EINTR) continue; perror("read"); break; }
if (n == 0) break; /* fin de fichero */
size_t completas = (size_t)n / sizeof(struct Lectura);
for (size_t i = 0; i < completas; i++) { suma_temp += lote[i].temperatura; total++; }
/* n % 24 != 0 significa registro partido: hay que arrastrar el resto */
}
printf("%ld lecturas, temperatura media %.2f °C\n", total, suma_temp / total);
if (close(fd) < 0) perror("close");
return 0;
}Las decisiones del programa, que son las que hay que aprender. O_CLOEXEC evita que el descriptor se herede si el proceso hace execve: si meteo-api lanza un script auxiliar, ese script no tendrá acceso a los datos. Leer en lotes de 98.304 bytes en lugar de 24 es la decisión de rendimiento fundamental, y viene de 01-06: cada llamada al sistema cuesta entre 0,5 y 1 µs, así que con lecturas de 24 bytes serían 720.000 llamadas ≈ 0,5 segundos solo en cambios de modo, mientras que con lotes de 4.096 lecturas son 176 llamadas ≈ 0,15 milisegundos, una mejora de más de 3.000× por escribir la misma lógica de otra forma. completas = n / 24 porque un read puede devolver un número de bytes que no sea múltiplo de 24, dejando un registro partido que el código de producción arrastra al siguiente lote. Y comprobar EINTR en read, por lo mismo que en write.
Un aviso de portabilidad honesto: leer una estructura de C directamente del disco funciona porque el mismo programa la escribió en la misma arquitectura. Si el fichero viajara entre máquinas habría que preocuparse del padding del compilador —aquí struct Lectura mide 24 bytes sin relleno porque los campos están ordenados de mayor a menor alineamiento— y del orden de bytes. Para datos internos de un servidor es legítimo; para un formato de intercambio, no.
Las tres tablas detrás de un descriptor
Ahora la pieza central. Cuando open devuelve 3, el núcleo ha tocado tres estructuras distintas:
graph LR
T1["<b>meteo-api (2841)</b><br/>tabla de descriptores<br/>0·1·2· <b>3 →</b>"]
T2["<b>agregador (2903)</b><br/>tabla de descriptores<br/>0·1·2· <b>3 →</b>"]
F1["<b>GLOBAL: struct file A</b><br/>desplaz. = 12.000.000<br/>modo = O_RDONLY"]
F2["<b>GLOBAL: struct file B</b><br/>desplaz. = 0<br/>modo = O_RDWR"]
I["<b>Inodo 1180934 en memoria</b><br/>tamaño, permisos, bloques"]
T1 --> F1 --> I
T2 --> F2 --> I
| Tabla | Ámbito | Qué guarda | Se ve en |
|---|---|---|---|
| Descriptores | Por proceso | Punteros a entradas de la tabla global + close-on-exec |
/proc/<pid>/fd/ |
| Ficheros abiertos | Global | Desplazamiento, modo, banderas, referencias, puntero al inodo | /proc/<pid>/fdinfo/<n> |
| Inodos en memoria | Global | El inodo del VFS (04-03): tamaño, permisos, dueño, bloques | stat |
La clave de todo, y la frase que hay que memorizar:
El desplazamiento no está en el proceso ni en el inodo: está en la tabla intermedia de ficheros abiertos. Quién comparta esa entrada, comparte el desplazamiento.
Se puede ver de verdad:
pos es el desplazamiento actual —el agregador va por la lectura número 500.000— y flags es el modo en octal. Y en /proc/2841/fd/ cada descriptor aparece como un enlace simbólico al recurso: el 0 a /dev/null, el 1 y el 2 al log, el 3 al fichero de datos, el 4 a socket:[38291]. Es el mismo directorio que usamos en 04-02 para truncar un fichero borrado sin nombre.
Consecuencias visibles: fork, dos open y dup
Las tres tablas explican tres comportamientos que, sin ellas, parecen arbitrarios.
Caso 1: fork(). El hijo recibe una copia de la tabla de descriptores, pero los punteros apuntan a las mismas entradas de la tabla global. Resultado: padre e hijo comparten el desplazamiento.
int fd = open("salida.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) {
write(fd, "HIJO\n", 5); /* escribe en 0, desplazamiento → 5 */
_exit(0);
}
wait(NULL);
write(fd, "PADRE\n", 6); /* escribe en 5 (¡NO en 0!), desplazamiento → 11 */El fichero contiene HIJO\nPADRE\n. El padre no ha sobrescrito nada porque el hijo avanzó el desplazamiento compartido. Esto no es un accidente: es lo que hace que ( echo a; echo b ) > f funcione en la shell, con dos procesos escribiendo ordenadamente en el mismo fichero.
Caso 2: dos open independientes. Cada open crea una entrada nueva en la tabla global, con su propio desplazamiento.
int fd1 = open("salida.txt", O_WRONLY); /* entrada A, desplazamiento 0 */
int fd2 = open("salida.txt", O_WRONLY); /* entrada B, desplazamiento 0 */
write(fd1, "AAAAA", 5); /* escribe en 0..4, A → 5 */
write(fd2, "BBBBB", 5); /* escribe en 0..4, B → 5 ¡SOBRESCRIBE! */El fichero contiene solo BBBBB. Los dos descriptores apuntan al mismo inodo pero a entradas distintas, así que cada uno lleva su cuenta y se pisan. Este es exactamente el problema que resuelve O_APPEND en el apartado 11.
Caso 3: dup y dup2. Duplican un descriptor: dos números distintos que apuntan a la misma entrada de la tabla global, y por tanto comparten desplazamiento. dup(fd) devuelve el número libre más bajo apuntando a lo mismo; dup2(fd, 1) fuerza que el descriptor 1 apunte a lo mismo que fd, cerrando antes el 1 si estaba abierto. La tabla que resume los tres casos:
| Situación | ¿Comparten tabla de descriptores? | ¿Comparten entrada global? | ¿Comparten desplazamiento? |
|---|---|---|---|
Dos open del mismo fichero |
— | No | No |
Padre e hijo tras fork |
No (copia) | Sí | Sí |
dup / dup2 |
Sí (mismo proceso) | Sí | Sí |
| Dos hilos del mismo proceso | Sí (misma tabla) | Sí | Sí |
Descriptores estándar y la redirección de la shell
Por convención, todo proceso arranca con tres descriptores abiertos: el 0 es stdin, el 1 es stdout —salida normal, bufferizada— y el 2 es stderr —errores, sin buffer—. Que stderr no tenga buffer es deliberado: un mensaje de error debe aparecer antes de que el programa se caiga, no quedarse en un búfer que nadie vaciará.
Y ahora, la redirección. Cuando escribes meteo-api > /var/log/meteora/meteo-api.log 2>&1, la shell hace exactamente esto entre el fork y el execve (02-01):
if (fork() == 0) { /* en el hijo */
int fd = open("/var/log/meteora/meteo-api.log",
O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, 1); /* el descriptor 1 (stdout) pasa a ser el log */
dup2(1, 2); /* el descriptor 2 (stderr) pasa a ser LO MISMO que el 1 */
close(fd); /* el original ya no hace falta: 1 y 2 lo mantienen vivo */
execve("/usr/bin/meteo-api", argv, envp); /* hereda 1 y 2 ya redirigidos */
}meteo-api no sabe nada de la redirección: escribe en el descriptor 1 como siempre, y ese 1 apunta al log. Toda la redirección de UNIX es esto: dup2 antes del execve.
Con esto se resuelve por fin la pregunta eterna: por qué el orden de > fichero 2>&1 importa.
| Orden | Qué hace la shell | Resultado |
|---|---|---|
cmd > f 2>&1 |
Primero dup2(fd_f, 1), luego dup2(1, 2) |
Ambos al fichero ✔ |
cmd 2>&1 > f |
Primero dup2(1, 2) (¡1 es aún la terminal!), luego dup2(fd_f, 1) |
stderr a la terminal, stdout al fichero ✘ |
2>&1 significa literalmente "haz que el 2 apunte a donde apunte el 1 en este momento". No crea un vínculo permanente. Si lo pones antes de la redirección, el 1 todavía es la terminal y ahí se queda el 2. Es un dup2 y por tanto una copia puntual del puntero, no un alias.
Como los dos descriptores comparten la misma entrada de la tabla global, comparten el desplazamiento, y por eso la salida normal y los errores se intercalan correctamente sin sobrescribirse. Si en lugar de 2>&1 hicieras cmd > f 2> f, serían dos open independientes con dos desplazamientos: se pisarían mutuamente, que es el caso 2 del apartado anterior.
Métodos de acceso: secuencial, directo e indexado
Acceso secuencial. Se lee de principio a fin, y cada operación continúa donde acabó la anterior. Es lo que hace el agregador al calcular las medias del día, y es el patrón más rápido porque el núcleo detecta la secuencia y activa la lectura anticipada (readahead), trayendo bloques por adelantado. Acceso directo o aleatorio: se salta a cualquier posición con lseek, que es lo que hace meteo-api cuando un cliente pide la lectura número 500.000 y no tiene sentido leer las 499.999 anteriores. La clave está en que los registros de tamaño fijo permiten calcular la posición con una multiplicación:
int leer_lectura(int fd, long i, struct Lectura *out) { /* versión ingenua */
if (lseek(fd, (off_t)i * sizeof *out, SEEK_SET) == (off_t)-1) return -1;
return (read(fd, out, sizeof *out) == sizeof *out) ? 0 : -1;
}
int leer_lectura_mt(int fd, long i, struct Lectura *out) { /* CORRECTA con hilos */
ssize_t n = pread(fd, out, sizeof *out, (off_t)i * sizeof *out);
return (n == sizeof *out) ? 0 : -1;
}La segunda versión merece atención. lseek + read son dos llamadas, y entre ellas otro hilo del mismo proceso puede mover el desplazamiento compartido: una condición de carrera de manual (03-01) que produce lecturas del registro equivocado. pread y pwrite reciben la posición como argumento y no tocan el desplazamiento, así que son atómicas y seguras entre hilos. En un servidor multihilo como meteo-api, usar pread no es una preferencia estilística: es corrección.
El coste de encontrar el registro 500.000 en cada método:
| Método | Operación | Coste | Accesos a disco |
|---|---|---|---|
| Secuencial hasta él | 500.000 lecturas | O(n) | ~2.930 bloques |
Directo con pread |
1 multiplicación + 1 lectura | O(1) | 1 bloque |
| Indexado por estación | Buscar en el índice + 1 lectura | O(log n) | 2-3 bloques |
Acceso indexado. Cuando la clave de búsqueda no es la posición —"todas las lecturas de la estación 42"—, hace falta una estructura auxiliar que traduzca clave → posición. Meteora mantiene en un fichero aparte un índice de entradas { uint32_t estacion_id; uint32_t primera; uint32_t cuantas; }: se carga el índice, que es pequeño, se busca la estación, y con primera y cuantas se hace un solo pread del rango completo. Es la idea que llevan al extremo las bases de datos con sus árboles B+, y la razón de que una consulta indexada sea instantánea y una sin índice recorra toda la tabla.
E/S bufferizada de la biblioteca C frente a llamadas directas
En 01-06 vimos que un printf no llega al terminal inmediatamente. Ahora podemos cerrar el tema del todo.
La biblioteca C ofrece una capa por encima de las llamadas al sistema, con FILE*: fopen, fread, fwrite, fprintf, fgets, fclose. Su valor es un búfer en espacio de usuario que agrupa muchas operaciones pequeñas en pocas llamadas al sistema.
Llamadas directas (open/read/write) |
Biblioteca C (FILE*) |
|
|---|---|---|
| Tipo | int fd |
FILE *fp |
| Búfer intermedio | Ninguno | 4-8 KiB en espacio de usuario |
| Llamadas al sistema | Una por operación | Una cada vez que el búfer se llena |
| Coste de escribir 1 byte 1.000 veces | 1.000 syscalls (~1 ms) | ~1 syscall (~1 µs) |
| Formateo | A mano | fprintf, fscanf |
| Control exacto de qué llega al núcleo | Total | Ninguno hasta el volcado |
| Portabilidad | POSIX | Estándar C, en todas partes |
La biblioteca decide sola entre tres modos de buffering: completo (vuelca al llenarse el búfer) para ficheros y tuberías, por líneas (vuelca al \n) para terminales interactivos, y sin búfer para stderr. De ahí sale un comportamiento que confunde a todo el mundo:
$ ./programa # ves la salida línea a línea, en tiempo real
$ ./programa | tee salida.log # ¡la salida aparece a bloques o al final!El programa no ha cambiado. Lo que ha cambiado es que stdout ya no es un terminal sino una tubería, así que la biblioteca pasa de buffering por líneas a completo. Si el programa se cuelga o lo matas, pierdes todo lo que quedaba en el búfer. Las soluciones son setvbuf(stdout, NULL, _IOLBF, 0) en el código, o stdbuf -oL ./programa desde fuera.
La distinción crítica, que mucha gente confunde:
fflush(fp) |
fsync(fd) |
|
|---|---|---|
| Mueve datos de... | El búfer de la biblioteca C | La caché de páginas del núcleo |
| ...hacia | La caché de páginas del núcleo | El dispositivo físico |
| ¿Sobrevive a matar el proceso? | Sí tras el fflush |
Sí |
| ¿Sobrevive a un corte de luz? | NO | Sí |
| Coste | ~1 µs | 0,1-10 ms |
fflush no garantiza persistencia. Solo pasa los datos del búfer de usuario al núcleo. Para que sobrevivan a un corte hace falta fsync, y para llegar al fsync desde un FILE* hay que hacer los dos pasos:
fflush(fp); /* búfer de la biblioteca → caché de páginas del núcleo */
fsync(fileno(fp)); /* caché de páginas → dispositivo físico */Olvidar el fflush antes del fsync es un error frecuente y sutil: sincronizas al disco lo que el núcleo tenía, pero los datos más recientes seguían en el búfer de tu propio proceso.
La caché de páginas y el camino real de una escritura
Cuando el ingestor ejecuta write(fd, &lectura, 24), ¿qué pasa realmente? Casi nunca lo que uno imagina:
graph TB
A["ingestor: write(fd, &lectura, 24)"] --> B["Búfer de la biblioteca C<br/>(si usa FILE*)"]
B -->|"fflush / búfer lleno"| C["<b>Caché de páginas del núcleo</b><br/>la página se marca SUCIA<br/>write() YA HA RETORNADO ✔"]
C -->|"fsync() explícito, o<br/>escritura diferida del núcleo"| D["Capa de bloques y planificador (02-05)"]
D --> E["Caché volátil del disco (DRAM del SSD)"]
E -->|"FUA / barrera de escritura"| F["Medio físico persistente ✔"]
El punto decisivo es el tercer bloque: write() retorna en cuanto los datos están en la caché de páginas, que es RAM. En ese momento el ingestor cree que ha escrito, y desde el punto de vista de cualquier otro proceso que lea el fichero así es, porque las lecturas también pasan por la caché. Pero en el disco todavía no hay nada: la página está marcada como sucia (dirty) y se escribirá más tarde. Eso es la escritura diferida (write-back), y la gobiernan unos parámetros del núcleo:
$ sysctl vm.dirty_ratio vm.dirty_background_ratio vm.dirty_expire_centisecs vm.dirty_ratio = 20 vm.dirty_background_ratio = 10 vm.dirty_expire_centisecs = 3000
dirty_background_ratio = 10: al superar el 10 % de la RAM en páginas sucias, los hilos de escritura del núcleo empiezan a volcar en segundo plano, sin frenar a nadie. dirty_ratio = 20: al superar el 20 %, el proceso que escribe se bloquea y se le obliga a volcar él mismo —es la causa de los "parones" de un programa que escribe mucho: no está calculando, está pagando la deuda acumulada—. Y dirty_expire_centisecs = 3000: una página sucia se vuelca a los 30 segundos como máximo, haya presión o no.
¿Por qué diferir en lugar de escribir ya? Por tres razones de peso. Agrupar escrituras: el ingestor escribe 24 bytes 8 veces por segundo, y si cada uno fuera al disco serían 8 operaciones diminutas por segundo sobre bloques de 4 KiB, mientras que diferidas se agrupan en una escritura por bloque lleno, una cada 21 segundos —una reducción de 170×—. Absorber reescrituras: si un programa escribe el mismo bloque diez veces en un segundo, solo se manda una. Y ordenar y fusionar, porque el planificador de E/S de 02-05 puede combinar peticiones adyacentes. El precio es exactamente lo que hay que entender:
Si
meteo-01pierde la corriente ahora mismo, se pierde todo lo que esté en la caché de páginas y no haya llegado al disco: hasta 30 segundos de lecturas, unos 240 registros. Y elingestorno tiene forma de saberlo, porque suswrite()devolvieron éxito.
fsync, fdatasync y qué se pierde en un corte
Para cerrar ese hueco existen tres llamadas:
int fsync(int fd); /* datos + TODOS los metadatos de este fichero, al disco */
int fdatasync(int fd); /* datos + solo los metadatos IMPRESCINDIBLES */
void sync(void); /* inicia el volcado de TODO el sistema */| Llamada | Vuelca | Coste típico (NVMe) | Cuándo usarla |
|---|---|---|---|
fsync(fd) |
Datos + inodo completo (fechas incluidas) | 0,5-2 ms | Cuando el fichero ha cambiado de tamaño |
fdatasync(fd) |
Datos + metadatos necesarios para releerlos | 0,3-1 ms | Reescrituras sin cambio de tamaño |
sync() |
Todo el sistema | 10 ms - varios segundos | Antes de apagar o de una instantánea |
La diferencia entre fsync y fdatasync es sutil pero real: si has añadido datos al final, el tamaño del fichero ha cambiado, y ese tamaño es un metadato imprescindible —sin él los datos nuevos no se pueden releer—, así que fdatasync también lo vuelca; lo que se ahorra es escribir el inodo por cambios irrelevantes como el mtime, que en escritura intensa puede suponer un 20-30 %.
La decisión práctica es un compromiso explícito entre durabilidad y rendimiento: sin fsync arriesgas hasta 30 segundos y consigues cientos de miles de escrituras por segundo; con fsync en cada write no arriesgas nada y te quedas en unas 1.000, el límite físico del disco; y con fsync cada N segundos eliges tú el punto intermedio.
Qué elige Meteora. El ingestor recibe 8 lecturas por segundo, y perder 30 segundos de datos meteorológicos sería molesto pero no catastrófico —las estaciones reintentan—; en cambio, un fsync por lectura limitaría el sistema a unas 1.000 escrituras por segundo y castigaría el SSD sin necesidad. La política elegida es fdatasync() cada 5 segundos, que acota la pérdida a unas 40 lecturas y cuesta 0,2 sincronizaciones por segundo:
static time_t ultimo_sync = 0;
void escribir_lectura(int fd, const struct Lectura *l) {
escribir_todo(fd, l, sizeof *l);
time_t ahora = time(NULL);
if (ahora - ultimo_sync >= 5) {
fdatasync(fd); /* acota la pérdida a 5 segundos */
ultimo_sync = ahora;
}
}Un aviso importante: fsync sobre un fichero no garantiza que su entrada de directorio esté en disco. Si acabas de crearlo, tras un corte podría existir el contenido pero no el nombre. Hay que hacer también fsync del directorio —abriéndolo con O_RDONLY|O_DIRECTORY—, y es el paso que casi todo el mundo olvida.
Escritura atómica con fichero temporal y rename()
Problema real. El agregador calcula las medias horarias y las publica en /var/lib/meteora/medias-horarias.json, que meteo-api lee para responder a los clientes. Si el agregador escribe directamente:
int fd = open("/var/lib/meteora/medias-horarias.json",
O_WRONLY | O_CREAT | O_TRUNC, 0640); /* ⚠ PELIGRO */
escribir_todo(fd, json, strlen(json));
close(fd);hay una ventana de inconsistencia: entre el O_TRUNC (que deja el fichero en 0 bytes) y el final de la escritura, cualquier lector obtiene un fichero vacío o truncado a la mitad. Con meteo-api sirviendo consultas continuamente, eso significa respuestas rotas varias veces al día. Y si el proceso muere a mitad de la escritura, el fichero queda permanentemente corrupto.
La solución es el patrón más importante de toda esta lección, y descansa en una garantía del sistema de archivos:
rename()es atómico. Para cualquier observador, el destino apunta al fichero antiguo o al nuevo, nunca a un estado intermedio, y nunca desaparece.
Es atómico porque, como vimos en 04-02, renombrar solo cambia el par (nombre, inodo) de una entrada de directorio: una operación que el sistema de archivos ejecuta como una unidad.
int publicar_atomico(const char *destino, const char *datos, size_t n) {
char tmp[PATH_MAX], dir[PATH_MAX];
snprintf(tmp, sizeof tmp, "%s.tmp.%d", destino, getpid()); /* único por proceso */
int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0640); /* 1. escribir todo */
if (fd < 0) return -1;
if (escribir_todo(fd, datos, n) < 0) { close(fd); unlink(tmp); return -1; }
if (fsync(fd) < 0) { close(fd); unlink(tmp); return -1; } /* 2. datos a disco */
if (close(fd) < 0) { unlink(tmp); return -1; }
if (rename(tmp, destino) < 0) { unlink(tmp); return -1; } /* 3. rename ATÓMICO */
snprintf(dir, sizeof dir, "%s", destino); /* 4. persistir el */
int dfd = open(dirname(dir), O_RDONLY | O_DIRECTORY); /* renombrado */
if (dfd >= 0) { fsync(dfd); close(dfd); }
return 0;
}Los cuatro pasos, y por qué ninguno sobra. (1) Escribir en un temporal con nombre único: nadie lo lee, así que puede estar incompleto el tiempo que haga falta. (2) fsync del temporal antes del rename: sin él, el rename —un metadato— podría llegar al disco antes que los datos, y un corte dejaría el nombre bueno apuntando a un fichero vacío; es el fallo real que sufrieron muchas aplicaciones con ext4 en 2009. (3) rename atómico, que además elimina el fichero antiguo en la misma operación: su contador de enlaces baja a 0 y los bloques se liberan cuando nadie lo tenga abierto (04-02). (4) fsync del directorio, para que el renombrado en sí sea persistente.
Y una propiedad preciosa que sale gratis: un lector que abrió el fichero antiguo sigue leyéndolo sin problemas después del rename, porque su descriptor apunta al inodo y no al nombre. No obtiene datos mezclados ni un error: obtiene íntegra la versión que había cuando abrió, con consistencia perfecta y sin ningún bloqueo. Este patrón es universal —lo usan Git, los gestores de paquetes, sqlite y los editores de texto al guardar—: si tienes que actualizar un fichero que alguien más puede estar leyendo, este es el patrón.
O_APPEND y las escrituras concurrentes al log
Tres procesos de Meteora escriben en /var/log/meteora/meteo-api.log. En el apartado 4 vimos que dos open independientes se pisan porque cada uno lleva su propio desplazamiento. Con un log, el escenario sería este:
Proceso A: desplazamiento 1000, escribe 50 bytes → 1000..1049 Proceso B: desplazamiento 1000, escribe 40 bytes → 1000..1039 ← ¡machaca a A!
Peor aún: aunque cada proceso hiciera lseek(fd, 0, SEEK_END) antes de escribir, serían dos llamadas al sistema y entre ellas cabe una expulsión del planificador. Es la condición de carrera clásica de 03-01, con la ventana entre el lseek y el write.
O_APPEND lo resuelve de raíz, y su garantía es fuerte:
Con
O_APPEND, cadawrite()posiciona al final del fichero y escribe en una sola operación atómica, protegida por un candado del inodo dentro del núcleo. Dos procesos no pueden intercalarse.
int fd = open("/var/log/meteora/meteo-api.log",
O_WRONLY | O_CREAT | O_APPEND, 0640);
/* Cada write se coloca al final ATÓMICAMENTE, sin lseek y sin carrera */
escribir_todo(fd, linea, strlen(linea));Cuatro precisiones sobre el alcance de la garantía. Es atómica dentro de una sola llamada write: si escribes una línea con dos write distintos, otro proceso puede colarse entre ellos, así que compón la línea entera en un búfer y escríbela de una vez. lseek es inútil con O_APPEND, porque el núcleo ignora el desplazamiento y va al final igualmente —por eso truncate -s 0 sobre un log con O_APPEND libera el espacio correctamente (04-02), mientras que sin él deja un fichero disperso—. POSIX garantiza la atomicidad hasta PIPE_BUF, aunque en Linux y sobre un sistema local funciona con líneas de cualquier tamaño razonable. Y sobre NFS no está garantizada, porque el cliente no puede hacer atómicamente "buscar el final y escribir" a través de la red: es el problema 2 de 04-03.
Por eso todo log debe abrirse con O_APPEND, y por eso >> en la shell lo usa mientras que > no.
Bloqueo de ficheros: flock frente a fcntl
O_APPEND resuelve las escrituras concurrentes a un log, pero no el problema general: impedir que dos instancias del agregador se ejecuten a la vez y calculen las mismas medias por duplicado.
Linux ofrece dos mecanismos de bloqueo, incompatibles entre sí:
flock (BSD) |
fcntl (POSIX) |
|
|---|---|---|
| Granularidad | Fichero entero | Rangos de bytes |
| Asociado a | La entrada de la tabla global | El par (proceso, inodo) |
Se hereda en fork |
Sí (comparten la entrada) | No |
Sobrevive a execve |
Sí | Sí |
| Se libera al cerrar cualquier descriptor del fichero | No | Sí (¡peligroso!) |
| Funciona sobre NFS | Mal | Regular (con lockd) |
| Entre hilos del mismo proceso | No distingue | No distingue |
| Facilidad de uso | Alta | Media |
La trampa de fcntl merece un aviso explícito, porque produce fallos muy difíciles de diagnosticar: cerrar cualquier descriptor del mismo fichero libera todos los bloqueos que el proceso tenía sobre él, así que si una biblioteca abre y cierra el fichero para leer una línea, tu bloqueo desaparece sin que nadie te avise. Ambos son consultivos (advisory): solo funcionan si todos los participantes cooperan pidiendo el bloqueo, y un proceso que lo ignore escribirá sin impedimento. La alternativa teórica es el bloqueo obligatorio (mandatory), que el núcleo impondría a todos comprobándolo en cada read y write. Linux lo tuvo —montando con -o mand y marcando el fichero setgid sin permiso de ejecución en el grupo—, pero era frágil, tenía condiciones de carrera propias y se eliminó del núcleo. En la práctica, todo el bloqueo de ficheros en Linux es consultivo, y eso está bien: es un acuerdo entre programas que cooperan, no un mecanismo de seguridad.
El fichero de bloqueo del agregador, que es el uso más común:
#include <sys/file.h>
int fd = open("/run/meteora/agregador.lock", O_RDWR | O_CREAT | O_CLOEXEC, 0640);
if (fd < 0) { perror("open lock"); return 1; }
if (flock(fd, LOCK_EX | LOCK_NB) < 0) { /* exclusivo, SIN bloquearse */
if (errno == EWOULDBLOCK) {
fprintf(stderr, "Ya hay otro agregador en marcha. Salgo.\n");
return 0; /* salida limpia, no es un error */
}
perror("flock"); return 1;
}
calcular_medias_horarias(); /* --- solo puede haber UNA instancia aquí --- */
publicar_atomico("/var/lib/meteora/medias-horarias.json", json, n);
close(fd); /* close() libera el bloqueo */Las decisiones clave: LOCK_NB hace que flock no se bloquee —si otra instancia lo tiene, devuelve EWOULDBLOCK y el programa sale limpiamente; sin él, las instancias se acumularían en espera y ejecutarían todas seguidas, justo lo que no quieres en una tarea horaria—; el bloqueo se libera solo cuando el proceso muere por cualquier causa, incluido kill -9 o un corte de luz, que es la ventaja decisiva frente a un fichero .pid —ese exige comprobar si el proceso sigue vivo, y el PID puede haber sido reutilizado—; y el fichero va en /run, que es tmpfs (04-02) y se limpia solo al arrancar.
Desde la shell, la utilidad flock hace lo mismo en una línea, y es lo que deberían usar tus tareas programadas:
flock -n /run/meteora/agregador.lock -c '/usr/bin/agregador --horario' || \
echo "otro agregador en marcha, omito esta ejecución"Y fcntl sigue siendo imprescindible cuando necesitas bloquear rangos de bytes: una base de datos que quiere bloquear el registro 500.000 sin impedir el acceso a los demás. Es la granularidad de 03-04 aplicada a ficheros.
Ficheros temporales seguros y condiciones de carrera TOCTOU
Crear un fichero temporal parece trivial y es una fuente clásica de vulnerabilidades:
/* ⚠ VULNERABLE: no hagas esto nunca */
char *nombre = tmpnam(NULL); /* devuelve "/tmp/tmpf3a9k" */
int fd = open(nombre, O_WRONLY | O_CREAT, 0600);El problema tiene nombre: TOCTOU, Time Of Check To Time Of Use. Entre el momento en que tmpnam comprueba que el nombre está libre y el momento en que tu open lo crea, hay una ventana. Un atacante que vigile /tmp —que es escribible por todos— puede, en esa ventana, crear un enlace simbólico con ese nombre apuntando a /etc/passwd. Tu open con O_CREAT seguirá el enlace y escribirá en /etc/passwd con tus privilegios. Si tu programa es setuid root (04-06), acabas de regalar la máquina.
La solución es mkstemp, que crea y abre en una sola operación atómica:
#include <stdlib.h>
char plantilla[] = "/var/lib/meteora/medias.XXXXXX"; /* debe acabar en 6 X y ser modificable */
int fd = mkstemp(plantilla); /* crea con O_CREAT|O_EXCL y permisos 0600 */
if (fd < 0) { perror("mkstemp"); return -1; }
/* 'plantilla' ahora contiene el nombre real, p. ej. /var/lib/meteora/medias.k3Bq7z */
escribir_todo(fd, datos, n);
fsync(fd);
close(fd);
unlink(plantilla); /* o rename(), si es el patrón de publicación atómica */Por qué mkstemp es seguro: usa O_CREAT | O_EXCL, así que si el nombre ya existe —incluido un enlace simbólico— falla en lugar de seguirlo, lo que cierra el TOCTOU; crea con permisos 0600; y devuelve el descriptor ya abierto, sin ninguna ventana entre la creación y el uso. La plantilla debe ser un array modificable, no un literal, porque mkstemp escribe en ella el nombre generado.
Un patrón aún más fuerte cuando no necesitas que nadie más vea el fichero: crearlo y borrarlo inmediatamente, siguiendo lo que aprendimos en 04-02. unlink(plantilla) justo después del mkstemp hace desaparecer el nombre mientras el inodo sigue vivo por el descriptor: nadie puede abrirlo ni sustituirlo, y el sistema libera el espacio solo aunque el proceso muera de un kill -9. Linux ofrece además O_TMPFILE, que crea un fichero anónimo sin nombre en ningún momento, eliminando incluso la ventana entre mkstemp y unlink.
La regla general contra TOCTOU vale mucho más allá de los temporales: no compruebes una ruta y actúes después sobre ella; opera directamente sobre el descriptor. Por eso access() seguido de open() es un antipatrón conocido —comprueba con los permisos reales y abre con los efectivos, con una ventana entre medias— y por eso existen openat, fstatat, fchmod y fchown, que trabajan sobre descriptores ya abiertos.
Ficheros dispersos y truncate
truncate y ftruncate cambian el tamaño de un fichero:
Reducir descarta lo que sobra y libera los bloques. Agrandar hace algo más interesante: el espacio nuevo se lee como ceros, pero no se reservan bloques para él. Eso es un fichero disperso (sparse file):
$ truncate -s 1G /tmp/disperso.dat $ ls -lh /tmp/disperso.dat -rw-r--r-- 1 joan joan 1,0G sep 1 14:02 /tmp/disperso.dat ← tamaño 1 GB $ du -h /tmp/disperso.dat 0 /tmp/disperso.dat ← ocupación REAL: 0 $ stat -c 'tamaño=%s bloques=%b' /tmp/disperso.dat tamaño=1073741824 bloques=0
Un fichero de 1 GB que ocupa cero bloques. El sistema de archivos simplemente no tiene punteros para esa región: cuando alguien lee ahí, el núcleo devuelve ceros sin tocar el disco, y los bloques se asignan solo cuando se escribe en ellos. De ahí la diferencia perpetua entre lo que muestran ls -l y stat -c %s —el tamaño lógico— y lo que muestran du y stat -c %b —la ocupación real—.
Los ficheros dispersos son útiles de verdad en imágenes de máquinas virtuales (un disco de 100 GB con 12 GB usados ocupa 12), en bases de datos que preasignan espacio y en descargas por partes. Y se crean sin querer con un lseek más allá del final seguido de un write, que es exactamente lo que ocurre al truncar a cero un log abierto sin O_APPEND: el proceso conserva su desplazamiento antiguo, escribe ahí, y crea un agujero disperso desde el byte 0. Dos precauciones: cp conserva los agujeros con --sparse=always pero muchas herramientas los rellenan de ceros, convirtiendo un fichero de 12 GB en uno de 100, y tar necesita -S para preservarlos.
Herramientas de inspección
Tres herramientas para ver qué está pasando de verdad con los ficheros de un proceso.
lsof — qué tiene abierto cada proceso:
$ sudo lsof -p 2841 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME meteo-api 2841 meteora cwd DIR 9,0 4096 1180928 /var/lib/meteora meteo-api 2841 meteora txt REG 8,3 2103448 264531 /usr/bin/meteo-api meteo-api 2841 meteora 1w REG 8,3 189234112 395102 .../meteo-api.log meteo-api 2841 meteora 3r REG 9,0 17280000 1180934 .../2026-08-31.dat meteo-api 2841 meteora 4u IPv4 38291 0t0 TCP *:8080 (LISTEN) meteo-api 2841 meteora 5u REG 0,25 134217728 312 /dev/shm/meteora-cache
Se lee de un vistazo el estado completo del servicio: su cwd, su binario (txt), el log abierto para escribir (1w), el fichero de datos para leer (3r), el socket de escucha y la caché compartida. Las opciones más útiles son lsof -p PID, lsof ruta, lsof +L1 para los borrados que siguen abiertos (04-02) y lsof -i :8080 por puerto.
/proc/<pid>/fd y /proc/<pid>/fdinfo — lo mismo sin instalar nada: ls -l /proc/2841/fd/ muestra a qué apunta cada descriptor, cat /proc/2841/fdinfo/3 da el pos y las banderas, ls /proc/2841/fd/ | wc -l cuenta cuántos usa y grep files /proc/2841/limits su techo. Contar descriptores es el diagnóstico de la fuga de descriptores: un servicio que abre ficheros y no los cierra acaba agotando su límite (1.024 por defecto, a menudo subido a 65.536) y falla con EMFILE, "Too many open files". Si el número crece monótonamente con el tiempo, tienes una fuga.
filefrag — cómo está repartido un fichero en el disco:
$ sudo filefrag -v /var/lib/meteora/lecturas/2026-08-31.dat Filesystem type is: ef53 File size of ...2026-08-31.dat is 17280000 (4219 blocks of 4096 bytes) ext: logical_offset: physical_offset: length: expected: flags: 0: 0.. 4218: 8394271.. 8398489: 4219: last,eof /var/lib/meteora/lecturas/2026-08-31.dat: 1 extent found
Un solo extent: los 4.219 bloques del fichero están físicamente contiguos, del bloque 8.394.271 al 8.398.489. Eso significa que leer el fichero entero es una sola operación secuencial, lo mejor posible. Cómo consigue ext4 este resultado escribiendo 24 bytes cada 125 milisegundos durante 24 horas es justo el tema de la siguiente lección.
Errores Comunes y Consejos
No comprobar el valor de retorno de read y write. Pueden transferir menos bytes de los pedidos sin que sea un error. Escribe una vez la función escribir_todo del apartado 1 y úsala siempre.
Confundir fflush con fsync. fflush mueve datos del búfer de tu proceso al núcleo; fsync los lleva al disco. Solo el segundo sobrevive a un corte de luz, y desde un FILE* hacen falta los dos, en ese orden. Por lo mismo, un write() que retornó no está en disco: está en la caché de páginas, y pueden pasar hasta 30 segundos.
Abrir un log sin O_APPEND. Dos procesos se sobrescribirán mutuamente, y truncate -s 0 dejará un fichero disperso en lugar de liberar espacio.
Escribir directamente sobre un fichero que otros leen. Usa siempre temporal + fsync + rename + fsync del directorio; son cuatro líneas más y eliminan toda una clase de fallos. Y no olvides el fsync del temporal antes del rename: sin él, un corte puede dejar el nombre nuevo apuntando a un fichero vacío, que es cambiar un fallo visible por uno silencioso.
Usar tmpnam, tempnam o mktemp en C. Todas tienen la carrera TOCTOU. Usa mkstemp o O_TMPFILE, que crean y abren atómicamente con permisos 0600. Por la misma razón, no compruebes con access() para actuar después: abre directamente y comprueba el error, u opera con openat y fstat sobre descriptores.
Esperar que flock proteja de un programa que no coopera. Es consultivo: solo funciona entre programas que lo piden, y en Linux el bloqueo obligatorio ya no existe. Y no confundas flock con fcntl: son mecanismos distintos que no se ven entre sí, y en fcntl cerrar cualquier descriptor del fichero libera todos tus bloqueos.
Consejo: usa pread/pwrite en código multihilo, que reciben la posición como argumento y eliminan la carrera entre lseek y read; y abre con O_CLOEXEC por defecto, para que los descriptores no se filtren a los procesos hijos —una fuga de información y una causa habitual de "target is busy"—.
Ejercicios
Ejercicio 1: demostrar las tres tablas
Escribe tres programas cortos en C que demuestren empíricamente los tres casos del apartado 4: (a) padre e hijo tras fork comparten desplazamiento; (b) dos open independientes del mismo fichero no lo comparten y se sobrescriben; (c) dup2 sí lo comparte. En cada uno, muestra el contenido final del fichero y el pos de /proc/<pid>/fdinfo/<fd> en el momento adecuado. Explica qué tabla concreta produce cada resultado.
Ejercicio 2: publicación atómica del agregador
Implementa en C (o en Python) la función que publica las medias horarias en /var/lib/meteora/medias-horarias.json de forma atómica, con los cuatro pasos completos. Después escribe un programa lector que abra el fichero en bucle y verifique que nunca lee un JSON incompleto, y ejecútalos a la vez durante un minuto. Finalmente, modifica el publicador para que escriba directamente con O_TRUNC y mide cuántas lecturas corruptas obtiene el lector. Explica el resultado.
Ejercicio 3: fsync y el compromiso durabilidad/rendimiento
Escribe un programa que añada un millón de estructuras Lectura de 24 bytes a un fichero, con cuatro políticas: (a) sin fsync; (b) fdatasync cada 1.000 registros; (c) fdatasync cada registro; (d) abriendo con O_SYNC. Mide el tiempo de cada una y calcula registros por segundo. Después, para cada política, calcula cuántos registros se perderían en un corte de luz con la tasa real de Meteora (8 lecturas/segundo) y razona cuál elegirías y por qué.
Soluciones
Solución 1
/* (a) fork COMPARTE desplazamiento */
int fd = open("a.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);
if (fork() == 0) { write(fd, "HIJO\n", 5); _exit(0); }
wait(NULL);
printf("pos del padre: %ld\n", (long)lseek(fd, 0, SEEK_CUR)); /* → 5 */
write(fd, "PADRE\n", 6); /* a.txt = "HIJO\nPADRE\n" (11 B) */
/* (b) dos open NO lo comparten */
int f1 = open("b.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);
int f2 = open("b.txt", O_WRONLY);
write(f1, "AAAAA", 5); write(f2, "BBBBB", 5); /* b.txt = "BBBBB" (5 B) */
/* (c) dup SÍ lo comparte */
int g1 = open("c.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);
int g2 = dup(g1);
write(g1, "AAAAA", 5); write(g2, "BBBBB", 5); /* c.txt = "AAAAABBBBB" (10 B) */(a) El padre ve pos = 5 sin haber escrito nada: fork copia la tabla de descriptores, pero sus entradas apuntan a la misma entrada de la tabla global, donde vive el desplazamiento. Lo mueve el hijo y lo ve el padre.
(b) Cada open creó su propia entrada en la tabla global, ambas con desplazamiento 0 y apuntando al mismo inodo; las dos escribieron en 0..4 y la segunda machacó a la primera.
(c) dup crea un descriptor nuevo apuntando a la misma entrada global, así que el desplazamiento es el mismo y las escrituras se encadenan.
Resumido: la tabla de descriptores decide qué números ve cada proceso; la tabla global de ficheros abiertos decide quién comparte desplazamiento; la tabla de inodos decide qué fichero es. Los tres casos difieren solo en la segunda.
Solución 2
import json, os, tempfile
def publicar_atomico(destino, datos):
d = os.path.dirname(destino)
fd, tmp = tempfile.mkstemp(dir=d, prefix=".medias-", suffix=".tmp") # mismo FS
try:
with os.fdopen(fd, "w") as f:
json.dump(datos, f)
f.flush() # búfer de Python → núcleo
os.fsync(f.fileno()) # núcleo → disco (paso 2)
os.replace(tmp, destino) # rename ATÓMICO (paso 3)
dfd = os.open(d, os.O_RDONLY)
try: os.fsync(dfd) # persistir el renombrado (paso 4)
finally: os.close(dfd)
except BaseException:
os.unlink(tmp) # no dejar basura si algo falla
raiseDos detalles imprescindibles: el temporal debe crearse en el mismo directorio que el destino, porque rename() no cruza sistemas de archivos (EXDEV, 04-02) y si lo pones en /tmp fallará o degenerará en copiar+borrar, perdiendo la atomicidad; y os.replace en lugar de os.rename, porque el primero garantiza la semántica POSIX de sobrescritura atómica también en Windows.
El lector verificador abre el fichero en bucle durante un minuto y cuenta cuántas veces json.load() lanza JSONDecodeError o FileNotFoundError. Resultado esperado con publicación atómica: corruptas=0, siempre, por muchas vueltas que dé. Con O_TRUNC directo, en un fichero de unos 200 KB, la ventana de inconsistencia dura 1-3 ms en cada publicación, y con un lector en bucle cerrado aparecen decenas o cientos de lecturas corruptas por minuto.
La explicación. Con O_TRUNC el fichero pasa por estados intermedios visibles: 0 bytes, luego parcialmente escrito, y cualquier lector que llegue en esa ventana obtiene JSON inválido. Con temporal + rename, el nombre apunta siempre a un inodo completo y el cambio de a cuál apunta es atómico; un lector que ya lo tuviera abierto sigue leyendo íntegra la versión antigua, porque su descriptor está atado al inodo y no al nombre.
Solución 3
Valores típicos sobre un NVMe (varían con el hardware, pero los órdenes de magnitud se mantienen):
| Política | Tiempo (1 M registros) | Registros/s | Sincronizaciones |
|---|---|---|---|
(a) Sin fsync |
0,9 s | 1.100.000 | 0 (el núcleo vuelca solo) |
(b) fdatasync cada 1.000 |
1,8 s | 555.000 | 1.000 |
(c) fdatasync cada registro |
~700 s | ~1.400 | 1.000.000 |
(d) O_SYNC |
~900 s | ~1.100 | 1.000.000 implícitas |
La conclusión es contundente: sincronizar en cada registro es 800 veces más lento. El disco no puede confirmar más de 1.000-3.000 sincronizaciones por segundo, porque cada una implica vaciar la caché volátil del dispositivo y esperar confirmación del medio; ese número es una característica física del hardware y no mejora escribiendo mejor código.
Datos en riesgo con la tasa real de Meteora (8 lecturas/segundo):
| Política | Ventana de pérdida | Registros perdidos | Datos |
|---|---|---|---|
(a) Sin fsync |
Hasta 30 s (dirty_expire) |
240 | 5,7 KB |
| (b) Cada 1.000 registros | 1.000/8 = 125 s | 1.000 | 24 KB |
| (b') Cada 5 segundos | 5 s | 40 | 960 B |
| (c) Cada registro | 0 | 0 | 0 |
Qué elegiría y por qué. Ni (a) ni (c). La (c) no aporta nada: perder unos segundos de datos meteorológicos es tolerable —las estaciones reintentan— y a cambio limitaría el sistema a 1.400 escrituras por segundo y multiplicaría el desgaste del SSD. La (a) es cómoda pero deja una ventana de 30 segundos que además no controlas, porque depende de la presión de memoria del sistema entero.
La política correcta es (b'), fdatasync cada 5 segundos: acota la pérdida a 40 registros con solo 0,2 sincronizaciones por segundo. Fíjate en que es (b') y no (b): sincronizar por tiempo da una garantía explicable a un cliente —"como máximo se pierden 5 segundos"— e independiente del ritmo de llegada, mientras que "cada 1.000 registros" significa 125 segundos con tráfico normal y horas si las estaciones envían poco. Acota siempre por tiempo, no por cantidad. Y fdatasync en lugar de fsync porque, al añadir al final, el único metadato imprescindible es el tamaño.
Conclusión
Cinco llamadas al sistema —open, read, write, lseek, close— bastan para todo, y las banderas de open son lo que las hace potentes: O_CREAT|O_EXCL para crear atómicamente, O_APPEND para logs, O_CLOEXEC para no filtrar descriptores. Dos reglas para no equivocarse: read y write pueden mover menos bytes de los pedidos —de ahí la función escribir_todo con su bucle y su EINTR—, y leer en lotes en lugar de registro a registro convierte 720.000 llamadas al sistema en 176, una mejora de más de 3.000× por reordenar la misma lógica.
Detrás de un descriptor hay tres tablas: la de descriptores, por proceso; la de ficheros abiertos, global, donde vive el desplazamiento; y la de inodos en memoria. Toda la fenomenología sale de la segunda: fork y dup comparten entrada, y por tanto desplazamiento; dos open no, y por tanto se sobrescriben. Eso explica que ( echo a; echo b ) > f funcione, y explica por fin 2>&1: es un dup2 que copia a dónde apunta el 1 en ese instante, por lo que ponerlo antes de > fichero deja stderr en la terminal.
Los métodos de acceso son secuencial (con lectura anticipada), directo —con registros de tamaño fijo, la posición es i × 24 y encontrar el registro 500.000 cuesta un acceso en lugar de 2.930— e indexado cuando la clave no es la posición; en código multihilo, pread/pwrite en lugar de lseek+read. Sobre la escritura hay dos capas de búfer, y confundirlas cuesta datos: el de la biblioteca C, que fflush vacía hacia el núcleo, y la caché de páginas, que solo fsync/fdatasync vacían hacia el disco. Un write() que ha retornado está en RAM, y la escritura diferida lo llevará al medio en hasta 30 segundos. Diferir es correcto —agrupa, absorbe reescrituras y permite reordenar—, pero la durabilidad hay que pedirla explícitamente, acotándola por tiempo: Meteora hace fdatasync cada 5 segundos, arriesgando 40 registros a cambio de no bajar de 1.400 escrituras por segundo a millones.
El patrón que más vas a usar es la publicación atómica: escribir en un temporal, fsync del temporal, rename() —que es atómico— y fsync del directorio. Ninguno de los cuatro pasos sobra, y de regalo un lector que ya tenía el fichero abierto sigue viendo íntegra la versión antigua, porque su descriptor apunta al inodo y no al nombre. Para los logs, O_APPEND convierte "ir al final y escribir" en una operación atómica del núcleo, siempre que compongas cada línea en un solo write. Y para que no haya dos agregador a la vez, flock con LOCK_NB sobre un fichero en /run, que se libera solo cuando el proceso muere y por eso no deja bloqueos huérfanos como un fichero PID; recordando que en Linux todo el bloqueo es consultivo.
Los temporales seguros se crean con mkstemp, que usa O_CREAT|O_EXCL y cierra la ventana TOCTOU que hace de tmpnam una vulnerabilidad; y la regla general es no comprobar una ruta para actuar después sobre ella, sino operar sobre descriptores ya abiertos. Los ficheros dispersos explican por qué ls y du discrepan, y filefrag nos ha dejado el dato con el que arranca la siguiente lección: los 4.219 bloques de 2026-08-31.dat están en un solo extent contiguo.
Y ahí está la pregunta pendiente. Ese fichero se ha escrito 24 bytes cada 125 milisegundos durante 24 horas, entre miles de escrituras de otros procesos, y sin embargo ha acabado perfectamente contiguo en el disco. ¿Cómo decide el sistema de archivos qué bloques asigna, y cómo consigue esa contigüidad? ¿Cómo se representan 4.219 bloques dentro de un inodo que solo tiene 60 bytes para punteros? ¿Y qué ocurre exactamente si se corta la luz justo entre escribir el dato, marcar el bloque como ocupado y actualizar el inodo, dejando esas tres cosas en desacuerdo?
Es lo que veremos en Asignación de Espacio, Journaling e Integridad.
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
