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

了解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的方法,而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

https://research.swtch.com/interfaces

https://darkcoding.net/software/go-the-price-of-interface/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值