了解Go interface
Go’s interfaces—static, checked at compile time, dynamic when asked for—are, for me, the most exciting part of Go from a language design point of view. If I could export one feature of Go into other languages, it would be interfaces.—— Russ Cox
拥有函数的语言一般都会落入以下两种情况之一:静态的为所有方法函数调用准备函数表(比如C++和Java),或者在每次函数调用的时候做一次函数查询并且缓存它们使调用更高效(比如Smalltalk和它的模仿者,JavaScript和Python也是)。
然而Go处于这两种模式的中间:它有函数表但是在运行的时候才计算它们
type Stringer interface {
String() string
}
func ToString(any interface{}) string {
if v, ok := any.(Stringer); ok {
return v.String()
}
switch v := any.(type) {
case int:
return strconv.Itoa(v)
case float:
return strconv.Ftoa(v, 'g', -1)
}
return "???"
}
type Binary uint64
func (i Binary) String() string {
return strconv.Uitob64(i.Get(), 2)
}
func (i Binary) Get() uint64 {
return uint64(i)
}
可以将 Binary
类型的值传递给 ToString
,它将使用 String
方法对其进行格式化,即使程序从未说过 Binary
打算实现 Stringer
接口,这是没有必要的:运行时可以看到 Binary
有一个 String
方法,所以可以代表它实现了 Stringer
接口,即使 Binary
的作者从未听说过 Stringer
。
Binary
类型的值只是一个由两个 32 位字组成的 64 位整数
接口的值表示为两个字对,一个指向存储在接口中的类型信息的指针,另一个指向相关数据的指针。将 b
分配给 Stringer
类型的接口值会设置接口的值的两个字。
接口值中的第一个字指向接口表或 itable。itable 从涉及类型的一些元数据开始,然后变成函数指针列表。请注意,itable 对应于interface类型,而不是动态类型。就我们的例子而言,保存Binary
类型的Stringer
的 itable 列出了用于满足Stringer
的方法,而String
:Binary
的其他方法(Get
)没有出现在 itable 中。
接口值的第二个字指向实际的数据,在这种情况下是b的副本。赋值var s Stringer = b
是对b
进行复制,而不是指向b
,原因与 var c uint64 = b
进行复制的原因相同:如果b
后来发生了变化,s
和c
应该保留原始值,而不是新值。存储在接口中的值可能非常大,但是在接口结构中只有一个字用于保存值,因此赋值会在堆上分配一块内存并在一个字的槽位中记录指针。
要检查接口值是否包含特定类型,如上面的类型切换,Go编译器生成的代码等同于C表达式s.tab->type
来获取类型指针并将其与所需类型进行比较。如果类型匹配,可以通过取消引用s.data
来复制值。
要调用s.String()
,Go编译器生成的代码相当于C表达式s.tab->fun[0](s.data)
:它从itable中调用适当的函数指针,将接口值的数据字作为函数的第一个参数(在本例中,仅有一个)。请注意,itable中的函数传递的是接口值的第二个字中的32位指针,而不是它指向的64位值。 通常,接口调用不知道这个字的含义以及它指向的数据有多少。相反,接口代码安排在itable中的函数指针期望的是存储在接口值中的32位表示。因此,本例中的函数指针是(*Binary).String
而不是Binary.String
。
我们正在考虑的示例是一个只有一个方法的接口。具有更多方法的接口在itable底部的fun列表中会有更多条目。
计算Itable
编译器为每个具体类型如Binary
、int
或func(map[int]string)
生成一个类型描述结构。除了其他元数据,类型描述结构包含该类型实现的方法列表。同样,编译器为每个接口类型如Stringer
生成一个(不同的)类型描述结构;它也包含一个方法列表。
接口的运行时通过在具体类型的方法表中查找在接口类型的方法表中列出的每个方法来计算itable。
通常,接口类型可能有ni个方法,具体类型可能有nt个方法。显然,查找从接口方法到具体方法的映射需要*O*(*ni* × *nt*)时间,但我们可以做得更好。通过对两个方法表进行排序并同时遍历它们,我们可以在O(ni + nt)时间内构建映射。
接口的代价
当您为类型interface{}
分配一个值时,Go 将调用 runtime.convT2E
来创建接口结构(了解更多关于Go接口内部的信息)。这需要内存分配,更多的内存分配意味着堆上有更多的垃圾,这意味着垃圾收集暂停的时间更长。
接下来我们使用一个例子测试使用接口会带来多少开销,直观的感受一下:
还是使用上文中的例子:
type Stringer interface {
String() string
}
func toString(any interface{}) string {
if v, ok := any.(Stringer); ok {
return v.String()
}
switch v := any.(type) {
case int:
return strconv.Itoa(v)
case float32:
return strconv.FormatFloat(float64(v), 'g', -1, 32)
case float64:
return strconv.FormatFloat(v, 'g', -1, 64)
}
return "???"
}
type Binary struct {
padding uint64
value uint64
}
func NewBinary(padding uint64, value uint64) Binary {
return Binary{padding: padding, value: value}
}
func (i Binary) String() string {
return strconv.FormatUint(i.Get(), 2)
}
func (i Binary) Get() uint64 {
return i.value
}
注意: 这里Binary
结构体里增加了一个padding字段,目的就是为了避免go interface的槽位优化,当数据大小刚好一个字时,存在interface value里第二个字里的就是实际的数据,不是指针。
然后测试文件为:
func BenchmarkIface(b *testing.B) {
// 每次循环都重新初始化
b.Run("multi-new", func(b *testing.B) {
for i := 0; i < b.N; i++ {
binary := NewBinary(300, 300)
s := Stringer(binary)
_ = toString(s)
}
})
// 每次循环都只重新初始化interface value
b.Run("once-new", func(b *testing.B) {
binary := NewBinary(300, 300)
for i := 0; i < b.N; i++ {
s := Stringer(binary)
_ = toString(s)
}
})
// 复用Binary实例和interface value
b.Run("all once new", func(b *testing.B) {
binary := NewBinary(300, 300)
s := Stringer(binary)
for i := 0; i < b.N; i++ {
_ = toString(s)
}
})
}
func BenchmarkString(b *testing.B) {
// 每次循环都重新初始化Binary实例
b.Run("multi-new", func(b *testing.B) {
for i := 0; i < b.N; i++ {
binary := NewBinary(300, 300)
_ = binary.String()
}
})
// 复用Binary实例
b.Run("once-new", func(b *testing.B) {
binary := NewBinary(300, 300)
for i := 0; i < b.N; i++ {
_ = binary.String()
}
})
}
可以看到,主要是两个benchmark,一个是测试使用interface的开销,一个测试直接调用实例方法,同时在循环里测试复用和不复用实例所带来的开销
结果
可以发现,interface带来的耗时开销最大可以比直接调用多出74%,而内存开销就是interface value的两个字指针以及itable的大小,在本例中内存是一倍多的开销。
Profile inspect
使用pprof来更具体的查看cpu开销情况
var testCnt = 1000000
func func1() {
binary := NewBinary(300, 300)
for i := 0; i < testCnt; i++ {
_ = binary.String()
}
}
func func4() {
binary := NewBinary(300, 300)
for i := 0; i < testCnt; i++ {
s := Stringer(binary)
_ = toString(s)
}
}
func func2() {
for i := 0; i < testCnt; i++ {
binary := NewBinary(300, 300)
_ = binary.String()
}
}
func func3() {
for i := 0; i < testCnt; i++ {
binary := NewBinary(300, 300)
s := Stringer(binary)
_ = toString(s)
}
}
func func5() {
binary := NewBinary(300, 300)
s := Stringer(binary)
for i := 0; i < testCnt; i++ {
_ = toString(s)
}
}
Heap inspect
func1: 12M
func3: 30.50M
可以看到76行占据的内存非常多,也就是在for循环里的s := Stringer(binary)
。
func5: 12.5M
Reference
https://research.swtch.com/interfaces
https://darkcoding.net/software/go-the-price-of-interface/