内联的策略和非内联性能对比
1 简介
语言的内联(inlining)机制在近年来逐渐演进,变得更智能、更复杂,在性能敏感场景中变得重要。
图为 fractal一阶微分可视化
如“叶函数内联”,“中间堆栈内联”, "启发式"内联都涉及 Go 的内联策略,但这些术语有点“半官方”,主要来源于对 Go 编译器源码(特别是 SSA 阶段)或社区讨论解读的约定俗成。
2、Go语言中内联机制和策略
直到 Go 1.14,垃圾回收器还使用堆栈检查前导码来中断,方法是将所有活动的协程堆栈设置为零,
迫使它们在下次进行函数调用时被困在运行时中。
在 Go 中函数方法只是一个具有预定义形式参数的函数,即接收者。
假设该方法不是通过接口调用的,调用 free 函数与调用方法的相对成本是相同的。
这个系统策略后面被一种机制所取代,该机制允许运行时暂停协程而无需等待它进行函数调用。
内联策略:
- 叶函数内联(Leaf Inlining)
定义:
指函数本身不调用其他函数(或只调用少量、可内联的函数),这类函数是内联的主要目标。
特点:
简单、短小、调用开销可直接消除。
编译器优先考虑这种函数做内联。
- 非叶函数 / 中间堆栈内联(Mid-stack Inlining)
定义:
以前只有“叶函数”可以内联;但从 Go 1.12 开始,
引入了中间堆栈内联(mid-stack inlining),允许在非叶函数(即还有函数调用)中也进行内联。
关键意义:
允许在调用链中某些函数也内联,只要不会造成深度或复杂性爆炸。更大胆地打破“只能最末端内联”的限制。
限制条件:
内联预算不能爆表(复杂度预算、函数体大小等)
有递归、defer、recover、闭包等特性时可能禁止内联
示例:
func mid(a int) int {
return add(a, 1) // add 是叶函数
}
func wrapper(a int) int {
return mid(a) * 2
}
在 mid-stack inlining 启用时,add 和 mid 都有可能被内联进 wrapper 中。
- 可内联判定标准(启发式)
Go 编译器会根据一套“启发式规则”来判断是否内联,主要参考:
函数体是否过大(指令条数、AST节点数)
是否含有复杂语义(defer、recover、闭包、循环递归)
是否是泛型(Go 1.18+,对泛型函数内联支持不断增强)
是否是导出函数(更谨慎)
目标平台特性(有时跟具体架构有关)
可以用这个命令查看具体哪些函数被内联:
go build -gcflags="-m" main.go
输出示例(来自 -gcflags="-m"):
main.go:5:6: can inline Add
main.go:10:7: inlining call to Add
说明:
can inline Add:编译器认为可以内联
inlining call to Add:实际做了内联
将调用堆栈底部的函数内联到其直接调用者的行为。
内联是一个递归过程,一旦函数被内联到其调用者中,编译器就可以将结果代码内联到其调用者中,
中间堆栈内联: 在调用堆栈中间内联函数时的另一种内联策略。
可以通过比较 with 和 without the annotation 的输出来自己检查这一点。
go test -bench=. -gcflags=-S//go:noinline
可以自己用 flag 来检查。-gcflags=-d=ssa/prove/debug=on
- Go 协程(goroutine)与内联函数的关系
每个 goroutine 有独立栈(初始约2KB,按需扩展),函数调用会在栈上分配帧。
- 内联影响:
内联可以减少栈帧的创建:内联后代码嵌入调用者上下文中,因此不需要单独的栈帧。
节省栈空间 & 减少内存拷贝:尤其在高并发场景下(大量 goroutine),内联可以明显降低栈压力。
3 内联 vs 非内联Benchmark 示例对比性能
- 自动内联
Go 编译器会根据一些启发式规则决定是否内联(主要是函数体积、调用频率、是否递归等)。
-
强制禁止内联
3
使用编译指令://go:noinline func myFunc() { ... }
可以防止函数被内联(用于调试或 Benchmark)。
- 查看哪些函数被内联?
使用编译器参数查看:
go build -gcflags="-m" main.go
会输出:
./main.go:10:6: can inline myFunc
./main.go:20:7: inlining call to myFunc
是否内联的性能对比,函数非内联可以添加
//go:noinline
注释来实现,从下一小节的Benchmark可以看出来内联可以带来性能提升。go源码里有很多//go:noinline注释
-
一个简单例子:
package inlinebench import "testing" func inlineAdd(a, b int) int { return a + b } //go:noinline func noinlineAdd(a, b int) int { return a + b } func BenchmarkInline(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = inlineAdd(1, 2) } _ = r } func BenchmarkNoInline(b *testing.B) { var r int for i := 0; i < b.N; i++ { r = noinlineAdd(1, 2) } _ = r }
运行 Benchmark:
go test -bench=. -benchmem
示例输出(不同机器略有不同):
BenchmarkInline-8 1000000000 0.29 ns/op 0 B/op 0 allocs/op
BenchmarkNoInline-8 250000000 4.92 ns/op 0 B/op 0 allocs/op
图形展示:
分析:
内联函数 inlineAdd:几乎免费,指令级优化后近似为一个加法操作。
非内联 noinlineAdd:仍需调用,开销在 4-5ns。
4 小结
可以使用指令来防止编译器内联 。如果我希望隔离内联的影响,比如常见的是使用注释。//go:noinline ,而不是使用 maxmax-gcflags=’-l -N’ 全局禁用优化。
从 Go 1.20 开始, 编译器开始对中间堆栈调用更积极地做 mid-stack inlining。 泛型函数逐步开始支持内联。内联 Budget 更智能,采用 SSA pass 分析。
内联策略的对照
策略类型 特点 内联目标 是否启用默认
叶内联 只内联叶函数 简单函数 ✅ 启用
中间堆栈内联 支持调用链中非末端函数的内联 函数链优化 ✅ 启用(Go 1.12+)
控制注释 //go:noinline 显式禁止 🚫 内联
内联阈值控制 基于复杂度、大小 启发式 自动评估
- 点赞
- 收藏
- 关注作者
评论(0)