Concurrency with Channels
System.Threading.Channels gives you a clean, async-first way to pass data safely between concurrent producers and consumers . Think of it as a thread-safe queue built for async / await : producers write items, consumers read them, and bounded channels even apply backpressure so a fast producer can't overwhelm a slow consumer. It's the modern replacement for blocking patterns like BlockingCollection T .
Learn Concurrency with Channels in our free C# course — an interactive lesson with worked examples, a practice exercise and a quick reference.
Part of the free C# course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
Picture a conveyor belt in a kitchen. Cooks (producers) place plates on the belt; servers (consumers) take them off and deliver them. The belt itself handles the hand-off safely — no two people grab the same plate. If you make the belt a fixed length (a bounded channel) and it fills up, the cooks simply wait for space before adding more: that's backpressure , keeping the kitchen from drowning in plates. And when the cooks are finished for the night, they flip a "no more orders" sign ( Complete() ), and the servers clear what's left and go home.
A Channel T is a thread-safe conduit with two ends. Producers use the Writer ; consumers use the Reader . Everything is async , so waiting never blocks a thread.
Because it's async-first, a channel pairs naturally with await foreach and Task.WhenAll — unlike the older, thread-blocking BlockingCollection T .
1. Your First Channel
Create a channel, write some items through its Writer , mark it complete, then drain it through its Reader . CreateUnbounded T () makes a channel with no capacity limit — fine when you trust the producer not to outrun memory. Run the worked example below.
2. The Producer/Consumer Pattern
The real value appears when the producer and consumer run concurrently . Start each on its own Task : the producer writes items as it generates them, the consumer reads them as they arrive via ReadAllAsync , and Task.WhenAll waits for both. The consumer's loop ends automatically when the writer calls Complete() .
Your turn. Write items into the channel and then signal completion. Fill in the two ___ blanks with the right method names.
3. Bounded Channels & Backpressure
A bounded channel has a fixed capacity. Once its buffer is full, WriteAsync awaits until the consumer frees a slot — automatically throttling a fast producer to the consumer's pace. This backpressure is the key safety feature: it stops an eager producer from piling up unbounded work in memory.
Now you try: consume every item from the reader using the streaming method. Fill in the ___ blank.
Before channels, the go-to producer/consumer tool was BlockingCollection T . It works, but it's fundamentally thread-blocking : a consumer calling Take() on an empty collection blocks a whole thread until an item appears.
Channels are async-first : WriteAsync and ReadAsync integrate with async / await , so waiting frees the thread instead of parking it. They also support multiple readers and writers, single-reader/single-writer optimizations, and the elegant await foreach (var x in reader.ReadAllAsync()) consumption. For modern async code, channels are the right default.
Here's a realistic pipeline: one producer enqueues jobs into a bounded channel (so it can't outrun the workers), and three consumers read the same channel to share the load. Each job is handed to whichever worker is free — a classic fan-out pattern in just a few lines.
Each item goes to exactly one worker, and Complete() lets every worker's loop end cleanly once the queue is drained.
Q: When should I use a bounded vs an unbounded channel?
Use bounded in production: it applies backpressure so a fast producer can't pile up unlimited items in memory. Unbounded is fine for small, trusted bursts where you know the volume stays modest.
When the producer calls writer.Complete() . The reader yields any remaining buffered items, then the await foreach over ReadAllAsync ends gracefully — no exception, no hang.
Q: How are channels different from BlockingCollection T ?
BlockingCollection T blocks whole threads while waiting. Channels are async-first: WriteAsync / ReadAsync await without blocking a thread, fitting async / await code far better.
Yes — that's a worker pool. Each item is delivered to exactly one available consumer, so several workers reading the same reader naturally share the workload in parallel.
No blanks this time — just a brief and an outline. Build an unbounded channel, run a producer that writes 1..5 and completes, and a consumer that reads with ReadAllAsync and prints each number's square. Use Task.WhenAll so Main waits for both. Run it and check your output against the expected lines.
Practice quiz
What problem do System.Threading.Channels primarily solve?
- Passing data safely between asynchronous producers and consumers
- Encrypting network traffic
- Rendering HTML
- Parsing JSON
Answer: Passing data safely between asynchronous producers and consumers. Channels are a thread-safe, async-friendly conduit for the producer/consumer pattern, letting one or more producers hand data to one or more consumers.
Which method creates a channel with a fixed capacity?
- Channel.Open<T>()
- new Channel<T>()
- Channel.CreateBounded<T>(capacity)
- Channel.CreateUnbounded<T>()
Answer: Channel.CreateBounded<T>(capacity). Channel.CreateBounded<T>(capacity) makes a bounded channel limited to the given number of buffered items; CreateUnbounded has no fixed limit.
What does a BOUNDED channel give you that an unbounded one does not?
- Faster reads always
- Backpressure: WriteAsync waits when the buffer is full
- Automatic encryption
- Synchronous-only access
Answer: Backpressure: WriteAsync waits when the buffer is full. A bounded channel applies backpressure: once the buffer is full, WriteAsync awaits until the consumer drains space, preventing a fast producer overwhelming memory.
Which pair of types do you use to write to and read from a channel?
- TextWriter / TextReader
- Producer<T> / Consumer<T>
- StreamWriter / StreamReader
- ChannelWriter<T> / ChannelReader<T>
Answer: ChannelWriter<T> / ChannelReader<T>. Channel<T> exposes a Writer (ChannelWriter<T>) and a Reader (ChannelReader<T>) that producers and consumers use respectively.
How do you asynchronously add an item to a channel?
- await writer.WriteAsync(item)
- writer.Enqueue(item)
- channel.Add(item)
- writer.Push(item)
Answer: await writer.WriteAsync(item). await writer.WriteAsync(item) writes an item, awaiting if a bounded channel is currently full so it respects backpressure.
What is the idiomatic way to consume every item from a channel until it completes?
- A plain for loop over Count
- await foreach (var item in reader.ReadAllAsync())
- reader.ToList()
- while (true) reader.Read()
Answer: await foreach (var item in reader.ReadAllAsync()). await foreach (var item in reader.ReadAllAsync()) streams items as they arrive and ends cleanly when the channel is completed.
How does a producer signal that no more items will ever be written?
- writer.Dispose()
- writer.Flush()
- channel.Close()
- writer.Complete()
Answer: writer.Complete(). writer.Complete() marks the channel completed; readers then finish their ReadAllAsync loop once the buffered items are drained.
What happens to a consumer's await foreach over ReadAllAsync after the writer completes?
- It throws an exception
- It loops forever
- It drains remaining items, then the loop ends gracefully
- It restarts from the beginning
Answer: It drains remaining items, then the loop ends gracefully. Once the writer completes, the reader yields any buffered items and then the await foreach loop ends naturally — no exception, no hang.
Compared with BlockingCollection<T>, channels are designed to be:
- Synchronous and blocking by nature
- Async-first, integrating with async/await and Tasks
- Only usable on a single thread
- A replacement for arrays
Answer: Async-first, integrating with async/await and Tasks. BlockingCollection<T> blocks threads; channels are async-first, so WriteAsync/ReadAsync await without blocking threads, fitting modern async code.
Why might several consumers read from the same channel?
- To share the workload, each taking different items in parallel
- Because channels require at least three readers
- To slow processing down
- To duplicate every item to each consumer
Answer: To share the workload, each taking different items in parallel. Multiple consumers reading one channel form a worker pool: each item is handed to one available consumer, spreading the work across them.