Go语言Interface Boxing原理与性能优化指南
# 前言
在Go语言中,interface{} 是一种强大的抽象机制,但将具体类型赋值给 interface{} (称为Boxing)会带来一定的性能开销。本文将深入分析 interface boxing 的原理、性能影响及优化实践。
# Interface Boxing原理
将具体类型的值赋值给interface{}的过程称为Boxing。在这个过程中:
- 值会在堆上分配新内存并拷贝
- 将指针及对应类型赋值给interface{}变量
- 这会带来额外性能开销并增加GC压力
# 基础示例分析
# 值类型赋值
将整型 1 赋值给 interface{} 变量 demo 中
func main() {
var demo interface{}
demo = 1 // 发生boxing,整型1逃逸到堆
fmt.Println(demo)
}
2
3
4
5
运行时添加 -gcflags="-m"
参数,查看逃逸分析的结果。可以看到 1 逃逸到堆中了。
$ go run -gcflags="-m" main.go
# command-line-arguments
./main.go:12:13: inlining call to fmt.Println
./main.go:11:9: 1 escapes to heap
./main.go:12:13: ... argument does not escape
1
2
3
4
5
6
# 对比具体类型
我们在对比用具体的类型来赋值 1,可以看到是没有逃逸的,但是发现 demo 发生了逃逸到堆,这是为什么呢?
func main() {
var demo int
demo = 1
fmt.Println(demo)
}
2
3
4
5
6
% go run -gcflags="-m" main.go
# command-line-arguments
./main.go:12:13: inlining call to fmt.Println
./main.go:12:13: ... argument does not escape
./main.go:12:14: demo escapes to heap
1
2
3
4
5
6
查看 fmt.Println 的源码实现可以发现,其参数是 interface{} 类型,将 demo 传参给 Println 时,也会发生 Boxing 过程,也会发生在堆中申请新内存以及复制的过程,所以会发生逃逸。
type any = interface{}
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
2
3
4
5
# 结构体示例
# 值类型结构体
将一个结构体赋值给 inteface{}
type Person struct {
Name string
}
func main() {
var demo interface{}
demo = Person{}
fmt.Println(demo)
}
2
3
4
5
6
7
8
9
可以看到 Person{} 是有发生逃逸的,这里就是发生了 Boxing
$ go run -gcflags="-m" main.go
# command-line-arguments
./main.go:12:13: inlining call to fmt.Println
./main.go:11:15: Person{} escapes to heap
./main.go:12:13: ... argument does not escape
{}
2
3
4
5
6
# 指针类型结构体
再来看直接将指针赋值给 interface{}
type Person struct {
Name string
}
func main() {
var demo interface{}
demo = &Person{}
fmt.Println(demo)
}
2
3
4
5
6
7
8
9
可以看到这里依然发生了堆逃逸,这是因为 &Person{} 取地址操作本身就是在堆上申请内存的,然后将地址赋值给 interface{} 的变量,这里是没有发生 boxing 的。
go run -gcflags="-m" main.go
# command-line-arguments
./main.go:12:13: inlining call to fmt.Println
./main.go:11:9: &Person{} escapes to heap
./main.go:12:13: ... argument does not escape
&{}
2
3
4
5
6
那么问题来了,这两种方式都会在堆上申请内存,那么两种方式是不是没有区别呢?
# 性能基准测试
# 切片操作性能
我们来对上面两种方式来进行 Benchmark 看看两者性能对比。
type Worker interface {
Work()
}
type LargeJob struct {
payload [4096]byte
}
func (LargeJob) Work() {}
func BenchmarkBoxedLargeSlice(b *testing.B) {
jobs := make([]Worker, 0, 1000)
for range b.N {
jobs = jobs[:0]
for j := 0; j < 1000; j++ {
var job LargeJob
jobs = append(jobs, job)
}
}
}
func BenchmarkPointerLargeSlice(b *testing.B) {
jobs := make([]Worker, 0, 1000)
for range b.N {
jobs := jobs[:0]
for j := 0; j < 1000; j++ {
job := &LargeJob{}
jobs = append(jobs, job)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
运行结果如下,可以看到两者内存申请是差不多的,但是效率上使用指针的要快上 15%,
$ go test -bench=. -benchmem .
goos: darwin
goarch: arm64
pkg: main/demo
cpu: Apple M4 Pro
BenchmarkBoxedLargeSlice-12 2935 406307 ns/op 4096014 B/op 1000 allocs/op
BenchmarkPointerLargeSlice-12 3434 342263 ns/op 4096010 B/op 1000 allocs/op
PASS
ok main/demo 3.589s
2
3
4
5
6
7
8
9
# 函数调用性能
var sink Worker
func call(w Worker) {
sink = w
}
func BenchmarkCallWithValue(b *testing.B) {
for range b.N {
var j LargeJob
call(j)
}
}
func BenchmarkCallWithPointer(b *testing.B) {
for range b.N {
j := &LargeJob{}
call(j)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
运行结果如下,可以看到两者内存申请差不多,但指针传递效率要更高。
% go test -bench=. -benchmem .
goos: darwin
goarch: arm64
pkg: main/demo
cpu: Apple M4 Pro
BenchmarkCallWithValue-12 2959195 388.3 ns/op 4096 B/op 1 allocs/op
BenchmarkCallWithPointer-12 3513249 339.7 ns/op 4096 B/op 1 allocs/op
PASS
ok main/demo 3.419s
2
3
4
5
6
7
8
9
# 什么时候允许Interface Boxing?
- 接口支持解耦和模块化,所以合理的使用接口来设计 API,即使有 Interface Boxing 的成本也是值得花费的
type Storage interface {
Save([]byte) error
}
func Process(s Storage) { /* ... */ }
2
3
4
- 如果值很小,成本也是忽略不计的
var i interface{}
i = 123 // safe and cheap
2
- 如果只是短暂的使用,开销也是小的
可以看到fmt.Println 中的实现是用接口作为接收参数
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
2
3
这里即使有Interface Boxing,但只是短暂的一次,成本也低。
fmt.Println("value:", someStruct) // implicit boxing is fine
# 最佳实践
- 传递给接口时使用指针。可以避免内存的重复复制与申请。
- 如果设计 API 时,类型已经确定并且是稳定的,尽可能避免使用 interface 。
- 尽可能使用特定类型的容器。