Content Entry

动态追踪技术(下) - 创造一种新语言:Y 语言 [章亦春]

动态追踪技术下篇,内容包括方法论、调试符号、死亡进程、传统的调试技术、Y 语言。内容穿越四海,纵贯九重天!

方法论

前面我们介绍到火焰图这样的基于采样的可视化方法,它其实算是非常通用的方法了。不管是什么系统,是用什么语言编写的,我们一般都可以得到一张某种性能维度上的火焰图,然后轻松进行分析。但更多的时候,我们可能需要对一些更深层次的更特殊的问题进行分析和排查,此时就需要编写一系列专门化的动态追踪工具,有计划有步骤地去逼近真正的问题。

在这个过程当中,我们推荐的策略是一种所谓的小步推进、连续求问的方式。也就是说我们并不指望一下编写一个很庞大很复杂的调试工具,一下子采集到所有可能需要的信息,从而一下子解决掉最终的问题。相反,我们会把最终问题的假设,分解成一系列的小假设,然后逐步求索,逐步验证,不断确定会修正我们的方向,不断地调整我们的轨迹和我们的假设,以接近最终的问题。这样做有一个好处是,每一个步骤每一个阶段的工具都可以足够的简单,那么这些工具本身犯错的可能性就大大降低。Brendan 也注意到他如果尝试编写多用途的复杂工具,这种复杂工具本身引入 bug 的可能性也大大提高了。而错误的工具会给出错误的信息,从而误导我们得出错误的结论。这是非常危险的。简单工具的另一大好处是,在采样过程当中对生产系统产生的开销也会相对较小,毕竟引入的探针数目较少,每个探针的处理程序也不会有太多太复杂的计算。这里的每一个调试工具都有自己的针对性,都可以单独使用,那么这些工具在未来得到复用的机会也大大提高。所以总的来说,这种调试策略是非常有益的。

值得一提的是,这里我们拒绝所谓的“大数据”的调试做法。即我们并不会去尝试一下子采集尽可能全的信息和数据。相反,我们在每一个阶段每一个步骤上只采集我们当前步骤真正需要的信息。在每一步上,基于我们已经采集到的信息,去支持或者修正我们原来的方案和原来的方向,然后去指导编写下一步更细化的分析工具。

另外,对于非常小频率发生的线上事件,我们通常会采用“守株待兔”的做法,也就是说我们会设一个阈值或其他筛选条件,坐等有趣的事件被我们的探针捕获到。比如在追踪小频率的大延时请求的时候,我们会在调试工具里,首先筛选出那些延时超过一定阈值的请求,然后针对这些请求,采集尽可能多的实际需要的细节信息。这种策略其实跟我们传统的尽可能多的采集全量统计数据的做法完全相反,正因为我们是有针对性地、有具体策略地进行采样分析,我们才能把损耗和开销降到最低点,避免无谓的资源浪费。

知识就是力量

我觉得动态追踪技术很好地诠释了一句老话,那就是“知识就是力量”。

通过动态追踪工具,我们可以把我们原先对系统的一些认识和知识,转变成可以解决实际问题的非常实用的工具。我们原先在计算机专业教育当中,通过课本了解到的那些原本抽象的概念,比如说虚拟文件系统、虚拟内存系统、进程调度器等等,现在都可以变得非常鲜活和具体。我们第一次可以在实际的生产系统当中,真切地观察它们具体的运作,它们的统计规律,而不用把操作系统内核或者系统软件的源码改得面目全非。这些非侵入式的实时观测的能力,都得益于动态追踪技术。

这项技术就像是金庸小说里杨过使的那把玄铁重剑,完全不懂武功的人自然是使不动的。但只要会一些武功,就可以越使越好,不断进步,直至木剑也能横行天下的境界。所以但凡你有一些系统方面的知识,就可以把这把“剑”挥动起来,就可以解决一些虽然基本但原先都无法想象的问题。而且你积累的系统方面的知识越多,这把“剑”就可以使得越好。而且,还有一点很有意思的是,你每多知道一点,就立马能多解决一些新的问题。反过来,由于我们可以通过这些调试工具解决很多问题,可以测量和学习到生产系统里面很多有趣的微观或宏观方面的统计规律,这些看得见的成果也会成为我们学习更多的系统知识的强大动力。于是很自然地,这也就成为有追求的工程师的“练级神器”。

记得我在微博上面曾经说过,“鼓励工程师不断的深入学习的工具才是有前途的好工具”。这其实是一个良性的相互促进的过程。

开源与调试符号

前面我们提到,动态追踪技术可以把正在运行的软件系统变成一个可以查询的实时只读数据库,但是做到这一点通常是有条件的,那就是这个软件系统得有比较完整的调试符号。那么调试符号是什么呢?调试符号一般是软件在编译的时候,由编译器生成的供调试使用的元信息。这些信息可以把编译后的二进制程序里面的很多细节信息,比如说函数和变量的地址、数据结构的内存布局等等,映射回源代码里面的那些抽象实体的名称,比如说函数名、变量名、类型名之类。Linux 世界常见的调试符号的格式称为 DWARF (1) (与英文单词“矮人”相同)。正是因为有了这些调试符号,我们在冰冷黑暗的二进制世界里面才有了一张地图,才有了一座灯塔,才可能去解释和还原这个底层世界里每一个细微方面的语义,重建出高层次的抽象概念和关系。

PIC: 「矮人」为我们指明道路

通常也只有开源软件才容易生成调试符号,因为绝大多数闭源软件出于保密方面的原因,并不会提供任何调试符号,以增加逆向工程和破解的难度。其中有一个例子是 Intel 公司的 IPP 这个程序库。IPP 针对 Intel 的芯片提供了很多常见算法的优化实现。我们也曾经尝试过在生产系统上面去使用基于 IPP 的 gzip 压缩库,但不幸的是我们遇到了问题—— IPP 会时不时的在线上崩溃。显然,没有调试符号的闭源软件在调试的时候会非常痛苦。我们曾经跟 Intel 的工程师远程沟通了好多次都没有办法定位和解决问题,最后只好放弃。如果有源代码,或者有调试符号,那么这个调试过程很可能会变的简单许多。

关于开源与动态追踪技术之间这种水乳相容的关系,Brendan Gregg 在他之前的一次分享当中也有提及。特别是当我们的整个软件栈(software stack)都是开源的时候,动态追踪的威力才有可能得到最大限度的发挥。软件栈通常包括操作系统内核、各种系统软件以及更上层的高级语言程序。当整个栈全部开源的时候,我们就可以轻而易举的从各个软件层面得到想要的信息,并将之转化为知识,转化为行动方案。

软件栈的典型构成

PIC: 软件栈的典型构成

由于较复杂的动态追踪都会依赖于调试符号,而有些 C 编译器生成的调试符号是有问题的。这些含有错误的调试信息会导致动态追踪的效果打上很大的折扣,甚至直接阻碍我们的分析。比方说使用非常广泛的 GCC 这个 C 编译器,在 4.5 这个版本之前生成的调试符号质量是很差的,而 4.5 之后则有了长足的进步,尤其是在开启编译器优化的情况下。

Linux 内核的支持

前面提到,动态追踪技术一般是基于操作系统内核的,而对于我们平时使用非常广泛的 Linux 操作系统内核来说,其动态追踪的支持之路是一个漫长而艰辛的过程。其中一个主要原因或许是因为 Linux 的老大 Linus 一直觉得这种技术没有必要。

最初 Red Hat 公司的工程师为 Linux 内核准备了一个所谓的 utrace(2)) 的补丁,用来支持用户态的动态追踪技术。这是 SystemTap 这样的框架最初仰赖的基础。在漫长的岁月里,Red Hat 家族的 Linux 发行版都默认包含了这个 utrace 补丁,比如 RHEL、CentOS 和 Fedora 之类。在那段 utrace 主导的日子里,SystemTap 只在 Red Hat 系的操作系统中有意义。这个 utrace 补丁最终也未能合并到主线版本的 Linux 内核中,它被另一种折衷的方案所取代。

Linux 主线版本很早就拥有了 kprobes(3) 这种机制,可以动态地在指定的内核函数的入口和出口等位置上放置探针,并定义自己的探针处理程序。

用户态的动态追踪支持姗姗来迟,经历了无数次的讨论和反复修改。从官方 Linux 内核的 3.5 这个版本开始,引入了基于 inode 的 uprobes(4) 内核机制,可以安全地在用户态函数的入口等位置设置动态探针,并执行自己的探针处理程序。再后来,从 3.10 的内核开始,又融合了所谓的 uretprobes(5) 这个机制,可以进一步地在用户态函数的返回地址上设置动态探针。uprobes 和 uretprobes 加在一起,终于可以取代 utrace 的主要功能。utrace 补丁从此也完成了它的历史使命。而 SystemTap 现在也能在较新的内核上面,自动使用 uprobes 和 uretprobes 这些机制,而不再依赖于 utrace 补丁。

最近几年 Linux 的主线开发者们,把原来用于防火墙的 netfilter 里所使用的动态编译器,即 BPF(6),扩展了一下,得到了一个所谓的 eBPF(7),可以作为某种更加通用的内核虚拟机。通过这种机制,我们其实可以在 Linux 中构建类似 DTrace 那种常驻内核的动态追踪虚拟机。而事实上,最近已经有了一些这方面的尝试,比如说像 BPF 编译器(8)(BCC)这样的工具,使用 LLVM 工具链来把 C 代码编译为 eBPF 虚拟机所接受的字节码。总的来说,Linux 的动态追踪支持是变得越来越好的。特别是从 3.15 版本的内核开始,动态追踪相关的内核机制终于变得比较健壮和稳定了。

硬件追踪

我们看到动态追踪技术在软件系统的分析当中可以扮演非常关键的角色,那么很自然地会想到,是否也可以用类似的方法和思想去追踪硬件。

我们知道其实操作系统是直接和硬件打交道的,那么通过追踪操作系统的某些驱动程序或者其他方面,我们也可以间接地去分析与之相接的硬件设备的一些行为和问题。同时,现代硬件,比如说像 Intel 的 CPU,一般会内置一些性能统计方面的寄存器(Hardware Performance Counter(9)),通过软件读取这些特殊寄存器里的信息,我们也可以得到很多有趣的直接关于硬件的信息。比如说 Linux 世界里的 perf(10) 工具最初就是为了这个目的。甚至包括 VMWare 这样的虚拟机软件也会去模拟这样特殊的硬件寄存器。基于这种特殊寄存器,也产生了像 Mozilla rr(11) 这样有趣的调试工具,可以高效地进行进程执行过程的录制与回放。

直接对硬件内部设置动态探针并实施动态追踪,或许目前还存在于科幻层面,欢迎有兴趣的同学能够贡献更多的灵感和信息。

死亡进程的遗骸分析

我们前面看到的其实都是对活着的进程进行分析,或者说正在运行的程序。那么死的进程呢?对于死掉的进程,其实最常见的形式就是进程发生了异常崩溃,产生了所谓的 core dump(12)文件。其实对于这样死掉的进程剩下的“遗骸”,我们也可以进行很多深入的分析,从而有可能确定它的死亡原因。从这个意义上来讲,我们作为程序员扮演着「法医」这样的角色。

最经典的针对死进程遗骸进行分析的工具便是鼎鼎大名的 GNU Debugger(13)(GDB)。那么 LLVM 世界也有一个类似的工具叫做 LLDB(14)。显然,GDB 原生的命令语言是非常有局限的,我们如果手工逐条命令地对 core dump 进行分析其实能得到地信息也非常有限。其实大部分工程师分析 core dump 也只是用 bt full 命令查看一下当前的 C 调用栈轨迹,抑或是利用 info reg 命令查看一下各个 CPU 寄存器的当前取值,又或者查看一下崩溃位置的机器代码序列,等等。而其实更多的信息深藏于在堆(heap)中分配的各种复杂的二进制数据结构之中。对堆里的复杂数据结构进行扫描和分析,显然需要自动化,我们需要一种可编程的方式来编写复杂的 core dump 的分析工具。

顺应此需求,GDB 在较新的版本当中(我记得好像是从 7.0 开始的),内置了对 Python 脚本的支持(15)。我们现在可以用 Python 来实现较复杂的 GDB 命令,从而对 core dump 这样的东西进行深度分析。事实上我也用 Python 写了很多这样的基于 GDB 的高级调试工具,甚至很多工具是和分析活体进程的 SystemTap 工具一一对应起来的。与动态追踪类似,借助于调试符号,我们可以在黑暗的“死亡世界”中找到光明之路。

黑暗世界里的光明之路

PIC: 黑暗世界里的光明之路

不过这种做法带来的一个问题是,工具的开发和移植变成了一个很大的负担。用 Python 这样的脚本语言来对 C 风格的数据结构进行遍历并不是一件有趣的事情。这种奇怪的 Python 代码写多了真的会让人抓狂。另外,同一个工具,我们既要用 SystemTap 的脚本语言写一遍,然后又要用 GDB 的 Python 代码来写一遍:无疑这是一个很大的负担,两种实现都需要仔细地进行开发和测试。它们虽然做的是类似的事情,但实现代码和相应的 API 都完全不同(这里值得一提的是,LLVM 世界的 LLDB 工具也提供了类似的 Python 编程支持,而那里的 Python API 又和 GDB 的不相兼容)。

我们当然也可以用 GDB 对活体程序进行分析,但和 SystemTap 相比,GDB 最明显的就是性能问题。我曾经比较过一个较复杂工具的 SystemTap 版和 GDB Python 版。它们的性能相差有一个数量级。GDB 显然不是为这种在线分析来设计的,相反,更多地考虑了交互性的使用方式。虽然它也能以批处理的方式运行,但是内部的实现方式决定了它存在非常严重的性能方面的限制。其中最让我抓狂的莫过于 GDB 内部滥用 longjmp(16) 来做常规的错误处理,从而带来了严重的性能损耗,这在 SystemTap 生成的 GDB 火焰图上是非常明显的。幸运地是,对死进程的分析总是可以离线进行,我们没必要在线去做这样的事情,所以时间上的考虑倒并不是那么重要了。然而不幸的是,我们的一些很复杂的 GDB Python 工具,需要运行好几分钟,即使是离线来做,也是让人感到很挫败的。

我自己曾经使用 SystemTap 对 GDB + Python 进行性能分析,并根据火焰图定位到了 GDB 内部最大的两处执行热点。然后,我给 GDB 官方提了两个 C 补丁,一是针对 Python 字符串操作(17),一是针对 GDB 的错误处理方式(18)。它们使得我们最复杂的 GDB Python 工具的整体运行速度提高了 100%。GDB 官方目前已经合并了其中一个补丁。使用动态追踪技术来分析和改进传统的调试工具,也是非常有趣的。

我已经把很多从前在自己的工作当中编写的 GDB Python 的调试工具开源到了 GitHub 上面,有兴趣的同学可以去看一下。一般是放在 nginx-gdb-utils(19) 这样的 GitHub 仓库里面,主要针对 Nginx 和 LuaJIT。我曾经利用这些工具协助 LuaJIT 的作者 Mike Pall 定位到了十多个 LuaJIT 内部的 bug。这些 bug 大多隐藏多年,都是 Just-in-Time (JIT) 编译器中的很微妙的问题。

由于死掉的进程不存在随时间变化的可能性,我们姑且把这种针对 core dump 的分析称之为“静态追踪”吧。

我编写的 GDB 调试命令

PIC: 我编写的 GDB 调试命令

传统的调试技术

说到 GDB,我们就不得不说一说动态追踪与传统的调试方法之间的区别与联系。细心的有经验的工程师应该会发现,其实动态追踪的“前身”就是在 GDB 里面设置断点,然后在断点处进行一系列检查的这种方式。只不过不同的是,动态追踪总是强调非交互式的批处理,强调尽可能低的性能损耗。而 GDB 这样的工具天然就是为交互操作而生的,所以实现并不考虑生产安全性,也不怎么在乎性能损耗。一般它的性能损耗是极大的。同时 GDB 所基于的 ptrace(20) 这种很古老的系统调用,其中的坑和问题也非常多。比如 ptrace 需要改变目标调试进程的父亲,还不允许多个调试者同时分析同一个进程。所以,从某种意义上来讲,使用 GDB 可以模拟一种所谓的“穷人的动态追踪”。

很多编程初学者喜欢使用 GDB 进行“单步执行”,而在真实的工业界的生产开发当中,这种方式经常是非常缺乏效率的。这是因为单步执行的时候往往会产生程序执行时序上的变化,导致很多与时序相关的问题无法再复现。另外,对于复杂的软件系统,单步执行很容易让人迷失在纷繁的代码路径当中,或者说迷失在所谓的“花园小径”当中,只见树木,不见森林。

所以,对于日常的开发过程当中的调试,其实我们还是推荐最简单也是看起来最笨的方法,即在关键代码路径上打印输出语句。这样我们通过查看日志等输出得到一个有很完整的上下文,从而能够有效进行分析的程序执行结果。当这种做法与测试驱动的开发方式结合起来的时候,尤为高效。显然,这种加日志和埋点的方式对于在线调试是不切合实际的,关于这一点,前面已经充分地讨论了。而另一方面,传统的性能分析工具,像 Perl 的 DProf、C 世界里的 gprof、以及其他语言和环境的性能分析器(profiler),往往需要用特殊的选项重新编译程序,或者以特殊的方式重新运行程序。这种需要特别处理和配合的性能分析工具,显然并不适用在线的实时活体分析。

凌乱的调试世界

当今的调试世界是很凌乱的,正如我们前面看到的有 DTrace、SystemTap、ePBF/BCC、GDB、LLDB 这些,还有很多很多我们没有提到的,大家都可以在网络上查到。或许这从一个侧面反映出了我们所处的这个真实世界的混乱。

有时候我就在想,或许我们可以去设计并实现一种大一统的调试语言——事实上我连名字都起好了,那就是 Y 语言。我很希望能够实现这个 Y 语言,让它的编译器能够自动生成各种不同的调试框架和技术所接受的输入代码。比如说生成 DTrace 接受的 D 语言代码,生成 SystemTap 接受的 stap 脚本,还有 GDB 接受的 Python 脚本,以及 LLDB 的另一种不兼容 API 的 Python 脚本,抑或是 eBPF 接受的字节码,乃至 BCC 接受的某种 C 和 Python 代码的混合物。

如果我们设计的一个调试工具需要移植到多个不同的调试框架,那么显然人工移植的工作量是非常大的,正如我前面所提到的。而如果有这样一个大一统的 Y 语言,其编译器能够自动把同一份 Y 代码转换为针对各种不同调试平台的输入代码,并针对那些平台进行自动优化,那么每一种调试工具我们就只需要用 Y 语言写一遍就可以了。这将是巨大的解脱。而作为调试者本人,也没有必要亲自去学习所有那些具体的调试技术的凌乱的细节,亲自去踩每一种调试技术的“坑”。

这是我个人的一个美好愿景。

有朋友可能要问为什么要叫做 Y 呢? 这是因为我的名字叫亦春,而亦字的汉语拼音的第一个字母就是 Y……当然了,还有更重要的原因,那就是它是用来回答以「为什么」开头的问题的语言,而「为什么」在英语里面就是「why」,而 why 与 Y 谐音。

等 Y 语言诞生以后,我打算再找机会和大家多分享一些比较完整的动态追踪的实例。

如何贡献

好了,跟大家说了这么多,我就是想吸引更多的工程师来关注并且参与到动态追踪技术这个领域中来,然后可以像 Brendan 和我一样去贡献一些开源的基于动态追踪的调试工具,同时也能在很底层的系统层面上去完善动态追踪所仰赖的核心技术和基础设施,包括 SystemTap 这样的框架,也包括操作系统内核里面的核心机制,比如 kprobes、uprobes、eBPF 之类。像基于 GDB 和 LLDB 的所谓的“静态追踪”工具里面的性能优化,也有很多值得做的工作。

期待我们在开源世界里再见!

鸣谢 (备注: 作者章亦春)

本文得到了我的很多朋友和家人的帮助。首先要感谢师蕊辛苦的听写笔录工作;本文其实源自一次长达一小时的语音分享。然后要感谢很多朋友认真的审稿和意见反馈,特别要感谢何伟平、杨书鑫、安邦、林孜、戴冠兰、池建强、扶凯等等很多好朋友提供的很多宝贵的意见和建议。同时也感谢我父亲和我妻子在文字上的耐心帮助。

参考链接

comments loading