CPU 100% 问题分析,我们把博客园踩过的坑又踩了一遍《一》

针对.net core 异常奇怪问题分析


以上问题都可能是因为线程池饥饿(ThreadPool starvation),理论上线程池饥饿一直是一个潜在的问题,在大规模异步服务出现之前,所需要的线程数是可以预测,因此这些问题很少发生(调试不容易发现)。但是随着更大规模异步应用,此问题发生的机率增加。
在极端情况下,吞吐量损失是如此糟糕,服务似乎没有取得进展。更常见的情况是,你只是得到你期望的,或者你不能处理负载的"突发"。(因此,在使用 20% CPU 时,在代替状态下效果很好,但当您获得突发时,它不会使用更多的 CPU 来为突发提供服务)。
这些一般症状告诉你的是,你有一个'瓶颈',但它不是CPU。基本上,需要一些其他资源来服务请求,每个请求必须等待,而这种等待限制了吞吐量。现在最常见的原因是请求需要另一台计算机上的资源(通常是数据库,但它可能是非计算机缓存(redis)或任何其他"进程外"资源)。
正如我们将看到的,这些常见问题相对简单,因为我们可以"指责"适当的组成部分。当我们查看请求为何需要很长时间的细目时,我们会清楚地看到某些部分(例如等待数据库的响应)需要很长时间,因此我们知道这就是问题所在。
然而,有一个阴险的情况,即"稀缺资源"本身是线程。这是一些操作(如数据库查询),完成,但当它这样做时,没有线程可以运行下一步服务请求,因此它只会停止,等待这样的线程变为可用。这一次显示为"较长"的数据库查询,但也会发生任何非 CPU 活动(例如,任何 I/O 或延迟),因此似乎非常 I/O 操作随机需要的时间比它应该需要的时间长。我们称此问题为 ThreadPool 饥饿,它是本文的重点。

从理论上讲,线程池饥饿一直是一个潜在的问题,但正如所解释的,在大规模异步服务出现之前,所需的线程数更可预测,因此问题很少发生。但是,随着更多大规模异步应用,此问题的可能性已经增加,因此我编写本文来描述如何诊断此问题是否影响您的服务,以及如果是此问题,该怎么办。但在我们做到这一点之前,一些背景是有帮助的

什么是线程池?

在讨论线程池之前,我们应该返回并描述什么是线程。线程是执行顺序程序所需的状态。对于一个很好的近似值,它是部分执行方法的"调用堆栈",包括每个方法的所有局部变量。关键是所有代码都需要线程才能"运行"。当程序启动时,会为它提供一个线程,并且多线程程序创建其他线程,每个线程彼此同时执行代码。

线程在并发量适中、每个线程都执行复杂操作的世界里有意义。但是,某些工作负载具有完全相反的特征:发生了许多并发事件,并且每个工作负载都在执行简单的事情。由于所有执行都需要一个线程,因此对于此工作负荷,重用线程是有意义的,因为它要执行许多小型(不相关的)工作项。这是一个线程池。它有一个非常简单的API。在.NET中,它是线程池.QueueuserWorkItem,它需要委托(方法)才能运行。当您调用 QueueUserWorkItem 时,线程池承诺运行您将来经过一段时间的委托。(注意 .NET 不鼓励直接使用线程池。Task.Factory.StartNew(操作)还对方法进行排队到 .NET 线程池,但可以更轻松地处理错误条件和等待结果。

什么是异步编程?

过去,服务使用"多线程"执行模型,其中服务将为处理的每个并发请求创建一个线程。每个此类线程将从头到尾为该特定请求执行所有工作,然后转到下一个。这适用于中低比例服务,但由于线程是一个相对昂贵的项目(通常您希望少于 1000 个,最好是 < 100),如果您希望服务能够同时处理 1000s 或 10000s 的请求,则此多线程模型不能很好地工作。

要处理此类规模,您需要一种异步编程ASP.NET此体系结构构建的。在此模型中,您不是具有"每个并发请求的线程",而是在执行长操作(通常是 I/O)时注册回调,并在等待时重用线程执行其他处理。此体系结构意味着只有少数线程(理想情况下是关于物理处理器的数量)可以处理大量(1000 个或更多)并发请求。您还可以查看异步编程需要线程池,因为当 I/O 完成时,您需要运行下一个"块"代码,因此需要一个线程。Asynchrounous 代码使用线程池来让此线程运行此小块代码,然后将线程返回到池中,以便线程可以运行一些其他(不相关的)块。

为什么大规模异步服务存在线程池饥饿问题?

当整个服务统一使用异步编程样式时,您的服务可以很好地扩展。线程在运行服务代码时从不阻塞,因为如果操作需要一段时间,则代码应该调用一个采用回调的版本(或返回 System.Threading.Tasks.Task),并导致代码的其余部分在 I/O 完成时运行(C# 有一个神奇的"await"语句,如下所示是块的,但实际上,当 await 语句完成时,会安排回调来运行下一个语句)。因此,线程永远不会阻塞,除非在线程池中等待更多的工作,因此只需要适量的线程(基本上是计算机上的 CPU 数量)。

不幸的是,当您的服务以大规模运行时,它不会花太多时间来破坏您的 perf。例如,假设您有 1000 个并发请求正在由 16核处理器计算机处理。使用异步,线程池只需要 16 个线程来为这 1000 个请求提供服务。为了简单起见,该计算机具有 16 个 CPU,并且线程池每个 CPU 只有一个线程(因此为 16),现在允许映像某人在请求处理开始时将 Sleep (200) 进行映像。理想情况下,您认为这只会导致每个请求延迟 200 毫秒,因此响应时间为 400 毫秒。但是,线程池只有 16 个线程(当一切都是异步时,这就足够了),这突然之间是不够的。只有前 16 个线程运行, 200 毫秒只是睡觉。只有在 200 毫秒后,这 16 个线程才能再次可用,因此另外 16 个请求可以转到等。因此,第一个请求延迟 200 毫秒,第二组延迟 400 毫秒,第三组延迟 600 毫秒。对于 1000 个同时请求的负载,平均响应时间高达 6 秒以上。现在,您可以了解为什么在某些情况下,吞吐量会从精细到可怕,只需非常少量的阻塞。

此示例是精心创建,但它说明了要点。如果添加任何阻塞,则线程池中所需的线程数会显著跳(从 16 到 1000)。.NET Threadpool 确实尝试注入更多线程,但以适中的速度(例如 1 或 2 秒)进行,因此在短期内几乎没有差别(您需要许多分钟才能到达 1000)。此外,您已经失去了异步的好处(因为现在每个请求都需要一个线程,这是您试图避免的)。(详细示列请参考

因此,您可以看到有一个"克利夫"与异步代码。只有"几乎从不阻止"时,才能获得异步代码的好处。如果 Do 块,并且以高比例运行,则很可能快速耗尽 Threadpool 的线程。线程池将尝试补偿,但它需要一段时间,坦率地说,你失去了异步的好处。正确的解决方案是避免在大规模服务中的"热"路径上阻塞。

什么通常会导致阻塞?

阻塞的常见原因包括

调用任何具有 I/O(因此可能会阻止)但不是异步(因为它不是异步 API,因此如果 I/O 不能快速完成,则必须进行阻止)
呼叫 Task. wait () 或 Task. GetResult (调用 Task. wait) 。使用这些 API 是危险信号。理想情况下,您采用异步方法,而是使用"await"。
因此,简言之,这就是为什么线程池饥饿变得越来越普遍。大规模异步应用程序变得越来越常见,并且很容易将阻塞引入到服务代码中,这迫使您离开异步的"黄金路径",这需要许多线程池线程,从而导致饥饿。

我如何知道线程池缺少线程?

你怎么知道这件坏事正在发生?您从上述症状开始,即您的 CPU 没有您想要的饱和。从这里,我会告诉你一些症状,你可以检查,给这个问题更明确的答案。详细信息确实会根据操作系统的变化而更改。我将展示窗口,案例,但我也将描述在Linux上做什么。

与一如既往,每当您有一个服务的问题,它是有用的得到详细的性能跟踪。在窗口中, 这意味着下载 PerfView采取 PerfView 跟踪

PerfView /线程时间收集
在 Linux 上,它当前意味着使用 perfCollect 进行跟踪,但是在 .NET Core 的第 2.2 版中,您将能够使用"dotnet 配置文件"命令收集跟踪(详细信息为 TBD,当发生这种情况时我将更新)。

如果使用应用程序见解,也可以使用应用程序见解分析器捕获跟踪。

当服务负载不足但性能不佳时,您只需要 60 秒的跟踪。

查找不断增加的线程计数。

线程池饥饿的一个关键症状是,线程池确实检测到它处于饥饿状态(有工作但没有线程),并且试图通过注入更多线程来修复它,但(根据设计)速度较慢(大约 1-2 次/秒)。因此,在 PerfView 的"事件视图"(在窗口中),您会看到新线程的 OS 内核事件以该速率显示。请注意,它大约以秒添加大约 2 个线程。

 

Linux 跟踪不包括"事件"视图中的 OS 事件,但每次创建线程时都会有 .NET 运行时事件告诉您。您应该查看以下事件

Microsoft-Windows-DotNETRuntime/IOThreadCreation/Start - 仅窗口(对于某些 I/O 上阻止的线程,它有一个特殊队列),用于新的 I/O 工作
微软-视窗-DotNETRuntime/线程池工人线程/开始- 登录到 Linux 和 Windows 的新工作人员
Microsoft-Windows-DotNETRuntime/ThreadPoolWorker 编辑调整/调整- 指示正常工作人员调整(将显示增加计数)
下面是您可能在 Linux 上看到的示例。

 

 

如果负载足够高(因此对更多线程的需要足够高),这将无限期地继续。它还可能出现在计算给定进程中线程的操作系统性能指标中。(因此,如有必要,您可以在没有 PerfView 的情况下进行操作)。

查找阻塞 API

如果您已经确定您确实有线程池饥饿问题,如前所述,可能的问题是您调用了占用线程的时间过长的阻塞 API。在窗口上,PerfView 可以通过"线程时间(具有启动活动)"视图显示要阻止的地点。此视图应显示"活动"节点中的所有服务请求,如果您查看这些请求,应看到正在使用BLOCKED_TIME模式。导致阻止的 API 是问题所在。

不幸的是,此视图目前在 Linux 上不可用,使用"perfCollect"。但是,应用程序见解分析器应该工作并显示等效信息。在运行时的 2.2 版本中,"dotnet 配置文件"还应用于临时集合 (TBD)。

积极主动。

并不是说您不需要等待服务融化,以发现代码中的"坏阻塞"。没有什么能阻止您在任何负载上运行 PerfView /Collect 在开发框中运行,只需查找阻止即可。这应该被修复。您不需要实际诱导大规模环境并看到线程池饥饿,您知道,如果您有大规模(1000 个并发请求),并且您阻止任何时间长度(即使 10 毫秒太多,如果每个请求都发生)。因此,您可以积极主动地解决问题,甚至在问题出现之前。

方法:强制线程池中有更多的线程

如前所述,线程池饥饿的真正解决方案是删除该消耗线程的阻塞。但是,您可能无法修改代码以轻松完成此工作,并且需要一些在短期内会有所帮助的东西。ThreadPool.SetMinThreads 可以设置 ThreadPool 的最小线程数(在窗口中有 I/O 线程池和所有其他工作的池,您必须从跟踪中查看不断创建的线程种类,以知道要设置哪一个线程)。也可以使用环境变量 COMPlus_ForceMinWorkerThreads 设置普通辅助线程最小值,但 I/O 线程池没有环境变量(但该变量仅存在于 Windows 上)。通常,这是一个糟糕的解决方案,因为您可能需要许多线程(例如 1000 或更多),而且效率很低。它只能用作临时差距措施。

总结:

现在您知道 .NET 线程池饥饿的基础知识了。

当您的服务性能不佳且 CPU 未饱和时,需要查找。
主要症状是线程数量不断增加(线程池尝试修复饥饿)
通过查看显示线程池添加线程的 .NET 运行时事件,可以更明确地确定问题。
然后,您可以使用正常的"线程时间"视图来找出请求期间阻止的是什么。
您可以积极主动,在大规模部署之前查找阻塞时间,并解决这些类型的可伸缩性问题。
删除阻塞是最好的,但如果不可能,增加 ThreadPool 的大小将至少使服务在短期内运行。
你拥有它。可悲的是,这变得比我们想象的更为普遍。我们可能添加更好的诊断功能,使这个更容易找到,但这个博客条目有助于在一段时间。

引用:https://docs.microsoft.com/zh-cn/archive/blogs/vancem/diagnosing-net-core-threadpool-starvation-with-perfview-why-my-service-is-not-saturating-all-cores-or-seems-to-stall?WT.mc_id=DT-MVP-5003325

posted @ 2021-02-02 16:24  FengLu-1  阅读(1177)  评论(0编辑  收藏  举报