Skip to main content

šŸ“ Lesson 3.2: Async Streams and Channels

Some data arrives over time — pages from an API, lines from a live log, sensor readings. Async streams let you produce and consume such data item-by-item with await, and channels let independent producers and consumers hand off work safely.

šŸŽÆ Learning Objectives

By the end of this lesson, you will be able to:

  • Produce an IAsyncEnumerable<T> with async + yield return
  • Consume async streams with await foreach
  • Flow cancellation into an async stream
  • Coordinate producers and consumers with Channel<T>
  • Apply backpressure with bounded channels

Estimated Time: 75 minutes

Project: Stream paged results and build a producer/consumer pipeline.

In This Lesson

Data That Arrives Over Time

You know two related tools already: iterators (yield return, Lesson 1.3) produce a sequence lazily but synchronously; async (Task<T>, Intermediate) awaits a single result. What about a sequence where producing each item requires awaiting?

šŸ“– Definition

Async stream (IAsyncEnumerable<T>): a sequence whose items are produced asynchronously — each one may involve an await. It combines the laziness of iterators with the non-blocking waiting of async.

Perfect fits: reading pages from a web API one at a time, tailing a growing file, or receiving messages as they arrive. You don't want to wait for all the data before processing any of it — you want to handle each item the moment it's ready.

Producing an Async Stream

An async iterator method returns IAsyncEnumerable<T>, is marked async, and combines await with yield return — the two features you already know, together:

public static async IAsyncEnumerable<int> GenerateAsync(int count)
{
    for (int i = 1; i <= count; i++)
    {
        await Task.Delay(500);   // simulate awaiting each item (e.g. an API page)
        yield return i;          // produce it as soon as it's ready
    }
}

A realistic example — paging through a web API, yielding each page's items as they load:

public static async IAsyncEnumerable<Item> GetAllItemsAsync(HttpClient client)
{
    int page = 1;
    while (true)
    {
        var items = await client.GetFromJsonAsync<List<Item>>($"/items?page={page}");
        if (items is null || items.Count == 0) yield break;   // no more pages

        foreach (var item in items)
        {
            yield return item;    // stream each item without loading every page first
        }
        page++;
    }
}

āœ… Best of both worlds

Like a normal iterator, this is lazy — no page is fetched until the consumer asks for the next item. Like async, the await frees the thread while a page is loading. The consumer starts processing page 1's items while page 2 hasn't even been requested yet.

await foreach

You consume an async stream with await foreach — like a normal foreach, but it awaits each item as it's produced:

await foreach (int n in GenerateAsync(5))
{
    Console.WriteLine($"Received {n}");   // prints one every ~500ms, as they arrive
}

Output (each line ~500ms apart):

Received 1
Received 2
Received 3
Received 4
Received 5

LINQ-style operators are available too, via the System.Linq.Async package (or built-in helpers), so async streams compose much like regular sequences.

šŸ’” Async stream vs. Task<List<T>>

Returning Task<List<T>> makes the caller wait for everything and holds it all in memory. An IAsyncEnumerable<T> delivers items as they become available and never needs the whole set in memory at once — ideal for large or open-ended sources.

Cancelling a Stream

Async streams integrate with the cancellation model from Lesson 3.1. The producer accepts a token marked [EnumeratorCancellation]; the consumer supplies one via WithCancellation:

using System.Runtime.CompilerServices;

public static async IAsyncEnumerable<int> GenerateAsync(
    int count,
    [EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 1; i <= count; i++)
    {
        await Task.Delay(500, token);   // token flows into the wait
        yield return i;
    }
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));

try
{
    await foreach (int n in GenerateAsync(100).WithCancellation(cts.Token))
    {
        Console.WriteLine(n);
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("Stream cancelled.");
}

šŸ’” Why [EnumeratorCancellation]?

It tells the compiler to route the token passed by WithCancellation into the iterator's token parameter. Without it, the consumer's token wouldn't reach the producer's awaits. It's the glue connecting the two sides of stream cancellation.

Channels

Async streams are great when one consumer pulls from one producer. But sometimes producers and consumers are separate, independent pieces running concurrently — one (or many) generating work, another (or many) processing it. That's the producer/consumer pattern, and System.Threading.Channels is the modern, async-friendly tool for it.

šŸ“– Definition

Channel: a thread-safe async queue. Producers WriteAsync items to the channel's Writer; consumers ReadAsync (or ReadAllAsync) from its Reader. The two sides are fully decoupled.

graph LR P1["Producer(s)
WriteAsync"] --> CH["Channel
(thread-safe queue)"] CH --> C1["Consumer(s)
ReadAllAsync"] style P1 fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style CH fill:#fff3cd,stroke:#ffc107,stroke-width:2px style C1 fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px
using System.Threading.Channels;

Channel<int> channel = Channel.CreateUnbounded<int>();

// Producer: runs concurrently, writes items, then signals completion
Task producer = Task.Run(async () =>
{
    for (int i = 1; i <= 5; i++)
    {
        await channel.Writer.WriteAsync(i);
        await Task.Delay(200);
    }
    channel.Writer.Complete();          // "no more items"
});

// Consumer: reads until the channel is completed and drained
await foreach (int item in channel.Reader.ReadAllAsync())
{
    Console.WriteLine($"Consumed {item}");
}

await producer;

āœ… Decoupled and safe

The producer and consumer run independently and never touch shared mutable state directly — the channel handles all synchronization. ReadAllAsync() conveniently exposes the reader as an async stream, so it plugs right into await foreach. Note the producer must call Complete(), or the consumer's loop waits forever.

Backpressure

An unbounded channel grows without limit — if the producer is faster than the consumer, memory balloons. A bounded channel caps its capacity and applies backpressure: when full, WriteAsync awaits until the consumer makes room, naturally throttling the producer to the consumer's pace.

var channel = Channel.CreateBounded<int>(capacity: 10);

// If the buffer holds 10 unconsumed items, this WriteAsync will
// asynchronously wait until the consumer reads one — no runaway memory.
await channel.Writer.WriteAsync(item);

šŸ’” Choosing bounded vs. unbounded

  • Bounded — the safe default for real systems: bounded memory and built-in flow control. Pick a capacity that balances throughput against memory.
  • Unbounded — simpler, but only when you're certain the producer can't outpace the consumer indefinitely (or the total volume is small).

āœ… Async streams vs. channels — which?

Use an async stream when a consumer pulls items from a single logical source (paging, generation). Use a channel when producers and consumers are separate concurrent parties that need to hand off work — especially multiple producers/consumers, or when you need buffering and backpressure between them.

Exercise & Quiz

šŸ‹ļø Exercise: A Producer/Consumer Pipeline

Objective: Build both an async stream and a channel-based pipeline.

Instructions:

  1. Create a new project called AsyncStreams.
  2. Part A: Write async IAsyncEnumerable<int> SquaresAsync(int n) that yields 1², 2², …, n², awaiting Task.Delay(100) before each. Consume it with await foreach and print each square.
  3. Part B: Create a bounded Channel<int>(5). Start a producer task that writes numbers 1..20 (then Complete()). Consume with await foreach (var x in channel.Reader.ReadAllAsync()), printing each and doing await Task.Delay(150) to simulate slow processing.
  4. Observe that the bounded channel throttles the producer to the consumer's pace.

Starter Code:

using System.Threading.Channels;

// Part A
await foreach (int sq in SquaresAsync(5))
    Console.WriteLine($"square: {sq}");

// Part B
var channel = Channel.CreateBounded<int>(5);
// TODO: producer Task.Run writing 1..20 then Complete()
// TODO: consumer await foreach over channel.Reader.ReadAllAsync()

static async IAsyncEnumerable<int> SquaresAsync(int n)
{
    // TODO: for 1..n, await Task.Delay(100), yield return i*i
}
šŸ’” Hint

Part A body: for (int i = 1; i <= n; i++) { await Task.Delay(100); yield return i * i; }. Part B producer: await channel.Writer.WriteAsync(i); in a loop, then channel.Writer.Complete();. Start it with Task.Run so the consumer can run at the same time.

āœ… Solution
using System.Threading.Channels;

// Part A
await foreach (int sq in SquaresAsync(5))
    Console.WriteLine($"square: {sq}");

// Part B
var channel = Channel.CreateBounded<int>(5);

Task producer = Task.Run(async () =>
{
    for (int i = 1; i <= 20; i++)
    {
        await channel.Writer.WriteAsync(i);   // waits when the buffer is full
        Console.WriteLine($"  produced {i}");
    }
    channel.Writer.Complete();
});

await foreach (int x in channel.Reader.ReadAllAsync())
{
    Console.WriteLine($"consumed {x}");
    await Task.Delay(150);                     // slow consumer
}

await producer;

static async IAsyncEnumerable<int> SquaresAsync(int n)
{
    for (int i = 1; i <= n; i++)
    {
        await Task.Delay(100);
        yield return i * i;
    }
}

In Part B you'll see "produced" run ahead by only ~5 items before pausing — the bounded channel applies backpressure until the slow consumer catches up.

šŸŽÆ Quick Quiz

Question 1: What does an async IAsyncEnumerable<T> method combine?

Question 2: How do you consume an IAsyncEnumerable<T>?

Question 3: What does a bounded channel provide that an unbounded one doesn't?

Summary

šŸŽ‰ Key Takeaways

  • An async stream (IAsyncEnumerable<T>) produces items lazily and asynchronously — async + yield return, consumed with await foreach.
  • It's ideal for data arriving over time (API paging, live feeds) — items are processed as they arrive, without buffering everything.
  • Flow cancellation in with [EnumeratorCancellation] on the producer and WithCancellation on the consumer.
  • Channels are thread-safe async queues connecting decoupled producers and consumers; ReadAllAsync() exposes the reader as an async stream. Remember to Complete().
  • Bounded channels add backpressure (bounded memory, flow control) — the safe default for real pipelines.

šŸ“š Additional Resources

šŸš€ What's Next?

You can now stream and coordinate async data. To close the module, we sweat the details that make async fast and correct. In Lesson 3.3: Async Performance and Pitfalls, you'll meet ValueTask, ConfigureAwait, and the classic deadlock traps.

šŸŽ‰ Streaming mastered!

You can produce, consume, and coordinate async data over time. Next: async performance details.