kevinyan815/gocookbook

接口方法调用时的动态派发

kevinyan815 opened this issue · 0 comments

注:所有内容摘录自 Go 语言接口的实现原理,这里是读书笔记。

动态派发(Dynamic dispatch)是在运行期间选择具体多态操作(方法或者函数)执行的过程,它是面向对象语言中的常见特性。Go 语言虽然不是严格意义上的面向对象语言,但是接口的引入为它带来了动态派发这一特性,调用接口类型的方法时,如果编译期间不能确认接口的类型,Go语言会在运行期间决定具体调用该方法的哪个实现

在如下所示的代码中,main 函数调用了两次 Quack 方法:

  1. 第一次以 Duck 接口类型的身份调用,调用时需要经过运行时的动态派发;
  2. 第二次以 *Cat 具体类型的身份调用,编译期就会确定调用的函数:
package main

type Duck interface {
	Quack()
}

type Cat struct {
	Name string
}

//go:noinline
func (c Cat) Quack() {
	println(c.Name + " meow")
}

func main() {
	var c Duck = &Cat{Name: "draven"}
	c.Quack()
	c.(*Cat).Quack()
}

因为编译器优化影响了我们对原始汇编指令的理解,所以需要使用编译参数 -N 关闭编译器优化。如果不指定这个参数,编译器会对代码进行重写,与最初生成的执行过程有一些偏差,例如:

  • 因为接口类型中的 tab 参数并没有被使用,所以优化从 Cat 转换到 Duck 的过程;
  • 因为变量的具体类型是确定的,所以删除从 Duck 接口类型转换到 *Cat 具体类型时可能会发生崩溃的分支;

c.Quack的执行过程可以分成以下三个步骤:

  1. 从接口变量中获取保存 Cat.Quack 方法指针的 tab.func[0]
  2. 接口变量在 runtime.iface 中的数据会被拷贝到栈顶;
  3. 方法指针会被拷贝到寄存器中并通过汇编指令 CALL 触发:

另一个调用 Quack 方法的语句 c.(*Cat).Quack() 代码前半部分都是在做类型转换,将接口类型转换成 *Cat 类型,最后才是方法调用。

基准测试

下面代码中的两个方法 BenchmarkDirectCallBenchmarkDynamicDispatch 分别会调用结构体方法和接口方法,在接口上调用方法时会使用动态派发机制,我们以直接调用作为基准分析动态派发带来了多少额外开销:

func BenchmarkDirectCall(b *testing.B) {
	c := &Cat{Name: "draven"}
	for n := 0; n < b.N; n++ {
		// MOVQ	AX, "".c+24(SP)
		// MOVQ	AX, (SP)
		// CALL	"".(*Cat).Quack(SB)
		c.Quack()
	}
}

func BenchmarkDynamicDispatch(b *testing.B) {
	c := Duck(&Cat{Name: "draven"})
	for n := 0; n < b.N; n++ {
		// MOVQ	"".d+56(SP), AX
		// MOVQ	24(AX), AX
		// MOVQ	"".d+64(SP), CX
		// MOVQ	CX, (SP)
		// CALL	AX
		c.Quack()
	}
}

我们直接运行下面的命令,使用 1 个 CPU 运行上述代码,每一个基准测试都会被执行 3 次:

$ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s -bench=.
goos: darwin
goarch: amd64
pkg: github.com/golang/playground
BenchmarkDirectCall      	500000000	         3.11 ns/op	       0 B/op	       0 allocs/op
BenchmarkDirectCall      	500000000	         2.94 ns/op	       0 B/op	       0 allocs/op
BenchmarkDirectCall      	500000000	         3.04 ns/op	       0 B/op	       0 allocs/op
BenchmarkDynamicDispatch 	500000000	         3.40 ns/op	       0 B/op	       0 allocs/op
BenchmarkDynamicDispatch 	500000000	         3.79 ns/op	       0 B/op	       0 allocs/op
BenchmarkDynamicDispatch 	500000000	         3.55 ns/op	       0 B/op	       0 allocs/op
  • 调用结构体方法时,每一次调用需要 ~3.03ns;
  • 使用动态派发时,每一调用需要 ~3.58ns;

在关闭编译器优化的情况下,从上面的数据来看,动态派发生成的指令会带来 ~18% 左右的额外性能开销。

这些性能开销在一个复杂的系统中不会带来太多的影响。一个项目不可能只使用动态派发,而且如果我们开启编译器优化后,动态派发的额外开销会降低至 ~5%,这对应用性能的整体影响就更小了,所以与使用接口带来的好处相比,动态派发的额外开销往往可以忽略

上面的性能测试建立在实现和调用方法的都是结构体指针上,当我们将结构体指针换成结构体又会有比较大的差异:

func BenchmarkDirectCall(b *testing.B) {
	c := Cat{Name: "draven"}
	for n := 0; n < b.N; n++ {
		// MOVQ	AX, (SP)
		// MOVQ	$6, 8(SP)
		// CALL	"".Cat.Quack(SB)
		c.Quack()
	}
}

func BenchmarkDynamicDispatch(b *testing.B) {
	c := Duck(Cat{Name: "draven"})
	for n := 0; n < b.N; n++ {
		// MOVQ	16(SP), AX
		// MOVQ	24(SP), CX
		// MOVQ	AX, "".d+32(SP)
		// MOVQ	CX, "".d+40(SP)
		// MOVQ	"".d+32(SP), AX
		// MOVQ	24(AX), AX
		// MOVQ	"".d+40(SP), CX
		// MOVQ	CX, (SP)
		// CALL	AX
		c.Quack()
	}
}

当我们重新执行相同的基准测试时,会得到如下所示的结果:

$ go test -gcflags=-N -benchmem -test.count=3 -test.cpu=1 -test.benchtime=1s .
goos: darwin
goarch: amd64
pkg: github.com/golang/playground
BenchmarkDirectCall      	500000000	         3.15 ns/op	       0 B/op	       0 allocs/op
BenchmarkDirectCall      	500000000	         3.02 ns/op	       0 B/op	       0 allocs/op
BenchmarkDirectCall      	500000000	         3.09 ns/op	       0 B/op	       0 allocs/op
BenchmarkDynamicDispatch 	200000000	         6.92 ns/op	       0 B/op	       0 allocs/op
BenchmarkDynamicDispatch 	200000000	         6.91 ns/op	       0 B/op	       0 allocs/op
BenchmarkDynamicDispatch 	200000000	         7.10 ns/op	       0 B/op	       0 allocs/op

直接调用方法需要消耗时间的平均值和使用指针实现接口时差不多,约为 ~3.09ns,而使用动态派发调用方法却需要 ~6.98ns 相比直接调用额外消耗了 ~125% 的时间,从生成的汇编指令我们也能看出后者的额外开销会高很多。

直接调用 动态派发
指针 ~3.03ns ~3.58ns
结构体 ~3.09ns ~6.98ns

*表1直接调用和动态派发的性能对比

从上述表格我们可以看到使用结构体实现接口带来的开销会大于使用指针实现,而动态派发在结构体上的表现非常差,这也提醒我们应当尽量避免使用结构体类型实现接口

使用结构体带来的巨大性能差异不只是接口带来的问题,带来性能问题主要因为Go语言在函数调用时是传值的,动态派发的过程只是放大了参数拷贝带来的影响。