CodeGym /Corsi /C# SELF /Prestazioni e gestione manuale del buffer

Prestazioni e gestione manuale del buffer

C# SELF
Livello 41, Lezione 3
Disponibile

1. Introduzione

Perché serve la buffering e confronto delle prestazioni

Abbiamo già capito che la buffering è una strategia che permette di raggruppare i dati in grandi "lotti" per non accedere al disco centinaia di migliaia di volte, ma molto meno spesso, trasferendo molti dati in una volta. Questo dà un notevole incremento delle prestazioni, specialmente con grandi quantità di dati.

Quando pensare alle prestazioni di I/O

  • Se lavori con file grandi (gigabyte, terabyte — ad esempio la collezione di log accumulata in anni dalla tua azienda).
  • Se serve tempo di risposta minimo (per esempio elaborazione di log in real time).
  • Se il numero di accessi ai file è enorme (per esempio rinominare/copiare in massa un archivio di foto).
  • Per dimostrare in un colloquio che capisci gli approcci moderni all'ottimizzazione (e che ti piace misurare la velocità, non solo "far funzionare qualcosa").

In .NET di default la maggior parte degli stream file ha già buffering, ma a volte serve una configurazione fine o una strategia particolare.

I principali "giocatori" — tipi di buffer

Classe Buffer di default Si può cambiare la dimensione Applicabilità
FileStream
C'è (4096 byte) Sì (tramite il costruttore) Stream file di base
BufferedStream
Sì (4096 byte) Sì (tramite il costruttore) "Wrapper" sopra uno stream
StreamReader/Writer
Sì (1024/1024 byte) Sì (costruttore) Lavoro con testo
  • BufferedStream può "impacchettare" un altro stream per migliorare le prestazioni (es. se lo stream di base non è ben bufferizzato o serve un buffer più grande).
  • La dimensione del buffer è un compromesso tra velocità e consumo di RAM.

2. Esempi:

Confrontiamo tre approcci per copiare un file grande:

  1. Senza buffer — byte per byte (sconsigliato, ma istruttivo!)
  2. Buffer di default — il normale FileStream con CopyTo
  3. Gestione manuale del buffer — passiamo il buffer noi stessi e ottimizziamo la dimensione

Per gli esperimenti creiamo una semplice utility di copia file da aggiungere all'app. Chiamiamo il file BigFile.bin.

class FileCopyBenchmarks
{
    // Copia byte per byte (anti-esempio — non farlo così!)
    public static void CopyOneByte(string source, string dest)
    {
        using var input = new FileStream(source, FileMode.Open, FileAccess.Read);
        using var output = new FileStream(dest, FileMode.Create, FileAccess.Write);

        int b;
        while ((b = input.ReadByte()) != -1)
        {
            output.WriteByte((byte)b);
        }
    }

    // Copia usando il buffer standard di FileStream
    public static void CopyWithDefaultBuffer(string source, string dest)
    {
        using var input = new FileStream(source, FileMode.Open, FileAccess.Read);
        using var output = new FileStream(dest, FileMode.Create, FileAccess.Write);

        input.CopyTo(output); // Usa il buffer interno (di solito 81920 byte)
    }

    // Copia con controllo manuale del buffer
    public static void CopyWithCustomBuffer(string source, string dest, int bufferSize = 1024 * 1024)
    {
        using var input = new FileStream(source, FileMode.Open, FileAccess.Read);
        using var output = new FileStream(dest, FileMode.Create, FileAccess.Write);

        byte[] buffer = new byte[bufferSize];
        int bytesRead;
        while ((bytesRead = input.Read(buffer, 0, buffer.Length)) > 0)
        {
            output.Write(buffer, 0, bytesRead);
        }
    }
}

Quale funzione è più veloce? Misuriamo i tempi di esecuzione.

Come misurare correttamente le prestazioni

In .NET il modo più semplice per misurare il tempo è usare Stopwatch:

static void Measure(Action action, string description)
{
    var sw = Stopwatch.StartNew();
    action();
    sw.Stop();
    Console.WriteLine($"{description}: {sw.ElapsedMilliseconds} ms");
}

Adesso proviamo a copiare lo stesso file con metodi diversi:

string source = "BigFile.bin";
string dest1 = "copy1.bin";
string dest2 = "copy2.bin";
string dest3 = "copy3.bin";

// Prepara in anticipo BigFile.bin (per esempio 100-500 MB) o usa un qualsiasi file grande.

Measure(() => FileCopyBenchmarks.CopyOneByte(source, dest1), "CopyOneByte (byte per byte)");
Measure(() => FileCopyBenchmarks.CopyWithDefaultBuffer(source, dest2), "CopyWithDefaultBuffer (standard)");
Measure(() => FileCopyBenchmarks.CopyWithCustomBuffer(source, dest3, 1024 * 1024), "CopyWithCustomBuffer (1 MB)");

Insidie e punti pericolosi

  • Se fai molte esecuzioni consecutive, la cache del SO può "scaldare" il disco e le misure successive risulteranno più veloci — per valutazioni realistiche è meglio rilanciare il programma e svuotare la cache.
  • Se il file è piccolo (10–20 KB), i benefici della buffering sono impercettibili — più grande è il file, più evidente la differenza.
  • Se imposti un buffer troppo grande (es. 100 MB), il consumo di memoria può schizzare e il resto del sistema ne soffrirà.

Visualizzazione dei risultati: tabella

Metodo Tempo (ms) per file da 500 MB
Byte per byte 100 000+
FileStream standard / CopyTo 1 000 — 5 000
Buffer manuale 1 MB 700 — 1 200

I numeri sono indicativi, ma la tendenza è chiara — buffer più grandi riducono le chiamate al disco e aumentano la velocità.

3. Anatomia della gestione manuale del buffer

Perché a volte si vuole comunque regolare la dimensione del buffer? Una semplice analogia: ti stai trasferendo in un nuovo appartamento. Puoi portare le cose una tazza alla volta, oppure prendere una grande scatola. Ma anche la scatola ha un limite, altrimenti non la puoi sollevare!

Come funziona la lettura con buffer manuale

// Esempio di gestione manuale della dimensione del buffer
int bufferSize = 1024 * 1024; // 1 MB
byte[] buffer = new byte[bufferSize];
int read;
while ((read = inputStream.Read(buffer, 0, buffer.Length)) > 0)
{
    outputStream.Write(buffer, 0, read);
}
  • Il metodo Read tenta di riempire tutto il buffer, ma può restituire meno se il file finisce.
  • La dimensione del buffer viene spesso scelta nell'intervallo da 32 KB a 4–8 MB — oltre non si ottengono quasi più benefici.
  • Non dimenticare la RAM, soprattutto se ci sono molte operazioni o thread contemporanei.

Sperimentare con la dimensione del buffer

Prova a cambiare la dimensione del buffer nel nostro esempio (32 KB, 128 KB, 1 MB, 4 MB) e osserva dove si raggiunge il picco di prestazioni. Di solito la "via di mezzo" è intorno a 1 MB.

Situazioni in cui il buffer manuale è preferibile

  • Quando serve controllare il consumo di memoria (es. l'app gira su un server debole).
  • Quando lo stream non è bufferizzato automaticamente (NetworkStream, stream custom).
  • Quando ci sono molte operazioni parallele — puoi assegnare a ciascun thread il proprio buffer di dimensione ottimale.
  • Quando vuoi accelerare al massimo l'elaborazione di file enormi (per esempio convertire un grosso CSV).

4. Best practice e errori tipici

Si può mettere un buffer enorme? "Tanta memoria, meglio così". Si può fare, ma perché? Un buffer troppo grande a volte peggiora le prestazioni: parte della memoria resta inutilizzata e il sistema può rallentare per problemi di caching.

La buffering manuale non accelera tutto. Per file piccoli o stream già ottimizzati (per esempio FileStream con un buffer interno grande) il guadagno sarà minimo e il codice diventa più complesso.

Trappola tipica: dimenticare di chiudere lo stream o non gestire le eccezioni — il file rimarrà bloccato. Usa using e gestisci gli errori con try-catch quando lavori con file.

2
Compito
C# SELF, livello 41, lezione 3
Bloccato
Copia di un file con buffer manuale
Copia di un file con buffer manuale
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION