据认为,开发大约需要10%的时间,而调试则需要90%的时间。 也许这句话被夸大了,但是任何开发人员都会同意调试是一个非常耗费资源的过程,尤其是在大型多线程系统中。
因此,调试过程的优化和系统化可以节省工时,提高解决问题的速度,并最终提高用户忠诚度,从而带来显着的收益。

在
DotNext 2018 Piter会议上的
Sergey Shchegrikovich (dotmailer)建议将调试视为可以描述和优化的过程。 如果您仍然没有确定错误的明确计划,请在Sergey报告的录像片段和文字记录下进行。
(在帖子末尾,我们向所有分支机构添加了
John Skeet的呼吁,请务必注意)
我的目标是回答这个问题:如何有效地修复错误以及应该关注的重点。 我认为这个问题的答案是一个过程。 调试过程由非常简单的规则组成,您很了解它们,但是您可能在不知不觉中使用了它。 因此,我的任务是将它们系统化,并通过一个示例演示如何变得更加有效。
我们将在调试过程中开发一种用于交流的通用语言,并且还将看到找到主要问题的直接路径。 在我的示例中,我将显示由于违反这些规则而发生的情况。
调试实用程序

当然,没有调试实用程序就无法进行任何调试。 我的最爱是:
下降服务
让我们从我的生活中的一个示例开始,在该示例中,我将展示调试过程的非系统性本质如何导致效率低下。
可能这发生在每个人身上,当您来到一个新团队中的新公司参加一个新项目时,那么从第一天开始,您就想获得无法弥补的收益。 我也是。 当时,我们有一个服务,该服务接收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()) {
这个故事的结论可以得出如下结论:两个星期太长,找不到一个不受欢迎的
dispose
时间太长; 我们找到了解决问题的办法-运气,因为没有特定的方法,所以没有系统性。
之后,我有一个问题:如何有效地首次亮相以及该怎么做?
为此,您只需要了解三件事:
- 调试规则
- 查找错误的算法。
- 主动调试技术。
调试规则
- 重复错误。
- 如果您尚未解决该错误,则不会解决。
- 了解系统。
- 检查插头。
- 分而治之。
- 清爽一下。
- 这是您的错误。
- 五为什么。
这些是描述自己的非常清晰的规则。
重复错误。 这是一个非常简单的规则,因为如果您不会犯错,那么就没有什么要解决的。 但是有不同的情况,尤其是对于多线程环境中的错误。 我们莫名其妙地出现了一个错误,该错误仅出现在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 — , .
, ?
- .
- .
- .
— Jon Skeet.
DotNext , ( ).