await foreach is inherently sequential. What happens when you need several async streams at once?
Earlier in this course you met IAsyncEnumerable<T> and await foreach — a sequence of values where getting the next one might require waiting, consumed one item at a time. That's the whole story for a single stream. But now that this Part has given you Channels, the ThreadPool, cancellation internals, and the full concurrency toolkit, a harder and very real question comes into focus: what if you have several independent async streams — several live sensor feeds, several paginated API sources, several open connections — and you need to process items from all of them as they arrive, not one stream fully drained before the next even starts?
That turns out to be a genuinely harder problem than it sounds. A single await foreach is, by construction, sequential — it can never race ahead to check a second stream while waiting on the first. Getting real concurrency across multiple streams needs an actual coordination point.
In this lesson, you'll build that coordination point using a Channel<T> as a merge point for multiple concurrent producers, go deeper on how cancellation interacts with an async iterator's own internal awaits, and look at the real, measurable per-item overhead of async streams — distinct from a plain synchronous iterator — so you know when that overhead actually matters.
Merging async streams means combining several independent IAsyncEnumerable<T> sources into a single stream that a consumer can await foreach over — where items show up in whatever order they actually become available across all the sources, not grouped by which source produced them, and not waiting for one source to finish before moving to the next.
A single await foreach loop calls MoveNextAsync() on one enumerator, one call at a time — inherently sequential by the shape of the interface itself. There is no built-in language construct for "await foreach over several sources at once, interleaved by arrival time." To get that, you need an explicit fan-in: one or more producer tasks, each independently draining its own source stream, all writing into a single shared, thread-safe hand-off point — precisely the job a Channel<T>, covered elsewhere in this Part, exists to do. A consumer then await foreachs over the channel, seeing a merged, arrival-ordered stream of everything the producers wrote.
Task.WhenAll coordinates a fixed set of Tasks that each produce one final value, all started up front. An async stream produces many values, over an open-ended period of time, and you want to react to each one as it shows up — not wait for every stream to fully finish before seeing anything. That's a fundamentally different shape of coordination problem, which is exactly why merging streams needs its own dedicated pattern rather than reusing the tools from earlier lessons on coordinating single-value Tasks.
Say you have three live async streams — three exchange feeds, or three queue partitions — and you need to react to whichever one has a new item, as soon as it does. The naive approach, looping through each stream and calling await foreach on the first one fully before moving to the second, drains stream 1 completely before ever looking at stream 2 or 3 — even if stream 2 has had ten items waiting the entire time. That's not "processing multiple streams concurrently" at all; it's processing them one after another, badly, with extra ceremony.
What's needed is genuine concurrency: each source stream advancing independently, on its own schedule, with items surfacing to the consumer in whatever order they actually arrive — not an order imposed by which source happens to be listed first in your code.
Give every source stream its own independent producer task, all writing into one shared Channel<T>, and let a single consumer read from that channel. Each producer advances at its own pace; the channel — a thread-safe, purpose-built hand-off structure covered fully elsewhere in this Part — takes care of safely coordinating multiple concurrent writers and one reader, which is exactly the kind of shared-mutable-state coordination the earlier lessons in this Part warned you never to hand-roll yourself.
await foreachChannel<T>; the consumer reads from the same channel's reader.await foreach over its own source, writing each item it receives into the channel as soon as it arrives — no producer waits on any other producer.Task.WhenAll over the producer tasks, then channel.Writer.Complete() — this is what eventually lets the consumer's loop end naturally instead of waiting forever.A general-purpose merge helper — combine any number of async streams of the same type into one:
public static async IAsyncEnumerable<T> MergeAsync<T>(
IEnumerable<IAsyncEnumerable<T>> sources,
[EnumeratorCancellation] CancellationToken ct = default)
{
var channel = Channel.CreateUnbounded<T>();
async Task PumpAsync(IAsyncEnumerable<T> source)
{
await foreach (T item in source.WithCancellation(ct))
{
await channel.Writer.WriteAsync(item, ct);
}
}
var producers = sources.Select(s => PumpAsync(s)).ToArray();
// When every producer has finished (or one has faulted),
// signal that no more items are coming.
_ = Task.WhenAll(producers).ContinueWith(
t => channel.Writer.TryComplete(t.Exception),
TaskScheduler.Default);
await foreach (T item in channel.Reader.ReadAllAsync(ct))
{
yield return item;
}
}Walking through the important lines:
PumpAsync call is its own independent task — it advances its own source stream on its own schedule, with no coordination needed between producers themselves.channel.Writer.TryComplete(t.Exception) propagates a producer's failure into the channel itself — an exception from any single source surfaces to the consumer's await foreach rather than vanishing into an unobserved task, exactly the fire-and-forget trap covered in this course's async-mistakes lesson.ct token is threaded into every internal await — both source.WithCancellation(ct) on the way in and channel.Writer.WriteAsync(item, ct) on the way out — which matters more than it might look; see Cancellation below.A trading dashboard subscribed to live price ticks from several exchange connections at once — each connection exposes its own IAsyncEnumerable<PriceTick>, and the dashboard needs one unified, arrival-ordered feed to render:
IAsyncEnumerable<PriceTick> nyseStream = _nyseConnection.SubscribeAsync(symbol, ct);
IAsyncEnumerable<PriceTick> nasdaqStream = _nasdaqConnection.SubscribeAsync(symbol, ct);
IAsyncEnumerable<PriceTick> lseStream = _lseConnection.SubscribeAsync(symbol, ct);
await foreach (PriceTick tick in MergeAsync(
new[] { nyseStream, nasdaqStream, lseStream }, ct))
{
_dashboard.UpdatePrice(tick.Exchange, tick.Price);
// Reacts to whichever exchange ticks first — NYSE, NASDAQ, and
// LSE all advance independently; nothing here waits for one
// exchange's feed to be exhausted before reacting to another.
}Without the merge, the dashboard would either have to poll each connection in a round-robin (adding latency and complexity) or fully drain one exchange's feed before ever looking at another (which, for a live, effectively-endless stream, would mean never seeing the other two exchanges at all). The channel-based merge gives genuinely fair, low-latency, concurrent consumption of all three feeds through one simple consumer loop.
Picture several foreign correspondents, each filing reports independently as news happens in their own city — nobody waits for the Tokyo correspondent to finish an entire day's reports before the London correspondent is allowed to file anything. Each report, from whichever correspondent, drops into one shared inbox the moment it's ready. A single editor works through that inbox in the order reports actually arrive — not grouped by city, not waiting for any one correspondent to "finish" (some may never finish, filing indefinitely). That inbox is the channel; the correspondents are the producer tasks; the editor's single pass through the inbox is the consumer's await foreach.
You already know .WithCancellation(token) on the consuming side of an await foreach, and [EnumeratorCancellation] for correctly wiring a token into an async-iterator method's own parameter. Merging multiple streams raises the stakes on a point that's easy to under-appreciate with a single stream: an iterator method can have internal awaits that have nothing to do with the enumerator's own MoveNextAsync() call — a retry delay, an internal buffered write, a connection handshake — and every single one of those internal awaits needs the same token passed into it explicitly, or it simply won't observe cancellation at all.
In the MergeAsync example above, notice that ct appears three separate times: once on source.WithCancellation(ct) (so a producer stops pulling from its slow source promptly), once on channel.Writer.WriteAsync(item, ct) (so a producer stuck waiting for channel capacity — see the bounded-channel backpressure pattern elsewhere in this Part — also unblocks promptly), and once more on the consumer's own channel.Reader.ReadAllAsync(ct). Miss any one of those three, and cancelling the token stops some of the pipeline while leaving another part of it running — this is exactly the "CancellationToken accepted but not passed through" mistake from this course's async-mistakes lesson, just with more internal awaits for it to hide in.
As covered elsewhere in this Part on Task internals, an async method compiles down to a state machine, and each resumption of that state machine after a suspension point is real, if small, work — not free. An async-iterator method (one using both async and yield return) combines two kinds of suspension in one state machine: pausing at an await, and pausing at a yield return. Every single call to MoveNextAsync() — one per item produced — is effectively a resume-and-run-forward-to-the-next-pause operation on that state machine.
For a stream producing a handful of items with real I/O waits in between, this overhead is completely irrelevant — the I/O wait dwarfs it by orders of magnitude. But for a "hot" async stream producing many small items in rapid succession — thousands or millions per second, with little or no genuine waiting between them — that per-item state-machine overhead becomes a real, measurable cost, distinct from and generally larger than the cost of advancing a plain synchronous IEnumerator<T>.MoveNext(), which is just an ordinary method call with no async infrastructure involved at all.
| Advancing to the next item | What's involved |
|---|---|
Plain synchronous iterator (yield return only) | A single state machine, resumed with an ordinary method call — no async machinery. |
Async iterator (async IAsyncEnumerable<T>) | A state machine combining both kinds of suspension, resumed via MoveNextAsync() — genuinely more moving parts per item. |
The practical takeaway isn't "avoid async streams" — it's "measure before assuming a hot async stream is free just because each individual await looks cheap in isolation." If a profiler shows the per-item overhead actually matters for your specific throughput target, that's exactly the kind of decision the capstone lesson at the end of this Part revisits directly.
A single await foreach over a single IAsyncEnumerable<T> is strictly sequential: it calls MoveNextAsync(), waits for that specific call to complete, processes the item, and only then calls MoveNextAsync() again. "Async" here means "doesn't block a thread while waiting" — it does not mean "multiple items are being produced or processed at the same time." Genuine concurrency across streams requires the explicit fan-in pattern this lesson builds.
It might be tempting to try to "parallelize" a single stream by having multiple threads call MoveNextAsync() on the same IAsyncEnumerator<T> instance at once. Don't — an enumerator's instance members are not documented as thread-safe (the same instance-member convention covered elsewhere in this Part applies here directly), and calling into the same enumerator concurrently from multiple threads is itself a race condition on the enumerator's own internal state, not a valid concurrency technique.
Starting producer tasks with a bare _ = Task.Run(...) or ignoring their results entirely — if one source's stream throws, the failure vanishes into an unobserved Task, exactly the fire-and-forget trap from earlier in this course, and the consumer never finds out.
Route a producer's failure into channel.Writer.TryComplete(exception), as shown above, so it surfaces where the consumer will actually see it.
Wiring .WithCancellation(ct) onto the consumer's own loop, while a producer's internal channel.Writer.WriteAsync(item) or a retry delay inside a producer never receives the same token.
Thread the same token into every internal await along the whole pipeline — the token has to reach each suspension point individually; nothing propagates it automatically.
Reaching for async IAsyncEnumerable<T> for an extremely high-frequency, low-latency producer (millions of tiny items per second) purely out of habit, without checking whether the per-item state-machine overhead actually matters for that workload.
Profile the actual throughput need first. For genuinely hot, tight-loop production of many small items with little real waiting, a different shape (batching items, or a plain synchronous producer feeding a channel) may outperform a naive one-item-per-MoveNextAsync() design.
await foreach, inherently sequential.await foreach is inherently sequential — genuine concurrency across multiple streams requires an explicit coordination point.TryComplete(exception)) so failures reach the consumer instead of vanishing as unobserved Task exceptions.CancellationToken has to be threaded into every internal await in the pipeline, not just the outer consumer loop — nothing propagates it automatically.MoveNextAsync() call on an async iterator is a real state-machine resume — small, but nonzero, and worth measuring on genuinely hot, high-frequency streams.You've built a real merge pattern for concurrent async streams and gone deeper on cancellation and overhead. Let's check the reasoning behind each piece.
1. Why can't a single `await foreach` loop, by itself, process items from two different IAsyncEnumerable<T> sources concurrently?
Correct: B
Why B is correct: await foreach's mechanics — await e.MoveNextAsync(), then process Current, then repeat — are fundamentally one-call-at-a-time on one enumerator. There's no built-in notion of "also check a second source while waiting on the first" within a single loop; that coordination has to be built explicitly, which is exactly why this lesson introduces the channel-based merge.
Why A is incorrect: The limitation isn't about argument count or syntax — it's about the sequential nature of how a single enumerator is driven, regardless of how many sources exist elsewhere in your code.
Why C is incorrect: You can absolutely create and hold multiple IAsyncEnumerable<T> instances in one method — the limitation is specifically about consuming them concurrently through one await foreach.
Why D is incorrect: Multiple await foreach statements are legal in one method (e.g., one after another) — that still doesn't make them run concurrently; each one still completes sequentially before the next runs, unless explicitly coordinated.
Reinforcement: Sequential-by-default is the whole reason a fan-in coordination point, like a channel, is needed for genuine multi-stream concurrency.
2. In the MergeAsync pattern from this lesson, what is the purpose of routing a producer task's exception into channel.Writer.TryComplete(exception) rather than letting the producer task simply fail on its own?
Correct: B
Why B is correct: If a producer task simply fails without this step, its exception sits on a Task nobody is awaiting directly — exactly the unobserved-exception fire-and-forget trap from earlier in this course. Routing it through TryComplete(exception) makes the channel itself fault, which the consumer's await foreach will then surface as a real, catchable exception.
Why A is incorrect: Nothing about TryComplete implements retry logic — it's purely about propagating a failure signal to the consumer, not recovering from it automatically.
Why C is incorrect: The exception is not converted to a log entry — it remains a real exception that the consumer's loop will throw when reached.
Why D is incorrect: Other producers are unaffected by one producer's failure being routed this way — they continue running independently; only the channel's eventual completion carries the failure.
Reinforcement: Without an explicit path for producer failures to reach the consumer, they disappear silently — exactly the kind of bug that's invisible until it causes a real incident.
3. A merge pipeline passes ct into the consumer's channel.Reader.ReadAllAsync(ct), but a producer's internal channel.Writer.WriteAsync(item) call omits the token. What happens when the token is cancelled while that specific producer is blocked waiting for channel capacity?
Correct: B
Why B is correct: Cancellation is cooperative and has to be threaded into each individual await explicitly — this lesson's whole point about internal awaits. A WriteAsync call that never received the token has no way to know cancellation was requested and can remain blocked, even while other parts of the pipeline that did receive the token respond correctly.
Why A is incorrect: Cancellation does not propagate automatically across unrelated awaits — each suspension point needs the token passed in explicitly to observe it, exactly as covered in this course's cancellation material.
Why C is incorrect: WriteAsync has an overload that doesn't require a token, so omitting it compiles fine — the danger here is a silent behavioral gap, not a compiler error.
Why D is incorrect: There's no automatic global cancellation sweep across a channel's pending operations — each call only responds to cancellation if it was itself given the token.
Reinforcement: A token has to reach every individual suspension point in a pipeline by hand — missing even one spot leaves a corner of the pipeline that cancellation can't touch.
4. Why is it inaccurate to say a single await foreach loop is "already running concurrently" simply because it's marked async?
Correct: A
Why A is correct: "Async" here describes not blocking a thread while waiting for the next item — a thread-efficiency property. It says nothing about multiple pieces of work happening at once. A single await foreach still processes exactly one item at a time, sequentially, regardless of how non-blocking each individual wait is.
Why B is incorrect: await foreach is completely valid, standard C# syntax for consuming an IAsyncEnumerable<T> — it's not an error at all.
Why C is incorrect: IAsyncEnumerable<T> can produce any number of items, including unboundedly many — that's the entire point of a stream, not a one-item limitation.
Why D is incorrect: Concurrency can be achieved through multiple mechanisms in .NET — the Parallel class is one option among several (including the Channel-based pattern in this lesson), not a required gateway to any form of concurrency.
Reinforcement: Don't conflate "non-blocking" with "concurrent" — they're related but distinct properties, and this confusion is exactly what leads people to expect a plain await foreach to somehow parallelize on its own.
5. A hot async stream produces one million tiny items per second with almost no real waiting between them. Compared to an equivalent plain synchronous iterator (yield return only, no async), what should you expect about per-item overhead, and what's the right response?
Correct: B
Why B is correct: An async iterator's state machine has to handle two kinds of suspension (await and yield return) at once, and every MoveNextAsync() call is a resume-and-run-to-next-pause operation on that machine — genuinely more moving parts than a plain synchronous MoveNext(), which is just an ordinary method call. At extreme, low-latency throughput this overhead can matter; the right response is measuring it, not assuming either "it's fine" or "it's a dealbreaker" without evidence.
Why A is incorrect: There genuinely is a structural difference in what each call involves — claiming zero difference contradicts the actual mechanics covered in this lesson.
Why C is incorrect: Async iterators are not universally faster — for most workloads (real I/O waits between items) the overhead is irrelevant; for hot, tight-loop production it can be a genuine, measurable cost, which is the opposite of "always faster."
Why D is incorrect: The state-machine overhead applies to each MoveNextAsync() call individually, not just the first one — it doesn't disappear after warm-up.
Reinforcement: The right instinct is "measure before optimizing" — this overhead is real but only worth addressing once profiling shows it's actually the bottleneck for your specific throughput target.
You can now genuinely process multiple async streams concurrently, thread cancellation through a whole pipeline correctly, and reason about per-item overhead instead of guessing. This Part's final lesson pulls every technique you've learned together into one high-throughput system.
dotnetmadeeasy.com — Learn C# and .NET, the right way.