C# Channels Explained: Producer-Consumer Concurrency Done Right
- A Channel<T> is two separate objects — ChannelWriter<T> and ChannelReader<T> — pass only the half each component needs. This enforces least-privilege and makes your pipeline topology self-documenting.
- Always call writer.
TryComplete()inside a finally block — not after your loop. If the producer throws, downstream consumers will hang forever waiting for items that will never arrive without this guarantee. - BoundedChannelFullMode.Wait gives you true backpressure with zero data loss; DropWrite silently discards items from WriteAsync with no error — never use it for data where loss is unacceptable.
Picture a busy airport baggage carousel. Passengers (producers) drop bags onto the belt, and handlers (consumers) grab them as they come around — nobody waits for the other to finish before moving on. A C# Channel is exactly that belt: a safe, ordered conveyor between threads where one side adds work and the other side processes it, without them ever needing to talk directly or step on each other's feet.
Modern applications — APIs under load, real-time data pipelines, game servers, IoT hubs — all share one ugly problem: work arrives faster than it can be processed, and naively spinning up a new thread per task melts your CPU. The textbook fix is the producer-consumer pattern, but implementing it correctly with locks, semaphores, and ConcurrentQueue is a minefield of deadlocks, race conditions, and forgotten cancellation tokens. Most teams either get it wrong or reach for a full message broker when a lightweight, in-process solution would do.
System.Threading.Channels, shipped in .NET Core 3.0 and fully mature in .NET 5+, is Microsoft's answer to this exact problem. It gives you a typed, async-first, backpressure-aware conduit between producers and consumers — all without a single lock in your application code. Under the hood it uses lock-free data structures, ValueTask to avoid heap allocations on the hot path, and cooperative cancellation baked into every operation. It is, in many ways, Go's channels brought idiomatically to C#.
By the end of this article you'll understand the difference between bounded and unbounded channels, how to wire up multiple producers and consumers, how to handle backpressure without losing data, how completion signalling works, and exactly what can go wrong in production. You'll leave with patterns you can copy into a real codebase today.
How Channels Are Structured Internally (and Why It Matters)
A Channel<T> is not a single object — it's two cooperating half-objects stitched together: a ChannelWriter<T> and a ChannelReader<T>. The writer is the intake funnel; the reader is the output tap. This split is intentional: you can hand the writer to producer code and the reader to consumer code, and neither side has access to the other's API. That's the same principle as exposing only IEnumerable<T> from a collection — least privilege by design.
Internally, an UnboundedChannel<T> backs its queue with a ConcurrentQueue<T> and a linked list of waiting readers stored as continuations. When the queue is empty and a consumer calls ReadAsync, the runtime doesn't block a thread — it parks a ValueTask continuation on a linked list. The moment a producer calls TryWrite, it checks that list first. If a waiter exists, it hands the item directly to that waiter's continuation, bypassing the queue entirely. Zero allocations, zero context switches.
A BoundedChannel<T> adds a capacity limit and a BoundedChannelFullMode enum that controls what happens when the channel is full: Wait (backpressure), DropNewest, DropOldest, or DropWrite. This is your primary tool for protecting downstream systems from being overwhelmed, and choosing the wrong mode is one of the most common production mistakes.
using System; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; // Demonstrates the split Writer/Reader ownership model and // shows that a reader parked on an empty channel costs zero threads. class ChannelInternalsDemo { static async Task Main() { // Create an unbounded channel — no capacity ceiling. // UnboundedChannelOptions lets you tune single-writer / single-reader // for extra lock-free optimisation when you know your topology. var options = new UnboundedChannelOptions { SingleWriter = false, // multiple producers allowed SingleReader = true, // only one consumer — enables faster single-reader path AllowSynchronousContinuations = false // keeps continuations off the writer's call stack }; Channel<string> logChannel = Channel.CreateUnbounded<string>(options); // Hand ONLY the writer to producer logic — it cannot call ReadAsync. ChannelWriter<string> writer = logChannel.Writer; // Hand ONLY the reader to consumer logic — it cannot call WriteAsync. ChannelReader<string> reader = logChannel.Reader; // --- Consumer: runs on its own task, drains the channel --- Task consumerTask = Task.Run(async () => { // ReadAllAsync returns an IAsyncEnumerable<T>. // It yields each item as it arrives and exits cleanly // when the writer signals completion (writer.Complete()). await foreach (string logEntry in reader.ReadAllAsync()) { Console.WriteLine($"[Consumer] Processed: {logEntry} | Thread {Thread.CurrentThread.ManagedThreadId}"); await Task.Delay(50); // simulate processing time } Console.WriteLine("[Consumer] Channel closed — exiting."); }); // --- Producers: two tasks writing concurrently --- Task producerA = Task.Run(async () => { for (int i = 1; i <= 5; i++) { string entry = $"ProducerA-Event-{i}"; // TryWrite is synchronous and allocation-free when the channel has capacity. // Use WriteAsync when the channel might be full (bounded channels). bool accepted = writer.TryWrite(entry); Console.WriteLine($"[ProducerA] Wrote '{entry}': accepted={accepted}"); await Task.Delay(30); } }); Task producerB = Task.Run(async () => { for (int i = 1; i <= 5; i++) { string entry = $"ProducerB-Event-{i}"; await writer.WriteAsync(entry); // async overload — awaits if channel is full Console.WriteLine($"[ProducerB] Wrote '{entry}'"); await Task.Delay(45); } }); // Wait for both producers to finish writing. await Task.WhenAll(producerA, producerB); // CRITICAL: signal that no more items will be written. // Without this, ReadAllAsync loops forever waiting for more data. writer.Complete(); Console.WriteLine("[Main] Writer completed."); // Wait for the consumer to drain everything before exiting. await consumerTask; Console.WriteLine("[Main] Done."); } }
[ProducerB] Wrote 'ProducerB-Event-1'
[Consumer] Processed: ProducerA-Event-1 | Thread 4
[ProducerA] Wrote 'ProducerA-Event-2': accepted=True
[Consumer] Processed: ProducerB-Event-1 | Thread 4
[ProducerB] Wrote 'ProducerB-Event-2'
[ProducerA] Wrote 'ProducerA-Event-3': accepted=True
[Consumer] Processed: ProducerA-Event-2 | Thread 4
... (remaining items interleaved based on timing)
[Main] Writer completed.
[Consumer] Channel closed — exiting.
[Main] Done.
Bounded Channels, Backpressure, and the BoundedChannelFullMode Trap
An unbounded channel will happily accept work forever — until your process runs out of memory. In production, you almost always want a BoundedChannel<T> with a deliberate capacity ceiling. That ceiling is your backpressure mechanism: it forces producers to slow down when consumers fall behind, rather than letting a queue grow unboundedly.
The BoundedChannelFullMode is where teams shoot themselves in the foot. The default is Wait — WriteAsync will asynchronously yield until space opens up. This is the safest mode: no data loss, natural backpressure. But if your producer is a hot loop calling TryWrite (the synchronous variant), it returns false silently when the channel is full. If you don't check that return value, you've just dropped data with zero indication.
DropOldest and DropNewest are useful for real-time telemetry — if the consumer is lagging, stale sensor readings are worthless anyway. But using either mode for financial transactions or audit logs is a disaster. DropWrite is the least surprising drop mode: it rejects the incoming item and returns false from TryWrite or throws ChannelClosedException from WriteAsync when the channel is full and mode is DropWrite — actually no, WriteAsync in DropWrite mode completes synchronously without writing and returns without error. That silent success from WriteAsync in DropWrite mode is the sneakiest gotcha in the entire API.
using System; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; // Real-world scenario: an order processing pipeline where // consumers are slow (DB writes) and producers are fast (HTTP intake). // We use BoundedChannel with Wait mode to apply natural backpressure. class BoundedChannelBackpressure { static async Task Main() { // Capacity of 5 simulates a small in-memory buffer. // In production this might be 500–5000 depending on item size and latency budget. var boundedOptions = new BoundedChannelOptions(capacity: 5) { FullMode = BoundedChannelFullMode.Wait, // producers wait — no data loss SingleWriter = false, SingleReader = false }; Channel<OrderRequest> orderChannel = Channel.CreateBounded<OrderRequest>(boundedOptions); using var cancellationSource = new CancellationTokenSource(); CancellationToken shutdownToken = cancellationSource.Token; // --- Two consumers simulating slow DB writers --- Task[] consumerTasks = new Task[2]; for (int consumerId = 1; consumerId <= 2; consumerId++) { int id = consumerId; // capture for closure consumerTasks[id - 1] = Task.Run(async () => { try { // ReadAllAsync respects cancellation AND channel completion. // It stops when either the token fires or writer.Complete() is called. await foreach (OrderRequest order in orderChannel.Reader.ReadAllAsync(shutdownToken)) { Console.WriteLine($"[Consumer-{id}] Processing order #{order.OrderId}"); await Task.Delay(200, shutdownToken); // simulate slow DB write Console.WriteLine($"[Consumer-{id}] Saved order #{order.OrderId}"); } } catch (OperationCanceledException) { Console.WriteLine($"[Consumer-{id}] Shutdown signal received."); } }); } // --- Fast producer simulating HTTP request intake --- Task producerTask = Task.Run(async () => { for (int orderId = 1; orderId <= 15; orderId++) { var order = new OrderRequest(orderId, $"Item-{orderId}"); Console.WriteLine($"[Producer] Attempting to queue order #{orderId}" + $" (channel count: {orderChannel.Reader.Count})"); // WriteAsync will AWAIT here if channel is full (5 items). // This is backpressure in action — the HTTP handler would naturally // slow down, preventing memory explosion. await orderChannel.Writer.WriteAsync(order, shutdownToken); Console.WriteLine($"[Producer] Queued order #{orderId}"); await Task.Delay(30); // producer is faster than consumers (30ms vs 200ms) } // Signal no more orders are coming. // TryComplete returns false if already completed — safe to call. orderChannel.Writer.TryComplete(); Console.WriteLine("[Producer] All orders submitted. Channel closed."); }); await Task.WhenAll(producerTask); await Task.WhenAll(consumerTasks); Console.WriteLine("[Main] All orders processed."); } } // A meaningful domain object — never use primitives for domain concepts in pipelines. record OrderRequest(int OrderId, string ItemName);
[Producer] Queued order #1
[Producer] Attempting to queue order #2 (channel count: 1)
[Producer] Queued order #2
...
[Consumer-1] Processing order #1
[Consumer-2] Processing order #2
[Producer] Attempting to queue order #6 (channel count: 5)
-- Producer BLOCKS here (awaits) because channel is full --
[Consumer-1] Saved order #1
[Producer] Queued order #6 <-- unblocked after consumer freed space
...
[Producer] All orders submitted. Channel closed.
[Consumer-1] Saved order #14
[Consumer-2] Saved order #15
[Main] All orders processed.
Fan-Out, Fan-In, and Pipeline Patterns for Real Workloads
Single producer, single consumer is the tutorial case. Real systems fan out (one channel feeds multiple workers), fan in (multiple channels merge into one), or build multi-stage pipelines (stage 1 output is stage 2 input). Channels compose cleanly for all three because Channel<T> is just a typed queue — you wire them by passing reader/writer references.
Fan-out is trivial: start N consumer tasks all calling ReadAllAsync on the same reader. The channel distributes work competitively — whichever consumer finishes first grabs the next item. No coordination code required. Fan-in is slightly more involved: you have M producer tasks each writing to their own channel, and one aggregator task that reads from all M readers concurrently and writes into a single output channel.
Pipelines shine when each stage has a different CPU or I/O profile. Stage 1 might parse raw bytes (CPU-bound), Stage 2 might enrich with a DB lookup (I/O-bound), Stage 3 might batch and flush to S3 (I/O-bound). Each stage gets its own bounded channel, giving you independent backpressure between stages. If Stage 2 is the bottleneck, its input channel fills up and slows Stage 1 — which is exactly what you want. The critical discipline: every intermediate channel must be completed when its feeding stage finishes, and every pipeline task must propagate its own errors, ideally through a CancellationTokenSource shared across all stages.
using System; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; // Three-stage pipeline: // Stage 1: Parse raw sensor strings into SensorReading structs (CPU-bound) // Stage 2: Enrich with location metadata via simulated DB lookup (I/O-bound) // Stage 3: Batch and 'flush' enriched readings (I/O-bound) class ThreeStagePipeline { static async Task Main() { using var linkedCts = new CancellationTokenSource(); CancellationToken pipelineToken = linkedCts.Token; // Stage 1 → Stage 2 channel: bounded to apply backpressure if DB is slow Channel<SensorReading> parsedReadingsChannel = Channel.CreateBounded<SensorReading>(new BoundedChannelOptions(50) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = true, // only one parser SingleReader = false // multiple enrichers }); // Stage 2 → Stage 3 channel: enriched readings awaiting batching Channel<EnrichedReading> enrichedReadingsChannel = Channel.CreateBounded<EnrichedReading>(new BoundedChannelOptions(20) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = false, // multiple enrichers write here SingleReader = true // single batcher reads here }); // ── Stage 1: Parser (single task, CPU-bound) ────────────────────────── Task parserTask = Task.Run(async () => { try { string[] rawSensorData = GenerateRawSensorData(count: 30); foreach (string rawLine in rawSensorData) { SensorReading reading = ParseSensorLine(rawLine); await parsedReadingsChannel.Writer.WriteAsync(reading, pipelineToken); } } catch (OperationCanceledException) { /* pipeline cancelled upstream */ } finally { // Always complete in finally — even if an exception occurs. // This unblocks downstream stages instead of hanging forever. parsedReadingsChannel.Writer.TryComplete(); Console.WriteLine("[Stage1-Parser] Complete."); } }); // ── Stage 2: Enrichers (fan-out — 3 parallel DB lookup workers) ─────── const int enricherCount = 3; Task[] enricherTasks = new Task[enricherCount]; for (int workerId = 0; workerId < enricherCount; workerId++) { int id = workerId; enricherTasks[id] = Task.Run(async () => { try { // All enrichers read from the SAME reader — competitive distribution. await foreach (SensorReading reading in parsedReadingsChannel.Reader.ReadAllAsync(pipelineToken)) { // Simulate async DB lookup string location = await LookupSensorLocation(reading.SensorId, pipelineToken); var enriched = new EnrichedReading(reading, location); await enrichedReadingsChannel.Writer.WriteAsync(enriched, pipelineToken); Console.WriteLine($"[Stage2-Enricher-{id}] Enriched sensor {reading.SensorId}"); } } catch (OperationCanceledException) { } }); } // Close the enriched channel only after ALL enrichers finish. // Task.WhenAll ensures we don't close too early. Task enricherCompletionTask = Task.Run(async () => { await Task.WhenAll(enricherTasks); enrichedReadingsChannel.Writer.TryComplete(); Console.WriteLine("[Stage2] All enrichers done. Enriched channel closed."); }); // ── Stage 3: Batcher (single task — collects into batches of 5) ──────── Task batcherTask = Task.Run(async () => { var batch = new List<EnrichedReading>(capacity: 5); try { await foreach (EnrichedReading enriched in enrichedReadingsChannel.Reader.ReadAllAsync(pipelineToken)) { batch.Add(enriched); if (batch.Count >= 5) { await FlushBatch(batch, pipelineToken); batch.Clear(); } } // Flush any remaining items after channel closes if (batch.Count > 0) await FlushBatch(batch, pipelineToken); } catch (OperationCanceledException) { } Console.WriteLine("[Stage3-Batcher] Complete."); }); await Task.WhenAll(parserTask, enricherCompletionTask, batcherTask); Console.WriteLine("[Pipeline] All stages finished."); } static string[] GenerateRawSensorData(int count) { var data = new string[count]; for (int i = 0; i < count; i++) data[i] = $"SENSOR_{i % 5}|{22.5 + i * 0.1:F1}|{DateTime.UtcNow:O}"; return data; } static SensorReading ParseSensorLine(string raw) { string[] parts = raw.Split('|'); return new SensorReading(parts[0], double.Parse(parts[1]), DateTimeOffset.Parse(parts[2])); } static async Task<string> LookupSensorLocation(string sensorId, CancellationToken ct) { await Task.Delay(40, ct); // simulate DB round trip return sensorId switch { "SENSOR_0" => "Warehouse-A", "SENSOR_1" => "Warehouse-B", "SENSOR_2" => "Loading-Dock", _ => "Unknown" }; } static async Task FlushBatch(List<EnrichedReading> batch, CancellationToken ct) { await Task.Delay(60, ct); // simulate S3/DB write Console.WriteLine($"[Stage3-Batcher] Flushed batch of {batch.Count} readings."); } } record SensorReading(string SensorId, double Temperature, DateTimeOffset Timestamp); record EnrichedReading(SensorReading Reading, string Location);
[Stage2-Enricher-1] Enriched sensor SENSOR_1
[Stage2-Enricher-2] Enriched sensor SENSOR_2
[Stage2-Enricher-0] Enriched sensor SENSOR_3
...
[Stage3-Batcher] Flushed batch of 5 readings.
[Stage3-Batcher] Flushed batch of 5 readings.
...
[Stage1-Parser] Complete.
[Stage2] All enrichers done. Enriched channel closed.
[Stage3-Batcher] Flushed batch of 5 readings. (remainder)
[Stage3-Batcher] Complete.
[Pipeline] All stages finished.
TryComplete() in a finally block guarantees completion propagates even on failure. Pair this with a shared CancellationTokenSource so all stages can be cancelled together when one dies. This pattern is called 'linked cancellation with completion guarantee' and it's what separates production-grade pipelines from tutorial code.Performance, Allocation Profiles, and When NOT to Use Channels
Channels are fast — but they're not free, and understanding their allocation profile helps you make informed choices. ReadAsync and WriteAsync return ValueTask<T>, not Task<T>. This means when the operation completes synchronously (item already available, or space already free), there is zero heap allocation. The happy path — a producer writing to a non-full channel when a consumer is ready — involves a direct continuation hand-off with no Task object, no GC pressure. That's the design goal.
Where you will see allocations: the IAsyncEnumerable<T> returned by ReadAllAsync allocates an enumerator object once per enumeration, not per item. Fine for most pipelines. But if you're in a sub-microsecond hot loop, use reader.TryRead() in a polling pattern instead — it's fully synchronous and allocation-free, at the cost of CPU spin.
Channels are the right tool when: work is naturally async, producers and consumers run at different rates, and you need the buffer to absorb bursts. They're the wrong tool when: you need broadcast (one write → many readers each get a copy — use IObservable/Rx or a custom event bus instead), when items need to survive process restarts (use a durable queue like RabbitMQ or Azure Service Bus), or when your 'pipeline' is purely synchronous and CPU-bound (use Parallel.ForEachAsync or PLINQ instead — channels add async overhead with no benefit).
using System; using System.Diagnostics; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; // Demonstrates TryRead polling for zero-allocation hot paths, // and shows how to measure actual throughput on a channel. class ChannelPerformancePatterns { static async Task Main() { await DemonstrateTryReadPolling(); await MeasureChannelThroughput(); } // TryRead pattern: useful when consumer is always faster than producer // and you want zero async overhead. Trades CPU for latency. static async Task DemonstrateTryReadPolling() { Channel<int> hotChannel = Channel.CreateUnbounded<int>( new UnboundedChannelOptions { SingleWriter = true, SingleReader = true }); // Producer: writes 10_000 integers as fast as possible _ = Task.Run(() => { for (int value = 0; value < 10_000; value++) hotChannel.Writer.TryWrite(value); // synchronous, allocation-free hotChannel.Writer.TryComplete(); }); // Consumer: polls with TryRead — no async, no allocation per item. // Falls back to async WaitToReadAsync only when channel appears empty. long sum = 0; int itemsRead = 0; while (await hotChannel.Reader.WaitToReadAsync()) // async only when empty { // Drain everything currently available without yielding. while (hotChannel.Reader.TryRead(out int value)) { sum += value; // actual work itemsRead++; } } Console.WriteLine($"[TryRead] Read {itemsRead} items. Sum={sum}"); } // Throughput benchmark: how many messages/sec can a bounded channel sustain? static async Task MeasureChannelThroughput() { const int messageCount = 500_000; Channel<int> benchmarkChannel = Channel.CreateBounded<int>( new BoundedChannelOptions(1_000) { FullMode = BoundedChannelFullMode.Wait, SingleWriter = true, SingleReader = true }); var stopwatch = Stopwatch.StartNew(); Task producerTask = Task.Run(async () => { for (int i = 0; i < messageCount; i++) await benchmarkChannel.Writer.WriteAsync(i); benchmarkChannel.Writer.TryComplete(); }); Task consumerTask = Task.Run(async () => { int consumed = 0; // WaitToReadAsync + TryRead combo: maximises throughput by batching // synchronous drains under the single async suspension point. while (await benchmarkChannel.Reader.WaitToReadAsync()) while (benchmarkChannel.Reader.TryRead(out _)) consumed++; Console.WriteLine($"[Benchmark] Consumed {consumed} messages."); }); await Task.WhenAll(producerTask, consumerTask); stopwatch.Stop(); double throughput = messageCount / stopwatch.Elapsed.TotalSeconds; Console.WriteLine($"[Benchmark] Throughput: {throughput:N0} messages/sec"); Console.WriteLine($"[Benchmark] Total time: {stopwatch.ElapsedMilliseconds}ms"); } }
[Benchmark] Consumed 500000 messages.
[Benchmark] Throughput: 4,812,345 messages/sec
[Benchmark] Total time: 103ms
(Actual throughput varies by hardware — expect 2M–8M msg/sec on modern hardware
with SingleWriter=true, SingleReader=true, WaitToReadAsync+TryRead pattern)
| Aspect | Channel<T> | ConcurrentQueue<T> | BlockingCollection<T> |
|---|---|---|---|
| Async support | Native (ValueTask-based) | None — sync only | None — thread-blocking |
| Backpressure | Built-in (BoundedChannel) | No — unbounded only | Partial (BoundedCapacity) |
| Completion signal | writer.Complete() + ReadAllAsync exits | Manual flag required | CompleteAdding() supported |
| Allocation on hot path | Zero (ValueTask sync path) | Zero (TryEnqueue) | Allocates — wraps in Task |
| Cancellation support | Every method accepts CancellationToken | None built-in | Partial (TryTake timeout) |
| Fan-out (multi-consumer) | Native — competitive distribution | Native — competitive | Native — but blocking threads |
| Broadcast (every reader gets item) | Not supported | Not supported | Not supported |
| Best use case | Async producer-consumer pipelines | Lock-free bags, work-stealing | Legacy code, sync pipelines |
🎯 Key Takeaways
- A Channel<T> is two separate objects — ChannelWriter<T> and ChannelReader<T> — pass only the half each component needs. This enforces least-privilege and makes your pipeline topology self-documenting.
- Always call writer.
TryComplete()inside a finally block — not after your loop. If the producer throws, downstream consumers will hang forever waiting for items that will never arrive without this guarantee. - BoundedChannelFullMode.Wait gives you true backpressure with zero data loss; DropWrite silently discards items from WriteAsync with no error — never use it for data where loss is unacceptable.
- The WaitToReadAsync + inner TryRead while loop is the maximum-throughput consumption pattern, beating ReadAllAsync by amortising the async suspension cost across many synchronous reads — benchmark before assuming ReadAllAsync is too slow.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QWhat is the difference between an UnboundedChannel and a BoundedChannel in C#, and how does BoundedChannel implement backpressure? Can you explain what happens internally when a producer calls WriteAsync on a full BoundedChannel with FullMode set to Wait?
- QIn a multi-stage pipeline where Stage 1 feeds Stage 2 via a Channel, how do you ensure that Stage 2 exits cleanly when Stage 1 finishes — even if Stage 1 throws an unhandled exception partway through? Walk me through the completion and cancellation strategy.
- QChannel<T>.ReadAllAsync returns IAsyncEnumerable<T>. In ultra-high-throughput scenarios, why might you choose the WaitToReadAsync + TryRead pattern instead, and what are the trade-offs? Also — what does AllowSynchronousContinuations=true do, and why is it dangerous in production?
Frequently Asked Questions
What is the difference between Channel and ConcurrentQueue in C#?
ConcurrentQueue<T> is a thread-safe queue with no async support — consumers must poll or block a thread waiting for items. Channel<T> is async-first: when empty, consumers await asynchronously without blocking any thread, and it supports completion signalling, backpressure via bounded capacity, and cancellation tokens on every operation. Channels are the modern replacement for the ConcurrentQueue + SemaphoreSlim pattern.
How do I stop a Channel consumer loop in C# without hanging?
Two conditions stop a ReadAllAsync loop: either the writer calls writer.Complete() (or TryComplete()), or the CancellationToken passed to ReadAllAsync is cancelled. You need at least one of these. Best practice: call writer.TryComplete() in a finally block inside your producer task, and pass a shared CancellationToken so either condition can trigger a clean exit.
Can multiple consumers read from the same Channel in C#?
Yes — you can start N tasks all calling ReadAllAsync on the same ChannelReader<T>. Work is distributed competitively: whichever consumer is free first grabs the next item. This is fan-out. However, Channels do NOT support broadcast (where every consumer gets a copy of every item) — for that pattern you need IObservable<T> via Reactive Extensions or a custom event bus.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.