Go 1.0 版本的 runtime 使用动态代码生成来实现闭包。我认为这样一点也不方便:它避免了修改工具链宽来表达函数的值与函数调用惯例。然而,自从它限制了 Go 可以运行的环境,这就很明显地警示我们不应该长期依赖动态的代码生成。它同样将一部分较小的工具链复杂化:栈追踪代码使用了非常简陋的试探法去处理闭包,而且 GDB 也无法在栈追踪中支持闭包。权威的解决方案是用一对指针来表示闭包,其中一个指向静态代码,而另一个指向可以存取捕捉的变量的动态环境。
作为 Go 1.1 版本动态代码生成的寻址的一部分,有必要广泛地研究一下 Go 是如何实现函数调用的。这篇文档描述了实现的一般要求和当前的实现方法,然后主要集中在如何同时实现可以避免使用动态代码产生和使用接收者方法的表达式,并从反射包中移除会导致 panic 的代码。我打算在 Go 1.1 版本中这样做。
本篇是我在 2012 年 9 月发布的一篇题为 “动态产生 Go 代码的替代方案” 的后续篇。
函数调用的种类
凡是在本篇中提及有关 “函数” 一词,均表示可以被执行的 Go 代码块,“调用” 则表示一个函数被调用的原理。
在Go中,有 4 个不同种类的函数:
- 顶级函数
- 带有值传递接收者的方法
- 带有指针传递接收者的方法
- 函数字面值
- 直接调用顶级函数
- 直接调用带有值传递接收者的方法
- 直接调用带有指针传递接收者的方法
- 间接调用函数的值
- 在接口中间接调用方法
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种可能的函数与调用的组合:
- 直接调用顶级函数/
- 直接调用带有值传递接收者的方法/
- 直接调用带有指针传递接收者的方法/
- 间接调用函数值 / 通过设置顶级函数
- 间接调用函数值 / 通过设置带有值传递的方法
- 间接调用函数值 / 通过带有指针传递的方法
- 间接调用函数值 / 通过调用函数字面值
- 在接口中间接调用其它方法 / 包含带有值传递的值方法
- 在接口中间接调用方法 / 包含带有指针传递的值方法
- 在接口中间接调用方法 / 包含带有指针传递的指针方法中
在这个列表中,斜杠的左侧表示在编译时便可预知的情况,右侧表示只有在运行时才能知道的情况。在编译时对于间接调用的代码生成不能依赖于运行时的值;相反的,有些间接调用的例子却能够被代码生成适配函数所处理,从而使间接调用达到期望值。
当前的实现方法
这一部分描述了对可能的函数调用的实现方案。
直接调用顶级函数。除了占有连续栈位置的结果外,直接调用顶级函数将会通过栈传递所有参数。这与关联的 C 编译器的调用规范相匹配。
直接调用方法。为了使函数的间接调用和直接调用能够产生相同的代码,带有值传递和指针传递的接收者的方法都被选择采用和调用带有接收者的顶级函数相同的规范来传递参数。
函数值的间接调用。除了真正的 CALL 或者 BL 指令,间接调用会被像调用顶级函数一样处理:在这个例子当中,函数值会采用包含代码地址的函数去执行。这说明了对顶级函数的间接调用会变成直接调用。正如我前面提到的,对于间接方法函数的调用会变成直接方法的调用。这同样导致对函数字面值的间接调用会变成直接调用。
在一般情况下,函数字面值的值有 2 个部分:可以在编译时被处理的函数和一些只有在函数字面值表达式获得实际值的时候才知道与哪些隐藏参数关联的函数。除了对函数值的间接调用,为了能够匹配调用的转换,现在的实现方法是通过运行时产生代码来提供对编译时产生含有隐藏参数的函数体的支持。
在接口中间接调用方法。接口的值是在允许的情况下,可以使字赋予类型的一个结对(类型,字),或者一个指向某个类型的字的指针。接口取回一个可以被执行的方法的地址并像间接调用含有接收者的函数一样进行直接调用。如果类型的值就是它自身的指针或者方法是一个指针方法,那么直接调用和间接调用的调用转换就和我前面说的相吻合了,因此可以说是调用相同的函数。如果类型的值是一个拥有具体大小的字的空指针和一个值传递的方法,同样也会应用相同的最优化的方法。否则,对于剩下的例子,换句话来讲,在类型的指针或空指针上调用一个值传递的方法,代码在被其它调用环境使用时将不会有相同的调用转换。一个转换适配函数必须在编译时进行转换,而且转换适配函数的地址要在接口被调用时保存在方法查询表中。举个例子,上面的值类型在像这样被用在一个方法表的适配函数中:
func Value.M·i(word uintptr, x int) {
v := *(*Value)(unsafe.Pointer(&word))
v.M(x)
}
如果值超过字的大小(在本例中正好相反,值小于字的大小),转换函数将会同等对待并在 &word 中省略 & 符号。
当前实现方法存在的问题
现在的实现方法是高雅的,完全地彻底地基于以下 3 个考虑:1. 间接调用应当与 C 的惯例匹配;2. 间接调用应当分配与直接调用相同的代码;3. 当拥有指针和指针方法的接口被调用时应当和直接调用分配相同的代码。
运行时代码生成。为了使函数字面值的值与间接调用相匹配,现在的实现方法是通过运用运行时的代码生成来实现对于局部变量的捕捉。在嵌入式或沙盒系统的环境下,运行时代码生成会变得非常耗费资源甚至无法完成。Go 不能依赖于此。比较常用的解决方案是制造两个字的函数值,一个代码指针指向编译时产生的代码,另一个数据指针则指向被捕捉到的局部变量。
方法值。在经过将近一年的讨论,我们一致研究决定将赋予在 Go 语言中方法值的意义,大致如下所示:
var r io.Reader
f := r.Read // f has type func([]byte) (int, error)
n, err := f(p)
甚至有一次我们达成了一致,我都不必麻烦地去写一份改变说明,因为 “f:=r.Read” 的实现将会隐藏在闭包中,如果强制人们像下面这样写将会更好:
f := func(p []byte) (int, error) { return r.Read(p) }
明确地指出闭包。同样我也非常地懒到不想去实现它。但我坚信总有一天我会想要做这个的。事实上,"f:=r.Read" 对于新手来说是非常莫名其妙的。双字的函数值的表达可能会琐碎地实现 “f := r.Read”。
反射。在反射中,v.Method(i) 会返回一个带有预期限制的接收者的第i个方法的类似的玩意。比如说,假设第 i 个方法的名称是 F,v.Method(i).Call(ValueOf(x), ValueOf(y)) 相当于 v.F(x, y) 的反射。实际上,方法和调用的两个不同的步骤说明了 v.Method(i) 自身一个代表着什么。今天它表示一个 reflect.Value 并且可以被用于调用或类型检查。然而,像 v.Method(i).Interface() 这样的接口方法会导致 panic,因为这里没有 Go 的值可以在 interface{} 中返回。
双字函数值的表示使用更琐碎的方法来创建一个带有预期限制的真正的方法,就像在例子 “f := r.Read” 中一样琐碎。因此接口的调用不再需要 panic(这对于同时修复他们很重要;如果我们修复了反射但却不允许使用 “f := r.Read” 的话,人们同样会用其它愚蠢的方法来使用反射而导致一个语言变得 2B,我们必须避免)。
新的实现方法
我们建议只改变 “间接调用函数值” 的实现机制。其它调用细节都保持不变。
新的实现方法避免了需要为运行时代码生成而创建一个指向数据内存的可变大小块的函数值指针,其中第一个字保存了代码的指针和调用代码所需要的其它数据。下图灰色部分展示了当前实现中函数变量的内存布局,黑色部分是新实现的改变:
在当前实现中,函数值保存了指向被调用的实际代码的指针。新的方案介绍了通过数据块的间接实现。
当前实现的调用顺序如下所示:
MOV …, R1
CALL R1
在新的实现中,调用顺序增加了一个间接环节,这也使得间接块(中间沙箱的地址)的地址被放在一个已知的寄存器中(R0):
MOV …, R0
MOV 0(R0), R1
CALL R1 # called code can access “data” using R0
根据上面的程序,请思考初始化一个 Go 函数的各种可能的方式:
f1 := TopLevel
f1(1)
f2 := Value.M
f2(v, 1)
f3 := (*Pointer).M
f3(&p, 1)
f4 := literal
f4(1)
除了调用可以捕捉到外部变量的函数字面值,这个过程不需要相关的数据,因此内存布局被简化成:
在这个例子中,中间沙箱就是单纯的 C 函数指针,因此 Go 函数值只是一个指向 C 函数指针的指针。额外的 C 函数指针字必须被分配好,但是分配的实际情况是由编译器来提前实现,从而使得指针字只能被分配在只读数据中,而且每次存储函数值都只会实现一次,无论程序中有多少地方需要这样做。
就是说 “f := MyFunc” 这个赋值会生成以下代码:
MOV $MyFunc·f(SB), f1
DATA MyFunc·f(SB)/8, $MyFunc(SB)
GLOBL MyFunc·f(SB), 10, $8
实际的存储指令记录了指向只读数据 MyFunc·f 的指针,指针本身也保存了一个指向 MyFunc 实际代码的指针。后缀 ·f 为间接层建立了一个单独的命名空间。在 GLOBL 这个声明中,10 位的标识字使用 8 位(只读内存)和 2 位(可在最终二进制文件时被合并的定义副本)。
这可以被应用于所有不需要关联数据的函数。在上面的代码片段中,不需要捕捉变量的 f1、f2、f3 和 f4 都使用这种模式。
如果函数字面值(f4)需要捕捉变量,那么一个更大的间接块需要在运行时分配。第一个字指向编译时生成的函数,块的其余部分保存指向捕捉到的变量的指针。函数 “runtime.closure” 的调用是用于创建闭包,就目前而言,这会为其分配内存然后将用于生成代码序列的实际机器指令填满剩余部分。新的实现只会分配内存并拷贝必要的代码和数据指针到其中,只有少量的复合数据。
像 “f := r.Read” 这样将一个函数表达式赋值给一个函数值会分配一个包含指向适配函数的指针的间接块,并拷贝 r。语句 “f := r.Read” 也会被进行大致如下的处理:
type funcValue struct {
f func([]byte) (int, error)
r io.Reader
}
func readAdapter(b []byte) (int, error) {
r := (*io.Reader)(R0+8)
return r.Read(b)
}
f := &funcValue{readAdapter, r}
反射。事实上,一个指向 C 函数的指针是一个合法的 Go 函数值,这意味着反射可以通过指针自身的函数 table 生成 Go 函数值。举例来说,像 *bytes.Buffer 这样的混合类型拥有一个关联的方法表。假设 table[0] 保存着 (*Buffer).Read 的代码地址(C 函数指针),table[1] 保存着 (*Buffer).Write 的地址,以此类推,当发生如下反射调用时:
reflect.TypeOf(new(bytes.Buffer)).Method(0)
必须创建一个与 (*Buffer).Read 相关的 Go 函数值,它可以返回 &table[0] 而不需要分配一个显式的间接块。
允许使用 “f := r.Read" 的其中一个目标是使得
f := reflect.ValueOf(new(bytes.Buffer)).Method(0).Interface()
与前个反射调用具有相同效果;当前的实现不允许调用接口并导致 panic。最明显的实现是为每个方法生成像 readAdapter 这样的函数,然后在反射表中记录指向这些函数的指针。这样做的缺点是会使得反射表和文本块不断增长,其中包括了需要在极大多数情况下都用不到的适配器。然而,我们可以包含一个使用反射的单独适配器来处理所有函数。它需要使用汇编语言编写,并使得前文中的调用会生成类似下面的东西:
typedef struct funcValue funcValue;
struct funcValue {
void (*adapter)(void);
void (*fn)(void);
uintptr rcvrArgBytes;
uintptr inArgBytes;
uintptr outArgBytes;
byte rcvr[0];
}
f := malloc(sizeof(funcValue) + sizeof(*bytes.Buffer))
f.adapter = adapter
f.fn = (*bytes.Buffer).Read;
f.rcvrArgBytes = sizeof(*bytes.Buffer);
f.inArgBytes = sizeof([]byte)
f.outArgBytes = sizeof(int, error);
memmove(&f.rcvr, r, sizeof(*bytes.Buffer));
适配器需要使用上下文中已记录的 funcValue 来完成函数的一般调用,类似(但更简洁)现在使用的 reflect.Call。这会比预生成自定义适配器稍慢些,但却避免了空间的浪费。
新实现的属性
相比较当前的实现,新实现牺牲了 Go 与 C 函数之间的直接调用。更确切地说,Go func(int) 和 C void(*) 在运行时具有相同类型。它们之间的区别必须被说明,运行时同样也要区别从 C char* 转换而来的 Go string。新的实现带来了另外 2 个更好更重要的属性:间接调用会变成对相同代码的直接调用,以及对接口的调用会被变成相同代码的直接函数调用,以防带有指针方法的指针调用。
新的实现要求在函数值的间接调用前的即时内存加载。在某些情况下间接的 CALL 指令会被延迟,但是只会影响到使用函数值的调用,对接口的调用没有影响。
新的实现在内存中保存函数值的大小,因此对函数值的代码赋值不需要修改任何东西,而且使用 C 或汇编语言编写的带有函数值参数的代码不需要重新计算参数框架或局部变量位移。当然,函数值意味着已经被改变,即使它的大小是一样的,所以当 C 或汇编语言尝试调用 Go 函数值时将会需要一些调整。
新的实现不会增长运行时反射的内存表需求。间接函数字只会在使用像语句 “f := MyFunc” 时被包括;反射使用不同的策略来避免重新分配,但同时又复用了已经存在的表。
向后不兼容
几乎没有现存的代码需要改变。只有使用 C 或汇编语言调用 Go 函数值的部分存在不兼容。这些代码需要更新为使用间接块。
直接编译 Go 代码来创建函数值和通过反射来创建函数值的均指向相同的底层代码,甚至不存在闭包的使用,然后会使用不同的指针来获取代码。因为不允函数之间的比较,新实现涉及到的改变只会破坏使用了 unsafe 代码的程序,以及那些使用 gccgo 编译的使用共享库的程序。
现在想要通过反射的 Value.Pointer 来返回唯一的函数标识符是不可能的了。唯有通过返回间接块的地址来对拥有不同 “Pointer()” 的函数进行区别。不幸地是,一些人现在可能依赖的 runtime.FuncForPc 的结果会变得没什么作用。相反的,我们将会使可能会返回相同指针的 Value.Pointer 的不同函数的指针包含其关联的代码指针和文档(例如:函数字面值的多个实例,或所有函数使用通用的反射适配器)。唯一可以保证的是,当且仅当函数为 nil 时 Value.Pointer 会返回 0。
实现计划
实现计划可以被分为以下这些步骤。每一步都是一个工作树。
1. 让函数值拥有一个间接字,保持原有运行时的代码生成。所有的函数值将会看齐来像第二影像(没有关联数据)。这要求编译器生成的 MyFunc·f 的字,运行时的任务是区分从 Go 函数值变化而来的 C 函数指针,并使反射能够识别新设计。
2. 改变函数字面值实现保存在间接块中捕捉到的指针而不是运行时的代码生成。这主要是编译器的改变,以及删除运行时代码生成的函数 runtime.closure。
3. 改变 reflect.MakeFunc 实现避免运行代码生成(修复问题 3736、3738、4081)。
4. 从回溯例程以及其它可能的地方删除闭包。
5. 增加对 “f := r.Read” 的支持(修复问题 2280)。
6. 使得反射的 v.Method(i).Interface() 可以工作(修复问题 1517)。
原文地址:https://docs.google.com/document/d/1bMwCey-gmqZVTpRax-ESeVuZGmjwbocYs1iHplK-cjo/pub