Index · How it works

1 min read
Senior2 min read
Rapid overview

How it works


What is a Coroutine?

A coroutine is a generalization of a subroutine that allows multiple entry points for suspending and resuming execution at certain locations. Unlike regular functions that run to completion, coroutines can pause mid-execution and yield control back to the caller.

Key Characteristics

  • Suspend and Resume: Can pause execution and continue later
  • State Preservation: Maintains local variables between suspensions
  • Cooperative Multitasking: Yields control voluntarily (not preemptively)
  • Lazy Evaluation: Produces values on demand

Iterator-Based Coroutines (yield return)

The yield return statement creates an iterator that produces values lazily.

Basic Iterator Example

public static IEnumerable<int> GenerateNumbers(int count)
{
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"Generating {i}");
        yield return i;  // Suspend here, return value
    }
    Console.WriteLine("Done generating");
}

// Usage - lazy evaluation
foreach (var num in GenerateNumbers(5))
{
    Console.WriteLine($"Received {num}");
    if (num == 2) break;  // Only generates 0, 1, 2
}

yield break

Use yield break to end iteration early:

public static IEnumerable<int> TakeWhilePositive(IEnumerable<int> source)
{
    foreach (var item in source)
    {
        if (item < 0)
            yield break;  // Stop iteration
        yield return item;
    }
}

Async Iterators (IAsyncEnumerable)

C# 8.0 introduced IAsyncEnumerable<T> for asynchronous streaming of data.

Basic Async Iterator

public static async IAsyncEnumerable<int> FetchDataAsync(int count)
{
    for (int i = 0; i < count; i++)
    {
        await Task.Delay(100);  // Simulate async work
        yield return i;
    }
}

// Consumption
await foreach (var item in FetchDataAsync(10))
{
    Console.WriteLine(item);
}

Cancellation Support

public static async IAsyncEnumerable<int> StreamWithCancellation(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    int i = 0;
    while (!cancellationToken.IsCancellationRequested)
    {
        await Task.Delay(100, cancellationToken);
        yield return i++;
    }
}

// Usage with cancellation
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await foreach (var item in StreamWithCancellation(cts.Token))
{
    Console.WriteLine(item);
}

ConfigureAwait in Async Iterators

await foreach (var item in FetchDataAsync(10).ConfigureAwait(false))
{
    // Runs on thread pool thread after first await
    ProcessItem(item);
}

Channels for Producer-Consumer

System.Threading.Channels provides high-performance async producer-consumer patterns.

Basic Channel Usage

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

// Producer
async Task ProduceAsync(ChannelWriter<int> writer)
{
    for (int i = 0; i < 100; i++)
    {
        await writer.WriteAsync(i);
        await Task.Delay(10);
    }
    writer.Complete();
}

// Consumer
async Task ConsumeAsync(ChannelReader<int> reader)
{
    await foreach (var item in reader.ReadAllAsync())
    {
        Console.WriteLine($"Consumed: {item}");
    }
}

// Run both
await Task.WhenAll(
    ProduceAsync(channel.Writer),
    ConsumeAsync(channel.Reader)
);

Bounded Channels with Backpressure

var options = new BoundedChannelOptions(10)
{
    FullMode = BoundedChannelFullMode.Wait  // Block when full
};
var channel = Channel.CreateBounded<WorkItem>(options);

// Producer will wait if channel is full
await channel.Writer.WriteAsync(new WorkItem());

Multiple Consumers

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

// Start multiple consumers
var consumers = Enumerable.Range(0, 3)
    .Select(id => Task.Run(async () =>
    {
        await foreach (var item in channel.Reader.ReadAllAsync())
        {
            Console.WriteLine($"Consumer {id}: {item}");
        }
    }))
    .ToArray();

// Producer writes, all consumers compete for items
for (int i = 0; i < 100; i++)
{
    await channel.Writer.WriteAsync(i);
}
channel.Writer.Complete();

await Task.WhenAll(consumers);

Coroutine Patterns

Pipeline Pattern

public static async IAsyncEnumerable<TOutput> TransformAsync<TInput, TOutput>(
    IAsyncEnumerable<TInput> source,
    Func<TInput, Task<TOutput>> transformer,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var item in source.WithCancellation(ct))
    {
        yield return await transformer(item);
    }
}

// Build a pipeline
var numbers = GenerateAsync(100);
var doubled = TransformAsync(numbers, async x => x * 2);
var filtered = WhereAsync(doubled, async x => x > 50);

await foreach (var item in filtered)
{
    Console.WriteLine(item);
}

Batching Pattern

public static async IAsyncEnumerable<IReadOnlyList<T>> BatchAsync<T>(
    IAsyncEnumerable<T> source,
    int batchSize,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    var batch = new List<T>(batchSize);

    await foreach (var item in source.WithCancellation(ct))
    {
        batch.Add(item);
        if (batch.Count >= batchSize)
        {
            yield return batch;
            batch = new List<T>(batchSize);
        }
    }

    if (batch.Count > 0)
        yield return batch;
}

Merge Pattern

public static async IAsyncEnumerable<T> MergeAsync<T>(
    params IAsyncEnumerable<T>[] sources)
{
    var channel = Channel.CreateUnbounded<T>();

    var producers = sources.Select(async source =>
    {
        await foreach (var item in source)
        {
            await channel.Writer.WriteAsync(item);
        }
    }).ToArray();

    _ = Task.WhenAll(producers).ContinueWith(_ => channel.Writer.Complete());

    await foreach (var item in channel.Reader.ReadAllAsync())
    {
        yield return item;
    }
}

State Machine Behind Iterators

The compiler transforms iterator methods into state machines.

What the Compiler Generates

// Your code
public IEnumerable<int> GetNumbers()
{
    yield return 1;
    yield return 2;
    yield return 3;
}

// Compiler generates something like:
private sealed class GetNumbersIterator : IEnumerable<int>, IEnumerator<int>
{
    private int state;
    private int current;

    public int Current => current;

    public bool MoveNext()
    {
        switch (state)
        {
            case 0:
                current = 1;
                state = 1;
                return true;
            case 1:
                current = 2;
                state = 2;
                return true;
            case 2:
                current = 3;
                state = -1;
                return true;
            default:
                return false;
        }
    }
}

Performance Considerations

Avoid Allocations in Hot Paths

// Bad - allocates iterator on each call
public IEnumerable<int> GetItems() => items.Where(x => x > 0);

// Better - use ValueTask for single values
public ValueTask<int> GetFirstAsync() =>
    new ValueTask<int>(cachedValue);

Use ConfigureAwait(false)

public async IAsyncEnumerable<T> ProcessAsync<T>(
    IAsyncEnumerable<T> source,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var item in source.WithCancellation(ct).ConfigureAwait(false))
    {
        yield return await TransformAsync(item).ConfigureAwait(false);
    }
}

Channel Performance Tips

// Use SingleReader/SingleWriter when applicable
var options = new UnboundedChannelOptions
{
    SingleReader = true,
    SingleWriter = true
};
var channel = Channel.CreateUnbounded<int>(options);

Exercises

Q: Implement an async iterator that reads lines from a file
Q: Create a debounce operator using channels
Q: Implement a rate limiter using IAsyncEnumerable
Q: Convert a callback-based API to IAsyncEnumerable