File e Buffering in Linguaggio C

Concetti Chiave
  • Il buffering è una tecnica utilizzata per migliorare le performance delle operazioni di input/output (I/O) in linguaggio C.
  • La funzione fflush() consente di forzare il flush del buffer di output, scrivendo tutti i dati memorizzati nel buffer sul file associato.
  • La funzione setvbuf() permette di specificare il tipo di buffering (completo, lineare o nessun buffering) e la dimensione del buffer utilizzato per le operazioni di I/O su un file.
  • La funzione setbuf() è una versione più semplice e obsoleta di setvbuf(), che consente di impostare un buffer personalizzato per un file specifico.

L'Input/Output e il concetto di Buffering

Nelle lezioni precedenti abbiamo visto che alla base del concetto di Input/Output (I/O) in linguaggio C ci sono gli stream, che rappresentano flussi di dati in entrata o in uscita. Gli stream possono essere associati a dispositivi di I/O come la tastiera, lo schermo, a file su disco o a socket, ossia connessioni di rete.

Uno dei problemi principali nell'I/O è la differenza di velocità tra il processore, la ram e i dispositivi di I/O. Ad esempio, leggere dati da un file su disco è molto più lento rispetto alla velocità con cui il processore può accedere ai dati in memoria RAM. Peggio ancora, scrivere dati su disco è un'operazione che può richiedere tempi ancora più lunghi.

La conseguenza è che non è efficiente per un programma accedere ad un file ogniqualvolta abbia bisogno di leggere o scrivere un dato.

Per ovviare a questo problema e ottenere performance accettabili, tutti i sistemi operativi implementano un meccanismo chiamato buffering. Il buffering consiste nel riservare una porzione di memoria ram, il buffer appunto, che viene utilizzata per memorizzare temporaneamente i dati letti o scritti su un file.

Quando il file viene chiuso oppure quando il buffer è pieno, i dati memorizzati nel buffer vengono effettivamente scritti sul file su disco. Questa operazione prende il nome di flush del buffer (flush in inglese significa letteralmente tirare lo sciaquone 😄).

Analogamente, anche il buffer di lettura (il buffer di input) funziona allo stesso modo. Quando un programma legge dati da un file, il sistema operativo legge una quantità maggiore di dati dal file e li memorizza nel buffer di input. Successivamente, quando il programma richiede i dati, questi vengono letti direttamente dal buffer, che è molto più veloce rispetto all'accesso diretto al file su disco. Quando il buffer di input si svuota, il sistema operativo legge nuovamente una quantità maggiore di dati dal file e li memorizza nel buffer.

La tecnica del buffering migliora significativamente le performance delle operazioni di I/O, dal momento che leggere o scrivere singoli byte in memoria richiede un tempo di due o tre ordini di grandezza inferiore rispetto all'accesso diretto al file su disco. Ovviamente, trasferire grandi blocchi di dati dal disco in memoria o viceversa è comunque un'operazione più lenta rispetto alla semplice lettura o scrittura in memoria, ma leggere o scrivere grandi blocchi di dati è comunque molto più efficiente rispetto a leggere o scrivere singoli byte.

Le funzioni della libreria standard del linguaggio C definite nell'header <stdio.h> effettuano il buffering in modo trasparente per il programmatore. Nella maggior parte dei casi, il programmatore non deve preoccuparsi del funzionamento interno del buffering.

Esistono, tuttavia, delle situazioni in cui il programmatore potrebbe voler controllarne esplicitamente il funzionamento. Per questo motivo, la libreria standard del linguaggio C fornisce tre funzioni per gestire il buffering:

  • fflush(): questa funzione forza il flush del buffer di output, scrivendo tutti i dati memorizzati nel buffer sul file associato.
  • setbuf(): questa funzione consente di impostare un buffer personalizzato per un file specifico, permettendo al programmatore di controllare la dimensione e la posizione del buffer utilizzato per le operazioni di I/O su quel file.
  • setvbuf(): questa funzione offre un controllo più dettagliato sul buffering, permettendo di specificare il tipo di buffering (completo, lineare o nessun buffering) e la dimensione del buffer.

Vediamo ora in dettaglio ciascuna di queste funzioni.

La funzione fflush

Per comprendere il comportamento della funzione fflush(), dobbiamo prima introdurre un concetto importante riguardante il buffering e l'input/output in generale:

Definizione

In linguaggio C (così come in UNIX) l'Input/Output è sincrono ma non sincronizzato

In linguaggio C (così come in UNIX) l'Input/Output è sincrono. Ciò significa che quando un programma esegue un'operazione di I/O, come la lettura o la scrittura di dati su un file, il programma attende che l'operazione venga completata prima di procedere con l'esecuzione delle istruzioni successive.

Non abbiamo ancora studiato le funzioni di lettura e scrittura su file, come ad esempio fread() e fwrite(), ma possiamo già affermare che quando un programma chiama una di queste funzioni, il programma rimane in attesa fino a quando l'operazione di lettura o scrittura non viene completata. Il flusso di controllo non riprende fino a quando l'operazione di I/O non è terminata.

Tuttavia, l'I/O in linguaggio C non è sincronizzato. Ciò significa che le operazioni di I/O non sono necessariamente eseguite nell'ordine in cui vengono chiamate nel codice del programma e, soprattutto, le operazioni di scrittura potrebbero non essere immediatamente visibili ad altri processi o thread che accedono allo stesso file. Questo perché il sistema operativo può utilizzare il buffering per ottimizzare le operazioni di I/O, ritardando la scrittura effettiva dei dati su disco fino a quando il buffer non è pieno o fino a quando il file viene chiuso.

Detto in altre parole, quando un programma esegue un'operazione di scrittura su un file, i dati potrebbero essere memorizzati temporaneamente nel buffer di output e non essere immediatamente scritti sul file su disco. Il sistema operativo può decidere di ritardare la scrittura effettiva dei dati su disco per ottimizzare le operazioni di I/O dato che queste ultime sono particolarmente lente e costose in termini di tempo di esecuzione.

Per chiarire meglio, possiamo pensare ad un'analogia. Supponiamo di voler spedire un pacco da Roma a Milano. Invece di trasportare il pacco direttamente da Roma a Milano, potremmo decidere di affidarlo ad un corriere. Il corriere potrebbe decidere di non consegnare immediatamente il pacco a Milano, ma di aspettare di avere più pacchi da consegnare nella stessa zona per ottimizzare il viaggio e ridurre i costi di trasporto. In questo modo, il corriere potrebbe ritardare la consegna del pacco, ma alla fine il pacco arriverà comunque a destinazione. Invocare un'operazione di scrittura su un file in linguaggio C è simile a consegnare il pacco al corriere: i dati potrebbero non essere immediatamente scritti sul file su disco, ma alla fine verranno comunque salvati.

Come abbiamo visto, solo quando chiudiamo un file con la funzione fclose(), abbiamo la certezza che tutti i dati memorizzati nel buffer di output vengano effettivamente scritti sul file su disco. Ma usare fclose() ogni volta che vogliamo assicurarci che i dati vengano scritti sul file non è sempre pratico, soprattutto se dobbiamo continuare a scrivere altri dati sul file.

Per questo motivo, la libreria standard del linguaggio C fornisce la funzione fflush(), che consente di forzare il flush del buffer di output, scrivendo tutti i dati memorizzati nel buffer sul file associato, senza chiudere il file.

La sintassi della funzione fflush() è la seguente:

int fflush(FILE *stream);

Questa funzione richiede come parametro un puntatore a un oggetto di tipo FILE, che rappresenta il file su cui vogliamo eseguire il flush del buffer di output. La funzione restituisce un valore intero: 0 se il flush è avvenuto con successo, oppure EOF (End Of File) in caso di errore.

Essa può essere chiamata in due modi:

  1. Passando il puntatore a un oggetto di tipo FILE:

    fflush(file);
    

    In questo caso, la funzione esegue il flush del buffer di output associato al file specificato.

  2. Passando il valore NULL come parametro:

    fflush(NULL);
    

    In questo caso, la funzione esegue il flush di tutti i buffer di output associati a tutti i file aperti dal programma.

Il vantaggio di utilizzare fflush() è che abbiamo la garanzia che tutte le modifiche apportate al file fino a quel momento vengano effettivamente scritte sul disco e saranno visibili ad altri processi o thread che accedono allo stesso file, senza dover chiudere il file.

La funzione setvbuf

La funzione fflush è utile per forzare il flush del buffer di output, ma in alcune situazioni potremmo voler avere un controllo più dettagliato sul funzionamento del buffering. Per questo motivo, la libreria standard del linguaggio C fornisce due funzioni aggiuntive: setbuf() e setvbuf().

La funzione setvbuf() consente di specificare il modo in cui uno stream deve essere bufferizzato e la dimensione e la posizione del buffer utilizzato per le operazioni di I/O su quel file. La sua sintassi è la seguente:

int setvbuf(FILE *stream, char *buffer, int mode, size_t size);

Uno dei parametri più importanti è mode, che specifica il tipo di buffering da utilizzare. Esistono tre modalità di buffering:

  • _IOFBF: Full Buffering, buffering completo.

    Usando questa modalità, in lettura i dati vengono letti dal file in blocchi di dimensione pari a size e memorizzati nel buffer. In scrittura, i dati vengono scritti sullo stream solo quando il buffer è pieno o quando viene eseguito un flush esplicito (ad esempio con fflush() o fclose()).

    Questa modalità è quella di default per tutti gli stream o file che non sono associati a dispositivi interattivi (come la tastiera o lo schermo). Quindi, quando apriamo un file su disco, il buffering completo è la modalità predefinita.

  • _IOLBF: Line Buffering, buffering di linea.

    In questa modalità, adatta specialmente per gli stream di caratteri, i dati vengono letti o scritti una riga (o linea) alla volta. In scrittura, il buffer viene svuotato (flush) ogni volta che viene incontrato un carattere di nuova linea ('\n'). In lettura, i dati vengono letti dal file fino a quando non viene incontrato un carattere di nuova linea o fino a quando il buffer non è pieno.

  • _IONBF: No Buffering, nessun buffering.

    In questa modalità, i dati vengono letti o scritti direttamente sul file senza utilizzare alcun buffer. Ogni operazione di lettura o scrittura viene eseguita immediatamente sul file.

Il secondo parametro buffer consente di specificare l'indirizzo di un'area di memoria che verrà utilizzata come buffer per le operazioni di I/O. Questo buffer può essere un array di caratteri allocato automaticamente, staticamente o dinamicamente.

Se il buffer è allocato automaticamente (ad esempio, come variabile locale all'interno di una funzione), esso deve rimanere valido per tutta la durata dell'uso dello stream, ma il vantaggio è che esso viene automaticamente deallocato quando la funzione termina.

Se, invece, il buffer è allocato staticamente (ad esempio, come variabile globale o statica), esso rimane valido per tutta la durata del programma. L'allocazione dinamica (ad esempio, utilizzando malloc()) consente di specificare la dimensione del buffer in modo più flessibile, ma richiede che il programmatore si occupi della deallocazione della memoria quando non è più necessaria, cosa che può essere anche un vantaggio in quanto il buffer può essere ridimensionato o liberato quando non serve più.

In generale, un buffer di grandi dimensioni può migliorare le performance delle operazioni di I/O, ma richiede più memoria. La scelta della dimensione del buffer dipende dalle esigenze specifiche dell'applicazione e dalle risorse di sistema disponibili.

Vediamo un esempio di utilizzo della funzione setvbuf() per impostare un buffer personalizzato per un file aperto in scrittura:

#include <stdio.h>
#include <stdlib.h>

int main() {
    FILE *file;
    char buffer[1024]; // Buffer di 1 KB

    // Aprire il file in modalità scrittura
    file = fopen("esempio.dat", "w");
    if (file == NULL) {
        printf("Errore nell'apertura del file.\n");
        return EXIT_FAILURE;
    }

    // Impostare il buffer personalizzato con buffering completo
    if (setvbuf(file, buffer, _IOFBF, sizeof(buffer)) != 0) {
        printf("Errore nell'impostazione del buffer.\n");
        fclose(file);
        return EXIT_FAILURE;
    }

    // Scrive dati su file

    // ...

    // Chiudere il file
    fclose(file);
    return EXIT_SUCCESS;
}

In questo esempio, abbiamo aperto un file chiamato esempio.dat in modalità scrittura e abbiamo creato un buffer automatico, ossia allocato sullo stack, di 1 KB. Successivamente, abbiamo chiamato la funzione setvbuf() per associare questo buffer al file aperto, specificando la modalità di buffering completo (_IOFBF). In questo modo, tutte le operazioni di scrittura sul file utilizzeranno il buffer personalizzato che abbiamo creato. Dato che il buffer è allocato automaticamente, non dobbiamo preoccuparci di deallocarlo manualmente; esso verrà automaticamente deallocato quando la funzione main() terminerà.

Quando si adopera la funzione setvbuf() bisogna tenere a mente due regole importanti:

Nota

setvbuf deve essere chiamata dopo l'apertura del file ma prima di qualsiasi operazione di I/O

La funzione setvbuf() deve essere chiamata subito dopo l'apertura del file con fopen(), ma prima di eseguire qualsiasi operazione di lettura o scrittura sul file. Se viene chiamata dopo aver già eseguito operazioni di I/O, il comportamento è indefinito e potrebbe portare a risultati imprevisti.

Nota

Il buffer deve essere deallocato solo dopo la chiusura del file

Se si utilizza un buffer allocato dinamicamente (ad esempio, con malloc()), è importante assicurarsi che il buffer rimanga valido per tutta la durata dell'uso dello stream. Pertanto, il buffer non deve essere deallocato (ad esempio, con free()) fino a quando il file non viene chiuso con fclose(). Deallocare il buffer prima della chiusura del file può portare a comportamenti indefiniti.

La funzione setvbuf() può essere invocata anche con il parametro buffer impostato a NULL. In questo caso, il sistema operativo alloca automaticamente un buffer della dimensione specificata dal parametro size. Ad esempio, per impostare il buffering di linea su un file aperto in scrittura, con un buffer di 1024 byte, possiamo scrivere:

setvbuf(file, NULL, _IOLBF, 1024); // Imposta il buffering di linea

Inoltre, il valore del parametro size può essere anche zero in questo caso. Così facendo, il sistema operativo sceglierà automaticamente una dimensione di buffer appropriata per il file aperto:

// Imposta il buffering completo con dimensione di buffer automatica
setvbuf(file, NULL, _IOFBF, 0);

In generale, la funzione setvbuf() restituisce 0 se l'operazione è avvenuta con successo, oppure un valore diverso da 0 in caso di errore:

  1. Se il parametro mode non è uno dei valori validi (_IOFBF, _IOLBF, _IONBF);
  2. Se la richiesta non può essere soddisfatta, ad esempio a causa di risorse di sistema insufficienti o di dimensione di buffer non valida.

La funzione setbuf

La funzione setbuf() è una versione vecchia e più semplice di setvbuf(). Essa consente di impostare un buffer personalizzato per un file specifico, ma non offre la stessa flessibilità di setvbuf() in termini di modalità di buffering e dimensione del buffer. Nella libreria standard del linguaggio C, setbuf() è considerata obsoleta e deprecata in favore di setvbuf().

La sintassi della funzione setbuf() è la seguente:

void setbuf(FILE *stream, char *buffer);

Anche in questo caso, il parametro stream è un puntatore a un oggetto di tipo FILE, che rappresenta il file su cui vogliamo impostare il buffer personalizzato. Il parametro buffer è un puntatore a un'area di memoria che verrà utilizzata come buffer per le operazioni di I/O su quel file.

Se il parametro buffer è NULL, la funzione disabilita il buffering per lo stream specificato, equivalente a utilizzare la modalità _IONBF con setvbuf(). In questo caso, tutte le operazioni di lettura o scrittura sul file verranno eseguite direttamente sul file senza utilizzare alcun buffer.

Se il parametro buffer non è NULL, la funzione imposta il buffer personalizzato per lo stream specificato, equivalente a utilizzare la modalità _IOFBF con setvbuf(). In questo caso, tutte le operazioni di lettura o scrittura sul file utilizzeranno il buffer specificato. Poiché setbuf() non consente di specificare la dimensione del buffer, essa assume che il buffer abbia una dimensione pari alla macro BUFSIZ, definita nell'header <stdio.h>. BUFSIZ rappresenta la dimensione predefinita del buffer utilizzata dalla libreria standard del linguaggio C per le operazioni di I/O.

Pertanto, le chiamate a setbuf() sono equivalenti alle seguenti chiamate a setvbuf():

  1. Se buffer è NULL:

    setvbuf(stream, NULL, _IONBF, 0); // Disabilita il buffering
    
  2. Se buffer non è NULL:

    // Imposta il buffering completo con buffer di dimensione BUFSIZ
    setvbuf(stream, buffer, _IOFBF, BUFSIZ);
    
Consiglio

Per i nuovi programmi, preferire setvbuf() a setbuf()

Anche se setbuf() è ancora supportata per motivi di retrocompatibilità, essa è considerata obsoleta e deprecata nella libreria standard del linguaggio C. Per i nuovi programmi, è consigliabile utilizzare setvbuf() al posto di setbuf(), in quanto offre un controllo più dettagliato sul funzionamento del buffering.