System.Threading.Channels: The Async Pipeline Primitive
You have an API endpoint that receives work. You have a background service that processes it. You need a thread-safe, async-friendly way to pass items between them without blocking threads or losing data. BlockingCollection<T> blocks. ConcurrentQueue<T> requires polling. Channel<T> does exactly what you want: async writes, async reads, optional backpressure, and zero thread blocking.
The Pattern
using System.Threading.Channels;
// Create a bounded channel: if 100 items are queued, writers waitvar channel = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(100){ FullMode = BoundedChannelFullMode.Wait, // writers await until space is available SingleReader = true // optimization hint for single consumer});The Producer (API Endpoint)
app.MapPost("/ingest", async (WorkItem item, Channel<WorkItem> channel) =>{ await channel.Writer.WriteAsync(item); return Results.Accepted();});WriteAsync completes instantly if there’s space. If the channel is full, it asynchronously waits — no thread is blocked, no request is lost.
The Consumer (Background Service)
public class WorkItemProcessor( Channel<WorkItem> channel, ILogger<WorkItemProcessor> logger) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var item in channel.Reader.ReadAllAsync(stoppingToken)) { try { logger.LogInformation("Processing {Id}", item.Id); await ProcessAsync(item, stoppingToken); } catch (Exception ex) { logger.LogError(ex, "Failed to process {Id}", item.Id); } }
logger.LogInformation("Channel closed. Shutting down."); }
private async Task ProcessAsync(WorkItem item, CancellationToken ct) { // Simulate work await Task.Delay(100, ct); }}ReadAllAsync returns an IAsyncEnumerable that yields items as they arrive. When the writer calls channel.Writer.Complete(), the loop exits gracefully.
Registration
var channel = Channel.CreateBounded<WorkItem>(100);
builder.Services.AddSingleton(channel);builder.Services.AddSingleton(channel.Reader);builder.Services.AddSingleton(channel.Writer);builder.Services.AddHostedService<WorkItemProcessor>();Bounded vs. Unbounded
| Type | Behavior | Use When |
|---|---|---|
CreateBounded<T>(n) | Writers wait when full | You need backpressure (most cases) |
CreateUnbounded<T>() | Never blocks writers | You trust producers won’t overwhelm memory |
When the Channel is Full
BoundedChannelFullMode controls what happens:
FullMode = BoundedChannelFullMode.Wait // Writer awaits (default, safest)FullMode = BoundedChannelFullMode.DropOldest // Discard oldest item, write new oneFullMode = BoundedChannelFullMode.DropNewest // Discard the item being writtenFullMode = BoundedChannelFullMode.DropWrite // Same as DropNewestKey Takeaway
Channel<T> is the async-native producer/consumer primitive. Bounded channels give you backpressure without blocking threads. Pair it with a BackgroundService consumer and you have a robust in-process pipeline in about 30 lines. If you’re using ConcurrentQueue + polling or BlockingCollection + thread waste, this is the upgrade.