本文概述
软件开发可能是一个非常复杂的过程。作为开发人员, 我们需要考虑很多不同的变量。有些不在我们的控制之下, 有些在实际代码执行时对我们来说是未知的, 有些则由我们直接控制。 .NET开发人员也不例外。
在这种情况下, 当我们在受控环境中工作时, 事情通常会按计划进行。一个例子就是我们的开发机器或我们可以完全访问的集成环境。在这种情况下, 我们可以使用工具来分析影响我们的代码和软件的不同变量。在这种情况下, 我们也不必处理服务器的繁重工作, 也不必处理并发用户尝试同时执行相同操作的情况。
在描述和安全的情况下, 我们的代码可以正常工作, 但是在重负载或其他一些外部因素的生产中, 可能会发生意外问题。生产中的软件性能很难分析。在大多数情况下, 我们必须在理论上处理潜在的问题:我们知道可能会发生问题, 但无法测试。这就是为什么我们需要以我们所用语言的最佳实践和文档为基础进行开发, 并避免常见错误。
如前所述, 当软件投入使用时, 可能会出错, 并且代码可能会以我们未计划的方式开始执行。当我们不得不处理问题而又无法调试或确定发生了什么情况时, 我们可能会遇到这种情况。在这种情况下我们该怎么办?
如果某个进程长时间使用超过90%的CPU, 则我们会遇到麻烦
鸣叫
在本文中, 我们将分析基于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)工作进程是Windows进程(w3wp.exe), 它运行Web应用程序, 并负责处理针对特定应用程序池发送到Web服务器的请求。 IIS服务器可能有多个应用程序池(和几个不同的w3wp.exe进程), 这些池可能会产生问题。根据该进程具有的用户(这在New Relic报告中显示), 我们确定问题出在我们的.NET C#Web表单旧版应用程序。
.NET Framework与Windows调试工具紧密集成在一起, 因此, 我们要做的第一件事是查看事件查看器和应用程序日志文件, 以查找有关正在发生的事情的有用信息。无论我们是否在事件查看器中记录了一些异常, 它们都没有提供足够的数据来进行分析。这就是为什么我们决定更进一步并收集更多数据的原因, 因此当事件再次发生时, 我们将做好准备。
数据采集
收集用户模式进程转储的最简单方法是使用Debug Diagnostic Tools v2.0或仅使用DebugDiag。 DebugDiag具有一组用于收集数据(DebugDiag集合)和分析数据(DebugDiag分析)的工具。
因此, 让我们开始定义使用调试诊断工具收集数据的规则:
-
打开DebugDiag集合, 然后选择Performance。
- 选择性能计数器, 然后单击下一步。
- 单击添加性能触发器。
- 展开”处理器(不是进程)”对象, 然后选择”处理器时间百分比”。请注意, 如果你使用的是Windows Server 2008 R2, 并且具有64个以上的处理器, 请选择”处理器信息”对象而不是”处理器”对象。
- 在实例列表中, 选择_Total。
- 单击添加, 然后单击确定。
-
选择新添加的触发器, 然后单击”编辑阈值”。
- 在下拉菜单中选择上方。
- 将阈值更改为80。
-
输入20作为秒数。你可以根据需要调整该值, 但请注意不要指定小数秒, 以防止错误触发。
- 单击确定。
- 点击下一步。
- 单击添加转储目标。
- 从下拉列表中选择Web应用程序池。
- 从应用程序池列表中选择你的应用程序池。
- 单击确定。
- 点击下一步。
- 再次单击下一步。
- 如果需要, 请输入规则名称, 并记下转储的保存位置。你可以根据需要更改此位置。
- 点击下一步。
- 选择立即激活规则, 然后单击完成。
描述的规则将创建一组小型转储文件, 这些文件的大小将非常小。最终转储将是具有完整内存的转储, 并且该转储会更大。现在, 我们只需要等待高CPU事件再次发生即可。
将转储文件保存在所选文件夹中后, 我们将使用DebugDiag Analysis工具来分析收集的数据:
-
选择性能分析器。
-
添加转储文件。
-
开始分析。
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类可以解决此问题, 因为它仅锁定在存储桶级别, 这将提高整体性能。虽然, 这是很大的一步, 但仍需要进一步分析。
评论前必须登录!
注册