System.Threading.Channels: bounded pipelines in .NET
System.Threading.Channels in .NET: why a bounded channel beats an unbounded queue, which BoundedChannelFullMode fits, and a consumer that drains safely.
The worker has been restarting every night for a week. Memory climbs from 300 MB to 4 GB between
midnight and one, the container gets killed, and the next instance starts clean and climbs again.
Nothing is leaking. A partner sends its daily click export at midnight, the redirect service
queues each event for analytics with a ConcurrentQueue<T> or a Task.Run per item, and the
analytics sink can absorb about a fifth of that rate. Every event the sink has not consumed yet
sits on the heap, waiting. Nobody decided what should happen when the consumer falls behind, so
the system made the default decision: keep everything until the operating system decides
otherwise.
System.Threading.Channels exists to make that decision explicit. A bounded channel has a
capacity, and when the capacity is reached the producer either waits or something is dropped,
according to a rule you wrote down. The rest of this article is about choosing that rule and
building the consumer that makes it hold.
The queue that never says no
Do the arithmetic for the incident above. Suppose the export arrives at 5,000 events per second
for two minutes and the sink drains 1,000 per second. The backlog grows by 4,000 per second, so
it holds 480,000 events when the burst ends. At a few hundred bytes per buffered event that is
over 100 MB of live heap, most of it promoted to Gen 2 because it survives so many collections,
and every event that references a header collection or a request body multiplies the figure. The
Task.Run variant is worse: 480,000 queued work items competing with request threads for the
pool, a starvation cliff by another route.
The producer never learned the consumer's rate, because an unbounded queue has no way to tell it.
// every redirect: no ceiling, no owner, no signal when the sink falls behind
_clicks.Enqueue(new ClickEvent(code, DateTimeOffset.UtcNow));
Channel.CreateUnbounded<T> has the same property, so switching to channels alone fixes nothing.
The fix is the bound.
A bounded channel makes the decision explicit
Channel.CreateBounded<T> takes a capacity, or a BoundedChannelOptions whose FullMode says
what happens on the write that would exceed it. The overload with an itemDropped callback is
how you count what the rule discards.
var clicks = Channel.CreateBounded<ClickEvent>(
new BoundedChannelOptions(capacity: 10_000)
{
FullMode = BoundedChannelFullMode.DropWrite,
SingleReader = true,
SingleWriter = false
},
itemDropped: _ => ClickMetrics.Dropped.Add(1));
builder.Services.AddSingleton(clicks);
builder.Services.AddSingleton(clicks.Writer);
builder.Services.AddSingleton(clicks.Reader);
builder.Services.AddHostedService<ClickWorker>();
Registering the ChannelWriter<T> and ChannelReader<T> separately means the request path never
sees the reader and the worker never sees the writer. The request path writes with TryWrite,
which never suspends, and returns the redirect.
Capacity 4, buffer full, item 5 arrives
Four answers to the same write
Wait lossless WriteAsync suspends; TryWrite returns false
fits: orders, payments: delay beats loss
DropOldest lossy write succeeds; item 1 is discarded
fits: latest value wins: a sensor, a heartbeat
DropNewest lossy write succeeds; item 4 is discarded
fits: keep the head of a burst
DropWrite lossy write reports success; item 5 never enters
fits: analytics clicks, sampled metrics
Four answers to "the buffer is full"
The BoundedChannelFullMode
enum has four members, and the choice is a product decision wearing an enum's clothes.
Wait is the only lossless mode. WriteAsync suspends until the reader frees a slot, and
TryWrite returns false so a caller can decide for itself. Backpressure flows upstream: a full
buffer slows the producer, which slows whoever called the producer, which is the point. Orders,
payments, anything you would rather delay than lose, take Wait, and the request path that writes
to it must tolerate the delay or time out honestly.
DropOldest evicts the head of the buffer to admit the new item. It fits data where only the latest value matters: a sensor reading, a presence heartbeat, a progress percentage.
DropNewest evicts the item most recently buffered, keeping the head of a burst intact. It is the rarest choice; use it when the earliest items in a burst carry the most value.
DropWrite rejects the incoming item and leaves the buffer alone. It fits analytics clicks, sampled metrics, anything where losing a fraction under overload is a report bug rather than a product bug.
One detail from the runtime source is worth knowing: under the three drop modes, TryWrite and
WriteAsync report success even when something was discarded. Only Wait returns false from
TryWrite. The itemDropped callback is therefore the only place a drop is visible, which is
why the registration above wires it to a counter from day one.
An unbounded queue is a bounded queue whose limit is the machine's memory and whose full mode is a crash. Choosing a capacity and a FullMode does not add a failure mode; it names the one you already had.
The consumer: a BackgroundService that drains
The reader side is a
BackgroundService
that reads until the channel completes. The simplest loop is await foreach over
ReadAllAsync, one item at a time. For a sink that prefers batches (a database insert, a broker
publish), wait for data, then drain what is already buffered with TryRead before touching I/O:
public sealed class ClickWorker(
Channel<ClickEvent> clicks,
IClickSink sink,
ILogger<ClickWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// On shutdown: refuse new writes, keep draining what was accepted.
stoppingToken.Register(() => clicks.Writer.TryComplete());
var batch = new List<ClickEvent>(capacity: 500);
while (await clicks.Reader.WaitToReadAsync(CancellationToken.None))
{
while (batch.Count < 500 && clicks.Reader.TryRead(out var click))
{
batch.Add(click);
}
try
{
await sink.WriteBatchAsync(batch, CancellationToken.None);
}
catch (Exception ex)
{
log.LogError(ex, "Lost a batch of {Count} clicks", batch.Count);
}
batch.Clear();
}
}
}
Two decisions in that loop deserve a sentence each. The stopping token is not passed to the
reads. Instead it completes the writer, after which WaitToReadAsync returns false once the
buffer is empty, so the worker drains everything it accepted before exiting. The host still
enforces its ShutdownTimeout, so size the capacity to drain inside it: 10,000 events in batches
of 500 is twenty sink calls. And the batch list is reused rather than allocated per iteration,
which matters at thousands of items per second; an allocation budget is a
real constraint on a hot loop, not a review nicety. The wider story of why a token accepted and
then ignored is worse than none is in the
CancellationToken guide.
Options that change the throughput
SingleReader and SingleWriter are promises, not enforcement. When they are true the channel
picks a cheaper implementation that skips some synchronization; break the promise and you get
undefined behavior, not an exception. One BackgroundService reading while many request threads
write is SingleReader = true, SingleWriter = false, the most common shape.
AllowSynchronousContinuations lets a write run the waiting reader's continuation inline on the
writer's thread. Leave it false unless you have measured a benefit, because it moves the
consumer's work onto the request path you were trying to protect.
What to measure
Three numbers say whether the pipeline is healthy. Queue depth, from Reader.Count when
CanCount is true, shows how far behind the consumer is right now. The drop counter from
itemDropped shows what the rule has cost so far; on a DropWrite channel it is the number to
alert on. And the age of the oldest buffered item, computed from the timestamp you stamp on each
event, shows the latency the consumer is delivering. Depth alone misleads:
a shrinking backlog can still be delivering its worst latency, and the age catches it.
BlockingCollection, and the broker
BlockingCollection<T> solved the bounded producer-consumer problem first. The difference is that
its Take blocks a thread, so a consumer waiting on an empty collection costs a pool thread that
does nothing, the same bill that async-await mistakes run up.
Channels are asynchronous end to end: a waiting reader holds a continuation, not a thread. In new
code, prefer the channel; the
namespace reference
lists the full reader and writer surface.
The larger caveat is that a channel is memory. It does not survive a crash, a deploy, or the OOM kill it was introduced to prevent if the capacity was set carelessly. Anything that must survive belongs in a durable queue, and the channel's job is to sit in front of that queue: absorb the burst, batch the writes, and hand the broker a steady stream instead of a spike.
Where the buffer meets the queue
That two-stage shape, an in-process buffer feeding a durable queue, is the design Katabench's System Design Studio asks for in Count the Clicks, a challenge in the URL shortener series: keep every analytics write off the redirect path, hand clicks to a queue, and let redundant counters batch them into their own store. The grader checks the structure (no synchronous analytics from the redirect tier, no click writes on the links table), not the throughput. The durable half is what the transactional outbox lab makes concrete: in four short lessons you store each event in PostgreSQL with its order, relay it to RabbitMQ, make a redelivered message harmless, and quarantine a poison message so it cannot block the rows behind it. The in-memory channel gets you through the burst. The outbox is what lets you sleep.