Go语言内敛机制

966 阅读6分钟

在写单元测试时候遇到了一个函数内敛导致无法执行单元测试的情况,之前一直没有好好研究,趁着这次项目对于单元测试的强行要求,在加强写单元测试的过程中,仔细的研究了一下Go语言的内敛机制,总结如下。

内敛是啥

简单来说,就是调用一个特别简单的函数,会直接将调用函数的内容展示在调用处,避免函数调用的消耗。

为嘛要内敛

函数调用不是免费的,存在三个步骤。 创建一个新的堆栈框(stack frame)并把调用者的详细信息记录下来。把任何会被被调用函数用到的寄存器内容保存到堆栈。计算被调用函数的地址,并执行跳转指令到那个新的地址。

内敛有两个原因,第一个是它消除了函数调用本身的开销;第二个是它使得编译器能更高效地执行其他的优化策略。

函数调用的开销 在任何语言中,调用一个函数都会有消耗。把参数编组进寄存器或放入栈中(取决于 ABI),在返回结果时倒序取出时会有开销。引入一次函数调用会导致程序计数器从指令流的一点跳到另一点,这可能导致管道阻塞。函数内部通常有前置处理,需要为函数执行准备新的栈帧,还有与前置相似的后续处理,需要在返回给调用方之前释放栈帧空间。

在 Go 中函数调用会消耗额外的资源来支持栈的动态增长。在进入函数时,goroutine 可用的栈空间与函数需要的空间大小相等。如果可用空间不同,前置处理就会跳到把数据复制到一块新的、更大的空间的运行时逻辑,而这会导致栈空间变大。当这个复制完成后,运行时跳回到原来的函数入口,再执行栈空间检查,函数调用继续执行。这种方式下,goroutine 开始时可以申请很小的栈空间,在有需要时再申请更大的空间。

这个检查消耗很小 — 只有几个指令 — 而且由于 Goroutine 是成几何级数增长的,因此这个检查很少失败。这样,现代处理器的分支预测单元会通过假定检查肯定会成功来隐藏栈空间检查的消耗。当处理器预测错了栈空间检查,必须要抛弃它推测性执行的操作时,与为了增加 Goroutine 的栈空间运行时所需的操作消耗的资源相比,管道阻塞的代价更小。

虽然现代处理器可以用预测性执行技术优化每次函数调用中的泛型和 Go 特定的元素的开销,但那些开销不能被完全消除,因此在每次函数调用执行必要的工作过程中都会有性能消耗。一次函数调用本身的开销是固定的,与更大的函数相比,调用小函数的代价更大,因为在每次调用过程中它们做的有用的工作更少。

消除这些开销的方法必须是要消除函数调用本身,Go 的编译器就是这么做的,在某些条件下通过用函数的内容来替换函数调用来实现。这个过程被称为内联,因为它在函数调用处把函数体展开了。

改进的优化机会 Cliff Click 博士把内联描述为现代编译器做的优化措施,像常量传播和死码消除一样,都是编译器的基本优化方法。实际上,内联可以让编译器看得更深,使编译器可以观察调用的特定函数的上下文内容,可以看到能继续简化或彻底消除的逻辑。由于可以递归地执行内联,因此不仅可以在每个独立的函数上下文处进行这种优化,也可以在整个函数调用链中进行。

内敛的一个弊端,在使用内联函数时,会导致生成的可执行文件变大,所以需要考虑内联的代码量、调用次数、维护内联关系的开销。

如何禁用内敛

一般在写单元测试的时候可能需要禁用内敛,对于单个方法可以使用//go:noinline,注意//go之间没有间隔。在全局使用时可以通过增加Option的方式,即-gcflags=-l的方式。另外,-gcflags="-l -l"则会打开内联,同时启用更激进的内联策略。

如何查看编译器的优化?

编译器会自动帮我们决定是否内敛,我们可以通过go build -gcflags="-m -m" ./...当前服务整个的内敛情况。

看看下面这段代码,

package main

func add(a, b int) int {
	return a + b
}

func iter(num int) int {
	res := 1
	for i := 1; i <= num; i++ {
		res = add(res, i)
	}
	return res
}

func main() {
	n := 100
	_ = iter(n)
}

执行go build -gcflags="-m -m" main.go会打印出如下信息,其中的cost是节点数,下面会提及。

./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
./main.go:7:6: cannot inline iter: unhandled op FOR
./main.go:10:12: inlining call to add func(int, int) int { return a + b }
./main.go:15:6: can inline main with cost 67 as: func() { n := 100; _ = iter(n) }

哪些函数不会被内敛?

函数中包含:闭包调用,defergoselectfor等情况,函数就不会被内敛。除了这些问题,还需要判断在解析AST时,Go中申请的节点个数,如果节点数超过80,函数调用就不会被内敛。

每个节点都会消耗一个预算。比如,a = a + 1这行代码包含了5个节点:AS, NAME, ADD, NAME, LITERAL。以下是对应的SSA

src/cmd/compile/internal/gc/inl.go文件中可以查看当前无法内敛的关键字操作。

case OCLOSURE,
        OCALLPART,
        ORANGE,
        OFOR,
        OFORUNTIL,
        OSELECT,
        OTYPESW,
        OGO,
        ODEFER,
        ODCLTYPE, // can't print yet
        OBREAK,
        ORETJMP:
        v.reason = "unhandled op " + n.Op.String()
        return true

代码panic之后还会打印堆栈吗?

举个例子,

package main

func sub(a, b int) {
    a = a - b
    panic("i am a panic information")
}

func max(a, b int) int {
    if a < b {
        sub(a, b)
    }
    return a
}

func main() {
    x, y := 1, 2
    _ = max(x, y)
}

执行代码输出结果,依然能够打印出具体的错误点。

panic: i am a panic information

goroutine 1 [running]:
main.sub(...)
        /Users/slp/go/src/workspace/example/main.go:5
main.max(...)
        /Users/slp/go/src/workspace/example/main.go:10
main.main()
        /Users/slp/go/src/workspace/example/main.go:17 +0x3a

由于Go内部会为每个存在内联优化的goroutine维持一个内联树(inlining tree),该树可通过go build -gcflags="-d pctab=pctoinline" main.go命令查看。

funcpctab "".sub [valfunc=pctoinline]
...
wrote 3 bytes to 0xc000082668
 00 42 00
funcpctab "".max [valfunc=pctoinline]
...
wrote 7 bytes to 0xc000082f68
 00 3c 02 1d 01 09 00
-- inlining tree for "".max:
0 | -1 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=59
--
funcpctab "".main [valfunc=pctoinline]
...
wrote 11 bytes to 0xc0004807e8
 00 1d 02 01 01 07 04 16 03 0c 00
-- inlining tree for "".main:
0 | -1 | "".max (/Users/slp/go/src/workspace/example/main.go:17:9) pc=30
1 | 0 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=29
--

参考文章