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:

Example.cs
// 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:

ImageProcessor.cs
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:

Example.cs
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:

Example.cs
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:

Example.cs
var options = new BoundedChannelOptions(10)
{
    FullMode = BoundedChannelFullMode.DropOldest // Drop oldest item to make room
};

The four modes are:

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:

Program.cs
builder.Services.AddSingleton(Channel.CreateBounded<EmailRequest>(500));
builder.Services.AddHostedService<EmailSenderService>();
EmailController.cs
[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();
    }
}
EmailSenderService.cs
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.