我正在尝试了解 .NET 中的线程和任务并行库。因此,我正在尝试使用两种方法同时运行任务,如下所示 -
一些背景 - 我从 https://jsonplaceholder.typicode.com/photos 端点获得了 5000 张照片的列表,我想下载这些照片(被丢弃,但本质上模仿下载)。我正在尝试使用不同的方法来做到这一点,并找出每种方法所花费的时间以及原因。
第一种方法是连续的,下载一张又一张照片,所需时间最长(大约 24 分钟)。这是可以理解的,因为我正在等待完成上一张照片的下载,直到下一张照片开始。所以这里没有抱怨。
第二种方法使用
List<Task>
并将每个照片下载任务添加到列表中,最后等待所有任务完成。这大约需要 1 分 7 秒。由于它并行下载多张照片,因此与第一种即顺序方法相比,预计时间会更短,情况也是如此。
.
第三种方法使用
Parallel.ForEachAsync()
。令我惊讶的是,下载所有照片花了 5 分 19 秒。我期望它的表现与第二种方法类似,但事实并非如此。
using System.Diagnostics;
using System.Text.Json;
var httpClient = new HttpClient();
var photosResponse = await httpClient.GetAsync("https://jsonplaceholder.typicode.com/photos");
var content = await photosResponse.Content.ReadAsStringAsync();
var photos = JsonSerializer.Deserialize<List<Photo>>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
})!;
var stopwatch = new Stopwatch();
stopwatch.Start();
// // 1.Sequential - Time taken: 23m 58s
// foreach (var photo in photos)
// {
// Console.WriteLine($"Downloading {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
// var imageResponse = await httpClient.GetAsync(photo.Url);
// _ = await imageResponse.Content.ReadAsByteArrayAsync();
// Console.WriteLine($"Downloaded {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
// }
// 2.Tasks - Time taken: 1m 7s
var tasks = new List<Task>();
foreach (var photo in photos!)
{
tasks.Add(DownloadPhotoTask(photo, httpClient));
}
await Task.WhenAll(tasks);
// // 3.Parallel.ForEach - Time taken: 5m 19s
// await Parallel.ForEachAsync(photos, (photo, _) => DownloadPhotoValueTask(photo, httpClient));
stopwatch.Stop();
Console.WriteLine($"Time elapsed: {stopwatch.Elapsed}");
return;
async Task DownloadPhotoTask(Photo photo, HttpClient httpClientInternal)
{
Console.WriteLine($"Downloading {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
var imageResponse = await httpClientInternal.GetAsync(photo.Url);
_ = await imageResponse.Content.ReadAsByteArrayAsync();
Console.WriteLine($"Downloaded {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
}
async ValueTask DownloadPhotoValueTask(Photo photo, HttpClient httpClientInternal)
{
Console.WriteLine($"Downloading {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
var imageResponse = await httpClientInternal.GetAsync(photo.Url);
_ = await imageResponse.Content.ReadAsByteArrayAsync();
Console.WriteLine($"Downloaded {photo.Id} on Thread {Environment.CurrentManagedThreadId}");
}
public record Photo(int Id, string Title, string Url);
有人可以帮助我理解第二种方法和第三种方法所花费的时间之间的显着差异吗?哪个更好?如果持续时间是唯一的参数,那么显然从我的测试来看,第二个是最好的方法。如果是的话,为什么我们需要
Parallel.ForEachAsync()
?
此外,如果您可以详细说明第二种和第三种方法的内部工作原理,那将会很有帮助。
Parallel.ForEachAsync
有限制并行度的选项,该方法默认设置为Environment.ProcessorCount
(源代码)。如果您想以无限并行度下载图像,冒着被视为DOS攻击者并被远程服务器阻止的风险,您可以将此选项设置为Int32.MaxValue
:
ParallelOptions parallelOptions = new()
{
MaxDegreeOfParallelism = Int32.MaxValue
};
await Parallel.ForEachAsync(photos, parallelOptions, async (photo, cancellationToken) =>
{
await DownloadPhotoValueTask(photo, httpClient);
});
这样,将使用
DownloadPhotoValueTask
中可用的所有线程在 ThreadPool
线程上调用 ThreadPool
。对于并发运行的异步操作数量没有限制。我的期望是,这种方法的性能将与第二种方法(Task.WhenAll
)非常相似。与在单个线程上创建任务相比,并行创建任务不会有太大区别。据了解,HttpClient
API 不会大量使用 CPU,因此无论哪种方式,所有 DownloadPhotoValueTask
任务都应该几乎立即创建。
顺便说一句,建议您将
cancellationToken
参数传递给 API HttpClient.GetAsync
和 HttpContent.ReadAsByteArrayAsync
,以便在出现错误时更快地完成并行循环。
这个线程中有很多错误信息,你的问题不在于一些神奇的“平衡器”,它被认为是
Parallel.ForEach/Async
的一部分(没有),也不在于它的并行度。事实上,您可以在单个线程上运行无限数量的 get
请求——您应该知道这一点,因为这就是您在 #2 示例中所做的事情。
#3 比 #2 慢的原因,以及 Theodor 无限并行度的东西 似乎 能够改进它,仅仅是因为 Linq 的工作方式。没错,你的问题是一个普通的 Linq 问题,它与任务、线程或 http 请求无关。
在#2中,您为每个线程调用一次
DownloadPhotoTask
,然后只需使用Task.WhenAll
等待它们的结果。然而,在 #3 中,您不会启动 any 任务,而是依靠 Parallel.ForEachAsync
来为您调用它们,并且无论它具有多少并行度,它都会以块的形式调用它们(因此上面的“修复”) .
但是没有什么可以阻止你做一些事情,比如启动所有任务(以及它们的下载),然后迭代它们:
var tasks = photos.Select(p => DownloadPhotoTask(p, httpClient)).ToArray();
await Parallel.ForEachAsync(tasks, task =>
{
await task;
// do something interesting with the results
});