译文 Go 的栈追踪

cuua for 翻译小组 · 2021年09月26日 · 382 次阅读
本帖已被设为精华帖!

Go 的栈追踪

栈跟踪在 Go 分析中起着关键作用。因此,让我们试着了解它,看看它如何影响我们程序分析的准确性。

介绍

所有 Go 分析器都通过收集堆栈跟踪的样本并将其放入pprof 配置文件 中来工作。让我们忽略一些细节,pprof 配置文件只是堆栈跟踪的频率表,如下所示:

stack trace count
main;foo 5
main;foo;bar 3
main;foobar 4

让我们放大上表中的第一个栈跟踪:main.fo。Go 开发者通常更熟悉看到如下所示的panic()runtime.stack() 的堆栈跟踪

goroutine 1 [running]:
main.foo(...)
    /path/to/go-profiler-notes/examples/stack-trace/main.go:9
main.main()
    /path/to/go-profiler-notes/examples/stack-trace/main.go:5 +0x3a

此文本格式已在在其他地方描述过 所以我们这里不讨论细节。相反,我们将深入探讨这些数据的来源。

Goroutine 栈

顾名思义,栈跟踪源自 “栈”。尽管细节各不相同,但大多数编程语言都有栈的概念,并使用它来存储局部变量、参数、返回值和返回地址等内容。生成堆栈跟踪通常涉及到在一个被称为 Unwinding 的过程中浏览堆栈,这将在后面详细介绍。

像 “x86-64” 这样的平台定义了栈布局呼叫约定 并鼓励其他编程语言采用它来实现互操作性。Go 不遵循这些惯例,而是使用自己独特的 呼叫惯例. Go(1.17?)的未来版本将采用更传统的 基于注册 约定来提高性能。然而,即使是新的约定也不会与平台兼容,因为这会对 goroutine 的可伸缩性产生负面影响。

Go 的栈布局在不同平台上略有不同。为了使事情易于管理,我们将假定在本文档的其余部分使用 “x86-64”。

栈布局

现在让我们仔细看看栈。每个 goroutine 都有自己的栈,至少为2 KiB 并从高内存地址向低内存地址增长。这可能有点让人困惑,这主要是一种历史惯例,因为当时地址空间非常有限,人们不得不担心栈与程序使用的其他内存区域发生碰撞。

下图显示了当前调用main.foo()的示例 goroutine 的堆栈,如上面的示例所示:

这张图片中有很多内容,但现在让我们关注以红色突出显示的内容。要获得栈追踪,我们首先需要的是当前程序计数器(pc)。这可以在一个名为 “rip”(指令指针寄存器)的 CPU 寄存器中找到,并指向另一个内存区域,该区域保存程序的可执行机器代码。由于我们当前正在调用main.foo(),'rip'指向该函数中的一条指令。如果您不熟悉寄存器,可以将其视为访问速度非常快的特殊 CPU 变量。其中一些命令,如'rip'、'rsp'或'rbp'有特殊用途,而其他命令则可以由编译器根据需要使用。

现在我们知道了当前函数的程序计数器,是时候查找调用者的'pc'值了,即所有也以红色突出显示的返回地址(pc)值。有各种各样的技术可以实现这一点,在 [Unwinding](#unwinding)一节中有描述。最终结果是一个程序计数器列表,这些计数器表示栈跟踪,就像您可以从runtime.Callers() 获取的栈跟踪一样. 最后但并非最不重要的一点是,这些 “pc” 值通常被翻译成人类可读的文件/行/函数名,如下面的 [符号化](# 符号化)部分所述。在 Go 本身中,您只需调用runtime.CallerFramers() 来符号化一个 pc 值的列表。

真实案例

查看好看的图片可以很好地获得对栈的高级理解,但也有其局限性。有时,为了全面理解,您需要查看原始位和字节。如果你对此不感兴趣,请跳到下一节。

要查看栈,我们将使用delve这是一个很好的 go 调试器。为了检查栈,我编写了一个名为 stackannotate.star 的脚本,可用于打印一个简单的example 程序 的带注释栈:

$ dlv debug ./examples/stackannotate/main.go 
Type 'help' for list of commands.
(dlv) source delve/stackannotate.star
(dlv) continue examples/stackannotate/main.go:19
Breakpoint 1 set at 0x1067d94 for main.bar() ./examples/stackannotate/main.go:19
> main.bar() ./examples/stackannotate/main.go:19 (hits goroutine(1):1 total:1) (PC: 0x1067d94)
    14: }
    15: 
    16: func bar(a int, b int) int {
    17:     s := 3
    18:     for i := 0; i < 100; i++ {
=>  19:         s += a * b
    20:     }
    21:     return s
    22: }
(dlv) stackannotate
regs    addr        offset  value               explanation                     
        c00004c7e8       0                   0  ?                               
        c00004c7e0      -8                   0  ?                               
        c00004c7e8     -16                   0  ?                               
        c00004c7e0     -24                   0  ?                               
        c00004c7d8     -32             1064ac1  return addr to runtime.goexit   
        c00004c7d0     -40                   0  frame pointer for runtime.main  
        c00004c7c8     -48             1082a28  ?                               
        c00004c7c0     -56          c00004c7ae  ?                               
        c00004c7b8     -64          c000000180  var g *runtime.g                
        c00004c7b0     -72                   0  ?                               
        c00004c7a8     -80     100000000000000  var needUnlock bool             
        c00004c7a0     -88                   0  ?                               
        c00004c798     -96          c00001c060  ?                               
        c00004c790    -104                   0  ?                               
        c00004c788    -112          c00001c060  ?                               
        c00004c780    -120             1035683  return addr to runtime.main     
        c00004c778    -128          c00004c7d0  frame pointer for main.main     
        c00004c770    -136          c00001c0b8  ?                               
        c00004c768    -144                   0  var i int                       
        c00004c760    -152                   0  var n int                       
        c00004c758    -160                   0  arg ~r1 int                     
        c00004c750    -168                   1  arg a int                       
        c00004c748    -176             1067c8c  return addr to main.main        
        c00004c740    -184          c00004c778  frame pointer for main.foo      
        c00004c738    -192          c00004c778  ?                               
        c00004c730    -200                   0  arg ~r2 int                     
        c00004c728    -208                   2  arg b int                       
        c00004c720    -216                   1  arg a int                       
        c00004c718    -224             1067d3d  return addr to main.foo         
bp -->  c00004c710    -232          c00004c740  frame pointer for main.bar      
        c00004c708    -240                   0  var i int                       
sp -->  c00004c700    -248                   3  var s int

脚本并不完美,栈上有一些地址暂时无法自动注释(欢迎贡献)。但是总的来说,你应该能够用它来检查你对前面介绍的抽象栈图的理解。

如果您想自己尝试一下,可以修改示例程序,将main.foo()作为 goroutine 生成,并观察其对栈的影响。

cgo

上面描述的 Go 的栈实现在与使用遵循平台调用约定(如 C)的语言编写的代码交互时,正在进行重要的权衡。Go 不能直接调用这些函数,而必须执行cgo 用于在 goroutine 堆栈和可以运行 C 代码的 OS 分配堆栈之间切换。这会带来一定的性能开销,而且在分析期间捕获栈跟踪也会带来复杂的问题,请参见runtime.SetCgoTraceback().

🚧 我将在未来尝试更详细地描述这一点。

Unwinding

展开(或栈遍历)是从栈收集所有返回地址的过程(请参见stack Layout)中的红色元素)。它们与当前的指令指针寄存器(rip)一起构成一个程序计数器(pc)值列表,可以通过 [符号化](# 符号化)将这些值转换为人类可读的栈跟踪。

Go 的运行时,包括内置探查器,专门使用 [gopclntab](#gopclntab)进行展开。但是,我们将首先描述 [Frame Pointer](#Frame Pointer)展开,因为它更容易理解,并且可能在将来得到支持. 在此之后,我们还将讨论 [DWARF](#DWARF),这是另一种展开 Go 栈的方法。

对于那些不熟悉它的人,下面是一个简单的图表,显示了我们将在这里讨论的典型 Go 二进制文件的相关部分。根据操作系统的不同,它们总是被包裹在 ELF、Mach-O 或 PE 容器格式中。

帧指针

帧指针展开是一个简单的过程,它遵循基本指针寄存器(rbp)指向栈上指向下一帧指针的第一个帧指针,依此类推。换句话说,它是沿着堆栈布局图形中的橙色线条前进。对于每个访问的帧指针,位于帧指针上方 8 个字节的返回地址(pc)将被沿途收集,就是这样:)

帧指针的主要缺点是,在正常程序执行期间,将其推到栈上会给每个函数调用增加一些性能开销。Go 作者在Go 1.7 发行说明 中评估,平均程序的平均执行开销为 2%. 另一个数据点是 Linux 内核,它的开销为5-10% ,例如 sqlite 和 pgbench。正因为如此,gcc之类的编译器提供了诸如fomit frame pointers之类的选项来省略它们以获得更好的性能。然而,这是一个魔鬼的交易:它会立即给您带来小的性能胜利,但它会降低您在将来调试和诊断性能问题的能力。因此,一般建议如下:

-始终使用帧指针编译。忽略帧指针是一种有害的编译器优化,会破坏调试器,遗憾的是,这通常是默认的。

-Brendan Gregg

在 Go 中,你甚至不需要这个建议。由于 64 位二进制文件默认启用 Go 1.7 帧指针,并且没有可用的-fomit-frame-pointers-footgun。这允许 Go 与第三方调试器和分析器(如 [Linux perf])兼容 (http://www.brendangregg.com/perf.html) 开箱即用。

如果您想看到一个简单的帧指针展开实现,可以查看这个玩具项目 它有一个轻量级的选项来替代runtime.Callers()。与下文所述的其他退绕方法相比,简单性本身就是明证。还应该清楚的是,帧指针展开具有O(N)时间复杂度,其中N是需要遍历的堆栈帧数。

尽管看起来很简单,但帧指针展开并不是灵丹妙药。帧指针由被调用方推送到栈,因此对于基于中断的评测,存在一种固有的条件,可能会导致您错过栈跟踪中当前函数的调用方。此外,单独展开帧指针无法识别内联函数调用。因此,至少gopclntabDWARF 的一些复杂性对于实现准确的退绕至关重要。

gopclntab

尽管帧指针在 64 位平台上可用,但 Go 并没有利用它们来展开(这可能会改变). 相反,Go 附带了它自己的特殊展开表,这些表嵌入在任何 Go 二进制文件的gopclntab部分中gopclntab代表 “go 程序计数器行表”,但这有点用词不当,因为它包含展开和符号化所需的各种表和元数据。

就展开而言,一般的想法是在gopclntab内部嵌入一个 “虚拟帧指针表”(称为 “pctab”),该表将程序计数器(pc)映射到栈指针(rsp)与其上方的 “返回地址(pc)” 之间的距离(也称为sp delta)。此表中的初始查找使用 “rip” 指令指针寄存器中的 “pc”,然后使用 “返回地址(pc)” 进行下一次查找,依此类推。这样,无论栈上是否有物理帧指针,都可以始终展开。

Russ Cox 最初在他的Go 1.2 运行时符号信息 中描述了一些涉及的数据结构文档,但它现在已经非常过时了,最好直接查看当前的实现。相关文件为runtime/traceback.goruntime/symtab.go ,那么让我们开始吧。

Go 的栈跟踪实现的核心是gentraceback() 从不同位置调用的函数。如果调用方是例如runtime.Callers()函数只需要进行展开,但是例如panic()需要文本输出,这也需要符号化。此外,代码必须处理链接寄存器架构 之间的差异比如 ARM,它的工作原理与 x86 略有不同。对于 Go 团队中的系统开发人员来说,这种展开、符号化、对不同体系结构的支持和定制数据结构的组合可能只是日常工作中的一部分,但这对我来说肯定很棘手,因此请注意我下面描述中的潜在不准确之处。

每个帧查找都从传递给findfunc() 的当前'pc'开始它查找包含'pc'的函数的元数据。历史上,这是使用'O(logn) 二进制搜索完成的,但现在 有一个类似哈希映射的索引findfuncbucket 结构,通常使用'O(1)`算法直接引导我们找到正确的条目。

_func 我们刚刚检索到的元数据包含一个进入 “pctab” 表的 “pcsp” 偏移量,该表将程序计数器映射到栈指针增量。要解码此信息,我们调用funcspdelta() 它对所有更改函数的'sp delta'的程序计数器进行线性搜索,直到找到最接近的('pc','sp delta')对。对于具有递归调用周期的栈,使用一个微小的程序计数器缓存来避免执行大量重复的工作。

现在我们有了栈指针 delta,我们几乎可以找到调用者的下一个返回地址(pc)值,并对其执行相同的查找,直到到达栈的 “底部”。但在此之前,我们需要检查当前的'pc'是否是一个或多个内联函数调用的一部分。这是通过检查当前_func_FUNCDATA_InlTree数据并对该表中的(pcinline index)对进行另一次线性搜索来完成的。以这种方式找到的任何内联调用都会将虚拟栈帧'pc'添加到列表中。然后我们继续使用本段开头提到的返回地址(pc)一词。

综上所述,在合理的假设下,gocplntab展开的有效时间复杂度与帧指针展开相同,即O(N)其中N是栈上的帧数,但具有更高的恒定开销。这是可以实验 验证的,但对于大多数应用程序,一个好的经验法则是假定展开栈跟踪的成本为~1µs。因此,如果您的目标是在生产中实现<1% 的 CPU 评测开销,那么您应该尝试将评测器配置为每个内核每秒跟踪的事件数不超过~10k。这是一个相当可观的数据量,但对于某些工具,如内置跟踪器 栈展开可能是一个重要的瓶颈。将来,这可以通过 Go core 添加 [对帧指针展开的支持] 来克服 (https://github.com/golang/go/issues/16638) 这可能会快50 倍 而不是当前的gopclntab实现。

最后但并非最不重要的是,值得注意的是,Go 附带了两个.gopclntab实现。除了我刚才描述的一个,在debug/gosym 中还有另一个链接器、go tool addr2line和其他人似乎使用的包。如果您愿意,您可以将它与debug/elf 或(debug/macho)结合使用,作为您自己的gopclntab 冒险 的起点,无论是好的还是坏的.

DWARF

DWARF 是一种被许多调试器理解的标准化调试格式(例如,delve )和分析器(例如 Linuxperf). 它支持gopclntab中的超集功能,包括展开和符号化,但因其非常复杂而闻名。众所周知,Linux 内核拒绝对内核栈跟踪采用 DWARF 展开:

-unwinders 的全部(也是唯一)目的是在出现 bug 时简化调试 […]。一个几百行长的 unwinders 对我来说根本不感兴趣。

-Linus Torvalds

这导致创造 ORC unwinders 它现在作为另一种 unwinders 在内核中可用。然而,ORCs 在 go 中不扮演任何角色,我们只需要在这里与 ELFs 和 DWARFs 作战。

Go 编译器总是为它生成的二进制文件发出 DWARF(v4)信息。该格式是标准化的,因此与gopclntab不同,外部工具可以依赖它。但是,DWARF 数据在很大程度上与gopclntab冗余,并对构建时间和二进制大小产生负面影响。因此,罗布·派克提议默认禁用它 ,但仍在讨论中。

gopclntab不同,DWARF 信息可以在构建时轻松地从二进制文件中剥离,如下所示:

go build -ldflags=-w <pkg>

就像 “fomit frame pointers” 一样,这有点像魔鬼交易,但有些人不相信 DWARF 和魔鬼之间的区别。因此,如果你愿意签署一份对同事的责任豁免书,你可以继续。说真的,如果 DWARF 符号能解决你的一个重要问题,我建议你只去掉它。一旦 DWARF 信息被剥离,您将无法使用 perf、delve 或其他工具来评测或调试生产中的应用程序。

符号化

符号化是将一个或多个程序计数器(pc)地址转换为人类可读的符号(如函数名、文件名和行号)的过程。例如,如果您有两个类似以下的'pc'值:

0x1064ac1
0x1035683

您可以使用符号化将它们转换为人类可读的栈跟踪,如下所示:

main.foo()
    /path/to/go-profiler-notes/examples/stack-trace/main.go:9
main.main()
    /path/to/go-profiler-notes/examples/stack-trace/main.go:5

在 Go 的运行时,符号化始终使用gopclntab部分中包含的符号信息。也可以通过runtime.CallerFramers() 访问此信息.

第三方探查器(如 Linux 性能)不能使用gopclntab,而必须依赖DWARF 进行符号化。

历史

为了支持第三方探测器,如perf Go 1.7(2016-08-15)版本开始在默认情况下为64 位二进制文件 启用帧指针

鸣谢

非常感谢迈克尔·普拉特审阅 本文档中gopclntab部分,并捕获我分析中的一些重大错误。

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
cuua 关闭了讨论 09月26日 02:45
astaxie 将本帖设为了精华贴 09月26日 03:17
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册