System.Threading.Channels
Overview
System.Threading.Channels is a high-performance, thread-safe, producer–consumer queue abstraction in .NET. It provides asynchronous, bounded/unbounded, single/multi-producer, single/multi-consumer channels optimised for high throughput with low allocation.
Channels are heavily used in:
- Background processing
- Actor-based workflows
- Pipelines
- Streaming data
- High-throughput I/O systems
- Scheduling, messaging, and job queues
Channels were introduced in .NET Core 3.0
Use Cases
Channels solve common producer–consumer challenges:
- Avoid manual locks
- Prevent blocking threads unnecessarily
- Control memory/pressure via bounded capacity
- Provide backpressure (slow consumer blocks producer)
- Work naturally with async/await
- Extremely fast vs alternatives like
BlockingCollection<T>
Architecture Diagram
flowchart LR
P1[Producer 1] --> W[ChannelWriter<T>]
P2[Producer 2] --> W
W --> C[Channel<T> Pipeline]
C --> R[ChannelReader<T>]
R --> Consumer1
R --> Consumer2
A Channel<T> exposes:
- Channel.Writer (ChannelWriter<T>) — producers write messages
- Channel.Reader (ChannelReader<T>) — consumers read messages
The channel controls buffering, backpressure, and concurrency rules.
Types of Channels
Unbounded Channels
var channel = Channel.CreateUnbounded<int>();
- No limit on messages.
- Fastest and simplest behaviour.
Bounded Channels
var channel = Channel.CreateBounded<int>(new BoundedChannelOptions(100)
{
SingleWriter = false,
SingleReader = false,
FullMode = BoundedChannelFullMode.Wait
});
Useful when:
- Preventing memory blow-up.
- Implementing backpressure.
- You know maximum parallel throughput.
FullMode options:
| Mode | Description |
|---|---|
| Wait | Producer waits when channel is full (recommended) |
| DropWrite | Drop new item if full |
| DropOldest | Remove oldest item to make space |
| DropNewest | Drop most recent message |
Single Reader / Single Writer Channels
Optimised variants:
var channel = Channel.CreateUnbounded<int>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }
);
When you know usage patterns, these are significantly faster.
Buffered vs Unbuffered
- Buffered: holds N items before applying backpressure
- Unbuffered: writer waits for reader (
CreateUnbuffered())
Basic Example — Producer/Consumer
Producer
var channel = Channel.CreateUnbounded<int>();
var producer = Task.Run(async () =>
{
for (int i = 0; i < 10; i++)
{
await channel.Writer.WriteAsync(i);
}
channel.Writer.Complete();
});
Consumer
var consumer = Task.Run(async () =>
{
await foreach (var value in channel.Reader.ReadAllAsync())
{
Console.WriteLine($"Consumed: {value}");
}
});
This pattern gives you:
- Non-blocking I/O
- Automatic backpressure when bounded
- Clean completion handling
Advanced Example — Background Worker Pipeline
Great pattern for .NET APIs, microservices, or background workers.
public class ProcessingPipeline
{
private readonly Channel<string> _channel;
public ProcessingPipeline()
{
_channel = Channel.CreateBounded<string>(100);
StartConsumers();
}
public ValueTask EnqueueAsync(string message)
=> _channel.Writer.WriteAsync(message);
private void StartConsumers()
{
for (int i = 0; i < 3; i++)
{
_ = Task.Run(async () =>
{
await foreach (var msg in _channel.Reader.ReadAllAsync())
{
await ProcessAsync(msg);
}
});
}
}
private Task ProcessAsync(string msg)
{
// Application logic
return Task.Delay(50);
}
}
Example API implementation
You might implement in an API via DI as follows.
Define the message, e.g. in ThumbnailGenerator.cs
public record ThumbnailGenerationJob(string Id, string OriginalFilePath, string FolderPath);
Register the channel in Program.cs
builder.Services.AddSingleton(_ =>
{
var channel = Channel.CreateBounded<ThumbnailGenerationJob>(new BoundedChannelOptions(100)
{
FullMode = BoundedChannelFullMode.Wait
});
return channel;
});
Inject channel and write to the channel in a class or controller
public sealed class ThumbnailsController(
...,
Channel<ThumbnailGenerationJob> channel,
...) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> UploadImage(IFormFile? file)
{
...
var id = Guid.NewGuid().ToString();
var folderPath = Path.Combine(_uploadDirectory, "images", id);
var fileName = $"{id}{Path.GetExtension(file.FileName)}";
var originalFilePath = await imageService.SaveOriginalImageAsync(file, folderPath, fileName);
var job = new ThumbnailGenerationJob(id, originalFilePath, folderPath);
await channel.Writer.WriteAsync(job);
...
return Accepted(statusUrl, new { id, status = ThumbnailGenerationStatus.Queued });
}
}
Inject the channel and consume in a service
public class ThumbnailGenerationService(
...,
Channel<ThumbnailGenerationJob> channel,
...) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var job in channel.Reader.ReadAllAsync(stoppingToken))
{
try
{
await ProcessJobAsync(job);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Error processing thumbnail generation job");
}
}
}
}
Comparison with Alternatives
Channels vs BlockingCollection
| Aspect | Channels | BlockingCollection |
|---|---|---|
| Async support | ✔ Native | ✘ Requires manual setup |
| Throughput | High | Moderate |
| Backpressure | ✔ Bounded + Wait | ✔ but less configurable |
| Legacy? | Modern | Legacy (pre .NET Core) |
Channels vs ConcurrentQueue
| Aspect | Channels | ConcurrentQueue |
|---|---|---|
| Blocking / waiting | ✔ | ✘ |
| Async operations | ✔ | ✘ |
| Backpressure | ✔ | ✘ |
| Signalling consumer | Built-in | Manual required |
Channels vs TPL Dataflow
| Aspect | Channels | TPL Dataflow |
|---|---|---|
| Ease of use | Simple | More complex |
| Full pipeline features | Limited | Complete (batching, chunking, linking) |
| Overhead | Low | Medium |
Channels offer a lighter, faster, simpler alternative to Dataflow when you don’t need advanced block chaining.
When to Use Channels
Perfect for
- High‐throughput producer/consumer pipelines
- Background workers
- Web API → background processing patterns
- Actor models
- Stream processing (e.g., Kafka consumers → processing queue)
- Job queues inside .NET services
- Concurrency optimisation without locks
Avoid when
- You need distributed messaging (use Kafka, Azure Service Bus)
- You need durability on crash
- You need cross-process communication
Channels are in-memory, not durable.
Error Handling
Handling completion
channel.Writer.Complete(new Exception("Something went wrong"));
Reader side
try
{
await foreach (var item in channel.Reader.ReadAllAsync())
{
// ...
}
}
catch (Exception ex)
{
Console.WriteLine($"Pipeline failed: {ex}");
}
Performance Notes (.NET 10)
- Channels are lock-minimised, using queues and semaphores internally.
SingleReaderandSingleWriterchannels eliminate unnecessary locking.- Performance significantly improved in .NET 7+ and .NET 10.
Benchmarks show channels are often 2–5x faster than BlockingCollection.
Summary
System.Threading.Channels is a high-performance, thread-safe producer–consumer abstraction introduced in .NET Core.
Channels support async writes/reads, bounded/unbounded buffering, backpressure, and lock-efficient concurrency optimisations**. It is ideal for high-throughput pipelines, background processing, actors, and message workflows inside a .NET service.
Channels expose a ChannelWriter and ChannelReader to allow multiple producers and consumers. They can be tuned for single-writer/single-reader scenarios for extra performance. Compared to alternatives like BlockingCollection or ConcurrentQueue, channels provide better async support, predictable throughput, and modern patterns aligned with async/await.
Use channels when you need in-memory, high-speed, async, producer-consumer coordination.
Further Reading / References
- Microsoft Docs (Channels): https://learn.microsoft.com/dotnet/core/extensions/channels
- Stephen Toub’s deep dive blog (excellent): https://devblogs.microsoft.com/dotnet/system-threading-channels
- Source code (runtime repo): https://github.com/dotnet/runtime/tree/main/src/libraries/System.Threading.Channels
Definitions
| Term | Description |
|---|---|
| Backpressure | Refers to the natural throttling that happens when a consumer can’t keep up with the producer, causing the channel to slow or block the producer. Essentially once the capacity of a channel is met, no more messages can be added until one is removed. This prevents uncontrolled memory growth, CPU exhaustion and runaway production that the consumer cannot process, but it does slow the writer side |