作为过程调试

据认为,开发大约需要10%的时间,而调试则需要90%的时间。 也许这句话被夸大了,但是任何开发人员都会同意调试是一个非常耗费资源的过程,尤其是在大型多线程系统中。

因此,调试过程的优化和系统化可以节省工时,提高解决问题的速度,并最终提高用户忠诚度,从而带来显着的收益。



DotNext 2018 Piter会议上的Sergey Shchegrikovich (dotmailer)建议将调试视为可以描述和优化的过程。 如果您仍然没有确定错误的明确计划,请在Sergey报告的录像片段和文字记录下进行。

(在帖子末尾,我们向所有分支机构添加了John Skeet的呼吁,请务必注意)



我的目标是回答这个问题:如何有效地修复错误以及应该关注的重点。 我认为这个问题的答案是一个过程。 调试过程由非常简单的规则组成,您很了解它们,但是您可能在不知不觉中使用了它。 因此,我的任务是将它们系统化,并通过一个示例演示如何变得更加有效。

我们将在调试过程中开发一种用于交流的通用语言,并且还将看到找到主要问题的直接路径。 在我的示例中,我将显示由于违反这些规则而发生的情况。

调试实用程序



当然,没有调试实用程序就无法进行任何调试。 我的最爱是:

  • Windbg除了调试器本身之外,还具有用于研究内存转储的丰富功能。 内存转储是进程状态的一部分。 在其中可以找到对象(调用堆栈)字段的值,但是不幸的是,内存转储是静态的。
  • PerfView是在ETW技术之上编写的探查器。
  • SysinternalsMark Russinovich编写的实用程序,它使您可以进一步深入研究操作系统的设备。

下降服务


让我们从我的生活中的一个示例开始,在该示例中,我将展示调试过程的非系统性本质如何导致效率低下。

可能这发生在每个人身上,当您来到一个新团队中的新公司参加一个新项目时,那么从第一天开始,您就想获得无法弥补的收益。 我也是。 当时,我们有一个服务,该服务接收html作为输入,并输出图片作为输出。

该服务是在.Net 3.0下编写的,而且时间很长。 该服务具有一个小功能-崩溃了。 经常摔倒,大约每两到三个小时一次。 我们对此进行了巧妙的修复-在下降之后,在服务的属性中设置重新启动属性。



服务对我们而言并不重要,我们可以生存下去。 但是我加入了该项目,因此我决定要做的第一件事就是修复它。

如果无法正常工作,.NET开发人员会去哪儿? 他们去了EventViewer。 但是,除了该服务失败的记录外,我什么都没找到。 没有有关本机错误的消息,也没有调用堆栈。



有一个行之有效的下一步工具-我们将整个main包装在try-catch

 try { ProcessRequest(); } catch (Exception ex) { LogError(ex); } 

这个想法很简单: try-catch会起作用,会给我们带来麻烦,我们会阅读并修复服务。 我们编译,部署到生产中,服务崩溃,没有错误。 添加另一个catch

 try { ProcessRequest(); } catch (Exception ex) { LogError(ex); } catch { LogError(); } 

我们重复该过程:服务崩溃,日志中没有错误。 最后可以提供帮助的finally是,它总是被调用。

 try { ProcessRequest(); } catch (Exception ex) { LogError(ex); } catch { LogError(); } finally { LogEndOfExecution(); } 

我们编译,部署,服务崩溃,没有错误。 这个过程已经过去了三天,现在已经有了想法,我们必须终于开始思考并做其他事情。 您可以做很多事情:尝试在本地计算机上重现错误,观察内存转储,等等。 好像又过了两天,我将修复此错误...

两个星期过去了。



我看着PerformanceMonitor,在那儿我看到一个服务崩溃,然后上升,然后又下降。 这种情况称为绝望 ,如下所示:



在各种各样的标签中,您是否要弄清楚问题出在哪里? 经过几个小时的冥想,问题突然出现:



红线是该进程拥有的本机句柄的数量。 本机句柄是对操作系统资源的引用:文件,注册表,注册表项,互斥量等。 对于某些奇怪的情况,句柄数量增长的下降与服务下降的时刻相吻合。 这导致一个想法,即在某处存在手柄泄漏。

我们进行一个内存转储,在WinDbg中打开它。 我们开始执行命令。 让我们尝试查看应由应用程序释放的那些对象的完成队列。

 0:000> !FinalizeQueue 

在列表的最后,我找到了一个Web浏览器。


解决方案很简单-使用WebBrowser并为其调用dispose

 private void Process() { using (var webBrowser = new WebBrowser()) { // Processing ... } } 

这个故事的结论可以得出如下结论:两个星期太长,找不到一个不受欢迎的dispose时间太长; 我们找到了解决问题的办法-运气,因为没有特定的方法,所以没有系统性。

之后,我有一个问题:如何有效地首次亮相以及该怎么做?

为此,您只需要了解三件事:

  1. 调试规则
  2. 查找错误的算法。
  3. 主动调试技术。

调试规则


  1. 重复错误。
  2. 如果您尚未解决该错误,则不会解决。
  3. 了解系统。
  4. 检查插头。
  5. 分而治之。
  6. 清爽一下。
  7. 这是您的错误。
  8. 五为什么。

这些是描述自己的非常清晰的规则。

重复错误。 这是一个非常简单的规则,因为如果您不会犯错,那么就没有什么要解决的。 但是有不同的情况,尤其是对于多线程环境中的错误。 我们莫名其妙地出现了一个错误,该错误仅出现在Itanium处理器和生产服务器上。 因此,调试过程中的第一个任务是找到测试平台的配置,在该配置上会产生错误。

如果您尚未解决该错误,则不会解决。 有时会发生这种情况:一个错误跟踪器包含了半年前出现的一个错误,很长一段时间以来没有人看到过,并且希望简单地将其关闭。 但是此时此刻,我们错过了了解的机会,了解我们的系统如何工作以及其实际发生情况的机会。 因此,任何错误都是学习新知识,了解系统的新机会。

了解系统。 布赖恩·克尼根(Brian Kernighan)曾经说过,如果我们很聪明地编写了这个系统,那么我们就必须加倍地聪明才能启动它。

规则的一个小例子。 我们的监控绘制图形:


这是我们的服务处理的请求数量的图表。 仔细研究之后,我们想到了可以提高服务速度的想法。 在这种情况下,日程安排会增加,有可能减少服务器数量。

优化Web性能的方法很简单:我们使用PerfView,在生产计算机上运行它,它会在3-4分钟内删除跟踪,然后将此跟踪带到本地计算机并开始研究它。

PerfView显示的统计数据之一是垃圾收集器。



查看这些统计数据,我们发现该服务花费了其85%的时间来收集垃圾。 您可以在PerfView中确切地看到这段时间在哪里度过。



在我们的例子中,这是在创建字符串。 更正本身表明:我们将所有字符串替换为StringBuilders。 在本地,我们的生产率提高了20-30%。 在生产环境中进行部署,将结果与旧时间表进行比较:



“了解系统”规则不仅涉及了解系统中的交互作用如何进行,消息如何进行,还涉及尝试对系统进行建模。

在示例中,该图显示了带宽。 但是,如果您从排队论的角度来看整个系统,那么事实证明,我们系统的吞吐量仅取决于一个参数-新消息的到达速度。 实际上,该系统一次只能容纳80条以上的消息,因此无法优化此计划。

检查插头。 如果打开任何家用电器的文档,则肯定会写在该文档中:如果该家用电器不起作用,请检查插头是否已插入插座。 在调试器中工作了几个小时之后,我经常发现自己以为我只需要重新编译或选择最新版本即可。

“检查即插即用”规则是关于事实和数据的。 调试不是从在生产计算机上运行WinDbg或PerfView开始的,而是从检查事实和数据开始的。 如果服务没有响应,则可能只是没有运行。

分而治之。 这是将调试作为过程包括在内的第一条,也是唯一的一条规则。 它与假设,假设的推广和检验有关。

我们的一项服务不想停止。



我们做一个假设:也许项目中存在一个循环,不断地处理某些事情。

您可以用不同的方式测试假设,一种选择是进行内存转储。 我们使用~*e!ClrStack从转储和所有线程中提取调用堆栈。 我们开始观察并看到三个流。







第一个线程在Main中,第二个线程在OnStop()处理程序中,第三个线程正在等待一些内部任务。 因此,我们的假设是没有道理的。 没有循环,所有线程都在等待某些东西。 最有可能陷入僵局。

我们的服务如下。 有两个任务-初始化和工作。 初始化打开与数据库的连接,工作程序开始处理数据。 它们之间的通信是通过使用TaskCompletionSource实现的通用标志进行的。

我们提出第二个假设:也许我们对第二个任务陷入僵局。 为此,您可以通过WinDbg分别查看每个任务。



事实证明,其中一项任务失败了,而第二项任务没有失败。 在项目中,我们看到了以下代码:

 await openAsync(); _initLock.SetResult(true); 

这意味着初始化任务将打开连接,然后将TaskCompletionSource设置为true。 但是,如果异常落在这里怎么办? 然后,我们没有时间将SetResult设置为true,因此对此错误的修复是这样的:

 try { await openAsync(); _initLock.SetResult(true); } catch(Exception ex) { _initLock.SetException(ex); } 

在此示例中,我们提出了两个假设:无限循环和死锁。 规则“分而治之”有助于定位错误。 逐次逼近解决了此类问题。

该规则中最重要的是假设,因为随着时间的流逝,它们变成了模式。 根据假设,我们使用不同的动作。

清爽一下。 这条规则是,您只需要从桌子上站起来走路,喝水,果汁或咖啡,就可以做任何事情,但是最重要的是分散您的注意力。

有一种非常好的方法称为鸭子。 根据该方法,我们必须说明闪避的问题。 你可以把同事当鸭子 。 而且,他不必回答,只需倾听并同意。 通常,在第一次讨论问题之后,您自己会找到解决方案。

这是您的错误。 我将通过一个例子来介绍这个规则。

一个AccessViolationException有一个问题。 在调用堆栈中,我看到它是在sql客户端内部生成LinqToSql查询时发生的。



从该错误中可以明显看出,内存的完整性受到侵犯。 幸运的是,那时我们已经使用了变更管理系统。 结果,几个小时后,事情发生了变得很清楚:我们在生产机器上安装了.Net 4.5.2。



因此,我们将错误发送给Microsoft,他们进行了检查,并与他们进行了交流,他们修复了.Net 4.6.1中的错误。



对我来说,这需要11个月的Microsoft支持工作,当然不是每天都进行,但是从开始修复到花费11个月。 此外,我们向他们发送了数十GB的内存转储,我们放置了数百个私有程序集来捕获此错误。 在所有这些时间里,我们无法告诉客户微软应该责备自己,而不是我们。 因此,该错误始终是您的。

五为什么。 我们公司使用Elastic。 Elastic适用于日志聚合。

您早上来上班,Elastic撒谎。



第一个问题是为什么是弹性的? 几乎立刻就明白了-主节点掉了。 它们协调整个群集的工作,当它们掉落时,整个群集将停止响应。 他们为什么不起来? 也许应该有一个自动启动? 搜索答案后,我们发现插件版本不匹配。 为什么主节点完全崩溃? 他们被OOM杀手杀死。 在Linux机器上就是这样,如果没有内存,它将关闭不必要的进程。 为什么没有足够的内存? 因为更新过程已经开始,所以从系统日志开始。 为什么以前有效,但现在不起作用? 并且由于我们一周前添加了新节点,因此主节点需要更多的内存来存储索引和集群配置。

问题“为什么?” 帮助找到问题的根源。 在示例中,我们可以多次关闭正确的路径,但是完整的修复方法如下所示:我们更新插件,启动服务,增加内存并为将来做笔记,下次在向群集中添加新节点时,我们需要确保主服务器上有足够的内存节点数

这些规则的应用使您可以揭示实际问题,将重点转移到解决这些问题上,并有助于沟通。 但是,如果这些规则形成一个系统,那就更好了。 并且有这样一个系统,它称为调试算法。

调试算法


第一次,我在John Robbins的《调试应用程序》一书中了解了调试算法。 它描述了调试过程,如下所示:



该算法对其内部循环很有用-使用假设。

在周期的每个转折中,我们都可以进行自我检查:我们是否对该系统了解更多? 如果我们提出假设,检查一下,它们不起作用,我们不会学习有关系统操作的任何新知识,那么可能是时候重新整理了。 目前有两个问题:您检验了哪些假设,现在检验了哪些假设。

该算法与我们上面讨论的调试规则非常吻合:重复错误-这是您的错误,描述问题-了解系统,制定假设-分而治之,测试假设-检查插头,确保已固定-五为什么。

我有一个很好的例子,这种算法。 我们的Web服务之一发生了异常。



我们的第一个想法不是我们的问题。 但是按照规则,这仍然是我们的问题。

首先,重复错误。 对于每千个请求,大约有一个StructureMapException ,因此我们可以重现该问题。

其次,我们试图描述问题:如果用户在StructureMap试图建立新的依赖关系时,用户对我们的服务发出了HTTP请求,则会发生异常。

第三,我们假设StructureMap是包装器,并且内部有些东西引发内部异常。 我们使用procdump.exe测试该假设。

 procdump.exe -ma -e -f StructureMap w3wp.exe 

事实证明,里面是NullReferenceException



通过研究此异常的调用栈,我们了解到它发生在StructureMap本身的对象生成器内部。



但是NullReferenceException不是问题本身,而是后果。 您需要了解它的发生位置和产生者。

我们提出以下假设:由于某种原因,我们的代码返回空依赖。 鉴于在.Net中,内存中的所有对象都是一一定位的,如果我们查看堆上位于NullReferenceException之前的对象,则它们很可能指向引发异常的代码。

在WinDbg中,有一个命令!lno Near Objects !lno 。 它表明我们感兴趣的对象是lambda函数,该函数在以下代码中使用。

 public CompoundInterceptor FindInterceptor(Type type) { CompoundInterceptop interceptor; if (!_analyzedInterceptors.TryGetValue(type, out interceptor)) { lock (_locker) { if (!_analyzedInterceptors.TryGetValue(type, out interceptor)) { var interceptorArray = _interceptors.FindAll(i => i.MatchesType(type)); interceptor = new CompoundInterceptor(interceptorArray); _analyzedInterceptors.Add(type, interceptor); } } } return interceptor; } 

在此代码中,我们首先检查Dictionary的值是否_analyzedInterceptors_analyzedInterceptors ,如果找不到,则在lock添加一个新值。

从理论上讲,此代码永远不能返回null。 但是这里的问题出在_analyzedInterceptors ,它在多线程环境中使用常规Dictionary ,而不是ConcurrentDictionary

找到了问题的根源,我们将其更新到最新版本的StructureMap,已部署,并确保已修复所有问题。 我们算法的最后一步是“学习和讲述”。 在我们的例子中,这是在锁中使用的所有Dictionary的代码中进行搜索,并检查它们是否正确使用。

因此,调试算法是一种直观的算法,可大大节省时间。 他专注于假设-这是调试中最重要的事情。

主动调试


主动调试的核心是回答“出现错误时会发生什么”的问题。



在错误生命周期图中可以看到主动调试技术的重要性。



问题在于错误的寿命越长,我们花费在它上面的资源(时间)就越多。

调试规则和调试算法将我们集中在发现错误的那一刻,我们可以弄清楚下一步该怎么做。 实际上,我们希望在创建错误时转移我们的注意力。 我认为我们应该做最小可调试产品(MDP),即产品具有在生产中进行有效调试所需的最少基础结构集。

MDP由两部分组成:适应度函数和USE方法。

健身功能。 它们在尼尔·福特(Neil Ford)和合著者《建筑进化架构》一书中得到了普及。 根据本书的作者,适应性功能的核心看起来是这样的:存在一个应用程序体系结构,我们可以从不同角度进行剖切,获得诸如可维护性性能等体系结构属性,并且对于每个此类部分,我们都必须编写一个测试适应性功能。 因此,适应度功能是体系结构测试。

对于MDP,适应性功能是可调试性测试。 您可以使用任何喜欢的东西编写此类测试:NUnit,MSTest等。 但是,由于调试通常是使用外部工具进行的,因此我将以Pester(powershell单元测试框架)为例进行演示。 它的优点是可以与命令行一起很好地工作。

例如,在公司内部,我们同意我们将使用特定的库进行记录; 记录时,我们将使用特定的模式; pdb字符应始终提供给符号服务器。 这将是我们将在测试中测试的约定。

 Describe 'Debuggability' { It 'Contains line numbers in PDBs' { Get-ChildItem -Path . -Recurse -Include @("*.exe", "*. dll ") ` | ForEach-Object { &symchk.exe /v "$_" /s "\\network\" *>&1 } ` | Where-Object { $_ -like "*Line nubmers: TRUE*" } ` | Should -Not –BeNullOrEmpty } } 

此测试验证是否已将所有pdb字符分配给符号服务器并正确分配,即其中包含行号的字符。 为此,请使用生产的编译版本,找到所有exe和dll文件,并将所有这些二进制文件通过syschk.exe实用程序传递,该实用程序包含在Windows调试工具包中。 syschk.exe实用程序会与符号服务器一起检查二进制文件,如果在其中找到了pdb文件,则会打印有关该文件的报告。 在报告中,我们查找“行号:TRUE”行。 最后,我们检查结果是否为“空或空”。

这些测试必须集成到连续的部署管道中。 集成测试和单元测试通过后,将启动适应性功能。

我将展示另一个示例,其中检查代码中的必要库。

 Describe 'Debuggability' { It 'Contains package for logging' { Get-ChildItem -Path . -Recurse -Name "packages.config" ` | ForEach-Object { Get-Content "$_" } ` | Where-Object { $_ -like "*nlog*" } ` | Should -Not –BeNullOrEmpty } } 

在测试中,我们获取所有package.config文件,并尝试在其中找到nlog库。 同样,我们可以验证是否在nlog字段内使用了相关ID字段。

USE方法。 MDP组成的最后一件事是您需要收集的指标。

我将以Brendan Gregg流行的USE方法为例进行演示。 : - , : utilization (), saturation (), errors (), .

, Circonus ( monitoring soft), .



, , , — , — , — , . , USE- .

- -, , , :

  • — .
  • — .
  • — .

, . , .



, , — . , , 4-5% CPU.



— , . etrace.

 etrace --kernel Process ^ --where ProcessName=Ex5-Service ^ --clr Exception 

realtime ETW-events .



, OutOfMemoryException . , , ? — , , .

 while (ShouldContinue()) { try { Do(); } catch (OutOfMemoryException) { Thread.Sleep(100); GC.CollectionCount(2); GC.WaitForPendingFinalizers(); } } 

— - . , .

 public class Cache { private static ConcurrentDictionary<int, String> _items = new ... private static DateTime _nextClearTime = DateTime.UtcNow; public String GetFromCache(int key) { if (_nextClearTime < DateTime.UtcNow) { _nextClearTime = DateTime.UtcNow.AddHours(1); _items.Clear(); } return _items[key]; } } 

, . , . USE .



— , .

, , .

  • — . , , — . — , -. , .
  • . ; Exception , , - .
  • Minimum Debuggable Product — , .

, ?


  1. .
  2. .
  3. .



— Jon Skeet. DotNext , ( ).

Source: https://habr.com/ru/post/zh-CN420793/


All Articles