调试的根本哲学
作者:Max Kanat-Alexander
发布日期:2017年7月17日
有些人调试时非常困难。主要原因是这些人认为调试系统需要“思考”而不是“观察”。
让我举个例子。假设你的Web服务器有5%的概率无法向用户提供页面,但没有任何提示。当你被问到“为什么?”时,你的第一反应是什么?你会立即尝试给出答案吗?你会开始猜测吗?如果是这样,你就做错了。
对这个问题的正确答案是:“我不知道。”
这引出了成功调试的第一步:开始调试时,要意识到你并不知道答案。
人们很容易认为自己已经知道答案。有时你猜对了——这种情况虽然不常发生,但足以让人误以为猜测是调试的好方法。然而大多数时候,你会花费数小时、数天甚至数周的时间猜测答案并尝试不同的修复方法,结果只是让代码变得更复杂。实际上,一些代码库中充满了对“bug”的“解决方案”,这些方案其实只是猜测——而这些“解决方案”是代码库复杂性的重要来源。
顺便说一句,我想分享一个有趣的原则:通常,如果你成功修复了一个bug,你的修复实际上会让系统的某部分消失、变得更简单或设计得更好。最好的修复通常是删除代码或简化系统。
回到调试过程本身,你应该做什么?猜测是浪费时间,想象问题的原因也是浪费时间——基本上,当你第一次遇到问题时,大脑中的大部分活动都是浪费时间。你唯一需要做的是:
- 记住正常系统的行为。
- 确定需要查看什么以获取更多数据。
这引出了调试的最重要原则:调试是通过收集数据直到你理解问题原因来完成的。
收集数据的方式几乎总是通过观察。对于无法提供页面的Web服务器,你可能会查看日志,或者尝试复现问题以便观察问题发生时服务器的行为。这就是为什么人们通常需要“复现步骤”——以便在bug发生时观察情况。
有时你需要收集的第一条数据是bug到底是什么。用户提交的错误报告通常数据不足。例如,用户报告“加载页面时,Web服务器没有返回任何内容”。这信息不够具体:他们尝试加载了什么页面?“没有返回任何内容”是什么意思?是空白页吗?你可能假设用户的意思,但通常你的假设是错误的。用户的编程或计算机技术经验越少,他们就越难准确描述问题而不需要你追问。在这种情况下,除非是紧急情况,我首先会向用户发送具体问题以澄清错误报告,并等待他们回复。在他们澄清之前,我完全不会着手解决。如果我在完全理解问题之前就尝试解决,我可能会浪费时间查看系统中与问题无关的随机部分。更好的做法是将时间花在更有生产力的事情上,等待用户回复,然后在获得完整的错误报告后研究原因。
需要注意的是,不要因为用户提交了不完整的错误报告而粗鲁或不友好。你比用户更了解系统,但这并不使你成为高人一等的存在。相反,以友好或直接的方式提问并获取信息。提交错误的人很少是故意犯傻——他们只是不知道,而帮助他们提供正确信息是你工作的一部分。如果人们经常不提供正确信息,你甚至可以在错误提交页面上添加问卷或表单,要求他们填写正确信息。关键是帮助他们,这样他们才能帮助你,让你轻松解决问题。
澄清错误后,你需要查看系统的各个部分。查看哪些部分取决于你对系统的了解。通常是日志、监控、错误消息、核心转储或系统的其他输出。如果你没有这些,可能需要在完全调试系统之前发布一个新版本以提供信息。虽然这看起来只是为了修复一个bug而做了很多工作,但实际上,发布一个提供足够信息的新版本通常比在没有信息的情况下在系统中漫无目的地寻找和猜测更快。这也是支持快速、频繁发布的另一个好理由——这样你可以快速发布提供新调试信息的新版本。有时你还可以仅向遇到问题的用户发布新版本,作为获取所需信息的捷径。
还记得我之前提到的必须记住正常系统的行为吗?这是因为调试的另一个原则:调试是通过将你拥有的数据与正常系统的数据进行比较来完成的。
当你看到日志中的消息时,那是正常消息还是错误?也许日志显示“警告:所有用户数据丢失”。这看起来像错误,但实际上你的Web服务器每次启动时都会打印这条消息。你必须知道正常的Web服务器会这样做。你要寻找正常系统不会显示的行为或输出。此外,你必须理解这些消息的含义。也许Web服务器可选地具有某些你未使用的用户数据库,这就是你收到警告的原因——因为你希望所有“用户数据”都丢失。
最终你会找到正常系统不会做的事情。但你不应该立即假设你找到了问题的原因。例如,也许它记录了一条消息:“错误:昆虫正在吃掉所有cookie”。你可以通过删除日志消息来“修复”这种行为。现在行为正常了,对吧?不,错了——实际的bug仍然在发生。这是一个相当愚蠢的例子,但人们会做不那么愚蠢的版本,这些版本并没有修复bug。他们没有深入问题的根本原因,而是用一些变通方法掩盖了bug,这些方法永远存在于代码库中,并为之后在该代码区域工作的每个人带来复杂性。甚至说“你会知道你已经找到了真正的原因,因为修复它就能修复bug”也不够准确。更接近事实的说法是:“当你确信修复它会使问题不再出现时,你就知道你已经找到了一个真正的原因。”这不是一个绝对的陈述——bug的“修复程度”有一个尺度。一个bug可以修复得更多或更少,通常取决于你希望解决方案“深入”到什么程度,以及你愿意花多少时间。通常当你找到了问题的合适原因并可以宣布bug已修复时,你会知道——这很明显。但我想警告你不要通过消除症状而不处理原因来掩盖bug。
当然,一旦你找到了原因,你就修复它。如果你其他步骤都做对了,这实际上是最简单的步骤。
因此,调试的四个主要步骤是:
- 熟悉正常系统的行为。
- 理解你并不知道问题的原因。
- 查看数据直到你知道问题的原因。
- 修复原因而不是症状。
这听起来很简单,但我看到人们经常违反这个公式。根据我的经验,大多数程序员在遇到bug时,喜欢坐着思考或讨论可能的原因——这两种都是猜测的形式。与可能拥有系统信息或建议在哪里查找数据以帮助你调试的其他人交谈是可以的。但坐着集体猜测bug的原因并不比你自己做更好,除非你喜欢与同事聊天。在那种情况下,你主要是在浪费一群人的时间,而不仅仅是浪费你自己的时间。
所以不要浪费人们的时间,也不要在代码库中创建不必要的复杂性。这种调试方法是有效的。它在每个代码库、每个系统上都每次有效。有时“数据收集”步骤相当困难,特别是对于无法复现的bug。但在最坏的情况下,你可以通过查看代码并尝试查看其中是否有bug,或绘制系统行为图并查看是否能发现问题来收集数据。我仅建议将其作为最后的手段,但如果你必须这样做,它仍然比猜测错误或假设你已经知道要好。
有时,通过查看正确的数据直到你知道,bug的解决几乎是神奇的。亲自尝试看看。它实际上可以很有趣。
-Max
评论
Steven Gordon 说道:
2017年7月17日下午9:03
想到两点:
- 无法估计修复一个bug需要多长时间,因为大部分工作是诊断。你无法提前知道诊断需要什么,因为你无法知道你还不知道什么。当你通过观察得到诊断后,你应该能够合理估计修复需要多长时间,但到那时你已经完成了大部分工作。
- 在完成本文中描述的诊断步骤之前,我建议编写覆盖由于bug而失败的案例的自动化测试。这不仅帮助你知道何时修复了bug,而且累积的先前bug的自动化测试验证了你没有破坏其他bug的修复。bug众所周知会复发。
Max Kanat-Alexander 说道:
2017年7月17日下午9:35
嘿Steven!两点都同意。我唯一要提醒的是,当你编写测试时,你不应该仅仅响应bug而编写“回归测试”,而是应该编写覆盖率高、设计良好的单元测试,通过一般性良好设计的测试暴露bug。这并不意味着当发现新bug时你不需要向单元测试添加另一个案例,但意味着基础测试应该围绕测试的基本原则设计。
-Max
Steven Gordon 说道:
2017年7月18日上午12:40
单元测试在实施解决方案期间似乎更合适,因为你可能为了修复bug而重构或重新编写代码。
Max Kanat-Alexander 说道:
2017年7月26日下午8:54
嗯,如果它们是好的单元测试,它们应该覆盖系统的功能。如果该功能被适当表达,那么你遇到的bug就是对该功能的违反,应该可以通过改进单元测试来捕获。
纯回归测试的问题是,你开发了一套大型的有些随机的测试,这些测试未来的价值可能不如设计良好的单元测试。在许多代码库中你可以看到这种情况的后果。测试也是必须维护的代码。
-Max
Simon 说道:
2017年7月18日上午4:08
嘿…我最近一直在尝试向一些新员工教授这些东西,他们很聪明,但相当新手。这是一个挑战,因为一半的时间,我一听到他们描述就知道bug是什么…这不是猜测,只是多年的经验指引我正确的方向。
但这种直觉对新员工没有帮助,因为他们少了十五年的经验。所以我需要抵制直接修复bug的冲动,而是带他们完成系统收集信息以自己找到bug的过程。
Jack 说道:
2017年7月18日上午11:52
同上我读了你的帖子,Max,我认为区分“思考”和“查看”可能(至少对我而言)很困难。
主要是因为很多时候,我已经拥有关于特定系统的大量数据。因此,当问题出现时,并不总是有一个“查看阶段”:数据已经收集完毕,只剩下“思考”部分。根据我知道的所有数据,我“思考”问题“在那里”。
这并不妨碍我在修复前检查我的猜测。
Max Kanat-Alexander 说道:
2017年7月26日下午8:57
是的,我绝对理解你们在说什么。我自己也经历过。但我还是尝试走正确的过程,因为平均来看,它往往更快(考虑到你猜错并必须重试的次数)。不过,对系统的经验确实有助于知道首先查看哪里。
-Max
Warren 说道:
2017年7月18日下午12:38
几年前,我将Simon Tatham关于提交错误报告的文章改编成了一本小电子书——http://cnx.org/contents/qofD1eNQ@2.4:31arWXa5@4/Submitting-Bug-Reports
这听起来非常相似…并回到了我多年来对故障排除的观察:相对而言,很少有人能有效调试;只有稍多的人能可重复地进行故障排除。
Max Kanat-Alexander 说道:
2017年7月26日下午8:59
那很酷。是的,令人惊讶的是有多少人在故障排除方面有困难。它需要一定程度的纪律(有时是耐心)来保持所需的简单性——简单地说,“好吧,我要获取这些数据,当我获取数据时,我会做出决定。”
-Max
jsc42 说道:
2017年7月18日下午11:50
我不同意你不应该先“猜测”的前提。也许这只是术语问题。我认为你的意思是“基于几乎只是直觉决定问题在哪里,希望更改会奏效”。对我来说,“猜测”意味着你利用对系统的知识优先调查最可能有用的区域,接受它可能 leads to a dead-end。否则,你对解决方案的搜索无异于漫无目的地徘徊希望到达目的地;而你真正想做的是获取地图,决定路线但知道可能有路障和绕行。
以你的Web服务器不提供页面为例,在从问题发现者那里获得更多线索后,我可能首先查看发送页面的部分,回溯到创建页面的部分。从已知(页面没有出现)开始,向后工作到原因。我不会从查看(比如)计算所得税的应用程序部分开始。
Warren 说道:
2017年7月19日上午4:30
正如我多年前的一位讲师所说,这都是关于应用斧头砍小问题规模。
如果你无法ssh到服务器,你从查看是否连接到网络开始,而不是查看硬盘是否已满(尽管后者可能是问题)。
David 说道:
2017年7月19日上午4:33
我也质疑了关于不“猜测”的部分。对我来说,调试开始时的“猜测”工作/思考是描述和分类报告行为的一种方式。这种思考/对话可以消除系统中不相关的部分,让程序员更好地关注最可能引起bug的原因。
我认为总体情绪是不要浪费时间胡乱编造,而是使用科学方法,我们观察行为并相应调整我们的理论。
George 说道:
2017年7月19日上午8:32
阿门。
多年来我发现调试主要是假设检验。你获取所有合理的信息,然后提出一个理论。然后设计一种方法来测试该理论。通过获取信息,好的调试工具是无价的。这个过程听起来有点像猜测,但它是有效的。
注意,我发现大约50%的时间解决方案是删除代码。
Mike W. 说道:
2017年7月20日下午6:03
我不得不不同意。但也许是术语问题。你描述的不是“猜测”。它是在正确的地方查看。
你不是在猜测根本原因是什么。你只是在问题区域查看,并使用你拥有的所有信息渠道调查(观察)那个区域。
当然,查看所得税计算来调试页面无法加载是愚蠢的,但这个错误不是由未能猜测引起的。它是由没有意识到你所有的错误(一切看起来不像正常服务器的东西)与页面加载部分的代码有关,而不是税务计算部分引起的。
这在文章中被称为你唯一需要用大脑做的事情之一:
- 确定需要查看什么以获取更多数据。
但猜测会是:“也许如果我增加数据库缓存,页面加载问题就会消失。”没有逻辑连接两者;根本没有理论。因为它是基于完全没有观察的纯粹猜测。人们真的经常这样做;我见过。那才是猜测。
智能地在合理的地方查看以收集更多信息,而不假设你已经知道解决方案,不是猜测。
Max Kanat-Alexander 说道:
2017年7月26日下午9:01
是的,基本上就是Mike说的。
-Max
Michael S. Meyers-Jouan 说道:
2017年7月19日上午9:10
在我看来,任何问题解决过程中都应该应用另一个原则。我们将其表达为“Mah nishtanah halailah hazeh mikol haleilot?”(英语是“这个夜晚与所有其他夜晚有何不同”)。换句话说,我们可以区分bug出现的上下文与bug不出现的“正常”上下文有何不同。这些信息通常帮助我们隔离最可能引起问题的系统部分——然后我们可以继续进行你的第三步,并有信心我们在查看最有用的数据。
Warren 说道:
2017年7月19日上午9:43
我听到这样描述:“每个看过《芝麻街》的孩子都知道如何开始故障排除,‘..这些东西中有一个与众不同..’”
Max Kanat-Alexander 说道:
2017年7月26日下午9:02
当然,这是一种看待方式!
另外,作为犹太人长大,我很喜欢你对问题的表述方式。带回许多逾越节的美好回忆!
-Max
Kurt Guntheroth 说道:
2017年7月19日上午9:59
我也不同意先猜测,尽管我同意在不知道bug是什么之前尝试修复可以礼貌地描述为疯狂。
我最尊敬的调试者是方法ical科学家。他们对哪里出错了做出假设。他们写下假设。他们寻找证据来确认或反驳假设。他们进行实验并记录这些实验的结果。
做出假设是关键的第一步。它告诉你在大型Web服务器中查看哪些数据。它告诉你在百万行代码库中检查哪部分。如果第一个假设未被确认,他们做出一个新的并查看更多数据。如果他们需要更改代码来确认假设,他们使用版本管理系统记录这些更改,以便在假设未被确认时可以回退。
文章其余部分非常正确。
John Hilberts 说道:
2017年7月19日上午11:33
上下文为王。我见过一个特别了解系统的人从酒吧里,手里拿着啤酒,用一行信息调试了一个人们已经工作了半天的重大问题,并第一次就猜中了。但他对系统了如指掌,可以利用直觉(集体过去的经验)基于非常有限的信息做出假设。
Mike W. 说道:
2017年7月20日下午6:27
在你做出假设之前,你必须观察。一旦你做出假设,你必须观察更多。
任何人都可以做出假设。但如果你没有先查看,没有仔细、密切地查看,并在查看之后进行更多查看,你的假设就只是空想,与任何事情无关。你甚至无法用它来修复问题。“也许有网络问题,我应该稍后再试。”“也许我使用了错误的编译器。”
你如何区分好假设和坏假设?实际上不是通过实验。一个好的假设基于观察,并适用于观察到最多差异的数据区域。一个坏的假设甚至没有考虑哪些区域与“正常服务器看起来像什么”有最多偏离。所以你追逐蝴蝶而不是查看错误最多的区域。
但实际上,假设只是告诉你更具体地查看哪里。当你真正找到bug的原因时,你不需要做任何猜测。“这一行缺少分号。”或者,“编写这个shell片段的程序员在他的函数中使用shell变量时,好像它们是局部作用域而不是全局作用域。”
Kurt Guntheroth 说道:
2017年7月20日下午7:02
我发现如果你必须在做出假设之前查看所有代码,软件看起来相当大。你有一些关于bug的行为信息来指导你的初始假设,并从那里逐步细化。
我们在争论措辞,而不是基本概念。
Max Kanat-Alexander 说道:
2017年7月26日下午9:08
嗯,我确实认为Mike和我说的话与你说的有轻微区别。
根据定义,假设是一个提出的解释,但我在查看某物之前甚至不会提出解释。而查看事物通常本身就会轻松生成解释,而不需要任何猜测。也就是说,通常当你查看足够多时,你不是在假设——你知道。
-Max
John Hilberts 说道:
2017年7月19日上午11:30
我创造了一个称为ULTRA调试的术语:
- 理解问题:无论什么方式适合你。我个人喜欢白板,映射出问题及其各种序列或系统。
- 日志:获取它们。阅读它们。
- 跟踪:将问题跟踪回代码或配置等。也可以将此步骤称为理论化,因为此时你正在形成假设。
- 复现:理想情况下在本地,但必要时在测试环境中。
- 更改:进行更改并将其推送到环境(请先测试)。一次只更改一件事。修复bug一次,因此投入自动化以再次捕获此类问题。
我发现这是一个有帮助的框架/首字母缩略词,用于我们行业中一项被低估且普遍未充分开发的技能。
Max Kanat-Alexander 说道:
2017年7月26日下午9:10
当然。“一次只更改一件事”是适用于调试之外的重要概念。
并非所有东西都有日志,并非每个问题都通过日志调试。此外,跟踪步骤最好通过更多查看完成,比如在调试器中运行系统,添加printf语句等。
-Max
Steven Gordon 说道:
2017年7月21日上午5:52
还有两个含义:
- 调试如此困难、耗时和破坏日程安排,以至于首先避免创建bug可能比知道如何诊断和移除它们更有价值。
- 因此,一旦bug被诊断和移除,工作尚未完成。不反思我们的工作过程或思维中的什么缺陷导致bug首先被创建,然后相应调整我们的工作方式,是不负责任的。如果我们没有随着时间的推移创建更少的bug,我们就没有学习。
特别是,如果我们的bug经常通过删除代码解决,那么也许我们应该得出结论:我们编写的代码超出了需要。也许,这可能是因为编写了额外的代码来覆盖预期需求,而不仅仅是我们当前交付功能所需的最少代码。TDD是一种只编写我们现在所需代码的好方法,同时也提供了单元测试的基础,以帮助避免以后破坏预期功能。
Max Kanat-Alexander 说道:
2017年7月26日下午9:12
确实!
第1点是我的书的主题。
第2点是http://www.codesimplicity.com/post/make-it-never-come-back/的主题。总的来说,再同意不过了。(我不是只使用TDD的人,但在一般观点上我们完全一致。)
-Max
Steven Gordon 说道:
2017年7月26日下午9:49
我希望我没有传达TDD是全部必要的观点。我的意思是TDD为回归测试形成了一个良好的初始基础,并且是避免编写今天不需要的推测性代码的好方法。
Max Kanat-Alexander 说道:
2017年7月26日下午9:54
是的,我明白你的意思。我只是指出TDD不是唯一的方法。
-Max
Mike T 说道:
2017年8月4日上午6:21
你说:“…猜测是错误的…确定需要查看什么…收集数据…查看日志…尝试复现问题…向用户发送具体问题以澄清错误报告…”我全都做。
三点:
- 严格来说,猜测和确定是不同的,但它们在日常对话中是可互换的术语。但一个是贬义词,另一个是褒义词,所以如果你在重要场合容易被胜过,用确定替换猜测。
- 从系统或用户那里收集信息有许多成本,所以你必须通过…确定…来缩小搜索范围,查看哪里,问谁,问多少开放式或 narrowly focused问题,或者是否简单尝试整个答案。判断是不可避免的。
- 它可能像音乐。你通过练习学习,也许长时间坚持一种方法,但一旦你掌握了诀窍,在适当的时候你可以尝试即兴创作。无论什么对你有用。
Some Programmer 说道:
2017年10月4日下午5:55
我同意这一点,大多数时候bug不在表面,必须彻底调试代码。我对文章的唯一警告是,虽然你不应该“猜测”解决方案甚至问题是什么,但你应该回忆熟悉的或常见的修复。我发现这也有助于找到我的断点的直接入口点。
好文章
Programming Tutorials 说道:
2017年11月3日上午9:57
嗨先生,感谢分享有关调试的有用信息。调试以非常简单的语言解释…干得好…再次感谢分享这篇文章。
Blog 2 CS 343 – James’ Blog 说道:
2018年9月21日上午11:50
[…]本周的博客基于这篇文章,该文章讨论了作者调试背后的哲学以及人们经常如何调试失败[…]
Week 4: 2/11-2/17 – CS373 Spring 2019: William Hamill 说道:
2019年2月18日上午9:55
[…] https://www.codesimplicity.com/post/the-fundamental-philosophy-of-debugging/ […]
Junhyung 说道:
2019年3月3日下午11:27
我发现你的文章非常有趣,并希望有一个韩语翻译。
可以翻译你的文章并发布在我的博客上吗?
当然,你将作为文章所有者被引用,原始文章的链接将提供在页面顶部。
Max Kanat-Alexander 说道:
2019年3月4日上午1:59
当然!