Inspection/Introspection

符号表对很多实用工具很有用--JIT 和动态 compiler,optimizers, debuggers, profilers, binary rewriters -- 检查程序,推理它的结构和类型。我们在 16.3.1 和 16.3.2 讨论 debugger 和 profiler。没有理由 metadata 只在外部工具中使用,实际上,Lisp 一直支持程序推理自己的内部结构和类型(有时候这种技术称为 introspection)。Java 和 C# 提供了类似的 反射 API,允许程序利用自己的 metadata。反射同样出现在包括 Prolog 在内的多种语言和所有主要的脚本语言中。在比如 Lisp 这种动态类型语言中,反射至关重要:允许库或者应用函数检查自己的参数。静态类型语言中,反射通过一些编程方式来支持而不是自身的特性。

16.3.1 反射

最普通的,反射可以用来打印诊断信息。假定我们在调试 Java 中的 queue,我们想要跟踪对象。在 dequeue 方法中,在返回前打印一下:

System.out.println("Dequeued a " + rtn.getClass().getName());

如果 dequeued 的对象是一个固定长度的整数,我们会看到

Dequeued a java.lang.Integer

更重要的是,反射在操作其他程序中很有用。比如,大多数程序的开发环境需要组织展示类,方法,和程序的变量。在具有反射的语言中,这些工具不需要检测源码:如果将已经编译的程序加载,可以使用反射 API 来查询编译器创建的符号表信息。解释器,debugger,profiler 以类似方式工作。在分布式系统中,程序使用反射来创建通用的序列化机制,能够将几乎任何结构转换成可以通过网络传输的字节流然后在另一端重构。(Java 和 C# 都在标准库中包含这样的能力,在基本语言之上实现)。在网络应用不断增长的世界,设置可以创建约定使得程序可以查询新发现的对象和其实现的方法,然后选择来调用。

当然,没有限制的使用反射也很危险。因为这允许应用可以“看到”类内部(比如列出私有成员),反射违反了抽象和数据隐藏的规则。可能会在某些环境中被禁止使用。通过限制目标代码可以与源不同的程度,可以消除某种形式的代码优化。

或许反射最大的缺陷,至少对于面向对象语言来说,是通过类型信息 case(switch) statement 的使用的诱惑:

image-20220901182756748

在 Lisp 中这种写法很常见,在面向对象语言中使用子类型多态写法更好。

Java Reflection

Other Languages

Annotations and Attributes

16.3.2 Symbolic Debugging

大多数程序员熟悉 debugger:内建在编程语言解释器,虚拟机和集成开发环境中。也可以是单独的工具,比如广为人知的 GDB。symbolic 是指 degger 对于高层语言 syntax 的理解--原始程序中的 symbol。早期的 debugger 只能理解汇编。

典型的 debug session中,用户开启一个 debugger 控制的程序,或者 attach debugger 到正在运行的程序中。debugger 允许用户执行两种主要操作。一种是查看或者修改程序数据,另一种是控制程序的执行:开始,停止,单步执行,建立停止点和观察点。停止点是运行到指定源代码位置就停止。观察点是如果一个指定变量被读到或被写程序就停止。停止点和观察点都可以有条件,只有当特定谓词被满足才会停止。

数据和控制操作都严重依赖 symbolic 信息。一个 symbolic debugger 需要解析源语言的 expression 并将其关联到 symbol。在 gdb 中,print a.b[i] 命令需要解析需要 print 的 expression,还需要知道程序停止的位置 scope 中, a 和 i,以及 b 是数组类型,下标通过 i 表示。类似的,命令 break 123 if i + j == 3 需要解析 i + j ,还需要知道在源代码 123 行有一个执行 statement,以及那一行中 i 和 j 的值。

数据和控制操作还依赖从外部操作程序的能力:停止,开始,读取/写入数据。这种控制可以通过三种方式实现。最简单的就是解释器。因为解释器可以直接访问程序的符号表,并且进入每个 statement 的执行中,在程序和调试器之间来回移动非常简单,并且可以让后者访问前者的数据。

动态二进制重写技术也应用于调试器控制。这个技术比较新,所以在生产级调试器中使用还不多。

对于编译型语言,调试器控制的第三种实现是最常见的。依赖于操作系统的支持。在 Unix 中,利用了内核服务 ptrace。 Ptrace 内核调用允许 debugger “grab” (attach to)到已经存在的进程或者在其控制下开启一个进程。追踪进程(debugger)可以拦截被追踪进程的 signal,并且可以读取和写入寄存器。如果被追踪进程正在运行,debugger 可以发送 signal 来停止它。如果当前是停止的,debugger 可以确定要恢复执行的地址,然后通过让内核开始运行一条指令或者直到收到某种 signal。

16.6 DWARF:

为了使能 symbolic debugging,编译器必须在每个 object 文件中包含符号表信息,并且 debugger 可以理解。DWARF 格式,被很多系统采用。原始版本在 1980 年代贝尔实验室开发,现在主要有 DWARF 社区维护。在 2015 年发布了第五个版本。

与很多专有格式不同,DEWARF 旨在包含众多(静态类型)编程语言和多重机器架构。除此之外,还编码了所有编程类型,名称,scpoe 的布局,和所有 stack frame 的布局,以及从源文件和行号到指令地址的映射。

重点是其简洁的编码。程序对象分层次描述,以一种让人联想到 AST 的方式。字符串名称和其他重复元素被精确捕获一次,然后间接引用。整数常数和引用使用可变编码,因此小值位数更少。或许是最重要的,栈布局和源码到地址的映射没有编码成显式的表,而是自动生成表的有限自动机,逐行。对于 GCD 示例程序,DWARF 的可读性表示将填满本书的四个以上完整页面。对象文件中的二进制表示只占用 571 字节。

或许对于用户来说调试最神秘的地方是 breakpoint, watchpoint, 单步执行。默认的工作在现代处理器的实现,依赖修改被追踪进程内存空间的能力--尤其是包含程序代码的部分。比如,假定被追踪进程现在停止状态,debugger 恢复其运行之前希望在函数 foo 开始设置 breakpoint。通过一种特殊的 trap 来代替函数的第一个指令来做到。

trap 指令是进程从操作系统发起请求的常规方式。在这个特殊场景中,内核将 trap 作为停止当前进程的指令,然后控制权交回 debugger。为了恢复在 breakpoint 被追踪的进程,debugger 将原始指令放回,告诉内核单步执行被追踪进程,然后再次使用 trap 代替 breakpoint 位置,最后恢复被追踪进程。对于条件 breakpoint,debugger 在断点发生时评估条件的谓词。如果 breakpoint 是无条件的,或者条件正确,debugger 跳转到命令循环等待用户输入。如果谓词为假,会自动恢复被追踪进程。如果 breakpoint 设置在内循环中,会频繁到达 breakpoint,但是条件很少为真,在 debugger 和被追踪进程之间的跳转就会有比较高的开销。

有些处理器支持硬件进行 breakpoint 更快。比如 x86,有四个 debugger 寄存器。如果执行过程到达地址,处理器模拟 trap 指令,保存调试器需要追踪进程的地址空间并消除需要还原需要的额外内核调用【译者注:细节参考原文,总之就是硬件支持减少了开销】

watchpoint 更 tricky 一点。到目前为止,最容易的实现依赖硬件支持。假设我们想要在程序修改变量 x 的时候进入 debugger 控制。x86 的 debugging 寄存器和其他现代处理器可以设置为模拟 trap 当程序写 x 的地址空间时。当处理器缺少这种硬件支持,或者当用户要求 debugger 设置比硬件支持更多的 breakpoint 和 watchpoint,有几种选择,但是都不是很好。最明显的就是处理每单步执行一次,都要检查 x 是否被修改。如果处理甚至不支持单步执行,debugger 只能设置临时 breakpoint 在存储指令之后,而不是每个指令之后。另外,debugger 可以修改被跟踪进程的地址空间,使得 x 的 page 不可写。进程每次写这个 page 将会触发 segment fault ,从而允许 debugger 干预。如果写操作是对于 x,debugger 跳转到命令循环。否则执行写操作,然后告诉内核恢复运行。

不幸的是,不断的在 debugger 与被追踪进程之间上下文切换开销影响软件 watchpoint 的性能:慢 1000 倍并不少见。基于动态二进制重写的 debugger 有潜力支持接近硬件寄存器速度的 watchpoint。思想很直观:被追踪进程被 debugger 追踪 cache 管理作为部分执行的追踪。每生成一次追踪,debugger 在每个存储后面加入指令来检查是否在写 x 的地址,如果是跳转到命令循环。

16.3.3 Performance Analysis

在调试好程序到生产使用之前,通常也会想要是不是可以提升下性能。侧写和分析工具种类繁多,我们专注于很多分析工具使用的运行时技术。

周期性采样 program counter(PC) 或许是最简单的测量方法。Unix 中经典的 prof 工具就使用这种方法。通过与 protf 链接,程序会周期性收到定时器信号,每微妙,回复 PC 计数器的增长。执行之后,prof 后续处理将 PC 与程序代码的地址映射关联起来,然后统计出每个 subroutine 和 loop 的耗时。

因为简单,prof 有些局限性。结果是近似的,不能获得细粒度的开销。也不能区分同一个 routine 在多个位置的调用。如果我们想要知道 A,B,C 哪个开销最大,它们都调用了 D,D 花费了大量时间,帮助并不大。如果我们想要知道哪个 D 开销最大,我们可以使用 gprof 工具,依赖编译器支持。随着程序执行,记录 D 被调用的位置和次数。 gprof 后续处理中可以区分调用位置。更好用的工具不仅记录了调用者和被调用者,还记录了栈状态,从而可以处理 D 在 A 中的时间开销是 B 和 C 中两倍的情况。

如果我们的程序由于算法原因表现不佳,那么知道它花了大量时间就够了。我们可以集中精力提升源代码。如果程序由于其他原因,我们需要知道为什么。也许是由于 cache 不亲和?分支预测不佳?处理器 pipeline 没有用到?解决这个问题的工具需要复杂的代码指令或者硬件支持。

【译者注:本节后续细节请参考原文,下面的是译者自己关注的点】

导致性能不佳的问题列表:

  • branch misprediction
  • TLB(address translation) miss
  • cache miss
  • Interrupts, executed instructions, and pipeline stall

性能计数器通常是稀缺资源。而且处理器之间大有不同,并且内核模式才可以访问,操作系统并不总是提供方便的接口。intel 支持的很好,移植工具是当前的热点研究。