Blog Programmazione

Riscrivere un file senza il rischio di trovarlo vuoto

Un programma che sovrascrive un file e viene interrotto a metà non lascia un file a metà: lascia un file vuoto. Trenta righe di libreria standard spostano il rischio da «o nuovo o niente» a «o nuovo o vecchio».

Uno script di manutenzione riscrive ogni notte il file di configurazione di un impianto: legge i parametri da un database, li formatta, li salva. Una notte il server perde corrente mentre lo script sta scrivendo. La mattina dopo il file non è corrotto e non è a metà: è vuoto, zero byte. E la versione di ieri, quella che funzionava, non esiste più da nessuna parte.

Non è sfortuna ed è il contrario di un caso raro: è il comportamento normale di una riga che quasi tutti scriviamo senza pensarci.

Perché «w» distrugge prima di scrivere

Quando apri un file con open(percorso, "w"), la lettera w non significa soltanto «in scrittura»: significa anche troncamento. Il file viene portato a zero byte nell’istante dell’apertura, prima che il programma abbia scritto un solo carattere. Il contenuto nuovo arriva dopo, a pezzi, e per un po’ non è nemmeno su disco: resta nel buffer che Python tiene in memoria e che viene svuotato quando si riempie o quando il file si chiude.

Fra il troncamento e l’ultimo svuotamento del buffer esiste quindi una finestra in cui il file su disco non contiene né il vecchio né il nuovo. Per un file di tre righe dura meno di un millesimo di secondo. Per un file che il programma costruisce via via — una riga per ogni record interrogato, una per ogni misura letta da un sensore — dura quanto dura il lavoro: il tempo che basta perché arrivi un errore, un timeout o un blackout.

Quella finestra, poi, non è pericolosa solo per chi scrive. È pericolosa anche per chi legge: un altro servizio, un’attività pianificata, il server web che in quel momento serve una pagina. Trova il file che c’è, e quello che c’è è niente.

La ricetta: scrivere accanto, poi spostare

La soluzione non è scrivere più in fretta: è non toccare il file di destinazione fino a quando il contenuto nuovo non è completo. Si scrive in un file di appoggio e lo si mette al posto dell’altro con una sola operazione del sistema operativo, che non ha stati intermedi osservabili. In Python quell’operazione è os.replace, che la docstring dell’interprete — leggibile con inspect.getdoc su Python 3.13.16 — descrive come «Rename a file or directory, overwriting the destination».

La garanzia che conta sta un piano più sotto, nella chiamata di sistema rename. La pagina di manuale Linux la formula così: «If newpath already exists, it will be atomically replaced, so that there is no point at which another process attempting to access newpath will find it missing». Tradotta: non esiste un istante in cui un altro processo trovi il file assente. Chi lo apre un momento prima vede la versione vecchia per intero, chi lo apre un momento dopo vede quella nuova per intero. Nessuno vede un file a metà.

Questo chiude il buco della visibilità, non quello della durabilità. Perché il contenuto sia davvero sul dispositivo e non solo nelle cache del sistema serve os.fsync, che la documentazione Python descrive così: «Force write of file with filedescriptor fd to disk. On Unix, this calls the native fsync() function; on Windows, the MS _commit() function», con l’avvertenza di svuotare prima il buffer di Python — «first do f.flush(), and then do os.fsync(f.fileno())». E ne serve un secondo, meno noto, sulla cartella: la pagina di manuale di fsync avverte che «Calling fsync() does not necessarily ensure that the entry in the directory containing the file has also reached disk. For that an explicit fsync() on a file descriptor for the directory is also needed».

Il codice

Messo insieme, è un gestore di contesto: apre il file di appoggio, te lo consegna, e sostituisce l’originale solo se il blocco arriva in fondo senza eccezioni. Solo libreria standard, provato con Python 3.13.16.

#!/usr/bin/env python3
"""Riscrivere un file senza poterlo trovare a meta'.
Python 3.13.16, solo libreria standard."""
import contextlib
import os
import tempfile
from pathlib import Path


@contextlib.contextmanager
def file_sostitutivo(percorso, codifica="utf-8", sincronizza_cartella=True):
    """Apre un file di appoggio nella stessa cartella e lo mette al posto
    di `percorso` solo se il blocco arriva in fondo senza errori."""
    percorso = Path(percorso)
    cartella = percorso.parent if str(percorso.parent) else Path(".")
    try:
        permessi = percorso.stat().st_mode & 0o777
    except FileNotFoundError:
        permessi = None

    fd, appoggio = tempfile.mkstemp(dir=cartella,
                                    prefix=percorso.name + ".",
                                    suffix=".parziale")
    try:
        with os.fdopen(fd, "w", encoding=codifica, newline="\n") as f:
            yield f
            f.flush()
            os.fsync(f.fileno())
        if permessi is not None:
            os.chmod(appoggio, permessi)
        os.replace(appoggio, percorso)
    except BaseException:
        Path(appoggio).unlink(missing_ok=True)
        raise

    if sincronizza_cartella and os.name == "posix":
        fd_cartella = os.open(cartella, os.O_RDONLY)
        try:
            os.fsync(fd_cartella)
        finally:
            os.close(fd_cartella)

Si usa come un normale open: with file_sostitutivo("impianto.conf") as f: e dentro si scrive. Se il blocco solleva un’eccezione a metà — eseguito: un ValueError dopo la prima scrittura — il file di appoggio viene cancellato e il file di destinazione resta esattamente com’era, contenuto e permessi.

La prova: uccidere il processo a metà

Questo banco di prova fa scrivere a un processo figlio duemila righe con una pausa di un millesimo di secondo ciascuna, lo uccide con SIGKILL dopo quattro decimi di secondo — un segnale che il programma non può intercettare né rimediare — e guarda che cosa resta del file di prima.

#!/usr/bin/env python3
"""Uccide il processo mentre riscrive un file e guarda che cosa resta."""
import os
import signal
import subprocess
import sys
import time
from pathlib import Path

from scrittura_atomica import file_sostitutivo

VECCHIO = "# configurazione valida\nsoglia = 12\ndestinatario = SEGNAPOSTO\n"


def scrivi_lentamente(f):
    for i in range(2000):
        f.write(f"misura_{i:04d} = {i * 3}\n")
        time.sleep(0.001)


def figlio(modo, percorso):
    if modo == "ingenuo":
        with open(percorso, "w", encoding="utf-8", newline="\n") as f:
            scrivi_lentamente(f)
    else:
        with file_sostitutivo(percorso) as f:
            scrivi_lentamente(f)


def prova(modo, ritardo):
    cartella = Path(__file__).parent / ("prova_" + modo)
    cartella.mkdir(exist_ok=True)
    for p in cartella.iterdir():
        p.unlink()
    bersaglio = cartella / "impianto.conf"
    bersaglio.write_text(VECCHIO, encoding="utf-8")
    os.chmod(bersaglio, 0o644)

    p = subprocess.Popen([sys.executable, __file__, "figlio", modo, str(bersaglio)])
    time.sleep(ritardo)
    p.send_signal(signal.SIGKILL)
    p.wait()

    resto = bersaglio.read_bytes()
    avanzi = sorted(x.name for x in cartella.iterdir() if x.name != "impianto.conf")
    print(f"[{modo}] processo ucciso dopo {ritardo} s")
    print(f"  byte in impianto.conf: {len(resto)} (prima erano {len(VECCHIO)})")
    print(f"  configurazione vecchia ancora intera: {resto == VECCHIO.encode()}")
    print(f"  permessi: {oct(bersaglio.stat().st_mode & 0o777)}")
    print(f"  file di appoggio rimasti: {avanzi}")


if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "figlio":
        figlio(sys.argv[2], sys.argv[3])
    else:
        prova("ingenuo", 0.4)
        prova("atomico", 0.4)

Uscita reale, con i due file nella stessa cartella:

[ingenuo] processo ucciso dopo 0.4 s
  byte in impianto.conf: 0 (prima erano 62)
  configurazione vecchia ancora intera: False
  permessi: 0o644
  file di appoggio rimasti: []
[atomico] processo ucciso dopo 0.4 s
  byte in impianto.conf: 62 (prima erano 62)
  configurazione vecchia ancora intera: True
  permessi: 0o644
  file di appoggio rimasti: ['impianto.conf.i18t6zvb.parziale']

Vale la pena guardare il primo numero: zero, non «metà». La versione ingenua non ha lasciato un file troncato a duecento righe, ne ha lasciato uno vuoto, perché il troncamento è avvenuto all’apertura e le righe scritte in quattro decimi di secondo erano ancora nel buffer. La versione sostitutiva ha lasciato il file di ieri intero, con i suoi permessi, più un file di appoggio orfano il cui nome cambia a ogni esecuzione.

Le quattro cose che si sbagliano

I permessi. tempfile.mkstemp crea il file in modo prudente: «The file is readable and writable only by the creating user ID», cioè 0o600 — misurato eseguendo, e coerente con la documentazione. Senza la riga di os.chmod il file sostituito eredita quei permessi: nella prova, un impianto.conf che era 0o644 diventa 0o600, e il servizio che lo legge con un altro utente smette di funzionare. Attenzione però a non fidarsi troppo di quella riga: copia i bit di permesso, non il proprietario, non le ACL, non gli attributi estesi.

La cartella del file di appoggio. Deve essere la stessa del file di destinazione, non la cartella dei temporanei di sistema. Lo spostamento funziona solo all’interno dello stesso filesystem: provato in container, os.replace da /dev/shm alla cartella di lavoro solleva OSError con errno 18, «Invalid cross-device link». La pagina di manuale lo elenca fra gli errori: «EXDEV oldpath and newpath are not on the same mounted filesystem».

Il secondo fsync. È quello che quasi tutti omettono, perché il programma funziona benissimo anche senza. Serve solo nel caso che conta: la macchina che si spegne di colpo. Senza di esso i dati possono essere sul dispositivo e la voce di cartella che li collega al nome definitivo no.

Gli avanzi. Un processo ucciso lascia un .parziale che nessuno cancellerà mai, e su uno script notturno diventano centinaia di file in un anno. Conviene che il programma, all’avvio, elimini i residui di suffisso noto accanto al proprio bersaglio: è una riga, e senza di essa la ricetta scambia un rischio di perdita di dati con una lenta perdita di spazio.

Che cosa è stato provato e che cosa no

Tutto il codice di questo articolo è stato eseguito su Linux, Python 3.13.16, con la sola libreria standard, e l’uscita riportata è quella reale. SIGKILL prova l’interruzione del programma, non il blackout: l’effetto dei due fsync contro la perdita di corrente non è stato misurato, e si appoggia a quello che le pagine di manuale dichiarano. La procedura non è stata provata su Windows.

Che cosa questa ricetta non promette

Su Windows os.replace esiste e sostituisce la destinazione, ma la garanzia citata sopra è un requisito POSIX e non va trasferita per analogia: la pagina Microsoft della funzione MoveFileEx, che è il modo in cui Win32 sostituisce un file esistente, descrive il flag MOVEFILE_REPLACE_EXISTING dicendo che «the function replaces its contents with the contents of the lpExistingFileName file» e non usa mai la parola «atomic», in nessun punto. Chi deve garantire questo comportamento su Windows ha una verifica da fare, non una certezza da ereditare.

C’è poi un prezzo. fsync è lento per costruzione: «The call blocks until the device reports that the transfer has completed», e su un disco rotante o su una scheda SD significa decine di millesimi di secondo. Va chiamato una volta per file, alla fine, mai dentro il ciclo che scrive le righe. Se il parametro sincronizza_cartella è lì è proprio per poterlo spegnere quando si riscrivono migliaia di file di cache, dove la perdita di uno non costa niente.

Infine, la ricetta protegge un file. Se il tuo aggiornamento tocca tre file che devono cambiare insieme — un indice e due archivi — l’atomicità del singolo os.replace non ti dà quella dell’insieme, e il blackout può lasciarti con l’indice nuovo e gli archivi vecchi. Quel problema si risolve diversamente: una cartella intera scambiata con un collegamento simbolico, oppure un database, che è un programma scritto da anni per fare esattamente questo.

Cosa puoi fare adesso

Cerca nei tuoi script le aperture in scrittura su file che esistono già e che qualcuno rilegge: configurazioni, indici, esportazioni verso un gestionale, file di stato letti da un’attività pianificata. Quelle sono le righe esposte. Dove il file è piccolo e nessuno lo legge in concorrenza puoi lasciarlo com’è; dove la perdita del contenuto vecchio costa una serata, sostituisci open con il gestore di contesto qui sopra — trenta righe, nessuna dipendenza — e aggiungi la pulizia dei .parziale all’avvio. Poi prova il tuo programma uccidendolo a metà: il banco di prova è sopra e costa un minuto. Un’interruzione che non hai mai provato non è un rischio che hai valutato.