One Unbounded Channel, 188 MB of Regret

작성자

카테고리:

← 피드로
DEV Community · Sukhpinder Singh · 2026-07-27 개발(SW)

Every producer/consumer queue I’ve written in the last few years sits on System.Threading.Channels, and nearly every one starts with Channel.CreateUnbounded. It’s the overload that asks no questions. No capacity to pick, no full-queue policy to design, WriteAsync never waits. Last week I caught myself typing it into yet another background worker and realized I’d never measured what that convenience costs when the consumer falls behind.

So I built the smallest pipeline that could embarrass me.

The setup

One producer, one consumer, 100,000 work items, each carrying a 2 KB payload — think serialized webhook deliveries waiting to go out. The consumer burns roughly 20 µs of simulated work per item (checksum passes standing in for serialize-and-send), so the producer is far faster than the consumer. That’s the whole experiment: a burst arriving quicker than it drains.

var channel = Channel.CreateUnbounded<WorkItem>(
    new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });

// producer — as fast as it can go
for (var i = 0; i < TotalItems; i++)
    await writer.WriteAsync(new WorkItem(i, new byte[2048]));
writer.Complete();

// consumer — ~20 µs of work per item
await foreach (var item in reader.ReadAllAsync())
    Process(item);

Enter fullscreen mode Exit fullscreen mode

A background task samples queue depth and managed heap every 15 ms while this runs. .NET 10, Release build, small Linux container. The numbers wobble a little between runs; the ratios don’t. Not a lab — the shape is what matters.

What “never blocks” actually costs

[unbounded] enqueue 0.56s, total 2.57s, processed 100,000, peak depth 90,769, peak heap 188 MB
  t= 0.00s  depth=   4,696  heap=    9 MB
  t= 0.62s  depth=  87,755  heap=  187 MB
  t= 1.39s  depth=  53,006  heap=  187 MB
  t= 2.16s  depth=  18,523  heap=  187 MB
  t= 2.56s  depth=     386  heap=  187 MB

Enter fullscreen mode Exit fullscreen mode

The producer finished in about half a second. The consumer needed another two. Everything in between lived inside the channel: 90,769 items queued at the peak, and the heap climbed from 9 MB to 188 MB in the first 600 milliseconds. It parked there for the rest of the run too — once the burst promoted all that data, the GC had no reason to hand it back quickly.

Nothing failed. That’s the trap. An unbounded channel converts “my consumer is slow” into “my process is fat,” silently, at a rate of one payload per write. My burst was 100k items because I picked that number. In production the burst is however far behind your consumer gets on a bad day, and nobody picked that number.

One line of backpressure

Same pipeline, one changed line:

var channel = Channel.CreateBounded<WorkItem>(
    new BoundedChannelOptions(1_000)
    {
        SingleReader = true,
        SingleWriter = true,
        FullMode = BoundedChannelFullMode.Wait   // the default
    });

Enter fullscreen mode Exit fullscreen mode

[bounded-wait] enqueue 2.23s, total 2.25s, processed 100,000, peak depth 1,001, peak heap 19 MB

Enter fullscreen mode Exit fullscreen mode

With FullMode.Wait, WriteAsync finally earns its Async suffix: when the queue holds 1,000 items, the producer’s await doesn’t complete until the consumer makes room. Enqueueing stretched from 0.56 s to 2.23 s — the producer now runs at consumer pace. That’s all backpressure is.

Look at the totals, though. 2.57 s unbounded, 2.25 s bounded. A tie, and on my box the bounded run was actually a touch ahead more often than not, presumably because the GC isn’t shoveling 188 MB around. I expected to pay a throughput tax for the flat memory and there wasn’t one. The work was always going to take about two seconds; the only question is where the waiting lives — in your heap as queued objects, or in the producer as an await you can see, time, and put a metric on.

That “where the waiting lives” bit is also the honest caveat. If your producer is an HTTP request handler, Wait turns memory pressure into request latency, and your clients become the queue. Sometimes that’s exactly right — it’s a natural throttle. Sometimes you’d rather TryWrite, return a 429, and keep your latency budget. Bounded doesn’t answer that question for you; it just forces you to answer it, which is the point.

Load shedding, measured honestly

The third mode is for when you’d rather lose work than delay it:

var channel = Channel.CreateBounded<WorkItem>(
    new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.DropOldest },
    dropped => Interlocked.Increment(ref droppedCount));

Enter fullscreen mode Exit fullscreen mode

[bounded-drop-oldest] enqueue 0.05s, total 0.08s, processed 2,761, dropped 97,239

Enter fullscreen mode Exit fullscreen mode

Done in 80 milliseconds, memory flat, and 97% of the work is gone. For live prices, sensor readings, or position updates — anything where only the freshest value matters — this is correct and beautifully cheap. For webhooks or emails it’s a slow-motion incident. My opinion: DropOldest without wiring up that ItemDropped callback to a counter is how you end up debugging “customers say emails are missing” with zero evidence. The channel will not tell you unless you ask.

Where I landed

Unbounded still has a place: draining a collection you already hold in memory, or any producer that’s naturally slower than its consumer. But in those cases a bounded channel costs you nothing, so the asymmetry only points one way. My default is now bounded, always, and I treat “I can’t name a capacity” as the actual smell — it means I haven’t decided what the system should do when it’s behind. My 1,000 was a lazy guess and it still capped the blast radius at 2 MB of payloads instead of 188.

This is one process with fake payloads and simulated work, so take the milliseconds loosely. The shape — heap tracking queue depth, backpressure trading memory for visible waiting at zero total cost — held on every run.

Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/006-bounded-channel-backpressure

What’s your full-queue policy — Wait, DropOldest, or “we’ll deal with it when the pod restarts”? Tell me in the comments, especially if backpressure has bitten you the other way.

— Sukhpinder, still overfilling queues nobody asked me to fill

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다