乐于分享
好东西不私藏

计算机和软件系统是可以被理解的

计算机和软件系统是可以被理解的

好文翻译,原文链接

https://blog.nelhage.com/post/computers-can-be-understood/

引言 🔗︎

本文试图描述一种思维方式,我意识到自己几乎在所有软件工作中都带着它。我将尝试阐明这种思维、它的一些影响和优势,以及它曾如何误导过我。

软件可以被理解 🔗︎

我对待软件时抱有一个深层的信念: 计算机和软件系统是可以被理解的。

对我来说,这个信念并非某种晦涩的理论主张,而是一种深切的感受:任何我可能关心的(关于计算机的)问题,都有一个可理解的答案,并且通过坚定的探索和学习就能触及。

从某些角度看,这个信念在今天显得相当激进。现代软件和硬件系统在众多不同的层次中包含了几乎难以想象的复杂性,每一层都构建在另一层之上。一种普遍且大体正确的观察是:没有哪个人能理解所有层次——比如一个现代 Web 应用,从晶体管和硅片开始,往上经过微架构、CPU 指令集、操作系统内核、用户态库、编译器、Web 浏览器、JavaScript 虚拟机、JavaScript 库,再到应用代码,更不用说加载这些代码所调用的所有网络服务了。

面对这种复杂性,人们很容易认为要学的东西太多,于是采用一种思维捷径:把我们打交道的系统最好当作黑盒(black box,即只关心输入输出而不关心内部原理)来处理,不需要深入理解。

我反对这种态度。你永远无法理解那个栈中每一层的每一个实现细节;但你可以理解所有层次到某个抽象级别,并且针对任何具体层次,你可以根据需要理解到几乎任何深度。

计算机不是魔法 🔗︎

计算机的核心是建立在一组(大多)确定性的基础之上的,它们在每个时钟节拍都遵循严格的规则。我们在这些基础上构建了一层又一层的抽象,每一层也都基于上一层的抽象,表现出(大多)可重现且确定的行为。

这里没有魔法。不存在这样一个层次:越过它我们就离开了逻辑和执行指令的领域,而遇到无法知晓的恶魔,做出任意且反复无常的决定。大多数行为都可以用下一层的概念来理解,而所有行为都可以通过向下深挖足够多的层次来弄明白。

源码、文档和逆向工程 🔗︎

在现代计算机系统中,有大量层次是开源的,可以直接通过阅读实现代码来理解。对于一个开发者,如果他在 Linux 服务器上部署了一个基于 MySQL 数据库的 Ruby on Rails 应用,那么涉及的所有相关软件都是开源的,需要时都可以阅读。

当系统不是开源时,通常会有详尽且精心编写的文档;在上面假设的系统中,我们遇到的第一个闭源层次很可能就是硬件本身,比如现代 x86 处理器。英特尔提供了数千页的文档,详细说明了其处理器的接口,出色地穷举了其硬件在执行代码时的几乎每一个行为细节。

当源码和文档不可用或不足时,有足够决心的工程师仍然可以通过实验和检查来逆向工程(reverse-engineer,即通过分析行为来推断内部原理)。通常,别人已经做过了,他们的成果可供你使用。安全工程师往往最擅长这项技能;我最喜欢的两个例子是:谷歌 Project Zero 团队对 Haswell 微架构分支预测器的逆向工程,以及安全工程师们对 macOS 内部进行逆向工程和文档化的各种努力,例如这篇关于 Safari 中 WebKit 堆内存管理的文档。

这两个例子是顶尖领域工程师们付出巨大努力的结果,他们有着多年的经验。我并不是说我能以接近那样的效率完成这些逆向工程项目。但对我来说,它们的存在证明了:(1) 如果我足够想去做,这项工作是能完成的;(2) 如果别人已经完成并发表了,我通常不必自己动手。

这种思维方式的体现 🔗︎

理解你的依赖 🔗︎

这种思维方式最直接的体现可能是:它会促使我——以及有类似思维方式的人——花时间去更多地了解自己所依赖的系统,了解它们如何工作、如何实现。如果我在用一个库或框架编写一个不算简单的应用,我几乎总会在笔记本上保留那个库的源码副本,并在需要调试奇怪行为或发现文档无法回答我的问题时,经常深入源码去查找。

调试 🔗︎

拥有这种习惯并且对自己的依赖了解很多,无疑是我调试棘手 Bug 时的超能力。如果你长时间使用某个工具,你一定会遇到工具中影响你的 Bug,至少能够用该工具的抽象概念准确地描述和诊断它们,从而生成可操作的 Bug 报告或最小复现案例,这是非常有价值的。

最棘手的 Bug 往往跨越多个层次,或者涉及层次之间泄漏的抽象边界。这些 Bug 通常无法在单一抽象层次上理解,有时需要同时从多个抽象层次观察行为才能完全搞懂。解决这类 Bug 几乎需要在你团队中找到能够自如穿梭于栈中多个层次之间的人;反过来,对于那些本来就习惯在不同层次间游走的工程师来说,这些 Bug 往往构成一个引人入胜的挑战,并成为他们最爱的“战斗故事”的素材。

我自己最喜欢的这类战斗故事之一是追踪并定位了我台式机上的一位内存比特翻转。如果不深入理解用户态库、内核、文件系统和硬件之间的相互作用,这类问题甚至无法被完全理解。我的调试战斗故事 Tumblr 上还有不少其他好例子。

文档 🔗︎

愿意且擅长阅读源码,可以减少你对文档的依赖。如果文档在某些方面有欠缺,你随时可以去看源码,从实现中找到权威答案。

在团队中拥有这种能力是非常强大的,因为即使是最好的文档也往往有漏洞。不过它也有缺点:我共事过的工程师中(包括我自己),那些最擅长阅读陌生代码库的人,有习惯性低估文档价值的风险(因为他们已经善于在没有文档的情况下找到答案),并且他们给自己系统写文档的能力可能比普通工程师还差。

安全 🔗︎

理解安全问题通常需要在多个抽象层次上工作。攻击者攻击系统时,不受任何层次的文档化或预期行为的约束;她关心的是系统 在实际中到底如何表现 ,可能包括某一层或输入“超出规格”的情况。C 语言规范只说缓冲区溢出是“未定义行为”;要理解如何将其转化为远程代码执行,或者推理 ASLR(地址空间布局随机化)、DEP(数据执行保护)等防御措施,就需要深入理解编译器、libc、底层硬件等对抽象 C 规范的实际实现。

我的职业生涯起步于安全领域,当时在 Ksplice 工作,该公司为 Linux 内核提供零停机的安全更新,主要作为安全特性。我在那里接触了大量安全 Bug,学到了很多现在的技能和自信——比如更深入地探索栈、理解所有抽象层次。

性能 🔗︎

理解和推理软件性能同样经常涉及理解栈中的多个层次。如果不了解 CPython(或 PyPy)的实现,就很难写出高效的 Python 代码;如果不了解生成的代码和底层硬件,你也写不出缓存高效的 C 代码。如果你走进一群性能工程师中,几乎总能发现几位工程师有这种习惯:总是深入挖掘,不断更好地理解越来越多的抽象层次。

构建心智模型 🔗︎

与尝试学习软件栈底层层次密切相关的一个习惯是:通过构建底层系统的详细心智模型(mental model,即大脑中对系统如何运作的内在理解)来理解软件。我不是把系统(语言、库、API 等)仅仅当作规则、行为和边界情况的集合来理解,而是尝试构建一个更小的核心原语模型,以及产生系统更大行为的规则或原理。

举个具体的例子,我这辈子写的 bash 脚本可能多到不好意思说。在某个时刻,我不再不断记忆那些碰巧有效或无效的具体模式(比如什么时候需要或不需要加引号?),而是退后一步,阅读 bash 文档,以理解 bash 在处理命令行时所遵循的各种展开阶段,以及哪些阶段在什么上下文中按什么顺序应用。这个知识并没有消除学习大量琐碎知识的需求——可以说,它增加了更多琐碎知识——但是拥有一个可以容纳知识的框架,既让我更容易记住这些琐事,又在面对新问题或新代码模式时增强了它的解释力。

我认为这里还有一个相关的信念,它与“计算机是可理解的”这一基本信念相互联系并得到强化:计算机系统往往是——怎么说呢——有系统的,并且具有某种可理解的核心里代数或逻辑,这个核心比完整的行为列表要小,却能生成或至少组织所有这些行为。另外,我还倾向于相信:投入精力学习这些底层系统,最终会在理解和使用系统方面得到回报。

单次调试 🔗︎

作为拥有良好软件系统心智模型以及这些系统大多是确定性的一个推论,我们可以仅凭对程序在时间点上行为的少量观察(比如一个堆栈跟踪、一行日志或一个核心转储),就能做出相当详细的推断。在最极端的情况下,开发者有时仅凭遇到一次 Bug 就能根因定位。借助对系统和手头代码的丰富心智模型,你可以进行逆向推理,例如:“啊,如果这个字段被设为 NULL,一定是有人设置了它……设置该字段的代码只有这里、这里和这里……只有第一个和第三个可能用 NULL 参数调用……”等等。

即使你不能“一次搞定”一个 Bug,这里也有一项通用技能:能够提出理论和假设,并基于对系统的观察来完善你的心智模型,从而提出更具体的问题,然后(在调试器中、用打印语句、通过阅读代码……)进行验证,进而进一步完善模型。丰富的心智模型以及能够前后推演的能力,对调试和学习一个系统是难以置信的帮助。

内核工程中的单次调试 🔗︎

系统越复杂、越不确定,从少量观察中可靠预测行为就越困难,我认为这在一定程度上解释了现代分布式系统中“可观测性”的趋势——你需要更多数据才能完全解释这些系统的行为。

然而,在栈的底部,系统工程师尤其是内核工程师中,这种技能集相当普遍。我见过 LKML(Linux 内核邮件列表)上的讨论:一位开发者贴出一个带有堆栈跟踪和寄存器转储的崩溃日志,然后几位资深内核“副官”会协作追查 Bug,基于寄存器转储与编译后代码之间的映射,以及他们对内核中所有处理相关数据结构的地方的理解,做出详细推断。

在 Oracle 工作时(Ksplice 被收购后),我与一些 Solaris 内核工程师交谈,得知他们甚至把这种方法推向了极致。他们显然有一个明确的目标:基于 单个崩溃报告 实现 100% 的根因定位率。为了实现这个目标,他们构建了大量复杂的崩溃报告和调试技术——这不仅仅是靠苦思冥想 Bug——但我认为这个目标根本上源于一个深层信念:他们的系统虽然复杂,但是可理解的,并且大多是确定性的,而他们有能力去推理它。我觉得这个故事非常鼓舞人心。

我认为从很多方面来看,内核开发是这种思维方式的主要受众。首先,尤其是在十年前我们还没有现代虚拟化技术的时候,调试内核崩溃的唯一方法常常是检查崩溃转储或日志跟踪——你可能连调试器都没有,甚至根本没法继续执行。其次,因为操作系统内核本质上是离硬件最近的软件,要完全解释你代码的行为和交互,你需要理解的层次相对较少。你(主要)只需要理解你的 C 编译器/编译后的代码,以及硬件本身。任何在用户空间(内核之上)开发的开发者,都有严格更多的层次需要处理。

这种思维方式的陷阱 🔗︎

我想在这里提一下这种思维方式的陷阱,以及我观察到它曾误导我的一些地方。总的来说,相信软件从根本上可理解,以及追求详细的心智模型,对我非常有益,但我想澄清:我不认为这是唯一有效或有用的方法,而且它也有自身的弱点。

必须理解的需求 🔗︎

相信软件系统可理解,很容易变成一种 必须 理解你所使用的系统的需求。在我对底层没有良好模型的情况下工作,会让我非常不舒服,而这种不适有时会妨碍我达成目标。

我发现很难仅仅通过一两个教程、做一些小修改或在示例基础上进行局部探索来开始一个复杂系统。除非我 理解 了所有交互组件的角色和关系(至少在高层次上),否则我会感到不安。

具体来说,前几天我试图搭建一个由 Amazon Lambda 支持的 HTTP 端点(我以前从未用过 Lambda)。关于这个任务,AWS 和其他地方有成千上万的教程。我很确信,如果随便挑一个,通过试错去用,我大概能在 30 分钟内完成任务。然而,我却固执地坚持从零开始,理解每一个需要的组件。因为在 AWS 上执行任何任务都需要拼凑大约 15 个令人麻木的复杂产品,我很快就打开了 30 个文档标签,无论怎么尝试端点都返回服务器错误,而且仍然没有更好地理解到底发生了什么。我最终放弃了,并认为这个问题本来就不值得花那么多时间去解决。

我确实相信,如果我有更多时间和耐心,最终我会对 Lambda 及其周边的 AWS 产品建立起相当深刻的概念性理解,并且能更好地调试我的部署或解决未来的问题。然而,那不是我的目标;我只是想在时间预算内得到一个能用的东西。结果我什么都没有得到,也没有获得更好的理解。“必须理解”的需求在处理复杂系统时可能具有诱惑力且有害,因为你的问题从根本上并不需要理解整个系统。

先做简单的事 🔗︎

我已经记不清有多少次了:面对依赖中的一个 Bug,我花了几天时间深入挖掘依赖来识别和隔离这个 Bug……结果却发现这个 Bug 在上游已经被修复了,而我们被锁定在了一个旧版本上。或者,我花时间试图调试一个没有调试符号的二进制文件的崩溃,仔细研究核心转储和 x86 反汇编,结果同事找到了一个调试版本,在那里重现了问题,然后在 gdb 会话的绿色草地上悠闲地穿行¹。

我有一套特定的软件和系统技能,恰好包括二进制逆向工程和快速熟悉陌生代码库。我拥有这些技能,部分是因为我痴迷于理解所使用的系统。然而,拥有这些技能并不意味着它们总是解决问题的最佳技能!

几乎总是值得先尝试更简单的方法(升级依赖、使用调试器、从工作示例中进行几次试错式的“搬运”),只有当这些工具失效时,才动用大杀器。

从好奇心开始 🔗︎

最后,我想谈谈如何开始培养这种思维方式,以及如何开始获得理解陌生计算机系统的具体技能。我学习这套工具已经花了大约二十年时间与计算机系统打交道,我担心无意中给人留下一种印象:计算机可以被理解,但只有像我这样有深度经验的人才能做到。虽然我只能自信地根据自己的经验来写,但我深信,无论你经验多寡,这里讨论的思维方式都是有价值的。

对于本文的实际应用,我的建议是:培养对你所使用的系统的深切好奇心。问自己它们是如何工作的,为什么那样工作,以及它们是如何构建的。问自己这样的问题:“我会如何构建这个库?”,找出你不知道答案的空白点,并把它们加入你心理的“待学清单”。

如果要一个更战术性的建议,我会从这一点开始:阅读你依赖的源码,如果你还没有这个习惯的话。你在 React 上写 Web 应用吗?试着获取一份源码,找个时间读一读。你在开发 Django 或 Rails 的 Web 应用?去看看那个框架的源码。甚至可以获取一份 Python 或 Ruby 的实现,看看内部。这些语言的大部分标准库都是用语言本身写的,所以你可以从不需要学习太多 C 语言(甚至完全不用学)开始。你的目标不必是一下子理解所有内容,甚至永远不需要——只是要建立你的理解,以及你的信心:你明天总能理解更多。

学习更多关于软件系统的知识是一项复利技能。你见过的系统越多,你能用来匹配未来系统的模式就越多,你能应用到未来问题上的技能、技巧和技术也就越多。理解极其复杂的系统起初可能遥不可及,但你尝试得越多,它就会变得越容易。

我最喜欢的公开体现这种思维方式的工程师之一是 Julia Evans,我有幸在 Stripe 与她共事。她对计算机如何工作有着令人难以置信的好奇心,并且非常擅长写作和演讲,不仅讲述她学到的东西,还讲述她是如何学到的,并传达出一种纯粹的好奇、兴奋和探索的感觉。我最喜欢的一些例子:

  • • 她关于如何进入内核开发领域的文章是一个很好的具体例子,展示了如何面对一个可怕的领域并找到进入的方法。
  • • 她关于如何成为一名巫师的演讲与本文中的许多想法相呼应,并提供了关于如何实现这些想法的实用建议。
  • • 她关于提出好问题的文章对于任何与他人合作,或者只是对学习计算领域更多知识感到好奇的人来说,都是一个极好的资源。

结语 🔗︎

计算机是复杂的,但不必是神秘的。我们关心的任何问题,原则上(通常在实践中也是)都能得到解答。未知的系统可以用好奇心和决心来接近,而不是恐惧。

这些信念是我——以及我认识的许多经验丰富的工程师——对待软件工程的一个重要方面。我希望本文能对这种思维方式进行一次有用的阐述。如果它鼓励你去尝试学习或理解一些新东西,给我发封邮件告诉我吧!


¹ 举一个具体的例子:几年前我发表了一篇关于隔离 eventmachine 内存泄漏的文章后,收到了不少人的反馈。他们批评我直接跳入原始内存镜像的探索,而不是先尝试像 Valgrind 或 tcmalloc 的泄漏检测器这样的工具。在那个案例中,我成功找到了 Bug,而且我不确定那些工具是否真的有效,但我认为这个观点是成立的:我的本能过分倾向于先尝试可能更难的方法。↩︎


#计算机可理解 #思维方式 #调试 #心智模型 #源码阅读 #逆向工程 #性能 #安全 #内核开发 #好奇心