Producer / Consumer
Decouples work generation from work processing using an in-memory queue as an intermediate buffer.
You have a flow that generates work faster than it can be processed. If the generator waits for the processor, you block the request. If you couple them directly, any slowness in the processor propagates upstream.
Split into two independent roles:
- Producer: generates work and deposits it into a queue. It doesn't know who will process it or when.
- Consumer: reads from the queue at its own pace and processes each item.
The queue acts as a buffer: it absorbs the speed difference between both sides.
[Producer] → [ queue / Channel<T> ] → [Consumer]
fast buffer slow
The consumer sleeps when the queue is empty — no polling, no wasted CPU. It wakes up only when an item arrives.
#How it works internally
The magic is in TaskCompletionSource<T>. When the consumer calls WaitToReadAsync and the queue is empty, the runtime suspends that code and completely releases the thread. When the producer calls WriteAsync, it internally calls TrySetResult(true) — that resolves the Task that was waiting and the runtime schedules the consumer to continue.
Producer: WriteAsync("item")
→ enqueues the item
→ TrySetResult(true) ← wakes up the consumer
→ runtime schedules continuation
→ consumer receives the item
→ processes it
→ goes back to sleep
No polling. No Thread.Sleep. No CPU consumed while waiting.
#C# Example
// Registration (singleton — same instance for producer and consumer)
services.AddSingleton(Channel.CreateBounded<string>(new BoundedChannelOptions(500)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false
}));
services.AddHostedService<MyConsumer>();
// Producer — can be a middleware, an endpoint, any service
public class MyProducer(Channel<string> channel)
{
public async Task EnqueueAsync(string item)
{
await channel.Writer.WriteAsync(item);
}
}
// Consumer — runs in background, independent of the HTTP cycle
public class MyConsumer(Channel<string> channel, ILogger<MyConsumer> logger)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
try
{
await ProcessAsync(item);
}
catch (Exception ex)
{
// try/catch INSIDE the loop — if one item fails, the consumer stays alive
logger.LogError(ex, "Failed processing {Item}", item);
}
}
}
}
The try/catch must be inside the await foreach, not outside. If you put it outside, one exception kills the entire loop and the consumer never processes anything again — with no visible error.
#Backpressure
With BoundedChannelOptions you control what happens when the queue fills up:
| FullMode | Behavior |
|---|---|
Wait |
Producer waits until a slot is available. The HTTP request stalls. |
DropNewest |
Discards the incoming item. Producer never blocks. |
DropOldest |
Discards the oldest item in the queue. |
For HTTP endpoints that cannot block, TryWrite is the alternative:
if (!channel.Writer.TryWrite(item))
return Results.StatusCode(503); // saturated, retry later
#Multiple consumers in parallel
If processing is slow, you can spin up N consumers on the same channel:
// Register the same HostedService N times
services.AddHostedService<MyConsumer>();
services.AddHostedService<MyConsumer>();
services.AddHostedService<MyConsumer>();
All read from the same singleton Channel<T>. Each item is processed by exactly one consumer.
#Monitoring
app.MapGet("/health/queue", (Channel<string> channel) =>
Results.Ok(new { pending = channel.Reader.Count }));
If pending grows indefinitely, the bottleneck is in the consumer — you need more instances or caching.
| Pro | Con |
|---|---|
| Producer and consumer completely decoupled | Messages are lost if the process dies (no persistence) |
| No polling — 0 CPU when the queue is empty | No dead-letter queue — a failing item is discarded |
Natural backpressure with Bounded |
No automatic retries |
| N consumers in parallel without changing the producer | Does not scale across processes — in-memory only |
#When NOT to use it
- If you need durability: a process crash loses all queued messages. Use Outbox Pattern + Service Bus instead.
- If you need automatic retries with dead-letter queues.
Channel<T>has none of that. - If you need to scale horizontally across multiple instances.
Channel<T>is in-memory — each process has its own queue. Use RabbitMQ, Azure Service Bus, or Kafka. - If processing is so fast that the enqueue latency matters — a direct call is simpler.
#Comparison with Service Bus
Channel<T> |
Service Bus / RabbitMQ | |
|---|---|---|
| Persistence | No — RAM | Yes — disk/network |
| Dead-letter | No | Yes |
| Retries | Manual | Automatic |
| Multi-process | No | Yes |
| Latency | Microseconds | Milliseconds |
| Infrastructure | None | External broker |
Channel<T> is a Service Bus in memory. No broker, no network, no operational cost — in exchange for durability.
#Availability
Channel<T> is available since .NET Core 3.0. No additional NuGet required.
using System.Threading.Channels;
Not available in .NET Framework. The legacy equivalent is BlockingCollection<T>, but it blocks real threads instead of using async/await.
#concurrency #async #channel #queue #background-service