为什么 Golang 函数赋值会产生内存分配?

Pure White · · 717 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

缘起

这几天在重构某段代码后,做了一次性能测试,火焰图中发现了一个十分奇怪的runtime.newobject的调用,大致占用2%,而找遍了整段代码都没有发现有新建对象相关的逻辑。于是迫不得已,祭出了汇编大法,终于定位到了问题所在。这篇文章会使用一段最小可复现的代码来分享这个问题以及背后的原因。

Show me the code

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

import (
_ "unsafe"
)

type MyFunc func()

type myFuncImplStruct struct {
}

//go:noinline
func (m *myFuncImplStruct) myFunc() {
return
}

//go:noinline
func (m myFuncImplStruct) myFunc2() {
return
}

//go:noinline
func myFunc() {
return
}

type myFuncContainer struct {
f MyFunc
}

//go:noinline
func newFuncContainer(f MyFunc) *myFuncContainer {
n := &myFuncContainer{}
n.f = f
return n
}

func main() {
m := &myFuncImplStruct{}
m2 := myFuncImplStruct{}
c1 := newFuncContainer(myFunc)
c2 := newFuncContainer(m.myFunc)
c3 := newFuncContainer(m2.myFunc2)

_, _, _ = c1, c2, c3
}

这段代码中,初看起来貌似在 main 函数中(不考虑 newFuncContainer 函数中导致的内存分配)没有运行时内存分配(m 会被优化成全局区,所以不会真的导致运行时内存分配),但是实际上在 main 中是有两次运行时内存分配的,这是怎么回事呢?

函数还能逃逸到堆上?

我们用-gcflags="-m"来打印一下编译器的优化信息,可以看到:

1
2
3
4
5
6
7
./main.go:13:7: m does not escape
./main.go:32:23: leaking param: f
./main.go:33:7: &myFuncContainer literal escapes to heap
./main.go:39:7: &myFuncImplStruct literal escapes to heap
./main.go:42:26: m.myFunc escapes to heap
./main.go:43:27: m2.myFunc2 escapes to heap
<autogenerated>:1: .this does not escape

竟然说 42、43 两行中的m.myFuncm2.myFunc2“逃逸到了堆上”?一个函数还能逃逸到堆上???

实锤了

虽然看起来貌似真的是这里导致的,但是我们说话做事要有证据,于是祭出汇编大法(-gcflags="-S"),看一下生成的汇编代码是啥样的:

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
"".main STEXT size=160 args=0x0 locals=0x18
……
0x0031 00049 (main.go:42) PCDATA $0, $1
0x0031 00049 (main.go:42) LEAQ type.noalg.struct { F uintptr; R *"".myFuncImplStruct }(SB), AX
0x0038 00056 (main.go:42) PCDATA $0, $0
0x0038 00056 (main.go:42) MOVQ AX, (SP)
0x003c 00060 (main.go:42) CALL runtime.newobject(SB)
0x0041 00065 (main.go:42) PCDATA $0, $1
0x0041 00065 (main.go:42) MOVQ 8(SP), AX
0x0046 00070 (main.go:42) LEAQ "".(*myFuncImplStruct).myFunc-fm(SB), CX
0x004d 00077 (main.go:42) MOVQ CX, (AX)
0x0050 00080 (main.go:42) PCDATA $0, $2
0x0050 00080 (main.go:42) LEAQ runtime.zerobase(SB), CX
0x0057 00087 (main.go:42) PCDATA $0, $1
0x0057 00087 (main.go:42) MOVQ CX, 8(AX)
0x005b 00091 (main.go:42) PCDATA $0, $0
0x005b 00091 (main.go:42) MOVQ AX, (SP)
0x005f 00095 (main.go:42) CALL "".newFuncContainer(SB)
0x0064 00100 (main.go:43) PCDATA $0, $1
0x0064 00100 (main.go:43) LEAQ type.noalg.struct { F uintptr; R "".myFuncImplStruct }(SB), AX
0x006b 00107 (main.go:43) PCDATA $0, $0
0x006b 00107 (main.go:43) MOVQ AX, (SP)
0x006f 00111 (main.go:43) CALL runtime.newobject(SB)
0x0074 00116 (main.go:43) PCDATA $0, $1
0x0074 00116 (main.go:43) MOVQ 8(SP), AX
0x0079 00121 (main.go:43) LEAQ "".myFuncImplStruct.myFunc2-fm(SB), CX
0x0080 00128 (main.go:43) MOVQ CX, (AX)
0x0083 00131 (main.go:43) PCDATA $0, $0
0x0083 00131 (main.go:43) MOVQ AX, (SP)
0x0087 00135 (main.go:43) CALL "".newFuncContainer(SB)
……

这下子实锤了,真的是这里导致的,但是为啥呢?我把一个函数赋值给某个变量,为什么会导致一次内存分配呢?函数名不是一个指针,指向函数所在的代码地址么?

Golang 函数调用机制

在 Golang 中,函数调用其实并不像 C 那么简单,有一定的分类:

函数调用分类

在 Go 中,一共有 4 种类型的函数:

  1. 顶层函数(普通的函数)
  2. 有值接收者的函数
  3. 有指针接收者的函数
  4. 函数字面量

有 5 种类型的函数调用:

  1. 直接调用顶层函数
  2. 直接调用有值接收者的函数
  3. 直接调用有指针接收者的函数
  4. 间接调用函数值(func value)
  5. 间接调用 interface 中函数

以下的示例程序展示了所有可能的函数调用方式:

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package main

func TopLevel(x int) {}

type Pointer struct{}

func (*Pointer) M(int) {}

type Value struct{}

func (Value) M(int) {}

type Interface interface{ M(int) }

var literal = func(x int) {}

func main() {
// direct call of top-level func
TopLevel(1)

// direct call of method with value receiver (two spellings, but same)
var v Value
v.M(1)
Value.M(v, 1)

// direct call of method with pointer receiver (two spellings, but same)
var p Pointer
(&p).M(1)
(*Pointer).M(&p, 1)

// indirect call of func value (×4)
f1 := TopLevel
f1(1)
f2 := Value.M
f2(v, 1)
f3 := (*Pointer).M
f3(&p, 1)
f4 := literal
f4(1)

// indirect call of method on interface (×3)
var i Interface
i = v
i.M(1)
i = &v
i.M(1)
i = &p
i.M(1)
Interface.M(i, 1)
Interface.M(v, 1)
Interface.M(&p, 1)
}

如上程序所示,一共有 10 种可能的调用组合:

  1. 直接调用顶层函数 /
  2. 直接调用值接收者函数 /
  3. 直接调用指针接收者函数 /
  4. 间接调用函数值(func value) / 函数值为顶层函数
  5. 间接调用函数值 / 函数值为值接收者函数
  6. 间接调用函数值 / 函数值为指针接收者函数
  7. 间接调用函数值 / 函数值函数字面量
  8. 间接调用 interface 中函数 / interface 为值,调用值接收者函数
  9. 间接调用 interface 中函数 / interface 为指针,调用值接收者函数
  10. 间接调用 interface 中函数 / interface 为指针,调用指针接收者函数

以上列表中,斜杠 / 左侧是在编译时就已知的信息,右侧是在运行时才知道的信息。在编译时生成的代码是不知道运行时的信息的,所以在运行时需要生成一些额外的适配器函数(adapter functions)来达成间接调用。

函数间接调用实现

看到这里,大家应该能隐约猜测到原因了,正如你所猜测,在我们开头的程序中,存在着间接调用,Go 分配的这个对象和间接调用脱不了关系。由于直接调用没啥可说的,所以我们略过不谈,只说间接调用。

在 Go 里面,间接调用的实现如下图:

实际上,Go 分配了一个额外的对象,其第一个字段是一个指向我们真实函数的指针,第二个对象是与函数强相关的一些数据(对,没错,说的就是接收者 receiver)。于是,一次函数调用实际上会生成类似如下的代码:

1
2
3
MOV …, R0
MOV 0(R0), R1
CALL R1 # called code can access “data” using R0

有一个例外,就是当一个函数并没有相关数据,如仅仅会捕获外部的局部变量的函数字面量,那么这个函数就不会有相关联的数据,于是内存布局如下:

在这个场景下,Go 会将这个变量的分配优化在只读区,不会在每次调用时都进行分配,也就是生成如下代码:

1
2
3
4
MOV $MyFunc·f(SB), f1

DATA MyFunc·f(SB)/8, $MyFunc(SB)
GLOBL MyFunc·f(SB), 10, $8

所以我们其实不必太过担心这种场景下的性能损耗,在这种场景下是 0 损耗的。

对于非例外的场景,一个适配器函数生成的代码类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
type funcValue struct {
f uintptr // 指向函数的指针
r associatedType
}

// 这里为实际函数签名
func funcAdapter(...) (...) {
r := (associatedType)(R0 + 8)
return r.f(...)
}

f := &funcValue{funcAdapter, r}

在调用时,调用的实际上是适配器函数,适配器函数随后去调用真实的函数。

为啥要这么干呢?

其实想想也很简单,对于值接收者和指针接收者函数,调用时第一个参数为 self,那么如果我现在是需要把某个关联在特定值 / 指针上的函数作为一个函数值赋值给某个函数变量时,我也需要一起把对应的值 / 指针信息一起带上,不然等我真正调用的时候,我怎么知道应该调用的是哪个值 / 指针上的方法呢?也就是说,传入函数的 self 值应该是多少呢?

说了那么多,到底为啥呢?

回到我们开头的问题,可以看到造成两次内存分配的罪魁祸首已然找到,在汇编代码里面其实也已经能看出端倪:

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
 0x0031 00049 (main.go:42)	PCDATA	$0, $1
0x0031 00049 (main.go:42) LEAQ type.noalg.struct { F uintptr; R *"".myFuncImplStruct }(SB), AX
0x0038 00056 (main.go:42) PCDATA $0, $0
0x0038 00056 (main.go:42) MOVQ AX, (SP)
0x003c 00060 (main.go:42) CALL runtime.newobject(SB)
0x0041 00065 (main.go:42) PCDATA $0, $1
0x0041 00065 (main.go:42) MOVQ 8(SP), AX
0x0046 00070 (main.go:42) LEAQ "".(*myFuncImplStruct).myFunc-fm(SB), CX
0x004d 00077 (main.go:42) MOVQ CX, (AX)
0x0050 00080 (main.go:42) PCDATA $0, $2
0x0050 00080 (main.go:42) LEAQ runtime.zerobase(SB), CX
0x0057 00087 (main.go:42) PCDATA $0, $1
0x0057 00087 (main.go:42) MOVQ CX, 8(AX)
0x005b 00091 (main.go:42) PCDATA $0, $0
0x005b 00091 (main.go:42) MOVQ AX, (SP)
0x005f 00095 (main.go:42) CALL "".newFuncContainer(SB)
0x0064 00100 (main.go:43) PCDATA $0, $1
0x0064 00100 (main.go:43) LEAQ type.noalg.struct { F uintptr; R "".myFuncImplStruct }(SB), AX
0x006b 00107 (main.go:43) PCDATA $0, $0
0x006b 00107 (main.go:43) MOVQ AX, (SP)
0x006f 00111 (main.go:43) CALL runtime.newobject(SB)
0x0074 00116 (main.go:43) PCDATA $0, $1
0x0074 00116 (main.go:43) MOVQ 8(SP), AX
0x0079 00121 (main.go:43) LEAQ "".myFuncImplStruct.myFunc2-fm(SB), CX
0x0080 00128 (main.go:43) MOVQ CX, (AX)
0x0083 00131 (main.go:43) PCDATA $0, $0
0x0083 00131 (main.go:43) MOVQ AX, (SP)
0x0087 00135 (main.go:43) CALL "".newFuncContainer(SB)

注意上述 LEAQ type.noalg.struct { F uintptr; R *"".myFuncImplStruct }(SB), AX这段代码,咱也别管啥意思,反正看到了一个和之前说的适配器很像的一个 struct,这个 struct 有两个字段,第一个是F uintptr,第二个是R *myFuncImplStruct;下面还有一个LEAQ type.noalg.struct { F uintptr; R "".myFuncImplStruct }(SB), AX,只不过这里的 R 是myFuncImplStruct的值而不是指针,这正好和我们代码吻合。

总结

好了,到这基本上这个问题清楚了,要优化的话也很简单,只要把实际上并不需要有值接收者或者指针接收者的函数改为顶层函数即可,或者尽可能不要将一个值接收者 / 指针接收者函数进行间接调用。

由此可以看出,有接收者的函数是有代价的,不能乱用啊,代码设计还是要合理,否则是会引入额外的性能开销的。

参考资料

  1. https://docs.google.com/document/d/1bMwCey-gmqZVTpRax-ESeVuZGmjwbocYs1iHplK-cjo/pub

有疑问加站长微信联系(非本文作者)

本文来自:Pure White

感谢作者:Pure White

查看原文:为什么 Golang 函数赋值会产生内存分配?

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

717 次点击  
加入收藏 微博
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传