2

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?

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
roverred
  • 1,841
  • 5
  • 29
  • 46
  • 1
    In the second example none of that method is actually async. Is your question basically what the benefit of using async when performing IO is? – ProgrammingLlama Apr 06 '23 at 03:20
  • 2
    I guess what I'm asking is: is this question really about `IAsyncEnumerable` or about asynchronous programming in general? – ProgrammingLlama Apr 06 '23 at 03:23
  • 1
    `IQueryable` implements too many interfaces, `.AsAsyncEnumerable` / `.AsEnumerable` just cast the result. Either way, the query will be executed when you start enumerating the results. Though these are bad examples, since you could filter the `IQueryable` and just return it. – Jeremy Lakeman Apr 06 '23 at 04:33
  • 1
    Also `streaming != async`. Both examples process results as they are received. But the `async` version won't block your thread when the result is waiting for I/O. – Jeremy Lakeman Apr 06 '23 at 04:35
  • @ProgrammingLlama mostly about how the` await foreach` against the `IAsyncEnumerable` works. I left a comment below Theodor's answer to clarify, if that helps at all. – roverred Apr 06 '23 at 06:48

2 Answers2

1

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.

Theodor Zoulias
  • 34,835
  • 7
  • 69
  • 104
  • Does that mean whenever something is converted into an `IAsyncEnumerable`, it'll have asynchronous iterator? The async iteration is where I'm most confused about. So when I iterate through a normal `List` it's actually a blocking IO operation? But if I convert the `List` into an `IAsyncEnumerable` the iteration will become a non-blocking async operation? My assumption was that to have an actual `async` iteration, the underlying `IEnumerable` data would have to be retrieved in an `async` way and has to be iterable without pulling in all the data once. – roverred Apr 06 '23 at 06:46
  • 3
    @roverred *So when I iterate through a normal List it's actually a blocking IO operation?* << There is no I/O operation in this case. `List` is already materialized view of a collection. In other words whenever you try to fetch the next item it will be already there. You don't have to perform any computation to produce the next value. – Peter Csala Apr 06 '23 at 07:03
  • 1
    @roverred I added in the answer some code that might help you understand what's going on. – Theodor Zoulias Apr 06 '23 at 07:33
1

What's the reason for converting to IAsyncEnumerable first and doing await on the foreach?

If you want to take advantage of the async I/O. Whenever you fetch data from a database in case of IEnumerable it is a blocking operation. The calling thread (your foreach) have to wait until the database response arrives. While in case of IAsyncEnumerable the caller thread (your await foreach) can be assigned to a different request. So, it provides better scalability.

Is it simply for easier syntax or are there benefits of doing so?

If the next item is (most likely) not available yet and you need to perform an I/O operation to fetch it then you can free up in the meanwhile the (otherwise blocking) caller thread.

Is there a benefit to converting any IEnumerable into IAsyncEnumerable, or only if the underlying IEnumerable is also streamable, for example through yield?

In your particular example you have two IAsyncEnumerables. Your datasource and your http response. You don't have to use both. It is absolutely okay to stream an IEnumerable. It is also okay to fetch asynchronously the datasource and return the result whenever all the data is available.

If I have a list fully loaded into memory already, is it pointless to convert it into an IAsyncEnumerable?

Well, if you want to stream the response then yes it is not needed to asynchronously fetch the datasource.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75