【问题标题】:Parallel.For spins up an unexpected amount of threadsParallel.For 启动了意外数量的线程
【发布时间】:2019-05-30 05:53:00
【问题描述】:

考虑以下代码:

var options = new ParallelOptions();
var urls = GetListOfUrls();

Parallel.ForEach(urls, options, url => {
    try {
        using (HttpClient client = new HttpClient()) {
            client.Timeout = TimeSpan.FromMinutes(30);
            Task task = client.GetAsync(url);
            task.Wait();
        }
    } catch (Exception exception) {
        Console.WriteLine(exception.Message);
    }

});

它在具有 8 个内核的 VM 上启动了约 670 个线程。这是正常的吗?我的理解是,TPL 的经验法则是每个核心 25 个线程,这将使其进入 200 个线程的范围。

附:在下面的代码中,GetListOfUrls() 返回一百万个 URL。

【问题讨论】:

  • 我不知道你的“经验法则”,你有来源吗?目标是 1 个线程/核心。
  • 线程的最大 nr 数为数千,如果 I/O 速度较慢,您将使用 1 个线程/500 毫秒到达那里。
  • 根据我的经验,这很正常。如果您的操作阻塞,则 TPL 将启动更多线程,并且许多场景属于 MaxDegreeOfParallelism 的“高级使用场景”:“当线程池的启发式无法确定要使用的正确线程数并可能最终注入太多线程时。例如,在长时间运行的循环体迭代中,线程池可能无法区分合理进度或活锁或死锁,并且可能无法回收为提高性能而添加的线程。“
  • @HenkHolterman 感谢您提供 HttpClient 链接。哇。修复似乎是一种反模式。

标签: c# .net task-parallel-library


【解决方案1】:

您的代码示例在编写时并未考虑到异步。你根本不需要Parallel.ForEachHttpClient.GetAsync 已经是异步的,没有必要将其包装在 CPU 绑定任务中。

private readonly _httpClient = new HttpClient();

var tasks = new List<Task>();
foreach(var url in urls)
{
    var task = DoWork(url);
    tasks.Add(task);
}
await Task.WhenAll(tasks);

foreach(var task in tasks)
{
  if (task.Exception != null)
    Console.WriteLine(task.Exception.Message);
}

public async Task DoWork(string url)
{                
  var json = await _httpClient.GetAsync(url);
  // do something with json
}

虽然Parallel.ForEach() 是循环和使用Task.Run() 的更有效版本,但它实际上应该只用于Cpu Bound work (Task.Run Etiquette and Proper Usage)。调用 URL 不是 CPU 绑定的工作,它是 I/O 工作(或更技术上称为IO Completion Port work)。

YOU'RE USING HTTPCLIENT WRONG AND IT IS DESTABILIZING YOUR SOFTWARE

感谢 HttpClient 链接。哇。修复似乎是一种反模式。

虽然它看起来像是一种反模式,但这是因为提供的解决方案实际上是一种反模式。 HttpClient 正在使用外部资源来完成它的工作,所以它应该被处理(它实现了IDisposable)但同时它应该被用作一个单例。这造成了问题,因为没有干净的方法来处理用作类上的静态属性/字段的单例。但是,由于它是由 mirosoft 编写的,我们不应该担心如果文档另有说明,我们总是需要处理我们创建的对象。

既然您指出 Parallel.ForEach 和 Task.Run 都不适用于 HttpClient 工作,因为它受 I/O 限制,您会推荐什么?

异步/等待

我已将百万部分添加到问题中

所以你需要限制并行任务的数量,所以:

var maximumNumberofParallelOperations = 1;
foreach(var url in urls)
{
    var task = DoWork(url);
    tasks.Add(task);
    while (allTasks.Count(t => !t.IsCompleted) >= maximumNumberofParallelOperations )
    {
      await Task.WhenAny(allTasks);
    }
}

【讨论】:

  • 这也不会扩展到一百万个 URL。
  • @HenkHolterman 他需要在问题本身中反映这一点。评论不会添加到问题标准中。
  • @ErikPhilips 没有提到百万个 URL 的原因是因为我的问题涉及创建线程的数量,而不是 HttpClient 的错误使用。
  • 我已将百万部分添加到问题中。
  • @ErikPhilips 既然您指出 Parallel.ForEach 和 Task.Run 都不适合 HttpClient 工作,因为它受 I/O 限制,您会推荐什么?
【解决方案2】:

这里并行是多余的,因为 HttpClient 方法是异步的。我建议改为异步调用它们。否则,您将创建大量线程,这些线程除了等待之外什么都不做。

另外,使用HttpClient 的单个实例。这将增加缓存命中并减少 HttpClient 启动线程以访问代理或 DNS 信息的需要。

var client = new HttpClient();
var tasks = urls.Select( url => client.GetAsync(url) ).ToList();
await Task.WhenAll(tasks);
var results = tasks.Select( task => task.Result ).ToList();

【讨论】:

  • 这被记录为(仅适用于少量任务)[docs.microsoft.com/en-us/dotnet/csharp/programming-guide/…
  • 我看了看,我相信你误用了这篇文章的建议。作者关心的是后处理可以多快开始。 OP 不执行任何后处理,因此该问题不适用。
  • 我从来没有过这样的要求。在那之前很久,TCP/IP 堆栈就会用完临时端口。
  • 没错。但 OP 确实有一百万个 URL。
  • 那么应该重构以使用某种队列。我同意 WhenAll 不适合该用例。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-10-15
  • 2015-08-11
  • 1970-01-01
相关资源
最近更新 更多