Producer/Consumer Pipelines in .NET with System.Threading.Channels
The producer/consumer problem is one of those patterns that every backend eventually needs and almost everyone implements badly the first time. Something generates work — incoming requests, file lines, messages from a queue — and something else processes it, usually at a different speed. The naive solutions are a hand-rolled ConcurrentQueue with a polling loop, or BlockingCollection<T> with dedicated threads. Both work, and both fight the grain of modern .NET, because both are fundamentally synchronous and blocking in a world that has moved to async. System.Threading.Channels is the answer the platform settled on, and once you have used it, the older approaches feel like holding your breath.
What a channel is
A Channel<T> is a thread-safe, asynchronous conduit between producers and consumers. It splits cleanly into two halves: a Writer that producers push items into, and a Reader that consumers pull items out of. Both sides expose async methods, so a consumer waiting for work or a producer waiting for space simply awaits instead of blocking a thread. You never touch a lock, and you never spin in a polling loop.
There are two flavours, and choosing between them is the single most important decision you will make:
csharp
using System.Threading.Channels;
// Unbounded: the writer never waits. Capacity is limited only by memory.
Channel<WorkItem> unbounded = Channel.CreateUnbounded<WorkItem>();
// Bounded: capacity is capped, which gives you backpressure.
Channel<WorkItem> bounded = Channel.CreateBounded<WorkItem>(
new BoundedChannelOptions(capacity: 1000)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = false
});The basic pipeline
A minimal single-producer, single-consumer setup looks like this. The producer writes items and then signals that it is finished; the consumer reads everything until the channel is marked complete:
csharp
var channel = Channel.CreateBounded<int>(100);
// Producer
Task producer = Task.Run(async () =>
{
for (int i = 0; i < 10_000; i++)
await channel.Writer.WriteAsync(i);
channel.Writer.Complete(); // no more items will ever be written
});
// Consumer
Task consumer = Task.Run(async () =>
{
await foreach (int item in channel.Reader.ReadAllAsync())
Process(item);
});
await Task.WhenAll(producer, consumer);ReadAllAsync returns an IAsyncEnumerable<T>, so await foreach drains the channel naturally and exits cleanly once the writer is completed and the buffer is empty. There is no sentinel value, no manual "are we done?" flag — completion is part of the channel's contract.
Backpressure: the reason bounded channels exist
The most valuable feature of channels is also the most overlooked. With an unbounded channel, a fast producer and a slow consumer is a silent disaster: items pile up in memory faster than they can be drained, and the process eventually falls over with an out-of-memory error. The queue absorbing the mismatch is invisible right up until it kills you.
A bounded channel makes the mismatch impossible to ignore, because it applies backpressure. When the buffer is full, WriteAsync simply awaits until the consumer makes room. The producer is throttled to the consumer's pace automatically, and memory use stays flat. The FullMode option lets you choose the policy when the channel is full:
csharp
var channel = Channel.CreateBounded<LogEntry>(
new BoundedChannelOptions(500)
{
// Wait — producer awaits until there is space (the default, safe choice)
// DropWrite — silently discard the new item
// DropOldest — evict the oldest item to make room
// DropNewest — evict the most recently queued item
FullMode = BoundedChannelFullMode.Wait
});For a logging or telemetry pipeline where losing the occasional sample is acceptable but blocking the application is not, DropOldest or DropWrite is exactly right. For work you cannot afford to lose, Wait is the correct, self-regulating default. Either way, you have made an explicit, visible decision about what happens under pressure — which is far better than discovering your implicit answer in a production incident.
Multiple consumers and fan-out
Because the reader side is thread-safe, scaling out is trivial: start several consumers reading from the same channel and the work is distributed across them. This is the standard pattern for parallelizing CPU- or IO-bound processing:
csharp
var channel = Channel.CreateBounded<WorkItem>(1000);
// Fan out to N concurrent consumers
int workerCount = Environment.ProcessorCount;
var workers = Enumerable.Range(0, workerCount).Select(_ => Task.Run(async () =>
{
await foreach (WorkItem item in channel.Reader.ReadAllAsync())
await HandleAsync(item);
})).ToArray();
// Producer side
await foreach (WorkItem item in ReadSourceAsync())
await channel.Writer.WriteAsync(item);
channel.Writer.Complete();
await Task.WhenAll(workers);Each worker pulls the next available item; the channel handles the synchronization. You can just as easily chain channels together — the consumer of one stage being the producer for the next — to build multi-stage pipelines where each stage runs concurrently and backpressure propagates all the way back up the chain.
Completion, cancellation, and exceptions
Three details separate a robust pipeline from a leaky one. First, completion: a consumer driven by ReadAllAsync will wait forever if no one ever calls Writer.Complete(). Completing the writer is not optional cleanup; it is the signal that lets consumers finish. With multiple producers, complete only once all of them are truly done.
Second, cancellation: every awaitable channel method accepts a CancellationToken, and ReadAllAsync(token) lets you tear a pipeline down promptly on shutdown.
Third, exceptions: if a producer fails, you can propagate the fault through the channel itself so consumers observe it rather than hanging:
csharp
try
{
await ProduceEverythingAsync(channel.Writer, cancellationToken);
channel.Writer.Complete();
}
catch (Exception ex)
{
// Consumers awaiting ReadAllAsync will observe this exception.
channel.Writer.Complete(ex);
}When to use channels — and when not to
Channels are the right tool whenever you have in-process producers and consumers running at different speeds and you want async, backpressured coordination without writing your own locking. They are lightweight, fast, and part of the runtime, so there is no dependency to add.
They are not a message queue. They live inside a single process and a single application's memory; if you need durability, delivery guarantees across services, or messages that survive a restart, you want a real broker, not a channel. And if your pipeline needs rich dataflow features — built-in batching, complex transformation blocks, throttling primitives — the older TPL Dataflow library still has its place. But for the common case, the one that used to mean a BlockingCollection and a couple of dedicated threads, channels are simpler, faster, and async to the core.
The pattern is old, but the ergonomics are new. A bounded channel, a producer that completes the writer, and a consumer looping over ReadAllAsync is a complete, correct, self-regulating pipeline in a dozen lines — no locks, no polling, no surprise out-of-memory crash when the load spikes. For something that every nontrivial backend eventually needs, that is about as good as it gets.


