1. Introduzione
Immagina di essere il direttore d'orchestra della tua applicazione. Se ogni volta che il violinista accorda il suo strumento (I/O lungo) aspetti che finisca prima di passare agli altri strumenti, tutta l'orchestra resterà ferma. Ma se il violinista dice: "Mi accordo, voi continuate pure, vi faccio un segnale quando sono pronto", — ecco l'asincronia!
Nel mondo di C# e .NET 9 ci sono strumenti pensati per questo tipo di "multitasking senza blocchi". I protagonisti di oggi sono le versioni asincrone di Read e Write, chiamate ReadAsync e WriteAsync.
Questi metodi ti permettono di avviare un'operazione di lettura o scrittura e subito "rilasciare" il flusso di esecuzione corrente, così può fare altro. Quando l'operazione di I/O termina (per esempio, i dati vengono letti o scritti sul disco), il tuo codice "si risveglia" e riprende da dove era rimasto.
Per usare questi metodi ci servono due parole magiche introdotte in C# nel 2012 con la versione 5.0 (e ora, in C# 14, sono ormai parte del linguaggio!):
- async: è un modificatore che aggiungi al metodo per dire al compilatore: "Dentro questo metodo ci saranno operazioni asincrone e userò await".
- await: è l'operatore che metti davanti a una chiamata asincrona (come ReadAsync o WriteAsync). Significa: "Avvia l'operazione, ma non aspettare qui la sua fine. Restituisci il controllo al chiamante e torna quando l'operazione è pronta".
Non preoccuparti se ora ti sembrano concetti vaghi. Avremo un livello dedicato a async e await (Livello 58) dove li vedremo in dettaglio. Per ora è importante capire che aiutano a non "bloccare" il thread principale.
2. ReadAsync: leggere con calma
Il metodo ReadAsync permette di leggere dati da uno stream in modo asincrono. Invece di aspettare che i byte vengano letti dal disco, avvii la lettura e passi subito a fare altro.
Questa è la sua firma principale per leggere in un buffer:
public virtual ValueTask<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
O, più usata nel C# moderno (e .NET 9), usando Memory<byte>:
public virtual ValueTask<int> ReadAsync(
Memory<byte> buffer,
CancellationToken cancellationToken = default
)
Analizziamo i parametri:
- buffer: è un array di byte (o Memory<byte>) dove verranno scritti i dati letti. Ricorda il buffering per le prestazioni: qui si usa allo stesso modo, ma per operazioni asincrone.
- offset: lo spostamento in buffer dove iniziare a scrivere i byte letti.
- count: il numero massimo di byte da leggere.
- CancellationToken cancellationToken: è molto utile per annullare l'operazione se non serve più (per esempio, l'utente ha chiuso l'app o ha premuto il pulsante "Annulla").
- ValueTask<int>: è una "promessa" che quando l'operazione finirà restituirà un intero (int) pari al numero di byte letti. ValueTask è una versione ottimizzata di Task per casi in cui il risultato può essere disponibile sincronamente o asincronamente.
Esempio 1: Lettura asincrona di un file
Immaginiamo di avere un file di testo grande e vogliamo leggerlo senza bloccare il thread principale. Ecco un esempio base:
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
// Funzione asincrona per leggere un file e contare le righe
public static async Task<int> CountLinesAsync(string filePath)
{
int lineCount = 0;
// Apertura asincrona del file
using FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
using StreamReader reader = new StreamReader(fileStream, Encoding.UTF8);
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
lineCount++;
}
return lineCount;
}
static async Task Main()
{
string filename = "bigtext.txt";
int count = await CountLinesAsync(filename);
Console.WriteLine($"Nel file {filename} righe: {count}");
}
}
Commenti sul codice:
- Nota il parametro useAsync: true su FileStream. È importante per la vera asincronicità.
- Usiamo await su ReadLineAsync per non bloccare il thread mentre si legge una riga.
- Il metodo Main è ora asincrono (C# 7+ lo permette).
Se questo fosse un'app grafica, durante la lettura del file (mentre ReadAsync aspetta i dati dal disco) l'utente potrebbe cliccare bottoni, scrollare e fare altre azioni, perché il thread UI non è bloccato. In una console è meno evidente, ma il principio è lo stesso.
3. WriteAsync: scrivere senza indugi
Analogamente a ReadAsync, WriteAsync permette di scrivere dati su uno stream in modo asincrono. È utilissimo quando devi scrivere grandi quantità di dati senza far aspettare l'applicazione la fine dell'operazione su disco.
Firme principali:
public virtual ValueTask WriteAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken = default
)
E usando ReadOnlyMemory<byte> (perché non modifichiamo il buffer durante la scrittura):
public virtual ValueTask WriteAsync(
ReadOnlyMemory<byte> buffer,
CancellationToken cancellationToken = default
)
I parametri sono simili a ReadAsync:
- buffer: array di byte (o ReadOnlyMemory<byte>) che contiene i dati da scrivere.
- offset: spostamento nel buffer da cui iniziare a leggere i dati da scrivere.
- count: numero di byte da scrivere.
- CancellationToken cancellationToken: per annullare l'operazione.
- ValueTask: non restituisce valore perché il numero di byte da scrivere è dato dal parametro count.
Esempio 2: Scrittura asincrona di un file
Ora scriviamo qualcosa su file in modo asincrono.
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
class Program
{
public static async Task WriteTestAsync(string filePath)
{
using FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true);
using StreamWriter writer = new StreamWriter(fs, Encoding.UTF8);
for (int i = 0; i < 10000; i++)
{
await writer.WriteLineAsync($"Riga numero {i}");
}
}
static async Task Main()
{
string filename = "testout.txt";
await WriteTestAsync(filename);
Console.WriteLine($"Scrittura di {filename} completata.");
}
}
Il ciclo qui scrive 10000 righe e il thread principale non viene bloccato: se fosse un'app GUI, l'interfaccia non "si pianterebbe".
Grazie a async e await, la nostra applicazione console può eseguire un'operazione di copia file rimanendo comunque "reattiva" all'input dell'utente (per esempio, alla pressione di Enter per annullare). Questo è un principio fondamentale per applicazioni moderne, performanti e reattive in C#.
4. Sfumature utili
Visualizzazione: come funziona la lettura/scrittura asincrona
┌───────────────────┐ Start Async Read ┌────────────────────────────────┐
│Il tuo codice (UI/logica)│ ─────────────────────→ │ OS/E/S: operazione asincrona │
└─────┬─────────────┘ └───────┬────────────────────────┘
│(fa altro) │(legge file, aspetta il disco)
│<────────────────────────────────────────→│
└─ Attende il Task, riceve il risultato ←──────────┘
Schema approssimativo: mentre il disco lavora lentamente, il codice può fare altro. Solo quando i dati servono — l'esecuzione aspetta il Task.
Applicazioni pratiche: dove serve davvero?
- Applicazioni desktop: se la tua app legge o scrive cose grandi (log, database, video), l'asincronia è un must. Anche se la tua macchina è veloce, l'utente potrebbe aprire un file in rete con velocità da "tartaruga".
- Backend o web app: decine o centinaia di utenti possono chiamare il server contemporaneamente. Se ogni thread si blocca su letture file — benvenuti rallentamenti e 502 Bad Gateway.
- App mobile: se aprire o scrivere un file impiega tempo, l'utente sentirà lag. Usiamo l'asincronia!
- Qualsiasi elaborazione massiva di file: applicazioni che lavorano su collezioni di file (archiver, parser, analyzer) giovano molto dall'I/O asincrono.
Lettura/scrittura sincrona vs asincrona
| Metodo | Blocca il thread? | Facile da implementare? | Migliori prestazioni? | Comodo per UI/server |
|---|---|---|---|---|
| Sincrono (Read/Write) | Sì | Sì | No | No |
| Asincrono (ReadAsync) | No | Quasi | Sì | Sì |
5. Nitty-gritty e best practice
Buffering è ancora importante: Anche con ReadAsync e WriteAsync, leggere o scrivere un byte alla volta resta molto inefficiente. L'asincronia rimuove il blocco, ma non accelera magicamente la lettura di ogni singolo byte. Taglie di buffer buone in partenza sono 4096-8192 byte; per file grandi prova 65536 o 131072.
"Async All The Way Down": Se cominci a usare async/await in un punto, di solito conviene estendere questo approccio lungo tutta la catena di chiamate: se C esegue un'operazione asincrona, C sarà async Task, B anche async Task, e A pure async Task. Altrimenti rischi blocchi e deadlock nelle UI.
Gestione delle eccezioni: Nel codice asincrono usa i normali try-catch. Spesso vedrai OperationCanceledException e IOException — gestiscile esplicitamente.
Rilascio risorse (await using): Per stream e altri oggetti IDisposable libera correttamente le risorse. Se il tipo implementa IAsyncDisposable, allora await using chiamerà DisposeAsync(); se solo IDisposable — verrà chiamato Dispose().
Cosa succede sotto il cofano (breve): Con await il compilatore trasforma il metodo in una macchina a stati: l'operazione parte, il metodo "si mette in pausa", il controllo torna al chiamante. Quando il risultato è pronto, il SynchronizationContext (in UI) o il ThreadPool (in console/server) riprende l'esecuzione dal punto di pausa. Questo permette a un singolo thread di gestire molte "task sospese" senza blocchi.
In conclusione, la programmazione asincrona con async e await è uno strumento potente per creare applicazioni reattive e scalabili. Permette al tuo codice di usare le risorse di sistema in modo efficiente, senza bloccare l'interfaccia utente o i thread sul server. All'inizio può risultare un po' strano, ma vale la pena impararlo! Nelle lezioni successive ci immergeremo più a fondo nell'asincronia e nel parallelismo. A presto!
GO TO FULL VERSION