A Channel<T> is an in-memory, thread-safe queue built for async producer-consumer code. One or more tasks write items into the channel, one or more tasks read them out, and the channel coordinates the handoff without locks in your code. This lesson covers how to create bounded and unbounded channels, the ChannelWriter<T> and ChannelReader<T> APIs, backpressure, multi-producer multi-consumer patterns, and when to pick Channel<T> over IAsyncEnumerable<T> or BlockingCollection<T>.
Real code rarely produces and consumes work at the same rate. An order intake endpoint accepts orders faster than the fulfillment service can charge cards. A photo upload handler receives images faster than the resize pipeline can process them. An analytics stream emits clickstream events faster than the database can ingest them. Producers and consumers run on their own schedules, and something in the middle has to absorb the difference.
The naive fix is a List<T> with a lock. That works for one producer and one consumer in a synchronous program, but it falls apart fast. Producers either spin in a loop checking "is there room?" or block a thread waiting. Consumers spin checking "is there work?" or block a thread waiting. In async code, blocking a thread defeats the entire point of async/await. You want the producer to suspend asynchronously when the buffer is full, and the consumer to suspend asynchronously when the buffer is empty, with both wakeups handled by the runtime.
Channel<T> is the type that handles this. It's a thread-safe, async-aware FIFO queue with optional capacity limits, designed for exactly this handoff. Writers call WriteAsync and the call completes when there's room. Readers call ReadAsync and the call completes when an item is available. Capacity, backpressure, and completion signaling are all built in.
The diagram shows the shape every channel example follows. Producers on the left write into a shared channel. Consumers on the right read from it. When capacity is bounded, the channel becomes the backpressure point: full means producers wait, empty means consumers wait. The runtime handles the suspension and wakeup; your code just calls WriteAsync and ReadAsync.
Channel<T> lives in System.Threading.Channels. You don't construct it with new; you use the static Channel factory class.
The two factory methods produce different concrete types, but both expose the same Channel<T> API: a Writer property of type ChannelWriter<T> and a Reader property of type ChannelReader<T>. Code that holds a ChannelWriter<T> doesn't know or care whether the underlying channel is bounded.
The trade-off between the two is straightforward. An unbounded channel never makes producers wait, so producers run at full speed. The cost is memory: if consumers fall behind, the buffer grows without limit, and a slow consumer eventually exhausts process memory. A bounded channel caps the buffer at the capacity you pick. Producers block (asynchronously) when the buffer is full, which slows them down to match the consumer. The cost is throughput when the system is bursty; the buffer might fill up and stall producers even when the consumer would have caught up in another second.
The rule of thumb most teams settle on: use bounded channels in production. Pick a capacity large enough to absorb normal bursts and small enough that runaway producers can't OOM the process. Unbounded channels are fine for tests and short-lived workloads where the total item count is known ahead of time.
An unbounded channel with a slow consumer is one of the easier ways to run a process out of memory. Every queued item stays in the buffer until it's read. If producers run 10x faster than consumers and the workload runs for a minute, you have a 60-second backlog sitting in RAM.
ChannelWriter<T> is the producer side. You get it from a channel's Writer property, and it exposes three operations that matter for almost every program.
WriteAsync(item) adds an item to the channel. On an unbounded channel it always completes synchronously. On a bounded channel it completes synchronously if there's room and asynchronously (suspending the caller) if the buffer is full. The returned ValueTask completes when the item is actually in the buffer.
TryWrite(item) is the synchronous, non-waiting version. It returns true if the item was accepted and false if the buffer was full or the channel was completed. It never throws on a full bounded channel, it just returns false. Use this when you want to attempt a write without ever waiting.
Complete() signals that no more items will ever be written. Readers waiting on an empty channel learn that no more work is coming, and ReadAllAsync ends its loop cleanly. After Complete(), any further WriteAsync throws ChannelClosedException.
The pattern is the same across every channel: write items, eventually call Complete(), expect failures after that. The producer is responsible for calling Complete() exactly once. If you forget, consumers using ReadAllAsync wait forever on an empty channel because they have no way to know production has stopped.
One detail that trips up newcomers: WriteAsync returns a ValueTask, not a Task. ValueTask is a value-type wrapper that avoids allocating a new Task object when the operation completes synchronously, which it usually does on a non-full channel. You still await it the same way; the allocation savings are part of why channels are cheap enough to use freely.
WriteAsync on a non-full channel typically completes synchronously, allocating nothing. The ValueTask return type is what makes that "zero allocation per write" claim true. The same call on a full bounded channel does allocate the asynchronous waiter, but only when it has to wait.
ChannelReader<T> is the consumer side. It exposes four operations you'll use constantly.
ReadAsync() returns the next item, suspending asynchronously if the channel is empty. The returned ValueTask<T> completes when an item arrives. If the channel is completed and empty, it throws ChannelClosedException.
TryRead(out T item) is the synchronous version. It returns true and an item if one's available right now, false otherwise. It never waits.
WaitToReadAsync() returns a ValueTask<bool> that completes with true when at least one item is available to read, and false when the channel is completed and empty. It's the building block for the "read everything until done" pattern.
ReadAllAsync() returns an IAsyncEnumerable<T> that yields every item until the channel is completed. It's the easiest way to consume a channel because it handles waiting, completion, and the loop in one expression.
Here's the same consumer written four ways, from lowest level to highest level.
ReadAllAsync reads until Complete() is called and the buffer empties, then the await foreach exits cleanly. No "is the channel done?" checks in your code.
The next-level-down version uses WaitToReadAsync and TryRead together. This pattern is useful when you want to drain everything currently buffered before doing other work, or when you want to batch reads.
The outer WaitToReadAsync returns false once the channel is completed and empty, ending the loop. The inner TryRead drains every item that's currently available without going async, which can be faster than calling ReadAsync once per item when many items arrive at once.
For most code, use ReadAllAsync. It's shorter, harder to get wrong, and the IL it generates is nearly identical to the WaitToReadAsync / TryRead version.
Pulling the APIs together into one realistic shape. A background order-processing service queues orders for charging. The web layer writes orders into the channel as they arrive. A single worker reads them and charges the card.
The exact ordering of "Queued" and "Charging" lines depends on the runtime's scheduling. On most systems the producer races ahead because it doesn't have the 50ms charge delay, so all five queue lines appear before the first charge. With a bounded capacity of 10 and only 5 items, the producer never blocks. If you change capacity to 2, the producer would have to wait twice while the consumer drained items.
The pattern is the entire shape of channels-in-production. Producer writes, signals Complete() when done. Consumer reads with ReadAllAsync until the loop ends naturally. Task.WhenAll waits for both halves to finish.
Bounded channels create backpressure: when the buffer fills, producers are forced to wait, which slows them down to match consumers. That's the entire point of the bound. Watch how it shapes timing.
The producer writes items 1 and 2 immediately because the buffer can hold two. The next write blocks until the consumer reads item 1. From then on, the producer can only write one item per 100ms because the consumer is the bottleneck. The buffer stays mostly full, and producer speed matches consumer speed. That's backpressure: the channel forces the producer to slow down so the buffer doesn't grow without limit.
This is the behavior you want in a real system. If the image-resize service is slower than the upload handler, the upload handler should slow down too, not pile work into memory until the process dies. A bounded channel gives you that automatically.
The actual timestamps vary by run; the OS thread scheduler decides exactly when each task wakes up. The pattern, however, is reliable: with capacity = 2, the producer ends up 2 items ahead of the consumer at most and stays roughly in lockstep.
What should WriteAsync do when the buffer is full? "Wait for room" is the default, but it's not the only choice. BoundedChannelOptions.FullMode controls the policy.
| Mode | Behavior when buffer is full | Use when |
|---|---|---|
Wait (default) | Producer suspends asynchronously until a slot opens | You want classic backpressure; producers should slow down |
DropNewest | The most recently buffered item is dropped to make room for the new one | Newer items are more valuable; you want freshness |
DropOldest | The oldest buffered item is dropped to make room for the new one | Older items are stale; processing the head first is fine |
DropWrite | The new item being written is dropped without notification | Producer must never wait, and missing data is acceptable |
The choice depends on what the data represents. For an order queue, you almost always want Wait: losing an order is unacceptable. For an event-ingestion pipeline emitting telemetry every second, DropOldest is reasonable because the latest metrics matter more than three-minute-old ones. For a real-time UI update channel where stale frames are worthless, DropOldest or DropNewest makes sense. DropWrite is the rarest; it's for cases where you want a sample of recent activity and absolutely cannot block.
Items 1, 2, and 3 were dropped as newer items arrived and the consumer hadn't read anything yet. Only the last two written items survived. If FullMode had been DropNewest, items 3, 4, and 5 would be dropped instead and the consumer would read 1 and 2.
DropOldest and DropNewest together with bounded capacity essentially turn a channel into a fixed-size ring buffer. Wait is what makes it a backpressure-aware queue. The mode you pick is part of the contract between producers and consumers, so pick deliberately.
Channels are thread-safe. Any number of producers can write concurrently from any number of tasks, and any number of consumers can read concurrently. The channel guarantees that each item written goes to exactly one reader.
Here's an image-resize pipeline. Three uploader tasks push images into a shared channel, and two worker tasks resize them. Both halves can be sized independently.
Output (one possible interleaving):
Two important properties hold. First, every job is processed exactly once across the two workers. The channel never duplicates an item or sends the same one to both consumers. Second, the exact ordering across workers is non-deterministic. Worker 1 might pick up the first job or Worker 2 might; the runtime decides. Within a single producer's outputs, items written earlier are buffered earlier, but across producers and consumers, you can't predict the order without external sequencing.
If you need a single-producer or single-consumer optimization, the channel can hint that to the implementation:
SingleReader = true tells the channel "only one task will ever read from this," and the channel can skip some internal synchronization. SingleWriter = true says the same about writers. If you violate the hint and use multiple readers or writers anyway, behavior is undefined: you might get duplicate items, dropped items, or torn reads. Set these only when you're certain.
The SingleReader and SingleWriter options aren't a huge performance win for most workloads, but there's no downside if you actually have the constraint. The bigger win is removing one layer of synchronization, which can matter in hot pipelines processing millions of items per second.
Complete() is how the producer tells the consumer "no more items are coming." It's the most commonly forgotten step in channel code, and forgetting it means consumers using ReadAllAsync block forever waiting for items that never arrive.
The rules to follow are short.
The producer owns Complete(). The consumer doesn't call it. If you have multiple producers, exactly one task is responsible for calling Complete() after all producers finish. The Task.WhenAll(...).ContinueWith(_ => writer.Complete()) pattern from the previous section is the standard way to coordinate this.
Complete() is idempotent: calling it once and only once is the convention. Calling it twice doesn't throw, but it's a sign of confused ownership. TryComplete() is the safer variant that returns false instead of doing anything if the channel was already completed, and that returns true on the first successful completion.
After Complete(), the channel still holds whatever items were already written. Consumers continue reading those items until the buffer empties, then ReadAllAsync exits cleanly. WriteAsync after Complete() throws ChannelClosedException, so producers must never write after signaling completion.
You can also signal an abnormal completion with an exception:
The consumer drains the buffered items first, then ReadAllAsync throws the completion exception. This is the right shape for "the producer hit a fatal error and the consumer needs to react." The exception flows through the channel and lands at the consumer.
C# offers three different tools for coordinating data between producers and consumers. They look similar at the API level and pick different fights.
Channel<T> is async-first, allocation-friendly, supports many producers and many consumers, and includes capacity and drop-mode controls. It's the right pick when producers and consumers run at independent rates and you need backpressure or buffering.
IAsyncEnumerable<T> is a sequence you can iterate with await foreach. The producer and consumer are tightly coupled: the consumer's await foreach drives the producer's yield return, so the producer naturally runs at the consumer's pace. There's no buffer, no capacity, and no way for the producer to run ahead.
BlockingCollection<T> is the pre-async-era version of the same idea. It's thread-safe and supports producer-consumer patterns, but its API is synchronous: Add and Take block the calling thread. In async code, blocking a thread is a serious cost because it pins a thread-pool thread instead of releasing it back to handle other work.
| Feature | Channel<T> | IAsyncEnumerable<T> | BlockingCollection<T> |
|---|---|---|---|
| Async-aware | Yes (WriteAsync, ReadAsync) | Yes (yield return + await foreach) | No (Add, Take block the thread) |
| Decouples producer/consumer rates | Yes; buffer absorbs differences | No; consumer drives producer | Yes; buffer absorbs differences |
| Multi-producer support | Yes | One producer per iterator | Yes |
| Multi-consumer support | Yes | One consumer per iterator | Yes |
| Backpressure | Built in (bounded channel) | Implicit (consumer drives) | Built in (bounded collection) |
| Allocation per item | Near-zero with ValueTask | Near-zero with IAsyncEnumerator | Allocates per Add/Take |
| Best for | Async pipelines with rate decoupling | Streaming a single async sequence | Legacy sync code or non-async producer-consumer |
The decision tree most teams use:
IAsyncEnumerable<T>.Channel<T>.BlockingCollection<T>. New async code shouldn't use it.The boundary between Channel<T> and IAsyncEnumerable<T> is the most common confusion. The clearest way to think about it: IAsyncEnumerable<T> is a "stream" (one producer, one consumer, tightly coupled); Channel<T> is a "queue" (many of either, decoupled by a buffer). When in doubt about which to use, ask whether you'd ever want the producer to run while the consumer is still processing the previous item. If yes, that's a channel. If no, that's an async enumerable.
10 quizzes