在Go语言中,interface{} 是一种强大的抽象机制,但将具体类型赋值给 interface{} (称为Boxing)会带来一定的性能开销。本文将深入分析 interface boxing 的原理、性能影响及优化实践。
将具体类型的值赋值给interface{}的过程称为Boxing。在这个过程中:
将整型 1 赋值给 interface{} 变量 demo 中
func main() {
var demo interface{}
demo = 1 // 发生boxing,整型1逃逸到堆
fmt.Println(demo)
}
1
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
1
2
3
4
5
6
我们在对比用具体的类型来赋值 1,可以看到是没有逃逸的,但是发现 demo 发生了逃逸到堆,这是为什么呢?
func main() {
var demo int
demo = 1
fmt.Println(demo)
}
1
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
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...)
}
1
2
3
4
5
将一个结构体赋值给 inteface{}
type Person struct {
Name string
}
func main() {
var demo interface{}
demo = Person{}
fmt.Println(demo)
}
1
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
{}
1
2
3
4
5
6
再来看直接将指针赋值给 interface{}
type Person struct {
Name string
}
func main() {
var demo interface{}
demo = &Person{}
fmt.Println(demo)
}
1
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
&{}
1
2
3
4
5
6
那么问题来了,这两种方式都会在堆上申请内存,那么两种方式是不是没有区别呢?
func BenchmarkBoxedNotInterface(b *testing.B) {
jobs := make([]int, 0, 1000)
for range b.N {
jobs = jobs[:0]
for j := 0; j < 1000; j++ {
jobs = append(jobs, j)
}
}
}
func BenchmarkBoxedWithInterface(b *testing.B) {
jobs := make([]interface{}, 0, 1000)
for range b.N {
jobs = jobs[:0]
for j := 0; j < 1000; j++ {
jobs = append(jobs, j)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
BenchmarkBoxedNotInterface 完全不需要申请堆内存,并且比 BenchmarkBoxedWithInterface 要快20多倍。
$ go test -bench=. -benchmem
goos: darwin
goarch: arm64
pkg: code/interface_demo
cpu: Apple M4 Pro
BenchmarkBoxedNotInterface-12 4832395 249.5 ns/op 0 B/op 0 allocs/op
BenchmarkBoxedWithInterface-12 232382 4596 ns/op 5952 B/op 744 allocs/op
PASS
ok code/interface_demo 9.662s
1
2
3
4
5
6
7
8
9
我们来对上面两种方式来进行 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)
}
}
}
1
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
1
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)
}
}
1
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
1
2
3
4
5
6
7
8
9
type Storage interface {
Save([]byte) error
}
func Process(s Storage) { /* ... */ }
1
2
3
4
var i interface{}
i = 123 // safe and cheap
1
2
可以看到fmt.Println 中的实现是用接口作为接收参数
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
1
2
3
这里即使有Interface Boxing,但只是短暂的一次,成本也低。
fmt.Println("value:", someStruct) // implicit boxing is fine
1
在Go语言中,interface{} 是一种强大的抽象机制,但将具体类型赋值给 interface{} (称为Boxing)会带来一定的性能开销。本文将深入分析 interface boxing 的原理、性能影响及优化实践。
将具体类型的值赋值给interface{}的过程称为Boxing。在这个过程中:
将整型 1 赋值给 interface{} 变量 demo 中
func main() {
var demo interface{}
demo = 1 // 发生boxing,整型1逃逸到堆
fmt.Println(demo)
}
1
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
1
2
3
4
5
6
我们在对比用具体的类型来赋值 1,可以看到是没有逃逸的,但是发现 demo 发生了逃逸到堆,这是为什么呢?
func main() {
var demo int
demo = 1
fmt.Println(demo)
}
1
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
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...)
}
1
2
3
4
5
将一个结构体赋值给 inteface{}
type Person struct {
Name string
}
func main() {
var demo interface{}
demo = Person{}
fmt.Println(demo)
}
1
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
{}
1
2
3
4
5
6
再来看直接将指针赋值给 interface{}
type Person struct {
Name string
}
func main() {
var demo interface{}
demo = &Person{}
fmt.Println(demo)
}
1
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
&{}
1
2
3
4
5
6
那么问题来了,这两种方式都会在堆上申请内存,那么两种方式是不是没有区别呢?
func BenchmarkBoxedNotInterface(b *testing.B) {
jobs := make([]int, 0, 1000)
for range b.N {
jobs = jobs[:0]
for j := 0; j < 1000; j++ {
jobs = append(jobs, j)
}
}
}
func BenchmarkBoxedWithInterface(b *testing.B) {
jobs := make([]interface{}, 0, 1000)
for range b.N {
jobs = jobs[:0]
for j := 0; j < 1000; j++ {
jobs = append(jobs, j)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
BenchmarkBoxedNotInterface 完全不需要申请堆内存,并且比 BenchmarkBoxedWithInterface 要快20多倍。
$ go test -bench=. -benchmem
goos: darwin
goarch: arm64
pkg: code/interface_demo
cpu: Apple M4 Pro
BenchmarkBoxedNotInterface-12 4832395 249.5 ns/op 0 B/op 0 allocs/op
BenchmarkBoxedWithInterface-12 232382 4596 ns/op 5952 B/op 744 allocs/op
PASS
ok code/interface_demo 9.662s
1
2
3
4
5
6
7
8
9
我们来对上面两种方式来进行 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)
}
}
}
1
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
1
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)
}
}
1
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
1
2
3
4
5
6
7
8
9
type Storage interface {
Save([]byte) error
}
func Process(s Storage) { /* ... */ }
1
2
3
4
var i interface{}
i = 123 // safe and cheap
1
2
可以看到fmt.Println 中的实现是用接口作为接收参数
func Println(a ...any) (n int, err error) {
return Fprintln(os.Stdout, a...)
}
1
2
3
这里即使有Interface Boxing,但只是短暂的一次,成本也低。
fmt.Println("value:", someStruct) // implicit boxing is fine
1