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à |
|---|---|---|---|
|
C'è (4096 byte) | Sì (tramite il costruttore) | Stream file di base |
|
Sì (4096 byte) | Sì (tramite il costruttore) | "Wrapper" sopra uno stream |
|
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:
- Senza buffer — byte per byte (sconsigliato, ma istruttivo!)
- Buffer di default — il normale FileStream con CopyTo
- 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.
GO TO FULL VERSION