问小白 wenxiaobai
资讯
历史
科技
环境与自然
成长
游戏
财经
文学与艺术
美食
健康
家居
文化
情感
汽车
三农
军事
旅行
运动
教育
生活
星座命理

了解Go interface以及其所带来的开销

创作时间:
作者:
@小白创作中心

了解Go interface以及其所带来的开销

引用
CSDN
1.
https://blog.csdn.net/qq_43845988/article/details/139658627

Go语言的接口(interface)机制是其设计中最吸引人的特性之一。本文将深入探讨Go接口的工作原理,包括运行时的itable计算过程,并通过具体的性能测试数据,帮助读者理解使用接口带来的开销。

了解Go interface

Go的接口——静态检查、编译时确定、运行时动态使用——对我来说,是Go语言设计中最令人兴奋的部分。如果我能将Go的一个特性移植到其他语言中,那一定是接口。—— 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 的方法,而 StringBinary 的其他方法(Get)没有出现在 itable 中。

接口值的第二个字指向实际的数据,在这种情况下是b的副本。赋值 var s Stringer = b 是对 b 进行复制,而不是指向 b,原因与 var c uint64 = b 进行复制的原因相同:如果 b 后来发生了变化,sc 应该保留原始值,而不是新值。存储在接口中的值可能非常大,但是在接口结构中只有一个字用于保存值,因此赋值会在堆上分配一块内存并在一个字的槽位中记录指针。

要检查接口值是否包含特定类型,如上面的类型切换,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

编译器为每个具体类型如 Binaryintfunc(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




© 2023 北京元石科技有限公司 ◎ 京公网安备 11010802042949号