在进一步解释什么是动态分派(Dynamic Dispatch)之前,我们先了解一个与之相对的概念:静态分派(Static Dispatch)。
静态分派,或称编译时分派,是指函数调用在编译期间就已经确定了具体要调用的函数。也就是说,编译器在编译时就已经确定了具体的方法地址,而不需要等到运行时才决定调用哪个方法。我们来看例子:
type Stringer interface {
String() string
}
type Binary uint64
func (i Binary) String() string {
return strconv.FormatUint(i.Get(), 2)
}
func (i Binary) Get() uint64 {
return uint64(i)
}
例子中,Binary类型的方法String和Get都是静态分派的。例如方法String,在其内部,它调用了i.Get():
Get方法是Binary类型的方法,编译器在编译这段代码时,已经明确知道 i 是Binary类型,并且Binary有一个名为Get的方法,知道其地址;- 编译器在编译时直接将
Get方法的地址绑定到调用处。调用Get时,并不需要运行时额外查找方法表或动态确定具体的调用目标。
同样,strconv.FormatUint(i.Get(), 2)也是编译时就确定的调用,FormatUint是strconv包下的一个普通函数,编译器可以确定其存在(实际上,编译期间编译器只确定了该函数的定义符号,不知道具体的函数地址,地址的确定需要等链接器帮忙填充)。
func Println(s Stringer) {
if s == nil {
return
}
fmt.Println(s.String())
}
我们来看上面这段代码。任何实现了Stringer接口的类型都可以作为参数传递给Println函数,比如Binary和。然后接口变量 s 调用String方法,并将返回值传递给fmt.Println()。
在这情况下,s 的具体类型是不确定的,或许是Binary,也或许是任何实现了Stringer的其他类型。由于类型不确定,所以我们无法在编译时确定s.String()调用具体的行为。这时就需要在运行时中根据传入的具体类型动态地决定应该调用哪个方法,即动态分派。
1. 接口表
一般,拥有方法的语言往往分为两个阵营:一是像 C++ 和 Java 那样静态地为所有方法调用准备表,二是像 Smalltalk 、Python 那样对每个调用进行方法查找,并添加缓存以提高调用效率[1]。
说明一下,这里提到的表通常指的是虚函数表(vtable,virtual method table)。虚函数表是较为常见的支持动态分派的机制,Java,C++ 都在使用这种机制实现动态分派。在 Java 中,每个定义了虚函数(能被继承、重写的方法)的类都会有一个虚函数表。虚函数表中记录了类自己拥有的方法以及它从 superclass 中继承过来的方法。对于从 superclass 中继承的方法,如果 subclass 对它进行了重写,那么函数表中的指针会指向 subclass 的方法数据;如果 subclass 没有重写,指针指向 superclass 的方法数据[2]。
Go 语言中不仅会维护一个类似于 vtable 的表,还会像 Smalltalk 和 Python 那样使用缓存来提高效率。而我们提到的这个表即是接口表 itab。
// itab 会分配到 不会被垃圾回收 的内存中
type itab struct {
inter *interfacetype // 指向接口类型的指针,描述了接口的类型元数据和方法集
_type *_type // 具体类型指针,储存这个接口绑定的实际类型的元数据
hash uint32 // 类型哈希的 copy,即_type.Hash,用于 type switches
_ [4]byte
fun [1]uintptr // 动态数组,存储一组函数指针,若fun[0]==0则表示_type没有实现inter
}
type interfacetype struct {
Type // 接口类型元数据
PkgPath Name // import path
Methods []Imethod // method set, sorted by hash
}
接口表是 Go 语言中非空接口的动态分派的重要实现机制。要了解 Go 的动态分派,就要了解接口表。而我们接下来需要探讨一下接口表的一些常规操作,比如缓存,查找,生成等。
2. 接口表的缓存
itab用于接口和具体类型之间的关联,是在接口调用时快速查找具体类型的实现方法。Go 运行时会通过itab进行接口类型到具体类型的映射,并缓存这些映射以提高接口调用的效率。
在 Go 1.10 以后,itab的存储使用的是一个容量为 2 幂次的开放寻址哈希表[3]。
// https://github.com/golang/go/blob/go1.22.0/src/runtime/iface.go#L24
type itabTableType struct {
size uintptr // 表示 itab 哈希表的大小。这个值总是 2 的幂
count uintptr // 当前哈希表中已经填充的条目数量
entries [itabInitSize]*itab // 实际存储 itab 条目的数组
}
var (
itabTable = &itabTableInit // pointer to current table
itabTableInit = itabTableType{size: itabInitSize} // starter table
)
const itabInitSize = 512
开放寻址是一种用于处理哈希表哈希冲突的实现方式,相比于使用链表处理哈希冲突(Go 1.10 以前使用的这种方式),它直接在哈希表内部找到新的空位置来存储冲突的元素。当出现哈希冲突时,则使用二次探测(quadratic probing)来寻找下一个存放位置[3]。(具体来说是应该是三角数序列探测)
在开放寻址哈希表中,如果存储的itab条目越来越多,负载系数就会逐渐升高,哈希冲突会变得更加频繁。这是因为开放寻址法通过探测空闲槽位来处理冲突,而当可用槽位变少时,找到合适位置的探测次数会显著增加。随着哈希冲突的增多,插入和查找操作的性能将从原本的常数时间复杂度 O(1) 逐渐退化为线性时间复杂度 O(n),尤其是在高负载系数的情况下。为了解决这一问题,负载系数被设置为了 75%[3]。当哈希表中 75% 的位置被占用时,就会触发扩容操作。表的大小会翻一倍,并重新计算所有哈希值,分配到新的哈希表中。
更加详细的缓存itab的细节请见:itabTableType,itabAdd。
3. 接口表的查找
现在我们知道 Go 是使用的开放寻址+二次探测来解决itab存储时产生的哈希冲突的。严格意义的二次探测会按照平方进行偏移的去寻找下一个可用的位置,当我们在哈希表中插入一个新元素时,若该元素的哈希位置已经被占用,二次探测不会按线性方式去探测下一个位置(如 h+1, h+2, h+3, ...),而是按平方进行探测:
- 第一次冲突:h = h+1^2
- 第二次冲突:h = h+2^2
- 第三次冲突:h = h+3^2
- ...
也可以按照平方进行左右偏移的去寻找下一个可用的位置,比如:
- 第一次冲突:h = h+1^2
- 第二次冲突:h = h-1^2
- 第三次冲突:h = h+2^2
- 第四次冲突:h = h-2^2
- 第五次冲突:h = h+3^2
- ...
二次探测每次跳跃的步长并非恒定,而是根据探测次数的平方值左右动态寻找下一个位置。这种探测方式随着冲突的增加会双向扩展到更远的位置,而不是挨着哈希表中的相邻位置。但 Go 缓存itab所使用的二次探测并非这种严格意义的二次探测,而一种三角数序列的探测方式,其代码详情如下:
func (t *itabTableType) find(inter *interfacetype, typ *_type) *itab {
mask := t.size - 1
// 异或后的哈希值又与 mask 与运算
// 确保计算出的哈希值不会超过大小范围 mask
// hash 被限制在 [0, size-1]内
h := itabHashFunc(inter, typ) & mask
for i := uintptr(1); ; i++ {
p := (**itab)(add(unsafe.Pointer(&t.entries), h*goarch.PtrSize))
// m := *p
m := (*itab)(atomic.Loadp(unsafe.Pointer(p)))
if m == nil {
return nil
}
if m.inter == inter && m._type == typ {
return m
}
h += i
h &= mask
}
}
// 异或运算,将两个哈希值混合在一起,从而生成新的唯一性哈希值
func itabHashFunc(inter *interfacetype, typ *_type) uintptr {
return uintptr(inter.Type.Hash ^ typ.Hash)
}
从代码上看,探测位置通过h += i更新,看起来与线性探测非常相似,但是从实现细节和整体行为来看,这又与二次探测的性质类似。 这种探测通过每次累加i来实现 :
- 第一次冲突:h = h + 1
- 第二次冲突:h = h + 1 + 2
- 第三次冲突:h = h + 1 + 2 + 3
- ...
这个序列虽然每次都在增加,但由于每次都叠加i,它产生的结果比单纯的线性递增更快地偏离原来的位置,又比二次方的增长缓和。可以理解为一种累积式的二次偏移,即总偏移量逐步变大。其探测序列为:h(i) = h0 + i*(i+1)/2 mod 2^k,即三角数序列。
至于为什么选择三角数序列探测呢?我想可能是由于二次探测序列的形式是平方值,它可能无法覆盖哈希表中的所有槽位。这会导致某些情况下,表中有空槽但无法找到,进而增大失败率。随着冲突次数的增加,探测步长呈平方增长,需要查找的槽位越来越远,这就会导致性能下降。特别是在负载因子较高时,需要探测的次数会大幅增加,影响插入效率。
与二次序列相比,三角数序列形式的探测方式增量较为连续,覆盖的槽位更为均匀,且序列增长速率相比于平方序列更为适中。这在查找空槽的步长更有效,不至于过早探测到很远的位置,进而减少未能找到空槽的情况。
4. 接口表的计算
还是用本文的例子,我们可以将实现了接口Stringer的类型Binary和Stringer看成一个组合,即[Stringer, Binary]的组合对。在一个 Go 程序中像[Stringer, Binary这种 [接口类型, 具体类型] 的组合对可能会有很多,而且大多数可能都不会被使用,程序编译期间无法合理地预先计算所有可能的itab,所以在 Go 语言早期itab都是由运行时动态生成的。
但这也导致在已知类型和已知接口之间进行转换时效率低下。如果生成的代码知道itab地址,则可能会更高效。但如果itab都由运行时生成,则不能。在 Go 1.7 以后,Go 则是让编译器在已知类型和已知接口之间转换时生成itab结构,这样生成的itab就可以作为生成代码中的常量地址。运行时将在初始化时把这些结构注册到itab哈希表中,并且仍然能够在从一种接口类型转换为另一种接口类型时根据需要动态创建更多结构[4]。
编译期间,Go 语言会让链接器收集所有的 itablink 符号,让模块数据维护一个由编译器生成的itab的切片[5]。该切片中的itab会在程序启动时被存入哈希表中。(详情可查看官方源码 itabsinit)
对于那些编译期间不能确定的组合对,Go 则是在运行时动态地计算itab。
// https://github.com/golang/go/blob/go1.22.0/src/runtime/iface.go#L35
func getitab(inter *interfacetype, typ *_type, canfail bool) *itab {
...
var m *itab
lock(&itabLock)
// 为新的 itab 分配一段连续且持久(不会被回收)的内存
// 大小为:itab 结构的大小 + inter的方法数减一
// goarch.PtrSize 表示平台对应的指针大小
m = (*itab)(persistentalloc(unsafe.Sizeof(itab{})+uintptr(len(inter.Methods)-1)*goarch.PtrSize, 0, &memstats.other_sys))
m.inter = inter
m._type = typ
// 哈希值用于 type switches。
// 然而,编译器会为 type switches 中使用的所有接口/类型对静态生成 itab
// (这些 itab 会在 itabsinit 中被添加到 itabTable)。
// 动态生成的 itab 从不参与 type switches,因此哈希值无关紧要。
// 注意:m.hash 不是运行时 itabTable 哈希表中使用的哈希值。
m.hash = 0
// 填充方法
m.init()
// 将所生成好的 itab 添加到 itabTable
itabAdd(m)
unlock(&itabLock)
...
}
创建itab还有重要的一个步骤:确定方法集。既然在getitab创建itab时就已经为itab预留了相关方法的内存空间,那么接下来我们需要关注的就应该是如何将具体类型的方法填充进itab的fun数组里。
func (m *itab) init() string {
inter := m.inter
typ := m._type
// 获取具体类型的非通用数据信息,即方法集
// Uncommon 详情见:
// https://github.com/golang/go/blob/go1.22.0/src/internal/abi/type.go#L293
x := typ.Uncommon()
// inter 和 typ 的方法都会按名称排序
ni := len(inter.Methods)
// 具体类型的方法数量
nt := int(x.Mcount)
// 通过偏移地址获取具体类型所有方法
xmhdr := (*[1 << 16]abi.Method)(add(unsafe.Pointer(x), uintptr(x.Moff)))[:nt:nt]
// 通过偏移地址获取 m.fun 中存储的所有方法.
// 在分配 itab 的内存时有预留空间,
// 故此时此处没有存储任何数据.
methods := (*[1 << 16]unsafe.Pointer)(unsafe.Pointer(&m.fun[0]))[:ni:ni]
var fun0 unsafe.Pointer
imethods:
// 遍历接口方法集.
for k := 0; k < ni; k++ {
i := &inter.Methods[k]
// 获取接口方法的类型、名称、包信息
itype := toRType(&inter.Type).typeOff(i.Typ)
name := toRType(&inter.Type).nameOff(i.Name)
iname := name.Name()
ipkg := pkgPath(name)
if ipkg == "" {
ipkg = inter.PkgPath.Name()
}
// 遍历具体类型方法集.
// 这里源码中进行了优化,整个遍历查找复杂度并非 O(ni*nt),而是 O(ni+nt)
// 优化详细见:
// https://github.com/golang/go/blob/go1.22.0/src/runtime/iface.go#L193
for j := 0; j < nt; j++ {
t := &xmhdr[j]
rtyp := toRType(typ)
tname := rtyp.nameOff(t.Name)
if rtyp.typeOff(t.Mtyp) == itype && tname.Name() == iname {
pkgPath := pkgPath(tname)
if pkgPath == "" {
pkgPath = rtyp.nameOff(x.PkgPath).Name()
}
if tname.IsExported() || pkgPath == ipkg {
// 通过 Method 结构中的 Ifn 偏移找到具体类型的方法
ifn := rtyp.textOff(t.Ifn)
if k == 0 {
fun0 = ifn // we'll set m.fun[0] at the end
} else {
methods[k] = ifn
}
continue imethods
}
}
}
// didn't find method
m.fun[0] = 0
return iname
}
m.fun[0] = uintptr(fun0)
return ""
}
这段方法其实很简单,它主要就是遍历接口的方法集(外循环)和具体类型的方法集(内循环),将与接口方法匹配的具体类型方法的地址依次放入itab结构中的fun字段中。