在本文中,我将解释如何使用 Delve[1] 跟踪你的 Go 程序,以及 Delve 如何在底层利用 eBPF 来最大限度地提高效率和速度。Delve 的目标是为开发者提供愉快高效的 Go 调试体验。本文我们将重点介绍如何优化函数跟踪子系统,以便你可以检查你的程序并更快地进行原因分析。Delve 的跟踪实现有两种不同的后端,一种基于 ,另一种使用 。
eBPF 是什么?
eBPF 是 Linux 4.x+ 里的一项内核技术,你可以把它想像成一个运行在 Linux 内核中的轻量级的沙箱虚拟机,可以提供对内核内存的经过验证的访问。eBPF 允许内核运行 BPF 字节码。尽管使用的前端语言可能会有所不同,但它通常是 C 的受限子集。一般情况下,使用 Clang 将 C 代码编译为 BPF 字节码,然后验证这些字节码,确保可以安全运行。这些严格的验证确保了机器码不会有意或无意地破坏 Linux 内核,并且 BPF 探针每次被触发时,都只会执行有限的指令。这些保证使 eBPF 可以用于性能关键的工作负载,例如数据包过滤,网络监控等。
从功能上讲,eBPF 允许你在某些事件(例如定时器,网络事件或函数调用)触发时运行受限的 C 代码。当在函数调用上触发时,我们称这些函数为探针,它们既可以用于内核里的函数调用(kprobe) 也可以用于用户态程序中的函数调用(uprobe)。
什么是程序跟踪?
tracing 是一种允许开发人员在程序执行期间看到在做什么的技术,与典型的调试技术相比,这种方法不需要与用户进行直接交互。最著名的跟踪工具之一是 ,它允许开发人员查看哪些系统在执行过程中调用了他们的程序。
虽然前面提到的 strace 工具对于深入了解系统调用很有用,但 Delve trace 命令可以让你更深入了解 Go 程序中的 “用户空间” 发生了什么。这种 Delve 跟踪技术允许你跟踪程序中的任意函数,以便查看这些函数的输入和输出。此外,你还可以使用这个工具深入了解你的程序的控制流程,而无需交互式调试会话的开销,因为它还会显示正在执行该函数的 goroutine。对于高并发的程序来说,这可能是一种无需启动完整的交互式调试会话即可深入了解程序执行情况的更快方法。
当然首先我们需要安装 dlv,直接使用下面的命令即可:
安装完成后可以查看 子命令的帮助信息:
如何使用 Delve 跟踪 Go 程序
Delve 允许你通过调用 子命令来跟踪你的 Go 程序,该子命令接受一个正则表达式并执行你的程序,在每个与正则表达式匹配的函数上设置跟踪点并实时显示结果。下面的程序是一个例子:
我们可以使用 命令来跟踪该程序,正常会输出如下所示内容:
我们在 后面提供的正则表达式是 ,它和我们 go 程序中的同名函数 想匹配,以 为前缀的输出表示被调用的函数并显示该函数的参数,而以 为前缀的输出表示函数的返回值以及与其关联的返回值,所有输入和输出行都以当时执行的 为前缀。
默认情况下, 命令使用的是基于 的后端,我们可以通过添加 标志来启用基于 eBPF(实验特性)的后端。同样使用前面的示例,我们可以使用下面的命令 ali 启用基于 eBPF 的后端:
当然需要注意的是需要在 Linux 系统上才能支持 eBPF 的后端,否则会出现下面的错误提示:
当然还需要你的系统内核版本要支持才可以,需要 4.x+ 版本的内核系统,否则也会出现类似如下所示的错误提示:
这里我们使用一个 Ubuntu 系统来进行测试,该系统版本内容为 5.x,符合要求。
现在我们重新执行如下所示的调试命令:
同样我们将收到类似 的输出结果,但是,背后的实现原理却完全不同而且更加高效。
ptrace 的低效率
默认情况下,Delve 会使用 系统调用来实现跟踪功能, 是一个系统调用,允许程序观察和操作同一台机器上的其他程序。实际上,在 Unix 系统上,Delve 使用这个 ptrace 功能来实现调试器提供的许多低级功能,例如读写内存、控制执行等。
虽然 ptrace 是一种有用且功能强大的机制,但它也存在固有的低效率问题。首先,ptrace 是一个系统调用,意味着我们必须跨越用户空间/内核空间边界,这增加了每次使用函数时的开销,调用 ptrace 的次数越多,开销就越大。以前面的应用为例,以下是使用 ptrace 实现跟踪的大致步骤:
使用 启动程序并附加调试器。
使用 在匹配所提供的正则表达式的每个函数处设置断点,并在被跟踪的进程的可执行内存中插入断点指令。
另外,在该函数的每个返回指令处设置断点。
再次使用 继续程序。
此步骤可能涉及多次 调用,因为我们需要读取函数入口的 CPU 寄存器、堆栈上的内存以及如果必须取消指针引用的堆上的内存。
再次使用 继续程序。
在函数返回时遇到断点,通过读取变量,可能涉及到更多的 调用,以读取寄存器和内存。
再次使用 继续程序。
直到程序结束。
显然,函数的参数和返回值越多,每次停止就越昂贵。所有调试器花费在进行 系统调用的时间,我们跟踪的程序都处于暂停状态,没有执行任何指令。从用户的角度来看,这使得程序的运行速度比原本要慢得多。对于开发和调试来说,这也许不是什么大问题,但是时间是宝贵的,我们应该尽量快速地完成事情。程序在跟踪过程中的运行速度越快,你就能越快找到问题的根本原因。
eBPF 为何比 ptrace 更快
一个最大的速度和效率改进是避免大量的系统调用开销,这是 eBPF[2] 发挥作用的地方,因为我们可以在函数入口和出口设置 ,并将小型的 eBPF 程序附加到它们上。Delve 使用 Cilium eBPF[3] 的 Go 库加载与 eBPF 程序进行交互。
每次触发 probe 时,内核将调用我们的 eBPF 程序,然后在它完成后继续主程序。我们编写的 eBPF 程序将处理函数入口和出口中列出的所有步骤,但不会有所有的系统调用上下文切换,因为程序直接在内核空间中执行。我们的 eBPF 程序可以通过 eBPF 环形缓冲区和映射数据结构与用户空间中的调试器通信,使 Delve 能够收集所需的所有信息。
这种方法的优点是,我们正在跟踪的程序需要暂停的时间大大减少。在触发 probe 时运行我们的 eBPF 程序比在函数入口和出口处调用多个系统调用要快得多。
使用 eBPF 调试与跟踪步骤
这里再概括一遍使用 eBPF 跟踪调试的流程:
启动程序并使用 附加到进程上。
在内核中加载所有需要跟踪的函数的 。
使用 继续执行程序。
在函数入口和出口触发 ,每当 probe 被触发,内核部分将运行我们的 eBPF 程序,该程序获取函数的参数或返回值,并将其发送回用户空间。在用户空间中,从 eBPF 环形缓冲区读取函数参数和返回值。
重复此过程直到程序结束。
通过使用这种方法,Delve 可以比使用默认的 ptrace 实现更快地跟踪程序。现在,你可能会问,为什么不将这种方法默认使用?事实上,未来很有可能会成为默认方法。但目前仍在进行开发,以改进这种基于 eBPF 的后端并确保它与基于 ptrace 的后端平衡性。当然我们仍然可以在执行 时使用 标志来使用它。
为了给出一个使用不同跟踪方法的程序的效率差异的大致数字,下面是我测试的另外一个程序的运行情况,如下所示:
数据本身就是最好的证明!
为什么不使用 uretprobe
如果你熟悉 eBPF、uprobes/uretprobes,你可能会问为什么我们的一切都使用 uprobes,而不是使用 uretprobes 来捕获返回参数呢。关于此的解释相当复杂,简单来说就是 Go 运行时在执行 Go 程序过程中需要多次检查调用堆栈,当 uretprobes 附加到函数时,它们将该函数的返回地址覆盖在堆栈上。当 Go 运行时检查堆栈时,它会找到该函数的意外返回地址,最终会导致程序致命退出。为了解决这个问题,我们只需使用 uprobes,并利用 Delve 的能力检查程序的机器指令来在每个函数的返回指令处设置探测器。
Delve 使用 eBPF 更快调试 Go 代码
Delve 的总体目标是帮助开发人员尽快地找到 Go 代码中的错误。为此,我们利用最新的方法和技术,并试图推动调试器可以完成的范围。Delve 在内部利用 eBPF 来最大化效率和速度。用户空间跟踪是任何工程师工具箱中的重要工具,我们的目标是使其高效易用。
参考资料
[1]
Delve: https://github.com/go-delve/delve
[2]
eBPF: https://ebpf.io/
[3]
Cilium eBPF: https://github.com/cilium/ebpf
领取专属 10元无门槛券
私享最新 技术干货