簡體   English   中英

如何實現一個高效的 WhenEach 流式傳輸任務結果的 IAsyncEnumerable?

[英]How to implement an efficient WhenEach that streams an IAsyncEnumerable of task results?

我正在嘗試使用C# 8提供的新工具更新我的工具集,其中一個似乎特別有用的方法是返回IAsyncEnumerable Task.WhenAll 這種方法應該 stream 任務一旦可用就會產生結果,因此將其命名為WhenAll沒有多大意義。 WhenEach聽起來更合適。 該方法的簽名是:

public static IAsyncEnumerable<TResult> WhenEach<TResult>(Task<TResult>[] tasks);

這種方法可以像這樣使用:

var tasks = new Task<int>[]
{
    ProcessAsync(1, 300),
    ProcessAsync(2, 500),
    ProcessAsync(3, 400),
    ProcessAsync(4, 200),
    ProcessAsync(5, 100),
};

await foreach (int result in WhenEach(tasks))
{
    Console.WriteLine($"Processed: {result}");
}

static async Task<int> ProcessAsync(int result, int delay)
{
    await Task.Delay(delay);
    return result;
}

預期 output:

已處理:5
已處理:4
已處理:1
已處理:3
已處理:2

我設法在循環中使用Task.WhenAny方法編寫了一個基本實現,但是這種方法存在問題:

public static async IAsyncEnumerable<TResult> WhenEach<TResult>(
    Task<TResult>[] tasks)
{
    var hashSet = new HashSet<Task<TResult>>(tasks);
    while (hashSet.Count > 0)
    {
        var task = await Task.WhenAny(hashSet).ConfigureAwait(false);
        yield return await task.ConfigureAwait(false);
        hashSet.Remove(task);
    }
}

問題是性能。 Task.WhenAny實現創建了所提供任務列表的防御性副本,因此在循環中重復調用它會導致 O(n²) 計算復雜度。 我幼稚的實現很難處理 10,000 個任務。 我的機器上的開銷將近 10 秒。 我希望該方法幾乎與內置Task.WhenAll ,可以輕松處理數十萬個任務。 如何改進WhenEach方法以使其表現得體?

通過使用本文中的代碼,您可以實現以下功能:

public static Task<Task<T>>[] Interleaved<T>(IEnumerable<Task<T>> tasks)
{
   var inputTasks = tasks.ToList();

   var buckets = new TaskCompletionSource<Task<T>>[inputTasks.Count];
   var results = new Task<Task<T>>[buckets.Length];
   for (int i = 0; i < buckets.Length; i++)
   {
       buckets[i] = new TaskCompletionSource<Task<T>>();
       results[i] = buckets[i].Task;
   }

   int nextTaskIndex = -1;
   Action<Task<T>> continuation = completed =>
   {
       var bucket = buckets[Interlocked.Increment(ref nextTaskIndex)];
       bucket.TrySetResult(completed);
   };

   foreach (var inputTask in inputTasks)
       inputTask.ContinueWith(continuation, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);

   return results;
}

然后更改您的WhenEach以調用Interleaved代碼

public static async IAsyncEnumerable<TResult> WhenEach<TResult>(Task<TResult>[] tasks)
{
    foreach (var bucket in Interleaved(tasks))
    {
        var t = await bucket;
        yield return await t;
    }
}

然后你可以像往常一樣打電話給你的WhenEach

await foreach (int result in WhenEach(tasks))
{
    Console.WriteLine($"Processed: {result}");
}

我對 10k 個任務進行了一些基本的基准測試,並在速度方面提高了 5 倍。

您可以將 Channel 用作異步隊列。 每個任務完成后都可以寫入通道。 通道中的項目將通過ChannelReader.ReadAllAsync作為 IAsyncEnumerable 返回。

IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<Task<T>> inputTasks)
{
    var channel=Channel.CreateUnbounded<T>();
    var writer=channel.Writer;
    var continuations=inputTasks.Select(t=>t.ContinueWith(x=>
                                           writer.TryWrite(x.Result)));
    _ = Task.WhenAll(continuations)
            .ContinueWith(t=>writer.Complete(t.Exception));

    return channel.Reader.ReadAllAsync();
}

當所有任務完成時,調用writer.Complete()以關閉通道。

為了測試這一點,此代碼生成具有遞減延遲的任務。 這應該以相反的順序返回索引:

var tasks=Enumerable.Range(1,4)
                    .Select(async i=>
                    { 
                      await Task.Delay(300*(5-i));
                      return i;
                    });

await foreach(var i in Interleave(tasks))
{
     Console.WriteLine(i);

}

產生:

4
3
2
1

只是為了好玩,使用System.ReactiveSystem.Interactive.Async

public static async IAsyncEnumerable<TResult> WhenEach<TResult>(
    Task<TResult>[] tasks)
    => Observable.Merge(tasks.Select(t => t.ToObservable())).ToAsyncEnumerable()

我真的很喜歡Panagiotis 提供的解決方案,但仍然希望引發異常,就像在 JohanP 的解決方案中一樣。

為了實現這一點,我們可以稍微修改一下,在任務失敗時嘗試關閉通道:

public IAsyncEnumerable<T> ToAsyncEnumerable<T>(IEnumerable<Task<T>> inputTasks)
{
    if (inputTasks == null)
    {
        throw new ArgumentNullException(nameof(inputTasks), "Task list must not be null.");
    }

    var channel = Channel.CreateUnbounded<T>();
    var channelWriter = channel.Writer;
    var inputTaskContinuations = inputTasks.Select(inputTask => inputTask.ContinueWith(completedInputTask =>
    {
        // Check whether the task succeeded or not
        if (completedInputTask.Status == TaskStatus.RanToCompletion)
        {
            // Write the result to the channel on successful completion
            channelWriter.TryWrite(completedInputTask.Result);
        }
        else
        {
            // Complete the channel on failure to immediately communicate the failure to the caller and prevent additional results from being returned
            var taskException = completedInputTask.Exception?.InnerException ?? completedInputTask?.Exception;
            channelWriter.TryComplete(taskException);
        }
    }));

    // Ensure the writer is closed after the tasks are all complete, and propagate any exceptions from the continuations
    _ = Task.WhenAll(inputTaskContinuations).ContinueWith(completedInputTaskContinuationsTask => channelWriter.TryComplete(completedInputTaskContinuationsTask.Exception));

    // Return the async enumerator of the channel so results are yielded to the caller as they're available
    return channel.Reader.ReadAllAsync();
}

這樣做的明顯缺點是遇到的第一個錯誤將結束枚舉並阻止返回任何其他可能成功的結果。 這是我的用例可以接受的權衡,但可能不適用於其他用例。

我要為這個問題再添加一個答案,因為有幾個問題需要解決。

  1. 建議創建異步可枚舉序列的方法應具有CancellationToken參數。 這會在await foreach循環中啟用WithCancellation配置。
  2. 建議當異步操作將延續附加到任務時,應在操作完成時清理這些延續。 因此,例如,如果WhenEach方法的調用者決定提前退出await foreach循環(使用breakreturn等),或者如果循環由於異常而提前終止,我們不想讓一堆死延續掛起左右,執着於任務。 如果在循環中重復調用WhenEach (例如,作為Retry功能的一部分),這一點尤其重要。

下面的實現解決了這兩個問題。 它基於Channel<Task<TResult>> 現在通道已成為 .NET 平台不可或缺的一部分,因此沒有理由避免使用基於更復雜TaskCompletionSource的解決方案。

public async static IAsyncEnumerable<TResult> WhenEach<TResult>(
    Task<TResult>[] tasks,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    var channel = Channel.CreateUnbounded<Task<TResult>>();
    using var linkedCts = CancellationTokenSource
        .CreateLinkedTokenSource(cancellationToken);
    var continuations = new List<Task>(tasks.Length);

    try
    {
        int pendingCount = tasks.Length;
        foreach (var task in tasks)
        {
            if (task == null) throw new ArgumentException(
                $"The tasks argument included a null value.", nameof(tasks));
            continuations.Add(task.ContinueWith(t =>
            {
                var accepted = channel.Writer.TryWrite(t);
                Debug.Assert(accepted);
                if (Interlocked.Decrement(ref pendingCount) == 0)
                    channel.Writer.Complete();
            }, linkedCts.Token, TaskContinuationOptions.ExecuteSynchronously |
                TaskContinuationOptions.DenyChildAttach, TaskScheduler.Default));
        }

        await foreach (var task in channel.Reader.ReadAllAsync(cancellationToken)
            .ConfigureAwait(false))
        {
            yield return await task.ConfigureAwait(false);
            cancellationToken.ThrowIfCancellationRequested();
        }
    }
    finally
    {
        linkedCts.Cancel();
        try { await Task.WhenAll(continuations).ConfigureAwait(false); }
        catch (OperationCanceledException) { } // Ignore
    }
}

finally塊負責取消附加的延續,並在退出之前等待它們完成。

await foreach循環中的ThrowIfCancellationRequested可能看起來多余,但實際上是必需的,因為ReadAllAsync方法的設計行為,這在此處進行了解釋。

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM