Search code examples
c#async-awaitasp.net-core-webapiienumerableiasyncenumerable

When to convert IEnumerable to IAsyncEnumerable


In the .NET documentation for Controller Action Return Types (doc link), it shows this example on how to return a async response stream:

[HttpGet("asyncsale")]
public async IAsyncEnumerable<Product> GetOnSaleProductsAsync()
{
    var products = _productContext.Products.OrderBy(p => p.Name).AsAsyncEnumerable();

    await foreach (var product in products)
    {
        if (product.IsOnSale)
        {
            yield return product;
        }
    }
}

In the example above, _productContext.Products.OrderBy(p => p.Name).AsAsyncEnumerable() converts the returned IQueryable<Product> into an IAsyncEnumerable. But the below example also works and streams the response asycnhronously.

[HttpGet("asyncsale")]
public async IAsyncEnumerable<Product> GetOnSaleProductsAsync()
{
    var products = _productContext.Products.OrderBy(p => p.Name);

    foreach (var product in products)
    {
        if (product.IsOnSale)
        {
            yield return product;
        }
    }

    await Task.CompletedTask;
}

What's the reason for converting to IAsyncEnumerable first and doing await on the foreach? Is it simply for easier syntax or are there benefits of doing so?

Is there a benefit to converting any IEnumerable into IAsyncEnumerable, or only if the underlying IEnumerable is also streamable, for example through yield? If I have a list fully loaded into memory already, is it pointless to convert it into an IAsyncEnumerable?


Solution

  • The benefit of an IAsyncEnumerable<T> over an IEnumerable<T> is that the former is potentially more scalable, because it doesn't use a thread while is is enumerated. Instead of having a synchronous MoveNext method, it has an asynchronous MoveNextAsync. This benefit becomes a moot point when the MoveNextAsync returns always an already completed ValueTask<bool> (enumerator.MoveNextAsync().IsCompleted == true), in which case you have just a synchronous enumeration masqueraded as asynchronous. There is no scalability benefit in this case. Which is exactly what's happening in the code shown in the question. You have the chassis of a Porsche, with a Trabant engine hidden under the hood.

    If you want to obtain a deeper understanding of what's going on, you can enumerate the asynchronous sequence manually instead of the convenient await foreach, and collect debugging information regarding each step of the enumeration:

    [HttpGet("asyncsale")]
    public async IAsyncEnumerable<Product> GetOnSaleProductsAsync()
    {
        var products = _productContext.Products.OrderBy(p => p.Name).AsAsyncEnumerable();
    
        Stopwatch stopwatch = new();
        await using IAsyncEnumerator<Product> enumerator = products.GetAsyncEnumerator();
        while (true)
        {
            stopwatch.Restart();
            ValueTask<bool> moveNextTask = enumerator.MoveNextAsync();
            TimeSpan elapsed1 = stopwatch.Elapsed;
            bool isCompleted = moveNextTask.IsCompleted;
            stopwatch.Restart();
            bool moved = await moveNextTask;
            TimeSpan elapsed2 = stopwatch.Elapsed;
            Console.WriteLine($"Create: {elapsed1}, Completed: {isCompleted}, Await: {elapsed2}");
            if (!moved) break;
    
            Product product = enumerator.Current;
            if (product.IsOnSale)
            {
                yield return product;
            }
        }
    }
    

    Most likely you'll discover that all MoveNextAsync operations are completed upon creation, at least some of them have a significant elapsed1 value, and all of them have zero elapsed2 values.