š 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>withasync+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.
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:
- Create a new project called
AsyncStreams. - Part A: Write
async IAsyncEnumerable<int> SquaresAsync(int n)that yields 1², 2², ā¦, n², awaitingTask.Delay(100)before each. Consume it withawait foreachand print each square. - Part B: Create a bounded
Channel<int>(5). Start a producer task that writes numbers 1..20 (thenComplete()). Consume withawait foreach (var x in channel.Reader.ReadAllAsync()), printing each and doingawait Task.Delay(150)to simulate slow processing. - 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 withawait 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 andWithCancellationon the consumer. - Channels are thread-safe async queues connecting decoupled producers and consumers;
ReadAllAsync()exposes the reader as an async stream. Remember toComplete(). - Bounded channels add backpressure (bounded memory, flow control) ā the safe default for real pipelines.
š Additional Resources
- Generate and consume async streams ā Microsoft Docs
- IAsyncEnumerable<T> ā API reference
- System.Threading.Channels ā Microsoft Docs
š 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.