Ja tenim el mapa complet: sabem què és un fitxer, com se li posa nom i com s'acobla l'arbre pel qual hi arribem. El que no hem fet encara és fer-lo servir des d'un programa. I aquí és on apareixen els comportaments que més maldecaps donen en producció, perquè gairebé tots venen d'una sola cosa que gairebé ningú no coneix: darrere d'un descriptor de fitxer hi ha tres taules, no una.

D'aquelles tres taules surten respostes a preguntes que probablement t'has fet. Per què un fill i el seu pare es trepitgen el desplaçament després d'un fork però dos open del mateix fitxer no. Per què 2>&1 ha d'anar després de > fitxer i no abans. Per què un write() que ha retornat correctament es pot perdre en un tall de llum. I per què l'agregador publica les seves mitjanes horàries escrivint en un fitxer temporal i reanomenant-lo, en lloc d'escriure directament al definitiu.

Aquesta lliçó és la més pràctica del mòdul: gairebé tot el que hi ha aquí ho escriuràs tu, en C o en Python, tard o d'hora. Recorrerem 2026-08-31.dat estructura a estructura, esbudellarem les tres taules, entendrem la memòria cau de pàgines al camí d'escriptura, construirem el patró de publicació atòmica, i evitarem que dos agregador s'executin alhora amb un fitxer de bloqueig.

Contingut

  1. Les crides al sistema: open, read, write, lseek, close
  2. Recórrer 2026-08-31.dat estructura a estructura
  3. Les tres taules darrere d'un descriptor
  4. Conseqüències visibles: fork, dos open i dup
  5. Descriptors estàndard i la redirecció de l'intèrpret d'ordres
  6. Mètodes d'accés: seqüencial, directe i indexat
  7. E/S amb memòria intermèdia de la biblioteca C enfront de crides directes
  8. La memòria cau de pàgines i el camí real d'una escriptura
  9. fsync, fdatasync i què es perd en un tall
  10. Escriptura atòmica amb fitxer temporal i rename()
  11. O_APPEND i les escriptures concurrents al registre
  12. Bloqueig de fitxers: flock enfront de fcntl
  13. Fitxers temporals segurs i condicions de cursa TOCTOU
  14. Fitxers dispersos i truncate
  15. Eines d'inspecció

Les crides al sistema: open, read, write, lseek, close

Cinc crides. Amb elles es fa absolutament tota la resta.

int     open (const char *ruta, int indicadors, mode_t mode);
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 desplacament, int origen);
int     close(int fd);

open resol el camí (04-02), comprova permisos (04-06) i retorna un descriptor de fitxer: un enter petit, no negatiu, que és l'índex en una taula del procés. Retorna -1 amb errno posat si falla.

Els indicadors són el cor d'open, i s'han de combinar amb |:

Indicador Què fa Quan fer-lo servir
O_RDONLY Només lectura (val 0, no és un bit) Lectors
O_WRONLY Només escriptura Escriptors purs
O_RDWR Lectura i escriptura Accés mixt
O_CREAT Crear si no existeix. Exigeix el tercer argument mode Crear fitxers
O_EXCL Amb O_CREAT, falla si ja existeix Creació atòmica, fitxers de bloqueig
O_APPEND Cada write va atòmicament al final Registres
O_TRUNC Si existeix i és escrivible, el buida a 0 bytes Regenerar un fitxer
O_DIRECT Salta la memòria cau de pàgines Bases de dades amb memòria cau pròpia
O_SYNC Cada write no torna fins a ser al disc Dades crítiques (molt lent)
O_NONBLOCK No bloquejar (FIFO, sòcols, 03-03) E/S asíncrona
O_CLOEXEC Es tanca en fer execve Gairebé sempre: evita fuites de descriptors

Tres precisions que eviten errors clàssics. O_RDONLY val 0, així que indicadors & O_RDONLY és sempre fals i cal fer servir indicadors & O_ACCMODE. O_CREAT sense el tercer argument deixa el mode amb brossa de la pila i el fitxer pot acabar amb permisos aleatoris: és una fallada de seguretat real. I O_CREAT | O_EXCL és atòmic: o el crees tu, o falla amb EEXIST; sense O_EXCL no hi ha manera de distingir «l'he creat jo» de «ja hi era», i aquella distinció és la base dels fitxers de bloqueig de l'apartat 12.

read i write transfereixen des de/cap al desplaçament actual del fitxer, i l'avancen. I aquí hi ha l'error més repetit del món UNIX:

read i write poden transferir MENYS bytes dels demanats, i això no és un error. Retornen quants n'han mogut de debò. Ignorar-ho produeix corrupció silenciosa de dades.

Amb fitxers regulars, un read curt només passa en arribar al final; però amb canonades, sòcols i terminals és el normal. La manera correcta d'escriure és sempre en bucle:

/* Escriu n bytes COMPLETS o retorna -1. Aquesta funció l'hauries de tenir sempre. */
ssize_t escriure_tot(int fd, const void *buf, size_t n) {
    const char *p = buf; size_t restants = n;
    while (restants > 0) {
        ssize_t escrits = write(fd, p, restants);
        if (escrits < 0) {
            if (errno == EINTR) continue;   /* senyal: reintentar, no és una fallada */
            return -1;                      /* error de debò */
        }
        p += escrits; restants -= escrits;
    }
    return (ssize_t)n;
}

El bucle cobreix les escriptures parcials i l'EINTR cobreix el cas que un senyal (03-03) interrompi la crida abans de transferir res. Un write de 17 MB de cop sobre un sòcol lent retornarà moltes vegades menys del que se li ha demanat.

lseek mou el desplaçament sense transferir res, amb tres orígens: SEEK_SET (des del principi), SEEK_CUR (relatiu a l'actual) i SEEK_END (des del final). L'idiomàtic lseek(fd, 0, SEEK_END) retorna la mida del fitxer, i lseek(fd, 0, SEEK_CUR) la posició actual. I close allibera el descriptor: comprova sempre el seu valor de retorn, perquè en sistemes de fitxers de xarxa pot retornar l'error d'un write diferit que va fallar, i és la teva última oportunitat d'assabentar-te'n.

Recórrer 2026-08-31.dat estructura a estructura

Amb això ja podem escriure el lector real de Meteora. Recordem el format: 720.000 estructures 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 exactes */
    uint32_t estacio_id;         /*  4 */
    int64_t  timestamp;          /*  8 */
    float    temperatura, humitat, pressio;   /*  4 + 4 + 4 */
};

int main(void) {
    int fd = open("/var/lib/meteora/lectures/2026-08-31.dat", O_RDONLY | O_CLOEXEC);
    if (fd < 0) { perror("open"); return 1; }

    off_t mida = lseek(fd, 0, SEEK_END);       /* mida total */
    lseek(fd, 0, SEEK_SET);                    /* tornar al principi */
    printf("%ld bytes = %ld lectures\n", (long)mida, (long)(mida / 24));

    struct Lectura lot[4096];     /* LOTS, no una a una: 98.304 bytes per syscall */
    long total = 0; double suma_temp = 0.0;

    for (;;) {
        ssize_t n = read(fd, lot, sizeof lot);
        if (n < 0) { if (errno == EINTR) continue; perror("read"); break; }
        if (n == 0) break;                     /* fi de fitxer */
        size_t completes = (size_t)n / sizeof(struct Lectura);
        for (size_t i = 0; i < completes; i++) { suma_temp += lot[i].temperatura; total++; }
        /* n % 24 != 0 significa registre partit: cal arrossegar la resta */
    }

    printf("%ld lectures, temperatura mitjana %.2f °C\n", total, suma_temp / total);
    if (close(fd) < 0) perror("close");
    return 0;
}

Les decisions del programa, que són les que cal aprendre. O_CLOEXEC evita que el descriptor s'hereti si el procés fa execve: si meteo-api llança un script auxiliar, aquell script no tindrà accés a les dades. Llegir en lots de 98.304 bytes en lloc de 24 és la decisió de rendiment fonamental, i ve de 01-06: cada crida al sistema costa entre 0,5 i 1 µs, així que amb lectures de 24 bytes serien 720.000 crides ≈ 0,5 segons només en canvis de mode, mentre que amb lots de 4.096 lectures són 176 crides ≈ 0,15 mil·lisegons, una millora de més de 3.000× per escriure la mateixa lògica d'una altra manera. completes = n / 24 perquè un read pot retornar un nombre de bytes que no sigui múltiple de 24, i deixa un registre partit que el codi de producció arrossega al lot següent. I comprovar EINTR a read, pel mateix que a write.

Un avís de portabilitat honest: llegir una estructura de C directament del disc funciona perquè el mateix programa la va escriure a la mateixa arquitectura. Si el fitxer viatgés entre màquines caldria preocupar-se del padding del compilador —aquí struct Lectura fa 24 bytes sense farciment perquè els camps estan ordenats de major a menor alineament— i de l'ordre de bytes. Per a dades internes d'un servidor és legítim; per a un format d'intercanvi, no.

Les tres taules darrere d'un descriptor

Ara la peça central. Quan open retorna 3, el nucli ha tocat tres estructures diferents:

graph LR
    T1["<b>meteo-api (2841)</b><br/>taula de descriptors<br/>0·1·2· <b>3 →</b>"]
    T2["<b>agregador (2903)</b><br/>taula de descriptors<br/>0·1·2· <b>3 →</b>"]
    F1["<b>GLOBAL: struct file A</b><br/>desplaç. = 12.000.000<br/>mode = O_RDONLY"]
    F2["<b>GLOBAL: struct file B</b><br/>desplaç. = 0<br/>mode = O_RDWR"]
    I["<b>Inode 1180934 en memòria</b><br/>mida, permisos, blocs"]
    T1 --> F1 --> I
    T2 --> F2 --> I
Taula Àmbit Què guarda Es veu a
Descriptors Per procés Punters a entrades de la taula global + close-on-exec /proc/<pid>/fd/
Fitxers oberts Global Desplaçament, mode, indicadors, referències, punter a l'inode /proc/<pid>/fdinfo/<n>
Inodes en memòria Global L'inode del VFS (04-03): mida, permisos, propietari, blocs stat

La clau de tot, i la frase que cal memoritzar:

El desplaçament no és al procés ni a l'inode: és a la taula intermèdia de fitxers oberts. Qui comparteixi aquella entrada, comparteix el desplaçament.

Es pot veure de debò:

$ cat /proc/2841/fdinfo/3
pos:    12000000
flags:  0100000
mnt_id: 29
ino:    1180934

pos és el desplaçament actual —l'agregador va per la lectura número 500.000— i flags és el mode en octal. I a /proc/2841/fd/ cada descriptor apareix com un enllaç simbòlic al recurs: el 0 a /dev/null, l'1 i el 2 al registre, el 3 al fitxer de dades, el 4 a socket:[38291]. És el mateix directori que vam fer servir a 04-02 per truncar un fitxer esborrat sense nom.

Conseqüències visibles: fork, dos open i dup

Les tres taules expliquen tres comportaments que, sense elles, semblen arbitraris.

Cas 1: fork(). El fill rep una còpia de la taula de descriptors, però els punters apunten a les mateixes entrades de la taula global. Resultat: pare i fill comparteixen el desplaçament.

int fd = open("sortida.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fork() == 0) {
    write(fd, "FILL\n", 5);      /* escriu a 0, desplaçament → 5 */
    _exit(0);
}
wait(NULL);
write(fd, "PARE\n", 5);          /* escriu a 5 (NO a 0!), desplaçament → 10 */

El fitxer conté FILL\nPARE\n. El pare no ha sobreescrit res perquè el fill va avançar el desplaçament compartit. Això no és un accident: és el que fa que ( echo a; echo b ) > f funcioni a l'intèrpret d'ordres, amb dos processos escrivint ordenadament al mateix fitxer.

Cas 2: dos open independents. Cada open crea una entrada nova a la taula global, amb el seu propi desplaçament.

int fd1 = open("sortida.txt", O_WRONLY);  /* entrada A, desplaçament 0 */
int fd2 = open("sortida.txt", O_WRONLY);  /* entrada B, desplaçament 0 */
write(fd1, "AAAAA", 5);                   /* escriu a 0..4,  A → 5 */
write(fd2, "BBBBB", 5);                   /* escriu a 0..4,  B → 5  SOBREESCRIU! */

El fitxer conté només BBBBB. Els dos descriptors apunten al mateix inode però a entrades diferents, així que cadascun porta el seu compte i es trepitgen. Aquest és exactament el problema que resol O_APPEND a l'apartat 11.

Cas 3: dup i dup2. Dupliquen un descriptor: dos números diferents que apunten a la mateixa entrada de la taula global, i per tant comparteixen desplaçament. dup(fd) retorna el número lliure més baix apuntant al mateix; dup2(fd, 1) força que el descriptor 1 apunti al mateix que fd, i tanca abans l'1 si estava obert. La taula que resumeix els tres casos:

Situació Comparteixen taula de descriptors? Comparteixen entrada global? Comparteixen desplaçament?
Dos open del mateix fitxer No No
Pare i fill després de fork No (còpia)
dup / dup2 Sí (mateix procés)
Dos fils del mateix procés (mateixa taula)

Descriptors estàndard i la redirecció de l'intèrpret d'ordres

Per convenció, tot procés arrenca amb tres descriptors oberts: el 0 és stdin, l'1 és stdout —sortida normal, amb memòria intermèdia— i el 2 és stderr —errors, sense memòria intermèdia—. Que stderr no en tingui és deliberat: un missatge d'error ha d'aparèixer abans que el programa caigui, no quedar-se en una memòria intermèdia que ningú no buidarà.

I ara, la redirecció. Quan escrius meteo-api > /var/log/meteora/meteo-api.log 2>&1, l'intèrpret fa exactament això entre el fork i l'execve (02-01):

if (fork() == 0) {                                  /* al fill */
    int fd = open("/var/log/meteora/meteo-api.log",
                  O_WRONLY | O_CREAT | O_TRUNC, 0644);
    dup2(fd, 1);       /* el descriptor 1 (stdout) passa a ser el registre */
    dup2(1, 2);        /* el descriptor 2 (stderr) passa a ser EL MATEIX que l'1 */
    close(fd);         /* l'original ja no cal: l'1 i el 2 el mantenen viu */
    execve("/usr/bin/meteo-api", argv, envp);   /* hereta 1 i 2 ja redirigits */
}

meteo-api no sap res de la redirecció: escriu al descriptor 1 com sempre, i aquell 1 apunta al registre. Tota la redirecció d'UNIX és això: dup2 abans de l'execve.

Amb això es resol per fi la pregunta eterna: per què l'ordre de > fitxer 2>&1 importa.

Ordre Què fa l'intèrpret Resultat
cmd > f 2>&1 Primer dup2(fd_f, 1), després dup2(1, 2) Tots dos al fitxer
cmd 2>&1 > f Primer dup2(1, 2) (l'1 encara és el terminal!), després dup2(fd_f, 1) stderr al terminal, stdout al fitxer ✘

2>&1 significa literalment «fes que el 2 apunti on apunti l'1 en aquest moment». No crea un vincle permanent. Si el poses abans de la redirecció, l'1 encara és el terminal i allà es queda el 2. És un dup2 i per tant una còpia puntual del punter, no un àlies.

Com que els dos descriptors comparteixen la mateixa entrada de la taula global, comparteixen el desplaçament, i per això la sortida normal i els errors s'intercalen correctament sense sobreescriure's. Si en lloc de 2>&1 fessis cmd > f 2> f, serien dos open independents amb dos desplaçaments: es trepitjarien mútuament, que és el cas 2 de l'apartat anterior.

Mètodes d'accés: seqüencial, directe i indexat

Accés seqüencial. Es llegeix de principi a fi, i cada operació continua on va acabar l'anterior. És el que fa l'agregador en calcular les mitjanes del dia, i és el patró més ràpid perquè el nucli detecta la seqüència i activa la lectura anticipada (readahead), que porta blocs per endavant. Accés directe o aleatori: se salta a qualsevol posició amb lseek, que és el que fa meteo-api quan un client demana la lectura número 500.000 i no té sentit llegir les 499.999 anteriors. La clau és que els registres de mida fixa permeten calcular la posició amb una multiplicació:

int llegir_lectura(int fd, long i, struct Lectura *out) {        /* versió ingènua */
    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 llegir_lectura_mt(int fd, long i, struct Lectura *out) {     /* CORRECTA amb fils */
    ssize_t n = pread(fd, out, sizeof *out, (off_t)i * sizeof *out);
    return (n == sizeof *out) ? 0 : -1;
}

La segona versió mereix atenció. lseek + read són dues crides, i entre elles un altre fil del mateix procés pot moure el desplaçament compartit: una condició de cursa de manual (03-01) que produeix lectures del registre equivocat. pread i pwrite reben la posició com a argument i no toquen el desplaçament, així que són atòmiques i segures entre fils. En un servidor multifil com meteo-api, fer servir pread no és una preferència estilística: és correcció.

El cost de trobar el registre 500.000 en cada mètode:

Mètode Operació Cost Accessos a disc
Seqüencial fins a ell 500.000 lectures O(n) ~2.930 blocs
Directe amb pread 1 multiplicació + 1 lectura O(1) 1 bloc
Indexat per estació Buscar a l'índex + 1 lectura O(log n) 2-3 blocs

Accés indexat. Quan la clau de cerca no és la posició —«totes les lectures de l'estació 42»—, cal una estructura auxiliar que tradueixi clau → posició. Meteora manté en un fitxer a part un índex d'entrades { uint32_t estacio_id; uint32_t primera; uint32_t quantes; }: es carrega l'índex, que és petit, es busca l'estació, i amb primera i quantes es fa un sol pread del rang complet. És la idea que porten a l'extrem les bases de dades amb els seus arbres B+, i la raó que una consulta indexada sigui instantània i una sense índex recorri tota la taula.

E/S amb memòria intermèdia de la biblioteca C enfront de crides directes

A 01-06 vam veure que un printf no arriba al terminal immediatament. Ara podem tancar el tema del tot.

La biblioteca C ofereix una capa per sobre de les crides al sistema, amb FILE*: fopen, fread, fwrite, fprintf, fgets, fclose. El seu valor és una memòria intermèdia en espai d'usuari que agrupa moltes operacions petites en poques crides al sistema.

Crides directes (open/read/write) Biblioteca C (FILE*)
Tipus int fd FILE *fp
Memòria intermèdia Cap 4-8 KiB en espai d'usuari
Crides al sistema Una per operació Una cada vegada que s'omple
Cost d'escriure 1 byte 1.000 vegades 1.000 syscalls (~1 ms) ~1 syscall (~1 µs)
Formatatge A mà fprintf, fscanf
Control exacte del que arriba al nucli Total Cap fins a l'abocament
Portabilitat POSIX Estàndard C, a tot arreu

La biblioteca decideix sola entre tres modes de memòria intermèdia: complet (aboca en omplir-se) per a fitxers i canonades, per línies (aboca al \n) per a terminals interactius, i sense memòria intermèdia per a stderr. D'aquí surt un comportament que confon tothom:

$ ./programa                  # veus la sortida línia a línia, en temps real
$ ./programa | tee sortida.log # la sortida apareix a blocs o al final!

El programa no ha canviat. El que ha canviat és que stdout ja no és un terminal sinó una canonada, així que la biblioteca passa de memòria intermèdia per línies a completa. Si el programa es penja o el mates, perds tot el que quedava a la memòria intermèdia. Les solucions són setvbuf(stdout, NULL, _IOLBF, 0) al codi, o stdbuf -oL ./programa des de fora.

La distinció crítica, que molta gent confon:

fflush(fp) fsync(fd)
Mou dades de... La memòria intermèdia de la biblioteca C La memòria cau de pàgines del nucli
...cap a La memòria cau de pàgines del nucli El dispositiu físic
Sobreviu a matar el procés? després del fflush
Sobreviu a un tall de llum? NO
Cost ~1 µs 0,1-10 ms

fflush no garanteix persistència. Només passa les dades de la memòria intermèdia d'usuari al nucli. Perquè sobrevisquin a un tall cal fsync, i per arribar al fsync des d'un FILE* cal fer els dos passos:

fflush(fp);            /* mem. intermèdia de la biblioteca → memòria cau de pàgines */
fsync(fileno(fp));     /* memòria cau de pàgines           → dispositiu físic */

Oblidar el fflush abans del fsync és un error freqüent i subtil: sincronitzes al disc el que el nucli tenia, però les dades més recents continuaven a la memòria intermèdia del teu propi procés.

La memòria cau de pàgines i el camí real d'una escriptura

Quan l'ingestor executa write(fd, &lectura, 24), què passa realment? Gairebé mai el que un s'imagina:

graph TB
    A["ingestor: write(fd, &lectura, 24)"] --> B["Memòria intermèdia de la biblioteca C<br/>(si fa servir FILE*)"]
    B -->|"fflush / memòria plena"| C["<b>Memòria cau de pàgines del nucli</b><br/>la pàgina es marca BRUTA<br/>write() JA HA RETORNAT ✔"]
    C -->|"fsync() explícit, o<br/>escriptura diferida del nucli"| D["Capa de blocs i planificador (02-05)"]
    D --> E["Memòria cau volàtil del disc (DRAM de l'SSD)"]
    E -->|"FUA / barrera d'escriptura"| F["Mitjà físic persistent ✔"]

El punt decisiu és el tercer bloc: write() retorna tan bon punt les dades són a la memòria cau de pàgines, que és RAM. En aquell moment l'ingestor creu que ha escrit, i des del punt de vista de qualsevol altre procés que llegeixi el fitxer és així, perquè les lectures també passen per la memòria cau. Però al disc encara no hi ha res: la pàgina està marcada com a bruta (dirty) i s'escriurà més tard. Això és l'escriptura diferida (write-back), i la governen uns paràmetres del nucli:

$ 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: en superar el 10 % de la RAM en pàgines brutes, els fils d'escriptura del nucli comencen a abocar en segon pla, sense frenar ningú. dirty_ratio = 20: en superar el 20 %, el procés que escriu es bloqueja i se l'obliga a abocar ell mateix —és la causa de les «aturades» d'un programa que escriu molt: no està calculant, està pagant el deute acumulat—. I dirty_expire_centisecs = 3000: una pàgina bruta s'aboca als 30 segons com a màxim, hi hagi pressió o no.

Per què diferir en lloc d'escriure ja? Per tres raons de pes. Agrupar escriptures: l'ingestor escriu 24 bytes 8 vegades per segon, i si cadascuna anés al disc serien 8 operacions minúscules per segon sobre blocs de 4 KiB, mentre que diferides s'agrupen en una escriptura per bloc ple, una cada 21 segons —una reducció de 170×—. Absorbir reescriptures: si un programa escriu el mateix bloc deu vegades en un segon, només se n'envia una. I ordenar i fusionar, perquè el planificador d'E/S de 02-05 pot combinar peticions adjacents. El preu és exactament el que cal entendre:

Si meteo-01 perd el corrent ara mateix, es perd tot el que sigui a la memòria cau de pàgines i no hagi arribat al disc: fins a 30 segons de lectures, uns 240 registres. I l'ingestor no té manera de saber-ho, perquè els seus write() van retornar èxit.

fsync, fdatasync i què es perd en un tall

Per tancar aquell forat existeixen tres crides:

int fsync(int fd);       /* dades + TOTES les metadades d'aquest fitxer, al disc */
int fdatasync(int fd);   /* dades + només les metadades IMPRESCINDIBLES */
void sync(void);         /* inicia l'abocament de TOT el sistema */
Crida Aboca Cost típic (NVMe) Quan fer-la servir
fsync(fd) Dades + inode complet (dates incloses) 0,5-2 ms Quan el fitxer ha canviat de mida
fdatasync(fd) Dades + metadades necessàries per rellegir-les 0,3-1 ms Reescriptures sense canvi de mida
sync() Tot el sistema 10 ms - diversos segons Abans d'apagar o d'una instantània

La diferència entre fsync i fdatasync és subtil però real: si has afegit dades al final, la mida del fitxer ha canviat, i aquella mida és una metadada imprescindible —sense ella les dades noves no es poden rellegir—, així que fdatasync també l'aboca; el que s'estalvia és escriure l'inode per canvis irrellevants com el mtime, que en escriptura intensa pot suposar un 20-30 %.

La decisió pràctica és un compromís explícit entre durabilitat i rendiment: sense fsync arrisques fins a 30 segons i aconsegueixes centenars de milers d'escriptures per segon; amb fsync a cada write no arrisques res i et quedes en unes 1.000, el límit físic del disc; i amb fsync cada N segons tries tu el punt intermedi.

Què tria Meteora. L'ingestor rep 8 lectures per segon, i perdre 30 segons de dades meteorològiques seria molest però no catastròfic —les estacions ho reintenten—; en canvi, un fsync per lectura limitaria el sistema a unes 1.000 escriptures per segon i castigaria l'SSD sense necessitat. La política escollida és fdatasync() cada 5 segons, que acota la pèrdua a unes 40 lectures i costa 0,2 sincronitzacions per segon:

static time_t ultim_sync = 0;

void escriure_lectura(int fd, const struct Lectura *l) {
    escriure_tot(fd, l, sizeof *l);
    time_t ara = time(NULL);
    if (ara - ultim_sync >= 5) {
        fdatasync(fd);                 /* acota la pèrdua a 5 segons */
        ultim_sync = ara;
    }
}

Un avís important: fsync sobre un fitxer no garanteix que la seva entrada de directori sigui al disc. Si acabes de crear-lo, després d'un tall podria existir el contingut però no el nom. Cal fer també fsync del directori —obrint-lo amb O_RDONLY|O_DIRECTORY—, i és el pas que gairebé tothom oblida.

Escriptura atòmica amb fitxer temporal i rename()

Problema real. L'agregador calcula les mitjanes horàries i les publica a /var/lib/meteora/mitjanes-horaries.json, que meteo-api llegeix per respondre els clients. Si l'agregador escriu directament:

int fd = open("/var/lib/meteora/mitjanes-horaries.json",
              O_WRONLY | O_CREAT | O_TRUNC, 0640);   /* ⚠ PERILL */
escriure_tot(fd, json, strlen(json));
close(fd);

hi ha una finestra d'inconsistència: entre l'O_TRUNC (que deixa el fitxer a 0 bytes) i el final de l'escriptura, qualsevol lector obté un fitxer buit o truncat a la meitat. Amb meteo-api servint consultes contínuament, això significa respostes trencades diverses vegades al dia. I si el procés mor a mitja escriptura, el fitxer queda permanentment corrupte.

La solució és el patró més important de tota aquesta lliçó, i descansa en una garantia del sistema de fitxers:

rename() és atòmic. Per a qualsevol observador, la destinació apunta al fitxer antic o al nou, mai a un estat intermedi, i mai no desapareix.

És atòmic perquè, com vam veure a 04-02, reanomenar només canvia el parell (nom, inode) d'una entrada de directori: una operació que el sistema de fitxers executa com una unitat.

int publicar_atomic(const char *desti, const char *dades, size_t n) {
    char tmp[PATH_MAX], dir[PATH_MAX];
    snprintf(tmp, sizeof tmp, "%s.tmp.%d", desti, getpid());     /* únic per procés */

    int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0640);       /* 1. escriure-ho tot */
    if (fd < 0) return -1;
    if (escriure_tot(fd, dades, n) < 0) { close(fd); unlink(tmp); return -1; }

    if (fsync(fd) < 0) { close(fd); unlink(tmp); return -1; }    /* 2. dades al disc  */
    if (close(fd) < 0) { unlink(tmp); return -1; }

    if (rename(tmp, desti) < 0) { unlink(tmp); return -1; }      /* 3. rename ATÒMIC  */

    snprintf(dir, sizeof dir, "%s", desti);                      /* 4. persistir el   */
    int dfd = open(dirname(dir), O_RDONLY | O_DIRECTORY);        /*    reanomenament  */
    if (dfd >= 0) { fsync(dfd); close(dfd); }
    return 0;
}

Els quatre passos, i per què cap no hi sobra. (1) Escriure en un temporal amb nom únic: ningú no el llegeix, així que pot estar incomplet el temps que calgui. (2) fsync del temporal abans del rename: sense això, el rename —una metadada— podria arribar al disc abans que les dades, i un tall deixaria el nom bo apuntant a un fitxer buit; és la fallada real que van patir moltes aplicacions amb ext4 el 2009. (3) rename atòmic, que a més elimina el fitxer antic en la mateixa operació: el seu comptador d'enllaços baixa a 0 i els blocs s'alliberen quan ningú no el tingui obert (04-02). (4) fsync del directori, perquè el reanomenament en si sigui persistent.

I una propietat preciosa que surt de franc: un lector que va obrir el fitxer antic continua llegint-lo sense problemes després del rename, perquè el seu descriptor apunta a l'inode i no al nom. No obté dades barrejades ni un error: obté íntegra la versió que hi havia quan va obrir, amb consistència perfecta i sense cap bloqueig. Aquest patró és universal —el fan servir Git, els gestors de paquets, sqlite i els editors de text en desar—: si has d'actualitzar un fitxer que algú altre pot estar llegint, aquest és el patró.

O_APPEND i les escriptures concurrents al registre

Tres processos de Meteora escriuen a /var/log/meteora/meteo-api.log. A l'apartat 4 vam veure que dos open independents es trepitgen perquè cadascun porta el seu propi desplaçament. Amb un registre, l'escenari seria aquest:

Procés A: desplaçament 1000, escriu 50 bytes → 1000..1049
Procés B: desplaçament 1000, escriu 40 bytes → 1000..1039   ← esclafa A!

Pitjor encara: encara que cada procés fes lseek(fd, 0, SEEK_END) abans d'escriure, serien dues crides al sistema i entre elles hi cap una expulsió del planificador. És la condició de cursa clàssica de 03-01, amb la finestra entre el lseek i el write.

O_APPEND ho resol d'arrel, i la seva garantia és forta:

Amb O_APPEND, cada write() es posiciona al final del fitxer i escriu en una sola operació atòmica, protegida per un forrellat de l'inode dins del nucli. Dos processos no es poden intercalar.

int fd = open("/var/log/meteora/meteo-api.log",
              O_WRONLY | O_CREAT | O_APPEND, 0640);
/* Cada write es col·loca al final ATÒMICAMENT, sense lseek i sense cursa */
escriure_tot(fd, linia, strlen(linia));

Quatre precisions sobre l'abast de la garantia. És atòmica dins d'una sola crida write: si escrius una línia amb dos write diferents, un altre procés s'hi pot colar entremig, així que compon la línia sencera en una memòria intermèdia i escriu-la d'una vegada. lseek és inútil amb O_APPEND, perquè el nucli ignora el desplaçament i va al final igualment —per això truncate -s 0 sobre un registre amb O_APPEND allibera l'espai correctament (04-02), mentre que sense ell deixa un fitxer dispers—. POSIX garanteix l'atomicitat fins a PIPE_BUF, encara que a Linux i sobre un sistema local funciona amb línies de qualsevol mida raonable. I sobre NFS no està garantida, perquè el client no pot fer atòmicament «buscar el final i escriure» a través de la xarxa: és el problema 2 de 04-03.

Per això tot registre s'ha d'obrir amb O_APPEND, i per això >> a l'intèrpret d'ordres el fa servir i > no.

Bloqueig de fitxers: flock enfront de fcntl

O_APPEND resol les escriptures concurrents a un registre, però no el problema general: impedir que dues instàncies de l'agregador s'executin alhora i calculin les mateixes mitjanes per duplicat.

Linux ofereix dos mecanismes de bloqueig, incompatibles entre si:

flock (BSD) fcntl (POSIX)
Granularitat Fitxer sencer Rangs de bytes
Associat a L'entrada de la taula global El parell (procés, inode)
S'hereta al fork (comparteixen l'entrada) No
Sobreviu a execve
S'allibera en tancar qualsevol descriptor del fitxer No (perillós!)
Funciona sobre NFS Malament Regular (amb lockd)
Entre fils del mateix procés No distingeix No distingeix
Facilitat d'ús Alta Mitjana

El parany de fcntl mereix un avís explícit, perquè produeix fallades molt difícils de diagnosticar: tancar qualsevol descriptor del mateix fitxer allibera tots els bloqueigs que el procés hi tenia, així que si una biblioteca obre i tanca el fitxer per llegir-ne una línia, el teu bloqueig desapareix sense que ningú t'avisi. Tots dos són consultius (advisory): només funcionen si tots els participants cooperen demanant el bloqueig, i un procés que l'ignori escriurà sense impediment. L'alternativa teòrica és el bloqueig obligatori (mandatory), que el nucli imposaria a tothom comprovant-lo a cada read i write. Linux el va tenir —muntant amb -o mand i marcant el fitxer setgid sense permís d'execució al grup—, però era fràgil, tenia condicions de cursa pròpies i es va eliminar del nucli. A la pràctica, tot el bloqueig de fitxers a Linux és consultiu, i això està bé: és un acord entre programes que cooperen, no un mecanisme de seguretat.

El fitxer de bloqueig de l'agregador, que és l'ús més comú:

#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) {       /* exclusiu, SENSE bloquejar-se */
    if (errno == EWOULDBLOCK) {
        fprintf(stderr, "Ja hi ha un altre agregador en marxa. Surto.\n");
        return 0;                              /* sortida neta, no és un error */
    }
    perror("flock"); return 1;
}

calcular_mitjanes_horaries();   /* --- només hi pot haver UNA instància aquí --- */
publicar_atomic("/var/lib/meteora/mitjanes-horaries.json", json, n);
close(fd);                      /* close() allibera el bloqueig */

Les decisions clau: LOCK_NB fa que flock no es bloquegi —si una altra instància el té, retorna EWOULDBLOCK i el programa surt netament; sense això, les instàncies s'acumularien en espera i s'executarien totes seguides, just el que no vols en una tasca horària—; el bloqueig s'allibera tot sol quan el procés mor per qualsevol causa, inclòs kill -9 o un tall de llum, que és l'avantatge decisiu enfront d'un fitxer .pid —aquell exigeix comprovar si el procés continua viu, i el PID pot haver estat reutilitzat—; i el fitxer va a /run, que és tmpfs (04-02) i es neteja sol en arrencar.

Des de l'intèrpret d'ordres, la utilitat flock fa el mateix en una línia, i és el que haurien de fer servir les teves tasques programades:

flock -n /run/meteora/agregador.lock -c '/usr/bin/agregador --horari' || \
    echo "un altre agregador en marxa, ometo aquesta execució"

I fcntl continua sent imprescindible quan necessites bloquejar rangs de bytes: una base de dades que vol bloquejar el registre 500.000 sense impedir l'accés als altres. És la granularitat de 03-04 aplicada a fitxers.

Fitxers temporals segurs i condicions de cursa TOCTOU

Crear un fitxer temporal sembla trivial i és una font clàssica de vulnerabilitats:

/* ⚠ VULNERABLE: no facis això mai */
char *nom = tmpnam(NULL);              /* retorna "/tmp/tmpf3a9k" */
int fd = open(nom, O_WRONLY | O_CREAT, 0600);

El problema té nom: TOCTOU, Time Of Check To Time Of Use. Entre el moment en què tmpnam comprova que el nom està lliure i el moment en què el teu open el crea, hi ha una finestra. Un atacant que vigili /tmp —que és escrivible per tothom— pot, en aquella finestra, crear un enllaç simbòlic amb aquell nom apuntant a /etc/passwd. El teu open amb O_CREAT seguirà l'enllaç i escriurà a /etc/passwd amb els teus privilegis. Si el teu programa és setuid root (04-06), acabes de regalar la màquina.

La solució és mkstemp, que crea i obre en una sola operació atòmica:

#include <stdlib.h>

char plantilla[] = "/var/lib/meteora/mitjanes.XXXXXX";  /* ha d'acabar en 6 X i ser modificable */
int fd = mkstemp(plantilla);            /* crea amb O_CREAT|O_EXCL i permisos 0600 */
if (fd < 0) { perror("mkstemp"); return -1; }

/* 'plantilla' ara conté el nom real, p. ex. /var/lib/meteora/mitjanes.k3Bq7z */
escriure_tot(fd, dades, n);
fsync(fd);
close(fd);
unlink(plantilla);       /* o rename(), si és el patró de publicació atòmica */

Per què mkstemp és segur: fa servir O_CREAT | O_EXCL, així que si el nom ja existeix —inclòs un enllaç simbòlic— falla en lloc de seguir-lo, cosa que tanca el TOCTOU; crea amb permisos 0600; i retorna el descriptor ja obert, sense cap finestra entre la creació i l'ús. La plantilla ha de ser un array modificable, no un literal, perquè mkstemp hi escriu el nom generat.

Un patró encara més fort quan no necessites que ningú més vegi el fitxer: crear-lo i esborrar-lo immediatament, seguint el que vam aprendre a 04-02. unlink(plantilla) just després del mkstemp fa desaparèixer el nom mentre l'inode continua viu pel descriptor: ningú no el pot obrir ni substituir, i el sistema allibera l'espai sol encara que el procés mori d'un kill -9. Linux ofereix a més O_TMPFILE, que crea un fitxer anònim sense nom en cap moment, i elimina fins i tot la finestra entre mkstemp i unlink.

La regla general contra TOCTOU val molt més enllà dels temporals: no comprovis un camí i actuïs després sobre ell; opera directament sobre el descriptor. Per això access() seguit d'open() és un antipatró conegut —comprova amb els permisos reals i obre amb els efectius, amb una finestra entremig— i per això existeixen openat, fstatat, fchmod i fchown, que treballen sobre descriptors ja oberts.

Fitxers dispersos i truncate

truncate i ftruncate canvien la mida d'un fitxer:

int truncate (const char *ruta, off_t longitud);
int ftruncate(int fd,           off_t longitud);

Reduir descarta el que sobra i allibera els blocs. Engrandir fa una cosa més interessant: l'espai nou es llegeix com a zeros, però no es reserven blocs per a ell. Això és un fitxer dispers (sparse file):

$ truncate -s 1G /tmp/dispers.dat
$ ls -lh /tmp/dispers.dat
-rw-r--r-- 1 joan joan 1,0G set  1 14:02 /tmp/dispers.dat     ← mida 1 GB
$ du -h /tmp/dispers.dat
0       /tmp/dispers.dat                                      ← ocupació REAL: 0
$ stat -c 'mida=%s blocs=%b' /tmp/dispers.dat
mida=1073741824 blocs=0

Un fitxer d'1 GB que ocupa zero blocs. El sistema de fitxers simplement no té punters per a aquella regió: quan algú hi llegeix, el nucli retorna zeros sense tocar el disc, i els blocs s'assignen només quan s'hi escriu. D'aquí la diferència perpètua entre el que mostren ls -l i stat -c %s —la mida lògica— i el que mostren du i stat -c %b —l'ocupació real—.

Els fitxers dispersos són útils de debò en imatges de màquines virtuals (un disc de 100 GB amb 12 GB fets servir n'ocupa 12), en bases de dades que preassignen espai i en descàrregues per parts. I es creen sense voler amb un lseek més enllà del final seguit d'un write, que és exactament el que passa en truncar a zero un registre obert sense O_APPEND: el procés conserva el seu desplaçament antic, hi escriu, i crea un forat dispers des del byte 0. Dues precaucions: cp conserva els forats amb --sparse=always però moltes eines els omplen de zeros, i converteixen un fitxer de 12 GB en un de 100, i tar necessita -S per preservar-los.

Eines d'inspecció

Tres eines per veure què està passant de debò amb els fitxers d'un procés.

lsof — què té obert cada procés:

$ 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

Es llegeix d'un cop d'ull l'estat complet del servei: el seu cwd, el seu binari (txt), el registre obert per escriure (1w), el fitxer de dades per llegir (3r), el sòcol d'escolta i la memòria cau compartida. Les opcions més útils són lsof -p PID, lsof cami, lsof +L1 per als esborrats que continuen oberts (04-02) i lsof -i :8080 per port.

/proc/<pid>/fd i /proc/<pid>/fdinfo — el mateix sense instal·lar res: ls -l /proc/2841/fd/ mostra a què apunta cada descriptor, cat /proc/2841/fdinfo/3 dona el pos i els indicadors, ls /proc/2841/fd/ | wc -l compta quants en fa servir i grep files /proc/2841/limits el seu sostre. Comptar descriptors és el diagnòstic de la fuita de descriptors: un servei que obre fitxers i no els tanca acaba esgotant el seu límit (1.024 per defecte, sovint apujat a 65.536) i falla amb EMFILE, «Too many open files». Si el número creix monòtonament amb el temps, tens una fuita.

filefrag — com està repartit un fitxer al disc:

$ sudo filefrag -v /var/lib/meteora/lectures/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/lectures/2026-08-31.dat: 1 extent found

Un sol extent: els 4.219 blocs del fitxer són físicament contigus, del bloc 8.394.271 al 8.398.489. Això significa que llegir el fitxer sencer és una sola operació seqüencial, el millor possible. Com aconsegueix ext4 aquest resultat escrivint 24 bytes cada 125 mil·lisegons durant 24 hores és justament el tema de la lliçó següent.

Errors Habituals i Consells

No comprovar el valor de retorn de read i write. Poden transferir menys bytes dels demanats sense que sigui un error. Escriu una vegada la funció escriure_tot de l'apartat 1 i fes-la servir sempre.

Confondre fflush amb fsync. fflush mou dades de la memòria intermèdia del teu procés al nucli; fsync les porta al disc. Només el segon sobreviu a un tall de llum, i des d'un FILE* calen tots dos, en aquest ordre. Pel mateix, un write() que ha retornat no és al disc: és a la memòria cau de pàgines, i poden passar fins a 30 segons.

Obrir un registre sense O_APPEND. Dos processos se sobreescriuran mútuament, i truncate -s 0 deixarà un fitxer dispers en lloc d'alliberar espai.

Escriure directament sobre un fitxer que altres llegeixen. Fes servir sempre temporal + fsync + rename + fsync del directori; són quatre línies més i eliminen tota una classe de fallades. I no oblidis el fsync del temporal abans del rename: sense ell, un tall pot deixar el nom nou apuntant a un fitxer buit, que és canviar una fallada visible per una de silenciosa.

Fer servir tmpnam, tempnam o mktemp en C. Totes tenen la cursa TOCTOU. Fes servir mkstemp o O_TMPFILE, que creen i obren atòmicament amb permisos 0600. Per la mateixa raó, no comprovis amb access() per actuar després: obre directament i comprova l'error, o opera amb openat i fstat sobre descriptors.

Esperar que flock protegeixi d'un programa que no coopera. És consultiu: només funciona entre programes que el demanen, i a Linux el bloqueig obligatori ja no existeix. I no confonguis flock amb fcntl: són mecanismes diferents que no es veuen entre si, i a fcntl tancar qualsevol descriptor del fitxer allibera tots els teus bloqueigs.

Consell: fes servir pread/pwrite en codi multifil, que reben la posició com a argument i eliminen la cursa entre lseek i read; i obre amb O_CLOEXEC per defecte, perquè els descriptors no es filtrin als processos fills —una fuita d'informació i una causa habitual de «target is busy»—.

Exercicis

Exercici 1: demostrar les tres taules

Escriu tres programes curts en C que demostrin empíricament els tres casos de l'apartat 4: (a) pare i fill després de fork comparteixen desplaçament; (b) dos open independents del mateix fitxer no el comparteixen i se sobreescriuen; (c) dup2 sí que el comparteix. En cadascun, mostra el contingut final del fitxer i el pos de /proc/<pid>/fdinfo/<fd> en el moment adequat. Explica quina taula concreta produeix cada resultat.

Exercici 2: publicació atòmica de l'agregador

Implementa en C (o en Python) la funció que publica les mitjanes horàries a /var/lib/meteora/mitjanes-horaries.json de manera atòmica, amb els quatre passos complets. Després escriu un programa lector que obri el fitxer en bucle i verifiqui que mai no llegeix un JSON incomplet, i executa'ls alhora durant un minut. Finalment, modifica el publicador perquè escrigui directament amb O_TRUNC i mesura quantes lectures corruptes obté el lector. Explica el resultat.

Exercici 3: fsync i el compromís durabilitat/rendiment

Escriu un programa que afegeixi un milió d'estructures Lectura de 24 bytes a un fitxer, amb quatre polítiques: (a) sense fsync; (b) fdatasync cada 1.000 registres; (c) fdatasync a cada registre; (d) obrint amb O_SYNC. Mesura el temps de cadascuna i calcula registres per segon. Després, per a cada política, calcula quants registres es perdrien en un tall de llum amb la taxa real de Meteora (8 lectures/segon) i raona quina triaries i per què.

Solucions

Solució 1

/* (a) fork COMPARTEIX desplaçament */
int fd = open("a.txt", O_WRONLY|O_CREAT|O_TRUNC, 0644);
if (fork() == 0) { write(fd, "FILL\n", 5); _exit(0); }
wait(NULL);
printf("pos del pare: %ld\n", (long)lseek(fd, 0, SEEK_CUR));    /* → 5 */
write(fd, "PARE\n", 5);                        /* a.txt = "FILL\nPARE\n" (10 B) */

/* (b) dos open NO el comparteixen */
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Í que el comparteix */
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 pare veu pos = 5 sense haver escrit res: fork copia la taula de descriptors, però les seves entrades apunten a la mateixa entrada de la taula global, on viu el desplaçament. El mou el fill i el veu el pare.

(b) Cada open va crear la seva pròpia entrada a la taula global, totes dues amb desplaçament 0 i apuntant al mateix inode; les dues van escriure a 0..4 i la segona va esclafar la primera.

(c) dup crea un descriptor nou que apunta a la mateixa entrada global, així que el desplaçament és el mateix i les escriptures s'encadenen.

Resumit: la taula de descriptors decideix quins números veu cada procés; la taula global de fitxers oberts decideix qui comparteix desplaçament; la taula d'inodes decideix quin fitxer és. Els tres casos difereixen només en la segona.

Solució 2

import json, os, tempfile

def publicar_atomic(desti, dades):
    d = os.path.dirname(desti)
    fd, tmp = tempfile.mkstemp(dir=d, prefix=".mitjanes-", suffix=".tmp")  # mateix FS
    try:
        with os.fdopen(fd, "w") as f:
            json.dump(dades, f)
            f.flush()                 # memòria intermèdia de Python → nucli
            os.fsync(f.fileno())      # nucli → disc  (pas 2)
        os.replace(tmp, desti)        # rename ATÒMIC  (pas 3)
        dfd = os.open(d, os.O_RDONLY)
        try:    os.fsync(dfd)         # persistir el reanomenament (pas 4)
        finally: os.close(dfd)
    except BaseException:
        os.unlink(tmp)                # no deixar brossa si alguna cosa falla
        raise

Dos detalls imprescindibles: el temporal s'ha de crear al mateix directori que la destinació, perquè rename() no creua sistemes de fitxers (EXDEV, 04-02) i si el poses a /tmp fallarà o degenerarà en copiar+esborrar, i perdrà l'atomicitat; i os.replace en lloc d'os.rename, perquè el primer garanteix la semàntica POSIX de sobreescriptura atòmica també a Windows.

El lector verificador obre el fitxer en bucle durant un minut i compta quantes vegades json.load() llança JSONDecodeError o FileNotFoundError. Resultat esperat amb publicació atòmica: corruptes=0, sempre, per moltes voltes que hi doni. Amb O_TRUNC directe, en un fitxer d'uns 200 KB, la finestra d'inconsistència dura 1-3 ms a cada publicació, i amb un lector en bucle tancat apareixen desenes o centenars de lectures corruptes per minut.

L'explicació. Amb O_TRUNC el fitxer passa per estats intermedis visibles: 0 bytes, després parcialment escrit, i qualsevol lector que hi arribi en aquella finestra obté JSON invàlid. Amb temporal + rename, el nom apunta sempre a un inode complet i el canvi de a quin apunta és atòmic; un lector que ja el tingués obert continua llegint íntegra la versió antiga, perquè el seu descriptor està lligat a l'inode i no al nom.

Solució 3

Valors típics sobre un NVMe (varien amb el maquinari, però els ordres de magnitud es mantenen):

Política Temps (1 M registres) Registres/s Sincronitzacions
(a) Sense fsync 0,9 s 1.100.000 0 (el nucli aboca sol)
(b) fdatasync cada 1.000 1,8 s 555.000 1.000
(c) fdatasync a cada registre ~700 s ~1.400 1.000.000
(d) O_SYNC ~900 s ~1.100 1.000.000 implícites

La conclusió és contundent: sincronitzar a cada registre és 800 vegades més lent. El disc no pot confirmar més de 1.000-3.000 sincronitzacions per segon, perquè cadascuna implica buidar la memòria cau volàtil del dispositiu i esperar confirmació del mitjà; aquell número és una característica física del maquinari i no millora escrivint millor codi.

Dades en risc amb la taxa real de Meteora (8 lectures/segon):

Política Finestra de pèrdua Registres perduts Dades
(a) Sense fsync Fins a 30 s (dirty_expire) 240 5,7 KB
(b) Cada 1.000 registres 1.000/8 = 125 s 1.000 24 KB
(b') Cada 5 segons 5 s 40 960 B
(c) A cada registre 0 0 0

Què triaria i per què. Ni la (a) ni la (c). La (c) no aporta res: perdre uns segons de dades meteorològiques és tolerable —les estacions ho reintenten— i a canvi limitaria el sistema a 1.400 escriptures per segon i multiplicaria el desgast de l'SSD. La (a) és còmoda però deixa una finestra de 30 segons que a més no controles, perquè depèn de la pressió de memòria del sistema sencer.

La política correcta és la (b'), fdatasync cada 5 segons: acota la pèrdua a 40 registres amb només 0,2 sincronitzacions per segon. Fixa't que és la (b') i no la (b): sincronitzar per temps dona una garantia explicable a un client —«com a màxim es perden 5 segons»— i independent del ritme d'arribada, mentre que «cada 1.000 registres» significa 125 segons amb trànsit normal i hores si les estacions envien poc. Acota sempre per temps, no per quantitat. I fdatasync en lloc de fsync perquè, en afegir al final, l'única metadada imprescindible és la mida.

Conclusió

Cinc crides al sistema —open, read, write, lseek, close— basten per a tot, i els indicadors d'open són el que les fa potents: O_CREAT|O_EXCL per crear atòmicament, O_APPEND per a registres, O_CLOEXEC per no filtrar descriptors. Dues regles per no equivocar-se: read i write poden moure menys bytes dels demanats —d'aquí la funció escriure_tot amb el seu bucle i el seu EINTR—, i llegir en lots en lloc de registre a registre converteix 720.000 crides al sistema en 176, una millora de més de 3.000× per reordenar la mateixa lògica.

Darrere d'un descriptor hi ha tres taules: la de descriptors, per procés; la de fitxers oberts, global, on viu el desplaçament; i la d'inodes en memòria. Tota la fenomenologia surt de la segona: fork i dup comparteixen entrada, i per tant desplaçament; dos open no, i per tant se sobreescriuen. Això explica que ( echo a; echo b ) > f funcioni, i explica per fi 2>&1: és un dup2 que copia on apunta l'1 en aquell instant, per la qual cosa posar-lo abans de > fitxer deixa stderr al terminal.

Els mètodes d'accés són seqüencial (amb lectura anticipada), directe —amb registres de mida fixa, la posició és i × 24 i trobar el registre 500.000 costa un accés en lloc de 2.930— i indexat quan la clau no és la posició; en codi multifil, pread/pwrite en lloc de lseek+read. Sobre l'escriptura hi ha dues capes de memòria intermèdia, i confondre-les costa dades: la de la biblioteca C, que fflush buida cap al nucli, i la memòria cau de pàgines, que només fsync/fdatasync buiden cap al disc. Un write() que ha retornat és a la RAM, i l'escriptura diferida el portarà al mitjà en fins a 30 segons. Diferir és correcte —agrupa, absorbeix reescriptures i permet reordenar—, però la durabilitat s'ha de demanar explícitament, acotant-la per temps: Meteora fa fdatasync cada 5 segons, i arrisca 40 registres a canvi de no baixar de 1.400 escriptures per segon a milions.

El patró que més faràs servir és la publicació atòmica: escriure en un temporal, fsync del temporal, rename() —que és atòmic— i fsync del directori. Cap dels quatre passos no hi sobra, i de regal un lector que ja tenia el fitxer obert continua veient íntegra la versió antiga, perquè el seu descriptor apunta a l'inode i no al nom. Per als registres, O_APPEND converteix «anar al final i escriure» en una operació atòmica del nucli, sempre que componguis cada línia en un sol write. I perquè no hi hagi dos agregador alhora, flock amb LOCK_NB sobre un fitxer a /run, que s'allibera sol quan el procés mor i per això no deixa bloqueigs orfes com un fitxer PID; recordant que a Linux tot el bloqueig és consultiu.

Els temporals segurs es creen amb mkstemp, que fa servir O_CREAT|O_EXCL i tanca la finestra TOCTOU que fa de tmpnam una vulnerabilitat; i la regla general és no comprovar un camí per actuar després sobre ell, sinó operar sobre descriptors ja oberts. Els fitxers dispersos expliquen per què ls i du discrepen, i filefrag ens ha deixat la dada amb què arrenca la lliçó següent: els 4.219 blocs de 2026-08-31.dat són en un sol extent contigu.

I aquí hi ha la pregunta pendent. Aquell fitxer s'ha escrit 24 bytes cada 125 mil·lisegons durant 24 hores, entre milers d'escriptures d'altres processos, i tanmateix ha acabat perfectament contigu al disc. Com decideix el sistema de fitxers quins blocs assigna, i com aconsegueix aquella contigüitat? Com es representen 4.219 blocs dins d'un inode que només té 60 bytes per a punters? I què passa exactament si es talla la llum just entre escriure la dada, marcar el bloc com a ocupat i actualitzar l'inode, i deixa aquelles tres coses en desacord?

És el que veurem a Assignació d'Espai, Journaling i Integritat.

Fonaments de Sistemes Operatius

Mòdul 1: Introducció als Sistemes Operatius

Mòdul 2: Gestió de Recursos

Mòdul 3: Concurrència

Mòdul 4: Estructures de Fitxers

Mòdul 5: Protecció i Seguretat del Sistema

Mòdul 6: Virtualització i Contenidors

Mòdul 7: Administració i Diagnòstic a la Pràctica

© Copyright 2026. Tots els drets reservats