1. Introduzione
Nelle applicazioni .NET moderne, specialmente quelle con high concurrency o asynchronous workflows, è comune dover scambiare dati tra parti diverse del sistema in modo sicuro e senza introdurre colli di bottiglia.
I C# Channels, introdotti in .NET Core 3.0 (System.Threading.Channels
), offrono un approccio performante e thread-safe per implementare il pattern Producer/Consumer in memoria.
2. Differenza tra Channel e Coda
Molti sviluppatori conoscono già le code (Queue
, ConcurrentQueue
, BlockingCollection
), ma i Channels hanno caratteristiche diverse:
Aspetto | Queue / ConcurrentQueue | Channel |
---|---|---|
Thread safety | Solo ConcurrentQueue è thread-safe | Sempre thread-safe |
Supporto async | Non nativo, serve gestione manuale | Integrato (WriteAsync , ReadAsync ) |
Backpressure | Non gestito automaticamente | Supporto built‑in (BoundedChannel) |
Scenario d’uso tipico | Buffer in memoria | Producer/Consumer ad alta concorrenza |
Notifica disponibilità | Polling o WaitHandle | Await asincrono diretto |
💡 In breve: un Channel è come una coda evoluta, progettata per scenari asincroni e con gestione avanzata della pressione sul flusso (backpressure).
3. Come funziona un Channel
Un Channel in C# è una struttura composta da due estremi ben distinti:
- Writer → usato dal Producer per inserire dati nel channel.
Questo lato fornisce metodi asincroni comeWriteAsync()
eTryWrite()
per aggiungere elementi alla coda interna.
Nel caso di un Bounded Channel, ilWriter
può sospendere l’operazione di scrittura se la coda è piena, applicando il meccanismo di backpressure. - Reader → usato dal Consumer per leggere dati dal channel.
Lato consumer troviamo metodi comeReadAsync()
eReadAllAsync()
che permettono di recuperare gli elementi non appena diventano disponibili, in modo asincrono e senza bloccare il thread.
Il channel funge quindi da ponte tra chi produce i dati e chi li consuma, isolando le due parti in modo che:
- Il Producer possa continuare a lavorare senza preoccuparsi di quando il Consumer leggerà i dati.
- Il Consumer possa elaborare i dati al proprio ritmo, senza dipendere direttamente dal Producer.
Esistono diversi tipi di Channels:
- Unbounded → capacità illimitata, nessun limite al numero di elementi in coda.
- Bounded → capacità fissa; se il limite è raggiunto, il Writer attende o scarta in base alla modalità (
BoundedChannelFullMode
). - SingleReader/SingleWriter → ottimizzati per scenari con un solo produttore o consumatore, riducendo overhead di sincronizzazione.
In sintesi, un Channel è più di una semplice coda:
- È thread-safe di default.
- Ha supporto nativo per async/await.
- Permette di applicare logiche di controllo del flusso (backpressure).
4. Diagramma base – Producer → Channel → Consumer con ritorno risultato

5. Esempio base – Checkout e‑commerce (Unbounded Channel)
In questo esempio simuliamo un sistema di e‑commerce che riceve ordini da un endpoint di checkout.
Ogni ordine viene inserito in un Channel e processato da un consumer in background.
L’obiettivo è:
- Non bloccare la risposta HTTP al momento del checkout
- Separare la logica di ricezione ordine dalla logica di elaborazione
Codice
public record Order(int Id, string CustomerName, decimal Amount);
public class CheckoutService
{
private readonly Channel<Order> _orderChannel;
public CheckoutService()
{
// Creiamo un channel senza limite di capacità
_orderChannel = Channel.CreateUnbounded<Order>();
// Avviamo un consumer in background
Task.Run(ProcessOrdersAsync);
}
// Metodo chiamato dal controller API
public async Task PlaceOrderAsync(Order order)
{
Console.WriteLine($"[Checkout] Ricevuto ordine #{order.Id}");
await _orderChannel.Writer.WriteAsync(order);
}
// Consumer che elabora gli ordini
private async Task ProcessOrdersAsync()
{
await foreach (var order in _orderChannel.Reader.ReadAllAsync())
{
Console.WriteLine($"[Processing] Ordine #{order.Id} in elaborazione...");
// Simula pagamento
await Task.Delay(500);
Console.WriteLine($"[Payment] Pagamento completato per #{order.Id}");
// Simula aggiornamento inventario
await Task.Delay(300);
Console.WriteLine($"[Inventory] Inventario aggiornato per #{order.Id}");
// Simula invio email di conferma
await Task.Delay(200);
Console.WriteLine($"[Email] Conferma inviata a {order.CustomerName}");
}
}
}
Spiegazione passo passo
public record Order(...)
Definiamo un record immutabile per rappresentare un ordine.
Contiene ID, nome cliente e importo.Channel<Order>
Creiamo un Channel di tipoOrder
, che conterrà tutti gli ordini in arrivo.
In questo caso usiamoCreateUnbounded
→ capacità illimitata, nessun limite sul numero di ordini.Task.Run(ProcessOrdersAsync)
Avviamo un consumer in background che resta in ascolto degli ordini.
UsaReadAllAsync()
per leggere finché il channel è aperto.PlaceOrderAsync
(Producer)
Questo metodo rappresenta il punto di ingresso (es. chiamato da un controller API).- Logga l’arrivo dell’ordine
- Scrive l’ordine nel channel (
Writer.WriteAsync
)
ProcessOrdersAsync
(Consumer)- Legge ogni ordine in arrivo
- Simula tre fasi: pagamento, aggiornamento inventario, invio email
- Usa
await Task.Delay()
per simulare operazioni I/O-bound
Perché usare un Channel in questo caso?
- Decoupling → il controller non deve preoccuparsi di come l’ordine viene elaborato
- Performance → l’API risponde subito al cliente, l’elaborazione prosegue in background
- Scalabilità → possiamo avere più consumer in parallelo per gestire più ordini contemporaneamente
6. Esempio – Checkout e‑commerce con Bounded Channel
L’esempio precedente usava un Unbounded Channel, cioè senza limiti di capacità.
Questo approccio è semplice e garantisce che nessun ordine venga scartato, ma in sistemi ad alto traffico può portare a un problema: accumulo incontrollato in memoria se il consumer non riesce a elaborare abbastanza velocemente.
Per prevenire questo rischio, possiamo usare un Bounded Channel, che introduce un limite massimo di elementi in coda e applica backpressure sui producer.
Come funziona il Bounded Channel
- Capacità massima: ad esempio 5 ordini in attesa.
- Se la coda è piena, il comportamento è definito da
BoundedChannelFullMode
:- Wait → il producer attende finché non c’è spazio.
- DropOldest → scarta l’ordine più vecchio.
- DropWrite → scarta il nuovo ordine.
- DropNewest → scarta l’ultimo inserito in coda.
In un e‑commerce, il più comune è Wait, per non perdere ordini e mantenere l’integrità dei dati.
Codice
csharpCopiaModificapublic class CheckoutService
{
private readonly Channel<Order> _orderChannel;
public CheckoutService()
{
// Creiamo un Bounded Channel con capacità di 5 ordini
var options = new BoundedChannelOptions(5)
{
FullMode = BoundedChannelFullMode.Wait // Attende se pieno
};
_orderChannel = Channel.CreateBounded<Order>(options);
// Avviamo il consumer in background
Task.Run(ProcessOrdersAsync);
}
public async Task PlaceOrderAsync(Order order)
{
Console.WriteLine($"[Checkout] Ricevuto ordine #{order.Id}");
await _orderChannel.Writer.WriteAsync(order);
Console.WriteLine($"[Checkout] Ordine #{order.Id} messo in coda");
}
private async Task ProcessOrdersAsync()
{
await foreach (var order in _orderChannel.Reader.ReadAllAsync())
{
Console.WriteLine($"[Processing] Ordine #{order.Id} in elaborazione...");
await Task.Delay(500);
Console.WriteLine($"[Done] Ordine #{order.Id} completato");
}
}
}
Vantaggi rispetto all’Unbounded Channel
- Protezione della memoria → il numero di ordini in attesa non supera mai la capacità configurata.
- Gestione del carico → il producer rallenta se il consumer è in difficoltà, evitando overload del sistema.
- Controllo esplicito del comportamento → con
FullMode
decidiamo cosa fare in caso di saturazione.
💡 Quando usarlo
Il Bounded Channel è ideale in scenari in cui:
Vogliamo applicare un meccanismo di backpressure per stabilizzare il sistema.
Il volume di messaggi può crescere rapidamente.
Non vogliamo saturare memoria e risorse.
7. Gestire il ritorno del risultato
In un’app reale, il client (es. API REST) vuole sapere l’esito dell’elaborazione.
Vediamo le tre principali strategie.
Soluzione 1 – TaskCompletionSource (processi rapidi)
public record OrderRequest(Order Order, TaskCompletionSource<string> Completion);
public class CheckoutService
{
private readonly Channel<OrderRequest> _orderChannel;
public CheckoutService()
{
_orderChannel = Channel.CreateUnbounded<OrderRequest>();
Task.Run(ProcessOrdersAsync);
}
public async Task<string> PlaceOrderAsync(Order order)
{
var tcs = new TaskCompletionSource<string>();
await _orderChannel.Writer.WriteAsync(new OrderRequest(order, tcs));
return await tcs.Task; // Attende risultato
}
private async Task ProcessOrdersAsync()
{
await foreach (var request in _orderChannel.Reader.ReadAllAsync())
{
try
{
await Task.Delay(500); // Simula lavoro
request.Completion.SetResult($"Ordine #{request.Order.Id} completato");
}
catch (Exception ex)
{
request.Completion.SetException(ex);
}
}
}
}
✅ Vantaggi: semplice, gestione eccezioni integrata
❌ Limiti: il client deve aspettare la fine dell’elaborazione
Soluzione 2 – Event Bus (processi lunghi e scalabili)
Diagramma flusso Event Bus
- Producer → Channel → Consumer → Service Bus → Client Subscriber / Polling
Esempio con Azure Service Bus
private readonly ServiceBusSender _sender;
private async Task ProcessOrdersAsync()
{
await foreach (var orderId in _orderChannel.Reader.ReadAllAsync())
{
// Simula elaborazione lunga
await Task.Delay(5000);
// Pubblica risultato su Azure Service Bus
var message = new ServiceBusMessage($"Order {orderId} completed");
await _sender.SendMessageAsync(message);
}
}
Soluzione 3 – Callback HTTP (notifica push diretta)
Diagramma flusso Callback
- Producer → Channel → Consumer → HTTP POST verso callbackUrl del client
Codice
private readonly HttpClient _httpClient = new();
private async Task ProcessOrdersAsync()
{
await foreach (var item in _orderChannel.Reader.ReadAllAsync())
{
var (order, callbackUrl, orderId) = item;
await Task.Delay(3000); // Simula elaborazione
var result = new { OrderId = orderId, Status = "Completed" };
await _httpClient.PostAsJsonAsync(callbackUrl, result);
}
}
✅ Vantaggi: il client riceve il risultato appena disponibile
❌ Limiti: il client deve esporre un endpoint HTTP
8. Quando usare cosa
Scenario | Soluzione consigliata |
---|---|
Processi rapidi (ms-secondi) | TaskCompletionSource |
Processi lunghi/minuti/ore | Event Bus |
Integrazione B2B diretta | Callback HTTP |
9. Conclusione
I C# Channels rappresentano un’evoluzione significativa rispetto alle classiche code in memoria.
Non si limitano a memorizzare elementi in una struttura FIFO:
- Offrono supporto asincrono nativo, integrandosi perfettamente con
async/await
e rendendo semplice la scrittura di codice non bloccante. - Implementano backpressure in modo automatico, fondamentale per mantenere la stabilità del sistema quando il carico supera la capacità di elaborazione.
- Sono thread-safe di default, riducendo la complessità nella gestione della concorrenza.
Grazie a queste caratteristiche, i Channels si integrano facilmente in architetture moderne, specialmente in contesti event-driven o microservices, dove la separazione tra producers e consumers è cruciale per mantenere il sistema scalabile e resiliente.
Nel contesto di un e‑commerce, l’uso dei Channels può:
- Ottimizzare il flusso di ordini, permettendo di accodarli e processarli in background senza rallentare la risposta al cliente.
- Isolare il carico tra le API pubbliche e la logica di business, garantendo che picchi di traffico non blocchino il sistema.
- Facilitare l’integrazione con sistemi di notifica come Service Bus, WebSocket o Callback HTTP, adattandosi facilmente sia a processi rapidi che a elaborazioni lunghe e asincrone.
In definitiva, scegliere i C# Channels significa adottare un approccio robusto, scalabile e mantenibile per la gestione dei flussi di dati interni, che non solo migliora le performance ma rende anche più semplice evolvere l’architettura del software nel tempo.