Concurrency with Channels
Reviewed & published by Brayan K
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>.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Create bounded and unbounded channels
- Write with ChannelWriter and read with ChannelReader
- Use WriteAsync and ReadAsync without blocking
- Consume with ReadAllAsync and await foreach
- Apply backpressure with bounded channels
- Complete a channel and build worker pools
💡 Real-World Analogy
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.
The mental model: one pipe, two ends
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.
- ✍️ channel.Writer — a ChannelWriter<T>; await writer.WriteAsync(item) adds an item.
- 📖 channel.Reader — a ChannelReader<T>; await reader.ReadAsync() or ReadAllAsync() takes items.
- 📏 bounded — fixed capacity; WriteAsync awaits when full (backpressure). unbounded — no limit.
- 🏁 writer.Complete() — signals "no more items"; readers finish once the buffer drains.
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.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// A channel is a thread-safe pipe between producers and consumers.
// Unbounded = no capacity limit on buffered items.
Channel<int> channel = Channel.CreateUnbounded<int>();
// PRODUCER: write three items, then mark the channel complete.
for (int i = 1; i <= 3; i++)
await channel.Writer.WriteAsync(i); // hand an item to the pipe
channel.Writer.Complete(); // "no more items coming"
// CONSUMER: read every item until the channel completes.
await foreach (int item in channel.Reader.ReadAllAsync())
Console.WriteLine($"got {item}"); // got 1 / got 2 / got 3
}
}
// ✅ Expected output:
// got 1
// got 2
// got 32. 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().
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Channel<string> channel = Channel.CreateUnbounded<string>();
// Run the producer and consumer AT THE SAME TIME on separate tasks.
Task producer = ProduceAsync(channel.Writer);
Task consumer = ConsumeAsync(channel.Reader);
// Wait for both to finish.
await Task.WhenAll(producer, consumer);
}
static async Task ProduceAsync(ChannelWriter<string> writer)
{
foreach (var job in new[] { "resize", "thumbnail", "watermark" })
{
await writer.WriteAsync(job);
await Task.Delay(100); // simulate producing work
}
writer.Complete(); // signal: done producing
}
static async Task ConsumeAsync(ChannelReader<string> reader)
{
// ReadAllAsync streams items as they arrive; the loop ends on Complete().
await foreach (string job in reader.ReadAllAsync())
Console.WriteLine($"processing {job}");
}
}Your turn. Write items into the channel and then signal completion. Fill in the two ___ blanks with the right method names.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Channel<int> channel = Channel.CreateUnbounded<int>();
// 🎯 YOUR TURN — fill in the blanks marked with ___
for (int i = 10; i <= 30; i += 10)
{
// 1) Asynchronously write i into the channel.
await channel.Writer.___(i); // 👉 the method: WriteAsync
}
// 2) Signal that no more items will be written.
channel.Writer.___(); // 👉 the method: Complete
await foreach (int item in channel.Reader.ReadAllAsync())
Console.WriteLine(item);
// ✅ Expected output:
// 10
// 20
// 30
}
}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.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// BOUNDED capacity = 2. When the buffer is full, WriteAsync AWAITS
// until the consumer frees a slot. This is BACKPRESSURE: a fast
// producer is naturally throttled to the consumer's pace.
Channel<int> channel = Channel.CreateBounded<int>(2);
Task consumer = Task.Run(async () =>
{
await foreach (int item in channel.Reader.ReadAllAsync())
{
Console.WriteLine($" consumed {item}");
await Task.Delay(200); // consumer is deliberately slow
}
});
for (int i = 1; i <= 5; i++)
{
Console.WriteLine($"producing {i}");
await channel.Writer.WriteAsync(i); // waits once the buffer is full
}
channel.Writer.Complete();
await consumer;
}
}Now you try: consume every item from the reader using the streaming method. Fill in the ___ blank.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
Channel<string> channel = Channel.CreateUnbounded<string>();
await channel.Writer.WriteAsync("alpha");
await channel.Writer.WriteAsync("beta");
channel.Writer.Complete();
// 🎯 YOUR TURN — fill in the blanks marked with ___
// 1) Stream EVERY item from the reader until the channel completes.
await foreach (string item in channel.Reader.___()) // 👉 method: ReadAllAsync
Console.WriteLine(item.ToUpper());
// ✅ Expected output:
// ALPHA
// BETA
}
}🔎 Deep Dive: channels vs BlockingCollection<T>
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.
// OLD: BlockingCollection — Take() BLOCKS a thread while waiting
var bc = new BlockingCollection<int>(boundedCapacity: 4);
int item = bc.Take(); // ties up a thread until data arrives
// NEW: Channels — ReadAsync AWAITS without blocking a thread
var ch = Channel.CreateBounded<int>(4);
int next = await ch.Reader.ReadAsync(); // frees the thread while waitingChannels 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.
Putting It Together: a Fan-Out Worker Pool
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.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// A realistic pipeline: one producer queues jobs into a BOUNDED
// channel (backpressure), and MULTIPLE consumers share the work.
Channel<int> channel = Channel.CreateBounded<int>(4);
// Producer: enqueue 8 jobs, then complete.
Task producer = Task.Run(async () =>
{
for (int id = 1; id <= 8; id++)
await channel.Writer.WriteAsync(id);
channel.Writer.Complete();
});
// Three consumers reading the SAME channel form a worker pool;
// each job goes to whichever consumer is free.
async Task Worker(int name)
{
await foreach (int job in channel.Reader.ReadAllAsync())
{
await Task.Delay(50); // simulate processing
Console.WriteLine($"worker {name} handled job {job}");
}
}
await Task.WhenAll(producer, Worker(1), Worker(2), Worker(3));
Console.WriteLine("all jobs done");
}
}Each item goes to exactly one worker, and Complete() lets every worker's loop end cleanly once the queue is drained.
Pro Tips
- 💡 Prefer bounded channels in production: backpressure protects memory when producers can outrun consumers.
- 💡 Always call Complete() when production ends, so consumer ReadAllAsync loops finish instead of waiting forever.
- 💡 Use await foreach with ReadAllAsync — it's the cleanest, most readable way to consume a channel.
- 💡 Scale consumers by running several workers over one reader to parallelize processing.
- 💡 Signal errors via Complete(exception) so consumers observe the failure when the loop ends.
Common Errors (and the fix)
- Consumer hangs forever: you never called writer.Complete(), so ReadAllAsync waits for items that never come. Complete the writer when production ends.
- "ChannelClosedException" on write: you wrote after calling Complete(). Once completed, no further writes are allowed.
- Unbounded channel grows without limit: a fast producer fills memory because there's no backpressure. Switch to CreateBounded<T>(capacity).
- Blocking with .Result on WriteAsync/ReadAsync: that defeats the async design and can deadlock. await them instead.
- Forgetting to await all tasks: if Main returns before Task.WhenAll(producer, consumers), work is cut short. Await every task.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Unbounded channel | Channel.CreateUnbounded<int>() | No capacity limit |
| Bounded channel | Channel.CreateBounded<int>(4) | Backpressure at 4 |
| Write an item | await writer.WriteAsync(x) | Awaits if full |
| Read one item | await reader.ReadAsync() | Awaits if empty |
| Read all items | reader.ReadAllAsync() | With await foreach |
| Finish producing | writer.Complete() | Ends reader loops |
Frequently Asked Questions
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.
Q: How do consumers know when to stop?
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.
Q: Can multiple consumers read one channel?
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.
Mini-Challenge: a Number-Squaring Pipeline
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.
using System;
using System.Threading.Channels;
using System.Threading.Tasks;
class Program
{
static async Task Main()
{
// 🎯 MINI-CHALLENGE: a number-squaring pipeline
// 1. Create an unbounded Channel<int>.
// 2. PRODUCER (a Task): write the numbers 1..5 with WriteAsync,
// then call Writer.Complete().
// 3. CONSUMER: use 'await foreach' over Reader.ReadAllAsync() to read
// each number and print its square (n * n).
// 4. await Task.WhenAll(producer, consumer) so Main waits for both.
//
// ✅ Expected output (order may interleave with production):
// 1
// 4
// 9
// 16
// 25
// your code here
}
}🎉 Lesson Complete
- ✅ A Channel<T> is a thread-safe, async pipe between producers and consumers
- ✅ CreateUnbounded has no limit; CreateBounded applies backpressure
- ✅ Write with Writer.WriteAsync; read with Reader.ReadAsync / ReadAllAsync
- ✅ await foreach (var x in reader.ReadAllAsync()) is the idiomatic consumer
- ✅ writer.Complete() ends consumer loops cleanly
- ✅ Channels are async-first — the modern alternative to BlockingCollection<T>
- 🎓 Congratulations — you've reached the end of the Expert Track!
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.
Continue this course
- Previous: Configuration & the Options Pattern
- Quick reference: C# cheat sheet