假设在 C# 中,我们有许多任务要执行,我们目前正在按顺序执行,但希望通过并行运行它们来加快速度。作为一个简单的例子,假设我们正在下载一堆这样的网页:
var urls = new [] {
"https://github.com/naudio/NAudio",
"https://twitter.com/mark_heath",
"https://github.com/markheath/azure-functions-links",
"https://pluralsight.com/authors/mark-heath",
"https://github.com/markheath/advent-of-code-js",
"http://stackoverflow.com/users/7532/mark-heath",
"https://mvp.microsoft.com/en-us/mvp/Mark%20%20Heath-5002551",
"https://github.com/markheath/func-todo-backend",
"https://github.com/markheath/typescript-tetris",
};
var client = new HttpClient();
foreach(var url in urls)
{
var html = await client.GetStringAsync(url);
Console.WriteLine($"retrieved {html.Length} characters from {url}");
}
为了实现并行化,我们可以将每个单独的下载都变成一个单独Task
的 withTask.Run
并等待它们全部完成,但是如果我们想限制并发下载的数量怎么办?假设我们一次只希望进行 4 次下载。
在这个简单的示例中,确切的数字可能无关紧要,但不难想象您希望避免对下游服务进行太多并发调用的情况。
在这篇文章中,我将介绍解决此问题的四种不同方法。
技术 1 - ConcurrentQueue第一种技术多年来一直是我的首选方法。基本思想是将工作放到一个队列中,然后让多个线程从该队列中读取。这是一个很好的简单方法,但它确实需要我们记住锁定队列,因为它将被多个线程访问。在这个例子中,我ConcurrentQueue
用来给我们线程安全。
我们用所有要下载的 url 填充队列,然后Task
为每个线程启动一个,该线程只是处于循环中试图从队列中读取,并在队列中没有更多项目时退出。我们将这些队列读取器任务中的每一个都放在一个列表中,然后用于Task.WhenAll
等待它们全部退出,这将在最终下载完成后发生。
var maxThreads = 4;
var q = new ConcurrentQueue(urls);
var tasks = new List();
for(int n = 0; n < maxThreads; n++)
{
tasks.Add(Task.Run(async () => {
while(q.TryDequeue(out string url))
{
var html = await client.GetStringAsync(url);
Console.WriteLine($"retrieved {html.Length} characters from {url}");
}
}));
}
await Task.WhenAll(tasks);
我仍然喜欢这种方法,因为它在概念上很简单。但是,如果我们在开始处理工作时仍然产生更多工作要做,那可能会有点痛苦,因为阅读器线程可能会过早退出。
技术 2 - SemaphoreSlim另一种方法(受此StackOverflow 答案的启发)使用SemaphoreSlim
等于initialCount
最大线程数的 a 。然后你WaitAsync
习惯等到可以排队另一个。因此,我们立即开始了四项任务,但必须等待其中第一项完成,然后WaitAsync
才能添加下一项。
var allTasks = new List();
var throttler = new SemaphoreSlim(initialCount: maxThreads);
foreach (var url in urls)
{
await throttler.WaitAsync();
allTasks.Add(
Task.Run(async () =>
{
try
{
var html = await client.GetStringAsync(url);
Console.WriteLine($"retrieved {html.Length} characters from {url}");
}
finally
{
throttler.Release();
}
}));
}
await Task.WhenAll(allTasks);
此处的代码比该ConcurrentQueue
方法更冗长,并且最终可能会包含一个包含大部分已完成任务的巨大列表,但如果您在执行它们的同时生成要完成的任务,则此方法确实具有优势。
例如,要将大文件上传到 Azure blob 存储,您可能会按顺序读取 1MB 块,但希望最多并行上传四个。您不想在上传之前读取所有块,因为这会在我们开始上传之前占用大量时间和内存。使用这种方法,我们可以生成要及时完成的工作,因为线程变得可用于上传,这更有效。
技术 3 - Parallel.ForEach该Parallel.ForEach方法起初似乎是解决这个问题的完美方法。您可以简单地指定MaxDegreeOfParallelism
,然后提供Action
对您的每个项目执行的IEnumerable
:
ar options = new ParallelOptions() { MaxDegreeOfParallelism = maxThreads };
Parallel.ForEach(urls, options, url =>
{
var html = client.GetStringAsync(url).Result;
Console.WriteLine($"retrieved {html.Length} characters from {url}");
});
看起来很简单,不是吗?但是,这里有一个令人讨厌的问题。因为Parallel.ForEach
需要一个Action
,而不是一个Func
它应该只用于调用同步函数。您可能会注意到我们最终在其.Result
后面放置了GetStringAsync
一个危险的反模式。
所以不幸的是,只有当你有一个要并行执行的同步方法时才应该使用这种方法。有一个 NuGet 包实现了Parallel.ForEach 的异步版本,所以如果你想写这样的东西,你可以试试:
await uris.ParallelForEachAsync(
async url =>
{
var html = await httpClient.GetStringAsync(url);
Console.WriteLine($"retrieved {html.Length} characters from {url}");
},
maxDegreeOfParalellism: maxThreads);
技术 4 - Polly Bulkhead
最后一种技术是使用 Polly 的“ Bulkhead ”隔离策略。隔板策略限制了可以进行的并发呼叫的数量,并且可以选择允许您将超过该数量的呼叫排队。
在这里,我们设置了一个限制数量的并发执行和无限数量的排队任务的隔板策略。然后我们简单地ExecuteAsync
重复调用隔板策略,让它要么立即运行它,要么在太多时将它排队。
var bulkhead = Policy.BulkheadAsync(maxThreads, Int32.MaxValue);
var tasks = new List();
foreach (var url in urls)
{
var t = bulkhead.ExecuteAsync(async () =>
{
var html = await client.GetStringAsync(url);
Console.WriteLine($"retrieved {html.Length} characters from {url}");
});
tasks.Add(t);
}
await Task.WhenAll(tasks);
技术 5 - Task.WhenAny
int maxCount = 4;
private async Task TaskCountLimit()
{
List tasks = new List();
foreach (var url in urls)
{
tasks.Add(Task.Run(async() =>var html = await client.GetStringAsync(url);
Console.WriteLine($"retrieved {html.Length} characters from {url}");));
if (tasks.Count >= maxCount)
{
await Task.WhenAny(tasks);
tasks = tasks.Where(x => x.Status == TaskStatus.Runing).ToList();
}
}
}
与我们的其他几个解决方案一样,我们将任务放入列表中并用于Task.WhenAll
等待它们。值得指出的是,这种模式实际上是为从多个线程(例如,从 ASP.NET 控制器操作)生成并发任务的情况而设计的。他们只需使用共享舱壁策略,然后您只需使用await bulkhead.ExecuteAsync(...)
. 因此,这种方法在其设计的情况下使用起来非常简单。
并行性可以极大地提高应用程序的整体性能,但如果误用,可能会导致比它解决的问题更多的问题。这些模式允许您使用有限数量的线程来处理一批作业。您应该选择哪一种取决于您生成任务的方式——您是否预先了解它们,或者它们是在您已经在处理早期任务时动态创建的?您是在单个线程上按顺序生成这些任务,还是多个线程能够即时生成额外的工作项?
当然,我相信还有很多其他巧妙的方法可以解决这个问题,所以请在评论中告诉我您首选的解决方案是什么。