寻找和分析 .NET 应用程序中的高 CPU 使用率
已发表: 2022-03-11软件开发可能是一个非常复杂的过程。 作为开发人员,我们需要考虑很多不同的变量。 有些不在我们的控制之下,有些在实际代码执行的那一刻我们不知道,有些是我们直接控制的。 .NET 开发人员也不例外。
鉴于这一现实,当我们在受控环境中工作时,事情通常会按计划进行。 一个例子是我们的开发机器,或者我们可以完全访问的集成环境。 在这些情况下,我们可以使用工具来分析影响我们的代码和软件的不同变量。 在这些情况下,我们也不必处理繁重的服务器负载,或者尝试同时做同样事情的并发用户。
在描述和安全的情况下,我们的代码可以正常工作,但在高负载或其他一些外部因素的生产中,可能会出现意想不到的问题。 生产中的软件性能很难分析。 大多数时候,我们必须在理论场景中处理潜在问题:我们知道问题可能会发生,但我们无法对其进行测试。 这就是为什么我们需要基于我们正在使用的语言的最佳实践和文档进行开发,并避免常见错误。
如前所述,当软件上线时,事情可能会出错,代码可能会以我们没有计划的方式开始执行。 当我们不得不处理问题而无法调试或确切知道发生了什么时,我们最终可能会陷入这种情况。 在这种情况下我们能做什么?
在本文中,我们将分析基于 Windows 的服务器上的 .NET Web 应用程序的高 CPU 使用率的真实案例场景,识别问题所涉及的进程,更重要的是,首先为什么会发生这个问题以及我们如何解决这个问题。
CPU 使用率和内存消耗是广泛讨论的话题。 通常很难确定特定进程应该使用多少资源(CPU、RAM、I/O)以及使用时间。 虽然有一件事是肯定的——如果一个进程在很长一段时间内使用超过 90% 的 CPU,我们就会遇到麻烦,因为在这种情况下服务器将无法处理任何其他请求。
这是否意味着流程本身存在问题? 不必要。 可能是该过程需要更多的处理能力,或者它正在处理大量数据。 首先,我们唯一能做的就是尝试找出发生这种情况的原因。
所有操作系统都有几种不同的工具来监视服务器中发生的事情。 Windows 服务器专门有任务管理器,性能监视器,或者在我们的例子中,我们使用了 New Relic Servers,这是一个很好的服务器监控工具。
首发症状及问题分析
部署应用程序后,在前两周的一段时间内,我们开始看到服务器出现 CPU 使用高峰,这导致服务器无响应。 我们必须重新启动它以使其再次可用,并且该事件在该时间段内发生了 3 次。 正如我之前提到的,我们使用 New Relic Servers 作为服务器监视器,它显示w3wp.exe
进程在服务器崩溃时使用了 94% 的 CPU。
Internet 信息服务 (IIS) 工作进程是运行 Web 应用程序的 Windows 进程 ( w3wp.exe
),负责处理发送到特定应用程序池的 Web 服务器的请求。 IIS 服务器可能有多个应用程序池(和几个不同的w3wp.exe
进程),它们可能会产生问题。 根据该流程拥有的用户(这在 New Relic 报告中显示),我们确定问题出在我们的 .NET C# Web 表单遗留应用程序上。
.NET Framework 与 Windows 调试工具紧密集成,因此我们尝试做的第一件事是查看事件查看器和应用程序日志文件以查找有关正在发生的事情的一些有用信息。 无论我们是否在事件查看器中记录了一些异常,它们都没有提供足够的数据来分析。 这就是为什么我们决定更进一步并收集更多数据的原因,所以当事件再次发生时,我们会做好准备。
数据采集
收集用户模式进程转储的最简单方法是使用调试诊断工具 v2.0 或简单的 DebugDiag。 DebugDiag 有一套用于收集数据(DebugDiag Collection)和分析数据(DebugDiag Analysis)的工具。
所以,让我们开始定义使用调试诊断工具收集数据的规则:
打开 DebugDiag Collection 并选择
Performance
。- 选择
Performance Counters
并单击Next
。 - 单击
Add Perf Triggers
。 - 展开
Processor
(不是Process
)对象并选择% Processor Time
。 请注意,如果您使用的是 Windows Server 2008 R2 并且拥有超过 64 个处理器,请选择Processor Information
对象而不是Processor
对象。 - 在实例列表中,选择
_Total
。 - 单击
Add
,然后单击OK
。 选择新添加的触发器并单击
Edit Thresholds
。- 在下拉列表中选择
Above
。 - 将阈值更改为
80
。 输入
20
作为秒数。 如果需要,您可以调整此值,但请注意不要指定少量秒数,以防止错误触发。- 单击
OK
。 - 单击
Next
。 - 单击
Add Dump Target
。 - 从下拉列表中选择
Web Application Pool
。 - 从应用程序池列表中选择您的应用程序池。
- 单击
OK
。 - 单击
Next
。 - 再次单击
Next
。 - 如果您愿意,请为您的规则输入一个名称,并记下将保存转储的位置。 如果需要,您可以更改此位置。
- 单击
Next
。 - 选择
Activate the Rule Now
并单击Finish
。
所描述的规则将创建一组相当小的小型转储文件。 最终转储将是具有完整内存的转储,并且转储将大得多。 现在,我们只需要等待高 CPU 事件再次发生。
一旦我们在选定的文件夹中有转储文件,我们将使用 DebugDiag 分析工具来分析收集的数据:
选择性能分析器。
添加转储文件。
开始分析。
DebugDiag 将需要几分钟(或几分钟)来解析转储并提供分析。 完成分析后,您将看到一个网页,其中包含摘要和大量有关线程的信息,类似于以下内容:
正如您在摘要中看到的那样,有一条警告说“在一个或多个线程上检测到转储文件之间的 CPU 使用率过高”。 如果我们点击推荐,我们将开始了解我们的应用程序的问题所在。 我们的示例报告如下所示:
正如我们在报告中看到的,有一个关于 CPU 使用率的模式。 所有具有高 CPU 使用率的线程都与同一类相关。 在跳转到代码之前,让我们看一下第一个。
这是我们问题的第一个线程的详细信息。 我们感兴趣的部分如下:
在这里,我们调用了我们的代码GameHub.OnDisconnected()
,它触发了有问题的操作,但在此调用之前,我们有两个 Dictionary 调用,这可以让我们了解正在发生的事情。 让我们看一下 .NET 代码,看看该方法在做什么:
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
我们这里显然有问题。 报告的调用堆栈说问题出在字典上,在这段代码中,我们正在访问字典,特别是导致问题的行是这一行:
if (onlineSessions.TryGetValue(userId, out connId))
这是字典声明:
static Dictionary<int, string> onlineSessions = new Dictionary<int, string>();
这个 .NET 代码有什么问题?
每个有面向对象编程经验的人都知道静态变量将被这个类的所有实例共享。 让我们更深入地了解静态在 .NET 世界中的含义。
根据 .NET C# 规范:
使用 static 修饰符声明一个静态成员,该成员属于类型本身而不是特定对象。
这是 .NET C# 语言规范关于静态类和成员的说明:
与所有类类型一样,当加载引用该类的程序时,.NET Framework 公共语言运行时 (CLR) 会加载静态类的类型信息。 程序无法准确指定加载类的时间。 但是,保证在程序中第一次引用该类之前加载它并初始化其字段并调用其静态构造函数。 静态构造函数只被调用一次,静态类在程序所在的应用程序域的整个生命周期内都保留在内存中。
非静态类可以包含静态方法、字段、属性或事件。 即使没有创建类的实例,静态成员也可以在类上调用。 静态成员总是由类名访问,而不是实例名。 无论创建了多少个类实例,都只存在一个静态成员的副本。 静态方法和属性不能访问其包含类型中的非静态字段和事件,并且它们不能访问任何对象的实例变量,除非它在方法参数中显式传递。
这意味着静态成员属于类型本身,而不是对象。 它们也由 CLR 加载到应用程序域中,因此静态成员属于托管应用程序的进程而不是特定线程。
鉴于 Web 环境是多线程环境,因为每个请求都是由w3wp.exe
进程生成的新线程; 并且鉴于静态成员是进程的一部分,我们可能会遇到这样的场景,即多个不同的线程尝试访问静态(由多个线程共享)变量的数据,这最终可能导致多线程问题。
线程安全下的 Dictionary 文档说明如下:
Dictionary<TKey, TValue>
可以同时支持多个读取器,只要不修改集合即可。 即便如此,通过集合枚举本质上不是线程安全的过程。 在枚举与写访问竞争的极少数情况下,必须在整个枚举期间锁定集合。 要允许集合被多个线程访问以进行读写,您必须实现自己的同步。
该声明解释了为什么我们可能会遇到此问题。 根据转储信息,问题出在字典 FindEntry 方法上:
如果我们查看字典 FindEntry 实现,我们可以看到该方法遍历内部结构(桶)以查找值。
所以下面的 .NET 代码正在枚举集合,这不是线程安全的操作。
public override Task OnDisconnected() { try { var userId = GetUserId(); string connId; if (onlineSessions.TryGetValue(userId, out connId)) onlineSessions.Remove(userId); } catch (Exception) { // ignored } return base.OnDisconnected(); }
结论
正如我们在转储中看到的,有多个线程试图同时迭代和修改一个共享资源(静态字典),最终导致迭代进入无限循环,导致线程消耗超过 90% 的 CPU .
这个问题有几种可能的解决方案。 我们首先实现的一个是以损失性能为代价来锁定和同步对字典的访问。 那个时候服务器每天都在崩溃,所以我们需要尽快修复这个问题。 即使这不是最佳解决方案,它也解决了问题。
解决此问题的下一步将是分析代码并为此找到最佳解决方案。 重构代码是一种选择:新的 ConcurrentDictionary 类可以解决这个问题,因为它只锁定在存储桶级别,这将提高整体性能。 虽然,这是一大步,还需要进一步分析。