📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What are System.Threading.Channels, and how would you build a bounded producer/consumer pipeline with them?

Bounded and unbounded channels, full modes, single reader and writer options, completion, ReadAllAsync and backpressure in background processing.

Senior Coding Advanced async backpressure channels producer-consumer

🎯 Key points

  • Channels are async-friendly producer/consumer queues
  • Bounded channels provide backpressure; unbounded ones can grow without limit
  • BoundedChannelFullMode chooses wait or drop behaviour
  • Writers call Complete (optionally with an exception); readers finish when drained
  • ReadAllAsync or WaitToReadAsync/TryRead loops consume items
  • SingleReader and SingleWriter are promises that enable faster paths
  • Multiple consumers do not preserve order; channels are not durable

⏱ 30-second answer

System.Threading.Channels provides fast, async-friendly queues for passing data between producers and consumers. Channel.CreateBounded<T>(capacity) gives backpressure: when full, WriteAsync waits (or drops items, depending on BoundedChannelFullMode). Channel.CreateUnbounded<T>() never blocks writers but can grow without limit. Writers call Complete() when done; readers loop with await foreach (var x in reader.ReadAllAsync(ct)) and finish when the channel is completed and empty. Options such as SingleReader and SingleWriter enable faster internal implementations. Channels are the standard building block for in-process background queues and pipelines.

🗣 Full answer (senior)

How I would answer this in an interview

"System.Threading.Channels provides fast, async-friendly in-memory queues for handing data from producers to consumers. A channel has a writer side and a reader side. Channel.CreateBounded<T>(capacity) gives backpressure: when it's full, WriteAsync waits, or items are dropped according to BoundedChannelFullMode, or I use TryWrite and reject the request. CreateUnbounded<T>() never makes writers wait, but it can grow until the process runs out of memory. The protocol: producers write and call Writer.Complete() when they're done (optionally with an exception). Consumers loop over Reader.ReadAllAsync(ct) and finish once the channel is completed and drained. To build a pipeline, I chain stages with bounded channels between them, so a slow stage pushes back on faster ones instead of filling memory. Each stage gets its own number of consumers, and completion and errors are propagated from stage to stage. Channels are my default for background queues in ASP.NET Core, with two caveats: they're not durable, and ordering isn't preserved across multiple consumers."


1. The API

Writer (ChannelWriter<T>) Reader (ChannelReader<T>)
WriteAsync(item, ct): waits when full (bounded Wait mode) ReadAsync(ct): waits for an item
TryWrite(item): false when full TryRead(out item): non-blocking
WaitToWriteAsync(ct) WaitToReadAsync(ct): false once completed and empty
Complete(exception?) / TryComplete ReadAllAsync(ct): IAsyncEnumerable<T>
Completion: task that completes (or faults) when done and drained
Count / CanCount (bounded channels support counting)

2. Bounded vs unbounded

Bounded Unbounded
Memory Capped by capacity Grows without limit
Producer when consumers are slow Waits / drops / rejects Keeps writing
Use for Anything fed by external traffic or with slow consumers Producers you fully control with a known bounded rate

3. Full modes

BoundedChannelFullMode When full Good for
Wait WriteAsync waits; TryWrite returns false Internal producers; HTTP endpoints using TryWrite → 429/503
DropOldest Remove the oldest queued item Telemetry, "latest value wins"
DropNewest Remove the newest queued item Rare
DropWrite Discard the item being written Best-effort events

.NET 6+ supports an itemDropped callback on CreateBounded so drops can be counted and logged.

4. Options

Option Effect
SingleReader / SingleWriter Promises that enable faster internal implementations; breaking them is undefined behaviour
AllowSynchronousContinuations Lets a reader continue on the writer's thread: faster, but the writer pays for the reader's work
Capacity, FullMode Bounded channel shape

5. Pipeline design checklist

Decision Typical answer
Capacity per stage Enough to absorb bursts; small enough to bound memory
Consumers per stage Match downstream capacity (DB connections, CPU cores, API limits)
Full behaviour at the edge Wait for internal producers; reject (429/503) for HTTP; drop oldest for telemetry
Ordering Partition by key into separate channels if per-key order matters
Completion Each stage completes the next stage's writer when all its consumers finish
Errors next.Writer.Complete(ex) so failures propagate instead of hanging
Shutdown Stop writes, drain within the host's shutdown timeout
Observability Queue depth, enqueue wait time, drops, processing time
Durability Channels are in-memory: use a broker or outbox if loss is unacceptable

Production example

Our [IoT ingestion API] wrote each device message straight to SQL, about [5,000 messages per second] in bursts. Steady load was fine, but bursts caused connection-pool exhaustion, database timeouts and API 500s. I put a bounded Channel<DeviceMessage> with capacity [10,000] in front of the database, read by [4] consumers that inserted in batches of [500] using SqlBulkCopy. The endpoint used TryWrite and returned 429 with Retry-After when the channel was full, so devices backed off instead of overwhelming the database. The 500s disappeared, database load became smooth, and channel depth became our key load metric, with an alert at [80%] capacity.

🔍 What the interviewer is checking

Checks modern producer/consumer design.

Green flags

  • Creates bounded channels with an explicit full mode.
  • Completes writers and propagates errors.
  • Uses multiple consumers and understands ordering.
  • Connects channels to backpressure and shutdown.

Red flags

  • Uses unbounded queues for external input.
  • Forgets completion.

📚 Learn in depth

1. Why channels instead of BlockingCollection<T>

BlockingCollection<T> Channel<T>
Waiting Blocks threads Async (ValueTask), no thread held
Allocation per op Higher Very low (pooled async operations)
Completion semantics CompleteAdding Complete(exception), Completion task
Fits ASP.NET Core / async code Poorly Well

2. The efficient read loop

while (await reader.WaitToReadAsync(ct))          // false when completed and empty
{
    while (reader.TryRead(out var item))           // drain what's available without awaiting
        Process(item);
}

ReadAllAsync is fine for most code; the loop above avoids an await per item in hot paths and makes batching easy:

var batch = new List<Msg>(500);
while (await reader.WaitToReadAsync(ct))
{
    while (batch.Count < 500 && reader.TryRead(out var m)) batch.Add(m);
    await InsertBatchAsync(batch, ct);
    batch.Clear();
}

3. Fan-out / fan-in with completion propagation

static Task RunStageAsync<TIn, TOut>(ChannelReader<TIn> input, ChannelWriter<TOut> output,
    int workers, Func<TIn, CancellationToken, ValueTask<TOut>> transform, CancellationToken ct)
{
    var tasks = Enumerable.Range(0, workers).Select(async _ =>
    {
        await foreach (var item in input.ReadAllAsync(ct))
            await output.WriteAsync(await transform(item, ct), ct);
    }).ToArray();

    return Task.WhenAll(tasks).ContinueWith(
        t => output.TryComplete(t.Exception?.GetBaseException()),
        CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
}

If any worker fails, the next stage's writer completes with the exception, its readers' Completion faults, and the failure surfaces at the end of the pipeline instead of everything hanging.

4. Preserving order per key

var partitions = Enumerable.Range(0, 8).Select(_ => Channel.CreateBounded<Evt>(1000)).ToArray();
int Partition(string key) => (int)((uint)key.GetHashCode() % (uint)partitions.Length);
await partitions[Partition(evt.AccountId)].Writer.WriteAsync(evt, ct);
// one consumer per partition -> per-account ordering preserved, 8-way parallelism overall

(Use a stable hash such as XxHash32 if partitioning must be consistent across processes.)

5. Internals in brief

  • Bounded channels use a lock plus a queue of items and queues of blocked readers/writers.
  • Waiters are represented by pooled IValueTaskSource objects, so waiting doesn't allocate a Task.
  • Single-reader/single-writer unbounded channels can use lock-free paths.

6. Version notes

Version Change
.NET Core 2.1 (NuGet) / .NET Core 3.0 (in-box) System.Threading.Channels
.NET Core 3.0 ReadAllAsync
.NET 5 Count/CanCount improvements
.NET 6 itemDropped callback for bounded channels
.NET 9 Unbounded prioritised channel (Channel.CreateUnboundedPrioritized)
.NET 10 Same core API

7. Try it yourself

  1. Run the code example; confirm the sum and that all stages complete.
  2. Make one parser throw on a specific input and confirm the final await foreach throws instead of hanging.
  3. Bounded capacity 5, fast producer, slow consumer (100 ms/item): log when the producer waits.
  4. Switch to DropOldest with an itemDropped callback and count drops.
  5. Add batching with the WaitToReadAsync / TryRead loop and measure DB round trips.
  6. Partition by key and verify per-key ordering with multiple consumers.

💻 Code

using System.Threading.Channels;

var raw = Channel.CreateBounded<string>(new BoundedChannelOptions(100)
{
    FullMode = BoundedChannelFullMode.Wait,
    SingleWriter = true
});
var parsed = Channel.CreateBounded<int>(new BoundedChannelOptions(100) { SingleReader = true });

// Stage 1: producer
var producer = Task.Run(async () =>
{
    for (int i = 0; i < 1_000; i++)
        await raw.Writer.WriteAsync(i.ToString()); // waits when the channel is full
    raw.Writer.Complete();
});

// Stage 2: four parsers in parallel
var parsers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
    await foreach (var line in raw.Reader.ReadAllAsync())
        await parsed.Writer.WriteAsync(int.Parse(line));
})).ToArray();

// Complete stage 3's input when all parsers finish (propagating failure if any).
_ = Task.WhenAll(parsers).ContinueWith(
    t => parsed.Writer.Complete(t.Exception),
    TaskScheduler.Default);

// Stage 3: single aggregator
long sum = 0;
await foreach (var n in parsed.Reader.ReadAllAsync())
    sum += n;

await producer;
Console.WriteLine($"Sum {sum}"); // 499500

⚠ Common mistakes

1. Unbounded channels for external input

What happens: Channel.CreateUnbounded<T>() behind an HTTP endpoint. Why it is wrong: slow consumers let memory grow until the process dies. Do instead: bounded channels with a deliberate full mode.

2. Never calling Writer.Complete()

What happens: producers finish but the channel is never completed. Why it is wrong: ReadAllAsync never ends; shutdown hangs. Do instead: complete when production ends, including on error (Complete(ex)).

3. Breaking SingleReader/SingleWriter promises

What happens: SingleReader = true but several consumers are started. Why it is wrong: undefined behaviour: lost or duplicated items. Do instead: set the flags only when strictly true.

4. Assuming order with multiple consumers

What happens: four consumers process an account's events. Why it is wrong: events finish out of order. Do instead: partition by key, one consumer per partition.

5. Swallowing consumer exceptions

What happens: a consumer task dies silently; others keep going or everything stalls. Why it is wrong: items stop being processed; the pipeline may hang. Do instead: per-item try/catch for recoverable errors; propagate fatal ones by completing the next stage with the exception.

6. WriteAsync with no limit on waiting in request handlers

What happens: requests wait indefinitely when the channel is full. Why it is wrong: request threads and connections pile up. Do instead: TryWrite + 429/503, or WriteAsync with a short timeout token.

7. Treating channels as durable queues

What happens: critical work (payments, emails) lives only in a channel. Why it is wrong: restarts and crashes lose it. Do instead: outbox or a message broker for must-not-lose work.

8. Not measuring queue depth

What happens: no visibility of backlog. Why it is wrong: consumers falling behind go unnoticed until memory or latency explodes. Do instead: export Reader.Count, wait times and drop counts.

9. Enabling AllowSynchronousContinuations casually

What happens: set for speed. Why it is wrong: consumer work runs inline on producer threads (possibly request threads). Do instead: leave it off unless benchmarks justify it.

10. One consumer doing slow I/O per item

What happens: a single reader performs one DB insert per message. Why it is wrong: throughput is capped by round-trip latency. Do instead: batch with TryRead loops, or add consumers up to downstream capacity.

🏛 Senior insight: trade-offs

Channels are my default for in-process queues because they make backpressure explicit. The design questions are capacity, what to do when full, how many consumers, and how completion and errors flow.

Decision Typical answer
Bounded or not Bounded for anything fed by external traffic
When full Wait for internal producers, reject (429/503) for HTTP, drop oldest for telemetry
Consumers Match downstream capacity
Ordering Partition by key if needed
Durability Channels are in memory; use a broker if loss is unacceptable

🔗 Follow-ups the interviewer may ask

  1. When: Candidate uses a channel for background work Why: Checks safe BackgroundService design
    How to answer

    Fire-and-forget means starting a task and not awaiting it, such as _ = SendEmailAsync(). The risks are: exceptions are never observed, scoped services like DbContext are disposed when the request ends (giving ObjectDisposedException), work is lost on shutdown or crash, there is no limit on how much work piles up, and HttpContext may be used after the request is gone. In ASP.NET Core, hand the work to a queue (Channel<T>) processed by a BackgroundService that creates its own scope, logs failures and drains on shutdown. If the work must not be lost, use a durable queue or an outbox table.

  2. When: Candidate uses ReadAllAsync Why: Checks async streams
    How to answer

    IAsyncEnumerable<T> is a sequence whose items arrive asynchronously. You produce one with an async iterator method that uses both await and yield return, and consume it with await foreach, which awaits MoveNextAsync() for each item and disposes the enumerator asynchronously. Cancellation is passed with .WithCancellation(ct) and received in the iterator through a parameter marked [EnumeratorCancellation]. It lets you process large or slow data item by item instead of buffering everything, for example EF Core AsAsyncEnumerable() or streaming JSON responses from ASP.NET Core. .NET 10 includes LINQ operators for it in the base library (System.Linq.AsyncEnumerable).

  3. When: Candidate builds a multi-stage pipeline Why: Compares with TPL Dataflow
    How to answer

    TPL Dataflow (System.Threading.Tasks.Dataflow, a NuGet package) is a library of blocks you link into a pipeline: BufferBlock<T>, TransformBlock<TIn,TOut>, TransformManyBlock, BatchBlock<T>, ActionBlock<T>, BroadcastBlock<T> and join blocks. Each block has its own buffering, parallelism (MaxDegreeOfParallelism) and capacity (BoundedCapacity), and LinkTo with PropagateCompletion = true flows completion and errors through. I choose it for multi-stage pipelines with different parallelism per stage, batching or fan-out, where writing the same with channels would need a lot of plumbing. For a single stage, Parallel.ForEachAsync or a channel is simpler.

🛠 Real-world problems: how you handle them

Absorbing IoT bursts with a bounded channel
Situation
An [IoT ingestion API] wrote each device message directly to SQL, at [5,000 messages per second] in bursts.
Signals
Database timeouts and API 500s during bursts; steady load was fine.
Investigation
Database metrics showed connection pool exhaustion caused by one insert per message.
Resolution
Introduced a bounded channel of [10,000] with [4] consumers inserting in batches of [500]; the API used `TryWrite` and returned 429 when full.
Prevention
Exposed channel depth as a metric with an alert at [80%] capacity.

How to tell it (STAR)

Our [ingestion API] failed during bursts because every message was a separate insert. I put a bounded channel in front of the database with a few batching consumers, and returned 429 when the channel was full so devices retried. The 500s disappeared and channel depth became our key load metric.

↩ Usually asked after

More in Async Advanced

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me