The producer-consumer pattern is everywhere: processing uploaded files, handling message queues, streaming data transformations. Before System.Threading.Channels, .NET developers reached for BlockingCollection<T> or BufferBlock<T> from TPL Dataflow. Channels offer a modern, async-native alternative that is faster and more flexible.
Bounded vs Unbounded Channels
A channel is a thread-safe queue with async read and write operations. You choose between two flavours:
// Unbounded: writers never block, memory grows as needed
var unbounded = Channel.CreateUnbounded<WorkItem>();
// Bounded: capacity limit with back-pressure
var bounded = Channel.CreateBounded<WorkItem>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait // Writer awaits when full
});
Bounded channels give you back-pressure for free. When the channel is full, WriteAsync asynchronously waits until a slot opens. This prevents a fast producer from overwhelming a slow consumer and consuming unbounded memory.
A Basic Pipeline
Every channel exposes a Writer and a Reader. The standard pattern separates them — the producer only sees the writer, the consumer only sees the reader:
public class ImageProcessor
{
private readonly Channel<string> _channel = Channel.CreateBounded<string>(
new BoundedChannelOptions(50)
{
SingleReader = false,
SingleWriter = false
});
public ChannelWriter<string> Writer => _channel.Writer;
public async Task StartConsumersAsync(int consumerCount, CancellationToken ct)
{
var consumers = Enumerable.Range(0, consumerCount)
.Select(_ => ConsumeAsync(ct));
await Task.WhenAll(consumers);
}
private async Task ConsumeAsync(CancellationToken ct)
{
await foreach (var filePath in _channel.Reader.ReadAllAsync(ct))
{
await ProcessImageAsync(filePath);
}
}
private async Task ProcessImageAsync(string path)
{
// Resize, compress, upload...
await Task.Delay(100); // Simulated work
Console.WriteLine($"Processed: {path}");
}
}
The producer writes file paths into the channel, then signals completion:
var processor = new ImageProcessor();
// Start 4 consumers
var consuming = processor.StartConsumersAsync(4, cancellationToken);
// Produce work
foreach (var file in Directory.EnumerateFiles(uploadDir, "*.jpg"))
{
await processor.Writer.WriteAsync(file, cancellationToken);
}
// Signal no more items
processor.Writer.Complete();
// Wait for consumers to drain
await consuming;
Setting SingleReader and SingleWriter
The BoundedChannelOptions and UnboundedChannelOptions have SingleReader and SingleWriter properties. Setting these to true when you know only one reader or writer exists enables internal optimisations:
var channel = Channel.CreateBounded<LogEntry>(new BoundedChannelOptions(1000)
{
SingleWriter = false, // Multiple threads write logs
SingleReader = true // One background consumer flushes to disk
});
The runtime uses a more efficient internal data structure when it knows there is only one reader. This can make a measurable difference in high-throughput scenarios.
Handling Back-Pressure
The FullMode option on bounded channels controls what happens when the channel is full:
var options = new BoundedChannelOptions(10)
{
FullMode = BoundedChannelFullMode.DropOldest // Drop oldest item to make room
};
The four modes are:
Wait— the writer asynchronously waits (default, most common)DropNewest— silently drops the item being writtenDropOldest— removes the oldest queued item to make spaceDropWrite— drops the current write
For most business logic, Wait is correct. DropOldest suits telemetry or logging where recent data matters more than completeness.
Using Channels in ASP.NET Core
Channels integrate naturally with hosted services. A common pattern is accepting work in a controller and processing it in the background:
builder.Services.AddSingleton(Channel.CreateBounded<EmailRequest>(500));
builder.Services.AddHostedService<EmailSenderService>();
[ApiController]
[Route("api/emails")]
public class EmailController : ControllerBase
{
private readonly ChannelWriter<EmailRequest> _writer;
public EmailController(Channel<EmailRequest> channel)
{
_writer = channel.Writer;
}
[HttpPost]
public async Task<IActionResult> Send(EmailRequest request)
{
await _writer.WriteAsync(request);
return Accepted();
}
}
public class EmailSenderService : BackgroundService
{
private readonly ChannelReader<EmailRequest> _reader;
public EmailSenderService(Channel<EmailRequest> channel)
{
_reader = channel.Reader;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var email in _reader.ReadAllAsync(ct))
{
await SendEmailAsync(email);
}
}
}
This pattern decouples request handling from potentially slow operations, returning 202 Accepted immediately while work proceeds asynchronously.
Performance
Channels are highly optimised. In benchmarks they consistently outperform BlockingCollection<T> for async scenarios because they avoid blocking threads. The internal implementation uses lock-free techniques where possible and minimises allocations. For most in-process producer-consumer needs, Channel<T> is the right default choice in modern .NET.