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
?
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.