- Published on
C# Channels: when, how, and why
- Authors
- Name
- Cristian Pique
Introduction
System.Threading.Channels is one of those .NET features that is easy to ignore until you need it. It is not as common as Task, async, or IEnumerable, but when the problem is a producer-consumer workflow, Channels often fit better than a homemade queue with a lock around it.
The basic idea is simple: one part of the application writes work into a channel, and another part reads from it. The writer and reader do not need to run at the same speed, and they do not need to know much about each other.
That separation is the main value.
When to use Channels
Channels are a good fit when work arrives in one place but should be processed somewhere else. For example:
- Background processing in a web API
- Event processing inside a service
- A small in-memory pipeline
- Buffering incoming messages before writing to a database
- Limiting how much work can be queued at once
I would not use Channels for everything asynchronous. If you only need to await one operation, use Task. If you are exposing a stream of values, IAsyncEnumerable<T> might be enough. If work must survive process restarts, use a real queue such as RabbitMQ, Azure Service Bus, SQS, or Kafka.
Channels live in memory. That makes them fast and simple, but also means they are not durable.
A small example
Here is a minimal producer-consumer example:
using System.Threading.Channels;
var channel = Channel.CreateUnbounded<string>();
var producer = Task.Run(async () =>
{
for (var i = 1; i <= 5; i++)
{
await channel.Writer.WriteAsync($"job-{i}");
}
channel.Writer.Complete();
});
var consumer = Task.Run(async () =>
{
await foreach (var job in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"Processing {job}");
await Task.Delay(250);
}
});
await Task.WhenAll(producer, consumer);
The important part is Complete(). Without it, the consumer keeps waiting because it does not know that no more items are coming.
Bounded channels and backpressure
Unbounded channels are convenient, but they can hide problems. If producers are faster than consumers, memory usage can grow without a clear limit.
In production code, I usually prefer a bounded channel:
var channel = Channel.CreateBounded<string>(
new BoundedChannelOptions(capacity: 100)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = true,
SingleWriter = false
});
With BoundedChannelFullMode.Wait, writers wait when the channel is full. That is backpressure. Instead of accepting infinite work and failing later, the system slows down at the boundary.
Other full modes can drop items, but I only use those when losing items is explicitly acceptable, such as telemetry or UI refresh signals.
Why not just use ConcurrentQueue?
ConcurrentQueue<T> is useful, but it only solves the thread-safe collection part. You still need to decide:
- How does the consumer wait efficiently?
- How does the writer signal completion?
- How do you stop the worker?
- How do you limit the queue size?
- How do you handle multiple consumers?
Channels give you these mechanics in one place. They are designed for asynchronous workflows, so you can avoid blocking threads while waiting for work.
Practical advice
Keep the channel close to the component that owns the workflow. Do not expose the full Channel<T> everywhere. Prefer exposing only ChannelWriter<T> to producers and keeping the reader private to the background worker.
Also be honest about failure. If processing a message fails, should it be retried, logged, ignored, or moved somewhere else? Channels do not answer that for you. They just move data between producers and consumers.
For small in-process pipelines, that is often exactly enough.