C# 如何枚举IAsyncEnumerable<;T>;并为每个元素调用一个异步操作,允许每个迭代/操作对并发?

C# 如何枚举IAsyncEnumerable<;T>;并为每个元素调用一个异步操作,允许每个迭代/操作对并发?,c#,concurrency,async-await,iasyncenumerable,C#,Concurrency,Async Await,Iasyncenumerable,我有一个IAsyncEnumerable流,其中包含从web下载的数据,我希望将每个数据段异步保存到SQL数据库中。所以我使用了库中的扩展方法。我的问题是,下载和保存每段数据都是按顺序进行的,而我更希望它同时进行 澄清一下,我不想同时下载多个数据,也不想同时保存多个数据。我想要的是,当我在数据库中保存一段数据时,下一段数据应该同时从web下载 下面是我当前解决方案的一个最小(人为)示例。下载五个项目,然后保存在数据库中。下载每个项目需要1秒,保存需要1秒: async IAsyncEnumera

我有一个
IAsyncEnumerable
流,其中包含从web下载的数据,我希望将每个数据段异步保存到SQL数据库中。所以我使用了库中的扩展方法。我的问题是,下载和保存每段数据都是按顺序进行的,而我更希望它同时进行

澄清一下,我不想同时下载多个数据,也不想同时保存多个数据。我想要的是,当我在数据库中保存一段数据时,下一段数据应该同时从web下载

下面是我当前解决方案的一个最小(人为)示例。下载五个项目,然后保存在数据库中。下载每个项目需要1秒,保存需要1秒:

async IAsyncEnumerable<string> GetDataFromWeb()
{
    foreach (var item in Enumerable.Range(1, 5))
    {
        Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} > Downloading #{item}");
        await Task.Delay(1000); // Simulate an I/O-bound operation
        yield return item.ToString();
    }
}

var stopwatch = Stopwatch.StartNew();
await GetDataFromWeb().ForEachAwaitAsync(async item =>
{
    Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} > Saving #{item}");
    await Task.Delay(1000); // Simulate an I/O-bound operation
});
Console.WriteLine($"Duration: {stopwatch.ElapsedMilliseconds:#,0} msec");
假设的理想产出:

04:55:50.000>下载#1
04:55:51.000>保存#1
04:55:51.000>下载#2
04:55:52.000>保存#2
04:55:52.000>下载#3
04:55:53.000>保存#3
04:55:53.000>下载#4
04:55:54.000>保存#4
04:55:54.000>下载#5
04:55:55.000>保存#5
持续时间:6000毫秒
我正在考虑实现一个名为
ForEachConcurrentAsync
的自定义扩展方法,该方法与前面提到的
ForEachAwaitAsync
方法具有相同的签名,但其行为允许同时对项进行枚举和操作。以下是此方法的存根:

/// <summary>
/// Invokes and awaits an asynchronous action on each element in the source sequence.
/// Each action is awaited concurrently with fetching the sequence's next element.
/// </summary>
public static Task ForEachConcurrentAsync<T>(
    this IAsyncEnumerable<T> source,
    Func<T, Task> action,
    CancellationToken cancellationToken = default)
{
    // What to do?
}
//
///对源序列中的每个元素调用并等待异步操作。
///在获取序列的下一个元素时,将同时等待每个操作。
/// 
公共静态任务ForEachConcurrentAsync(
这是一个数不清的来源,
Func action,
CancellationToken CancellationToken=默认值)
{
//怎么办?
}
如何实现此功能

其他要求:

  • 在取消或失败的情况下泄漏正在运行的任务是不可接受的。方法完成时,应完成所有已启动的任务
  • 在枚举和操作都失败的极端情况下,应该只传播两个异常中的一个,并且任何一个都可以
  • 该方法应该是真正异步的,并且不应该阻塞当前线程(除非
    action
    参数包含阻塞代码,但这是调用者防止的责任)

  • 澄清:

    15:57:26.226 > Downloading #1
    15:57:27.301 > Downloading #2
    15:57:27.302 > Saving #1
    15:57:28.306 > Downloading #3
    15:57:28.307 > Saving #2
    15:57:29.312 > Downloading #4
    15:57:29.340 > Saving #3
    15:57:30.344 > Downloading #5
    15:57:30.347 > Saving #4
    15:57:31.359 > Saving #5
    Duration: 6 174 msec
    
  • 如果保存数据比从web下载数据花费的时间更长,则该方法应而不是提前继续下载更多项目。最多只能提前下载一条数据,而保存前一条数据

  • web数据的
    IAsyncEnumerable
    是这个问题的起点。我不想更改
    IAsyncEnumerable
    的生成器方法。我希望在枚举可枚举项时对其元素进行操作(通过将它们保存到数据库中)


  • 听起来您只需要跟踪上一个操作的任务,并在下一个操作任务之前等待它

    public static async Task ForEachConcurrentAsync<T>(
        this IAsyncEnumerable<T> source,
        Func<T, Task> action,
        CancellationToken cancellationToken = default)
    {
        Task previous = null;
        try
        {
            await source.ForEachAwaitAsync(async item =>
            {
                if(previous != null)
                {
                    await previous;
                }
    
                previous = action(item);
            });
        }
        finally
        {
            if(previous != null)
            {
                await previous;
            }
        }
    }
    
    公共静态异步任务ForEachConcurrentAsync( 这是一个数不清的来源, Func action, CancellationToken CancellationToken=默认值) { Task previous=null; 尝试 { 等待源。ForEachAwaitAsync(异步项=> { 如果(上一个!=null) { 等待前世; } 先前=行动(项目); }); } 最后 { 如果(上一个!=null) { 等待前世; } } } 剩下的就是撒上取消代码。

    这是我的解决方案。
    我必须将序列更改为数组才能访问下一个元素。
    不确定填充阵列是否符合您的要求

    想法是在返回当前项目之前开始下载下一个项目

        private static async Task Main(string[] args)
        {
            var stopwatch = Stopwatch.StartNew();
            await foreach (var item in GetDataFromWebAsync())
            {
                Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} > Saving #{item}");
                await Task.Delay(1000); // Simulate an I/O-bound operation
    
            }
    
            Console.WriteLine($"Duration: {stopwatch.ElapsedMilliseconds:#,0} msec");
        }
    
        private static async IAsyncEnumerable<string> GetDataFromWebAsync()
        {
            var items = Enumerable
                .Range(1, 5)
                .Select(x => x.ToString())
                .ToArray();
    
            Task<string> next = null;
    
            for (var i = 0; i < items.Length; i++)
            {
                var current = next is null 
                    ? await DownloadItemAsync(items[i]) 
                    : await next;
    
                var nextIndex = i + 1;
                next = StarNextDownloadAsync(items, nextIndex);
                
                yield return current;
            }
        }
    
        private static async Task<string> StarNextDownloadAsync(IReadOnlyList<string> items, int nextIndex)
        {
            return nextIndex < items.Count
                ? await DownloadItemAsync(items[nextIndex])
                : null;
        }
    
        private static async Task<string> DownloadItemAsync(string item)
        {
            Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} > Downloading #{item}");
            await Task.Delay(1000);
            return item;
        }
    

    您需要从
    操作
    调用中收集任务,然后执行
    任务。当所有任务都
    结束时。@juharr理想情况下,我希望在枚举过程中避免跟踪所有任务。一个
    IAsyncEnumerable
    理论上可以发出无限的元素。如果你真的得到一个无限的项目集合,那么代码将永远不会完成,但要处理这个问题,你只需要一个缓冲区,一旦它达到极限,你可以在任何时候执行
    ,然后删除已完成的任务。因为您可以在第一次保存之前完成所有下载,所以在不跟踪任务的情况下无法继续迭代集合,除非您想启动并忘记任务。@juharr yeap,维护有限的任务缓冲区当然是可能的。我不知道这将如何帮助我实现我想要的行为。关于“在第一次保存之前完成所有下载”,这不是我想要的功能。最多只能下载一段数据,而保存前一段数据。理论上,我是说从集合中获取项目的时间可能比执行操作的时间要短得多,在这种情况下,我假设您希望按顺序而不是并行获取项目,但您希望这些操作不会延迟获取下一项。如果你说要等到第一个项目保存后再下载第二个项目,那么你对下载所需时间的预期是完全错误的。谢谢juharr的回答。它很好地涵盖了问题的基本功能!但是我不能接受,因为它不满足问题的第一个附加要求。如果
    源IAsyncEnumerable
    失败,则可能会留下一个正在运行的任务,在火灾和火灾中无法观察到该任务-