Async .NET · Lesson 9

Asynchronous streams

Consume values as an async producer makes them available.

An asynchronous sequence has repeated completion boundaries

A Task carrying a collection produces one completed collection result. An IAsyncEnumerable can instead supply elements over time, with the consumer awaiting each next element. The sample uses an async stream to produce three integers in sequence. Its purpose is to show incremental consumption, not to simulate a network protocol or prove a performance benefit. The producer’s lifetime extends across enumeration, so ordering, early exit, failure, and cleanup belong in the sequence contract rather than being treated as unrelated implementation details.

Each yielded number reaches the consumer once

NumbersAsync begins its loop at one. For each iteration it awaits a small delay and yields the current value. The await foreach consumer prints each yielded item, so the expected output is one, two, and three on separate lines. The next loop iteration proceeds through the async-iteration protocol rather than returning a prebuilt array. The one-millisecond delays are not measurements of exactly when lines appear; the guaranteed fact is the producer’s sequential order and the consumer’s one print per yielded element.

await foreach (var item in NumbersAsync()) Console.WriteLine(item);

Yield preserves producer state between requests

The iterator retains the loop variable and its current position across suspension and yield boundaries. Returning one value does not mean the entire producer method has completed. The consumer can request another element until the sequence finishes normally or another terminal path occurs. This incremental model can avoid building every result before the first is consumed, but an implementation can still retain large state internally. An async-stream return type alone is not proof of bounded memory or efficient production.

Async streams are not automatic parallel pipelines

The loop shown awaits and yields one item at a time. It does not start three independent timers simultaneously or process several items on separate cores. Adding asynchronous syntax is not equivalent to adding a concurrency policy. Another mistake is forgetting that an exception can arise while requesting a later element after earlier values were already consumed. A caller must decide whether those partial results remain useful and who reports the failure, rather than assuming the stream is all-or-nothing because its element type is simple.

Practice: change the produced sequence deliberately

Change the upper loop bound from three to five and predict all five output lines. Then change only the initial loop value to two and explain which values are now omitted. Add a consumer-side projection, such as printing each received value doubled, while keeping the producer’s sequence unchanged. These variations separate production rules from presentation rules and make it clear which layer owns each transformation. Keep timer values out of the expected-output calculation because the lesson does not depend on measured elapsed time.

  • Record the producer values and consumer output values in separate columns.
  • Do not infer parallel execution merely because more than one await appears in the source.

Practice: stop consumption early and observe cleanup

For an independent lifetime exercise, break from await foreach after printing two. Wrap the producer loop in a try/finally that prints a cleanup marker, then verify the marker when the consumer exits early. The iterator’s disposal path matters even though the third value was never requested. Next simulate a controlled producer failure before yielding three and handle it around enumeration. Compare normal completion, consumer break, and producer failure as distinct outcomes with explicit ownership of partial results.

  • The base sample has no cancellation parameter; adding one requires forwarding a consumer token through a deliberate iterator contract.
  • Test early exit after the first item as well as after the second to check the same lifetime rule.

Choose a stream because incremental delivery matters

A clear stream API states whether ordering is stable, whether enumeration can be repeated, how cancellation is accepted, and what cleanup happens when the consumer stops. The sample’s local iterator uses ordinary managed features available in SharpRune without claiming access to desktop-only resources. When adapting it to real work, decide whether incremental delivery is actually useful or whether a single Task of a materialized result is simpler. The correct abstraction follows result and lifetime requirements, not the desire to add async keywords to an existing loop.

  • Baseline output is 1, 2, 3 on separate lines.
  • Yielding one value preserves producer state; it does not complete the whole iterator.
  • Early exit, later failure, cancellation support, and partial-result policy need explicit stream contracts.

A working example

using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

await foreach (var item in NumbersAsync()) Console.WriteLine(item);
async IAsyncEnumerable<int> NumbersAsync()
{
    for (var i = 1; i <= 3; i++)
    {
        await Task.Delay(1);
        yield return i;
    }
}

Expected output

1
2
3
Try this
Await foreach asks the producer for one asynchronous next item at a time.

Further reading