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

💡 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.

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 3

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().

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 waiting

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.

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

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Unbounded channelChannel.CreateUnbounded<int>()No capacity limit
Bounded channelChannel.CreateBounded<int>(4)Backpressure at 4
Write an itemawait writer.WriteAsync(x)Awaits if full
Read one itemawait reader.ReadAsync()Awaits if empty
Read all itemsreader.ReadAllAsync()With await foreach
Finish producingwriter.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

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

Related lessons