Task.WhenAny - 避免 O(N²) 问题的替代列表



我一直在努力提高我对c#中async代码的理解和使用,特别是如何将其集成到现有的同步代码中。

我有以下测试程序,它基本上是来自https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/start-multiple-async-tasks-and-process-them-as-they-complete?pivots=dotnet-6-0的测试,带有一个同步调用者和一个LinqPad可运行包装器。

void Main()
{
var a = new A();

List<string> urls = new List<string>() 
{
"https://learn.microsoft.com/dotnet",
"https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.whenall?view=net-6.0",
"https://stackoverflow.com/questions/11836325/await-operator-can-only-be-used-within-an-async-method"
};

a.GetUrlContentLengths(urls).Dump();
}
public class A
{   
public int GetUrlContentLengths(IEnumerable<string> urls)
{
return Task.Run<int>(async() => await GetUrlContentLengthsAsync(urls)).Result;
}

public async Task<int> GetUrlContentLengthsAsync(IEnumerable<string> urls)
{
System.Net.Http.HttpClient client = new System.Net.Http.HttpClient();
IEnumerable<Task<int>> downloadTasksQuery = urls.Select(x => ProcessUrlAsync(x, client));
var downloadTasks = downloadTasksQuery.ToList();
int total = 0;

while (downloadTasks.Any())
{
Task<int> finishedTask = await Task.WhenAny(downloadTasks);
downloadTasks.Remove(finishedTask);
total += await finishedTask;
}

return total;
}


public  async Task<int> ProcessUrlAsync(string url, System.Net.Http.HttpClient client)
{
byte[] content = await client.GetByteArrayAsync(url);
Console.WriteLine($"{url,-60} {content.Length,10:#,#}");
return content.Length;
}
}

这个链接文档描述了O(n²)问题:

我们在这里有效地创建了一个O(N2)算法:对于每个任务,我们在列表中搜索要删除它的任务,这是一个O(N)<操作/strong>,我们为每个任务注册一个延续,也就是也是O(N)操作

那么这个对Dictionary的微小改变会解决这个问题并使整个事情成为O(n)操作吗?

public async Task<int> GetUrlContentLengthsAsync(IEnumerable<string> urls)
{
System.Net.Http.HttpClient client = new System.Net.Http.HttpClient();
IEnumerable<Task<int>> downloadTasksQuery = urls.Select(x => ProcessUrlAsync(x, client));
var downloadTasks = downloadTasksQuery.ToDictionary(xk => xk.GetHashCode(), xv => xv);
int total = 0;

while (downloadTasks.Any())
{
Task<int> finishedTask = await Task.WhenAny(downloadTasks.Values);
downloadTasks.Remove(finishedTask.GetHashCode());
total += await finishedTask;
}

return total;
}

那么对Dictionary的这一微小变化是否会修复此问题并将整个事情作为O(n)操作?

。搜索List<T>确实是一个O(n)操作,但是消除这个操作并没有消除所有while循环中发生的O(n)操作。在Task.WhenAny方法中隐藏了另外一个O(n)操作,与在列表中搜索相比,它对降低代码速度的影响(开销)要大得多。隐藏操作是在downloadTasks集合中所有未完成的任务上附加延续,然后在任何任务完成时分离这些延续。这需要做很多工作,因为它涉及内存分配和同步开销,避免这种情况的唯一方法是避免使用WhenAny-in-a-loop反模式。这是你的算法的另一个O(n)实现。它是O(n),因为通过Task.WhenAll方法,每个任务只附加了一个延续:

public async Task<int> GetUrlContentLengthsAsync(IEnumerable<string> urls)
{
HttpClient client = new();
int total = 0;
Task<int>[] higherOrderTasks = urls.Select(async url =>
{
int result = await ProcessUrlAsync(url, client).ConfigureAwait(false);
Interlocked.Add(ref total, result);
return result;
}).ToArray();
await Task.WhenAll(higherOrderTasks);
return total;
}

为每个ProcessUrlAsync任务创建一个高阶任务,该任务包装该任务并合并任务完成时应该运行的代码。await ProcessUrlAsync之后的延续可能彼此并发地运行,因此您可能必须同步对任何可能必须改变的共享状态的访问,如上面示例中的total变量。除非您确定您的代码将运行在将同步延续的SynchronizationContext上,在这种情况下,您还应该删除.ConfigureAwait(false)

在这种特殊情况下,实际上可以完全摆脱高阶任务和共享状态,如下所示:

public async Task<int> GetUrlContentLengthsAsync(IEnumerable<string> urls)
{
HttpClient client = new();
Task<int>[] tasks = urls
.Select(url => ProcessUrlAsync(url, client))
.ToArray();
int[] results = await Task.WhenAll(tasks);
return results.Sum();
}

最新更新