我可能并不会使用golang interface

3,387 阅读16分钟

谈到interface,我们大致应该会有这样的疑问

  • interface是什么?
  • 他跟面向对象语言中的接口有啥区别?
  • 他的底层原理是什么样的?
  • interface的优缺点是什么?
  • interface有哪些常见的特殊情况和使用技巧?

上述大概涵盖了,我们的主要的疑问,有问题是好事儿,我们慢慢来看看。

1.什么是interface

在Go中,接口是一组方法签名。 当类型为接口中的所有方法提供定义时,就说实现了该接口。 它与OOP世界非常相似。 接口指定类型应具有的方法,类型决定如何实现这些方法。

例如,WashingMachine可以是具有方法签名Cleaning()Drying()的接口。 任何提供Cleaning()Drying()方法定义的类型都可以说是实现了WashingMachine接口。

2. 和其他语言中的接口的异同

很多面向对象语言都有接口这一概念,例如 Java 和 C#。Java 的接口不仅可以定义方法签名,还可以定义变量,这些定义的变量可以直接在实现接口的类中使用:

public interface PersonInterface {
    public String name = "defalut";
    public void sayHello();
}

上述代码定义了一个必须实现的方法 sayHello 和一个会注入到实现类的变量 name。在下面的代码中,PersonInterfaceImpl 就实现了 PersonInterface 接口:

public class PersonInterfaceImpl implements PersonInterface {
    public void sayHello() {
        System.out.println(MyInterface.hello);
    }
}

Java 中的类必须通过上述方式显式地声明实现的接口,但是在 Go 语言中实现接口就不需要使用类似的方式。首先,我们简单了解一下在 Go 语言中如何定义接口。定义接口需要使用 interface 关键字,在接口中我们只能定义方法签名,不能包含成员变量,一个常见的 Go 语言接口是这样的:

type Handler interface {
	ServeHTTP(ResponseWriter, *Request)
}

如果一个类型需要实现 Handler 接口,那么它只需要实现 ServeHTTP(ResponseWriter, *Request)方法,下面的 "github.com/julienschmidt/httprouter" 软件包的Router结构体就是 ServeHTTP 接口的一个实现:

// ServeHTTP makes the router implement the http.Handler interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request)

细心的读者可能会发现上述代码根本就没有 Handler 接口的影子,这是为什么呢?Go 语言中接口的实现都是隐式的,我们只需要实现 ServeHTTP(ResponseWriter, *Request)方法实现了 Handler 接口。Go 语言实现接口的方式与 Java 完全不同:

  • 在 Java 中:实现接口需要显式的声明接口并实现所有方法;
  • 在 Go 中:实现接口的所有方法就隐式的实现了接口;

我们使用上述 Router 结构体时并不关心它实现了哪些接口,Go 语言只会在传递参数、返回参数以及变量赋值时才会对某个类型是否实现接口进行检查.

3. 他的底层原理是什么样的?

接口也是 Go 语言中的一种类型,它能够出现在变量的定义、函数的入参和返回值中并对它们进行约束。但是空接口类型interface{}是一个特殊的类型,他能够作为任何一种类型的接受类型。为了更好的深入后面的内容,我们先来了解一下函数和方法调用: Go中有4种不同类型的函数:

  • 顶级函数
  • 值接受者函数
  • 指针接受者函数
  • 函数字面量

5种不同类型的调用:

  • 直接调用顶级函数
  • 直接调用值接受者函数
  • 直接调用指针接受者函数
  • 接口上方法的间接调用
  • 函数值的间接调用

它们混合在一起,构成了功能和调用类型的10种可能的组合:

  • 直接调用顶级函数/
  • 直接调用带有值接收器的方法/
  • 直接调用带有指针接收器的方法/
  • 接口上方法的间接调用/包含值方法的值/
  • 接口上的方法的间接调用/包含带有值方法的指针
  • 接口上的方法的间接调用/包含带有指针方法的指针
  • 间接调用func值/设置为顶级func
  • 间接调用func值/设置为value方法
  • 间接调用func值/设置为指针方法
  • 间接调用func值/设置为字面量func

(斜杠将编译时已知的内容与仅在运行时发现的内容分隔开。)

我们将首先花几分钟来回顾这三种直接调用,然后在本章的其余部分中,我们将重点转移到接口和间接方法调用上。 我们不会在本章中介绍函数字面量,因为这样做首先需要我们熟悉闭包的机制..我们将不可避免地在适当的时候这样做。

3.1的直接调用概述

看一下下面的例子:

package main

func Add(a, b int32) int32 {
	return a + b 
}

type Adder struct{
	id int32 
}
//go:noinline
func (adder *Adder) AddPtr(a, b int32) int32 {
	return a + b
}
//go:noinline
func (adder Adder) AddVal(a, b int32) int32 {
	return a + b
}

func main() {
    Add(10, 32) // direct call of top-level function

    adder := Adder{id: 6754}
    adder.AddPtr(10, 32) // direct call of method with pointer receiver
    adder.AddVal(10, 32) // direct call of method with value receiver

    (&adder).AddVal(10, 32) // implicit dereferencing
}

让我们快速查看为这4个调用中的每个调用生成的代码。

  • 直接调用顶级函数
0x0021 00033 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:41)	PCDATA	$0, $0
	0x0021 00033 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:41)	MOVQ	$137438953482, AX
	0x002b 00043 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:41)	MOVQ	AX, (SP)
	0x002f 00047 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:41)	CALL	"".Add(SB)
	0x0034 00052 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:43)	MOVL	$0, "".adder+24(SP)

正如我们从第一章已经知道的那样,我们看到这转化为直接跳转到.text节中的全局函数符号,并将参数和返回值存储在调用者的堆栈框架中。

直接调用顶级函数:直接调用顶级函数会传递堆栈上的所有参数,并期望结果占据后续的堆栈位置。
  • 直接调用带有指针接收器的方法

首先,接收器通过adder := Adder{id: 6754}

0x003c 00060 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:43)	MOVL	$6754, "".adder+24(SP)

(我们的堆栈帧上的多余空间已作为帧指针前导码的一部分进行了预先分配,为简洁起见,此处未显示。) 然后是对adder.AddPtr(10, 32) 的实际方法调用:

0x0044 00068 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	PCDATA	$2, $1
	0x0044 00068 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	LEAQ	"".adder+24(SP), AX
	0x0049 00073 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	PCDATA	$2, $0
	0x0049 00073 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	MOVQ	AX, (SP)
	0x004d 00077 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	MOVQ	$137438953482, AX
	0x0057 00087 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	MOVQ	AX, 8(SP)
	0x005c 00092 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:44)	CALL	"".(*Adder).AddPtr(SB)

查看汇编输出,我们可以清楚地看到,对方法的调用(无论它具有值接收器还是指针接收器)与函数调用几乎相同,唯一的区别是接收器作为第一个参数传递。 在这种情况下,我们通过在帧的顶部加载有效地址(LEAQ)"".adder+28(SP)的来做到这一点,从而使第一个参数成为·&adder. 请注意,编译器如何编码接收器的类型,以及它是直接在符号名称中的值还是指针:

"".(*Adder).AddPtr
直接调用方法:为了对func值的间接调用和直接调用使用相同的生成代码,选择为方法(值和指针接收器)生成的代码,使其具有与顶层函数相同的调用约定。 以接收者为主导。
  • 使用值接收器直接调用方法

如我们所料,使用值接收器会产生与上面非常相似的代码。 看一下adder.AddVal(10, 32):

	0x0061 00097 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:45)	MOVL	"".adder+24(SP), AX
	0x0065 00101 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:45)	MOVL	AX, (SP)
	0x0068 00104 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:45)	MOVQ	$137438953482, AX
	0x0072 00114 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:45)	MOVQ	AX, 4(SP)
	0x0077 00119 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:45)	CALL	"".Adder.AddVal(SB)

不过,看起来似乎有些棘手:生成的程序集甚至在任何地方都没有引用"".adder + 28(SP),即使我们的接收器当前位于该位置。 那么,这里到底发生了什么? 好吧,由于接收者是一个值,并且由于编译器能够静态推断该值,它不会从当前位置(28(SP))复制现有值,而是直接在堆栈上创建一个新的,相同的Adder值,并将此操作与第二个参数的创建合并以保存 在此过程中再增加一条指令。再次注意该方法的符号名如何明确表示它期望值接收器。

隐式取消引用

我们还没有看到最后一个调用:(&adder).AddVal(10,32).在这种情况下,我们使用指针变量来调用一个期望值接收器的方法。 Go会以某种方式自动取消引用指针并设法进行调用。 为何如此?

编译器如何处理这种情况取决于所指向的接收方是否已转义到堆中。

情况1:接收者在堆栈上 如果接收器仍在堆栈上,并且其大小足够小,可以按几条指令进行复制(如此处的情况),则编译器只需将其值复制到堆栈的顶部,然后对它进行简单的方法调用即可。 无聊(尽管有效)。 让我们继续进行案例B。

情况2:接收者在堆上

如果接受者已经逃逸到了堆上,编译器需要采用一个巧妙的方法:它将产生一个新的方法(带有指针接受者),包裹"".Adder.AddVal,并且替换原始被包裹者调用"".Adder.AddVal为一个包裹者调用"".(*Adder).AddVal

因此,包装器的唯一任务是确保接收者在传递给包装器之前已被正确解除引用,并且确保所涉及的所有参数和返回值都在调用者和包装器之间正确地来回复制。

注意:在汇编输出中,这些包装器方法被标记为<autogenerated>

下面是生成的包装器的带注释的清单,希望能帮助您理清头绪

"".(*Adder).AddVal STEXT dupok size=147 args=0x18 locals=0x28
	0x0000 00000 (<autogenerated>:1)	TEXT	"".(*Adder).AddVal(SB), DUPOK|WRAPPER|ABIInternal, $40-24
	... // 省略其他部分
	0x0026 00038 (<autogenerated>:1)	MOVL	$0, "".~r2+64(SP)
	0x002e 00046 (<autogenerated>:1)	CMPQ	""..this+48(SP), $0 // 检测接受者是否为空
	0x0034 00052 (<autogenerated>:1)	JNE	56
	0x0036 00054 (<autogenerated>:1)	JMP	115      // 如果为nil,跳到115 panic
	0x0038 00056 (<autogenerated>:1)	PCDATA	$2, $1
	0x0038 00056 (<autogenerated>:1)	PCDATA	$0, $1
	0x0038 00056 (<autogenerated>:1)	MOVQ	""..this+48(SP), AX
	0x003d 00061 (<autogenerated>:1)	TESTB	AL, (AX)
	0x003f 00063 (<autogenerated>:1)	PCDATA	$2, $0
	0x003f 00063 (<autogenerated>:1)	MOVL	(AX), AX  // 解引用指针接收器
	0x0041 00065 (<autogenerated>:1)	MOVL	AX, ""..autotmp_5+24(SP)
	0x0045 00069 (<autogenerated>:1)	MOVL	AX, (SP)  // 并将参数值移到参数1
	0x0048 00072 (<autogenerated>:1)	MOVL	"".a+56(SP), AX
	0x004c 00076 (<autogenerated>:1)	MOVL	AX, 4(SP)
	0x0050 00080 (<autogenerated>:1)	MOVL	"".b+60(SP), AX
	0x0054 00084 (<autogenerated>:1)	MOVL	AX, 8(SP)
	0x0058 00088 (<autogenerated>:1)	CALL	"".Adder.AddVal(SB)  // 调用被包装者方法
	0x005d 00093 (<autogenerated>:1)	MOVL	16(SP), AX  // copy被包装这返回值
	0x0061 00097 (<autogenerated>:1)	MOVL	AX, ""..autotmp_4+28(SP)
	0x0065 00101 (<autogenerated>:1)	MOVL	AX, "".~r2+64(SP)
	0x0069 00105 (<autogenerated>:1)	MOVQ	32(SP), BP
	0x006e 00110 (<autogenerated>:1)	ADDQ	$40, SP
	0x0072 00114 (<autogenerated>:1)	RET
	0x0073 00115 (<autogenerated>:1)	CALL	runtime.panicwrap(SB)
	0x0078 00120 (<autogenerated>:1)	UNDEF

显然,考虑到为了往返传递参数而需要进行的所有复制,这种包装器可能会导致相当多的开销。 特别是在被包装的只是一些指令的情况下。 幸运的是,实际上,编译器会直接将包装内联到包装器中以分摊这些成本(至少在可行时)。

请注意符号定义中的WRAPPER指令,该指令指示该方法不应出现在回溯中(以免使最终用户感到困惑),也不能从被包装者引发的恐慌中恢复 。

WRAPPER:这是一个包装函数,不应视为禁用恢复。

如果包装的接收者为nil,则runtime.panicwrap函数会引发恐慌,这很容易解释。 这是其完整列表,以供参考

// 如果通过一个nil指针接受者调用被包装的值方法panicwrap将产生恐慌
// 从生成的包装器代码中调用它。
func panicwrap() {
	pc := getcallerpc()
	name := funcname(findfunc(pc))
	// name is something like "main.(*T).F".
	// We want to extract pkg ("main"), typ ("T"), and meth ("F").
	// Do it by finding the parens.
	i := bytealg.IndexByteString(name, '(')
	if i < 0 {
		throw("panicwrap: no ( in " + name)
	}
	pkg := name[:i-1]
	if i+2 >= len(name) || name[i-1:i+2] != ".(*" {
		throw("panicwrap: unexpected string after package name: " + name)
	}
	name = name[i+2:]
	i = bytealg.IndexByteString(name, ')')
	if i < 0 {
		throw("panicwrap: no ) in " + name)
	}
	if i+2 >= len(name) || name[i:i+2] != ")." {
		throw("panicwrap: unexpected string after type name: " + name)
	}
	typ := name[:i]
	meth := name[i+2:]
	panic(plainError("value method " + pkg + "." + typ + "." + meth + " called using nil *" + typ + " pointer"))
}

这就是函数和方法调用的全部内容,我们现在将重点介绍主要内容:接口。

3.2 接口的解析

  • 数据结构的概况 在理解它们如何工作之前,我们首先需要构建组成接口的数据结构的心智模型,以及它们在内存中是如何布局的。 为此,我们将快速浏览一下runtime包,以了解从Go实现的角度来看,接口实际上是什么样子的。

iface结构体

type iface struct {
	tab  *itab
	data unsafe.Pointer
}

因此,接口是一个非常简单的结构,它维护2个指针:

  • tab保存了一个itab对象的地址,它嵌入了描述接口类型和它所指向的数据类型的数据结构。
  • data是指向该接口保存的值的原始(例如:unsafe)指针。

虽然这个定义非常简单,但它已经为我们提供了一些有价值的信息:因为接口只能保存指针,所以我们封装到接口中的任何具体值都必须有它的地址。

通常,这会导致堆分配,因为编译器采用保守的路由并迫使接收器转义。

即使标量类型也是如此!

package main


type Addifier interface{ 
	Add(a, b int32) int32 
}

type Adder struct{ 
	name string 
}


//go:noinline
func (adder Adder) Add(a, b int32) int32 {
	return a + b 
}

func main() {
    adder := Adder{name: "myAdder"}
    adder.Add(10, 32)	      // doesn't escape
    Addifier(adder).Add(10, 32) // escapes
}
➜  simpletest go tool compile -m demo2.go
demo2.go:14:7: Adder.Add adder does not escape
demo2.go:21:13: Addifier(adder) escapes to heap
<autogenerated>:1: (*Adder).Add .this does not escape
<autogenerated>:1: leaking param: .this
➜  simpletest

我们可以清楚地看到,每次创建新的Addifier接口并使用我们的adder变量对其进行初始化时,实际上都会发生sizeof(Adder)的堆分配。

在本章的后面,我们将看到与接口一起使用时,即使简单的标量类型也可以导致堆分配。

让我们将注意力转向下一个数据结构:itab

// layout of Itab known to compilers
// allocated in non-garbage-collected memory
// Needs to be in sync with
// ../cmd/compile/internal/gc/reflect.go:/^func.dumptypestructs.
type itab struct {
	inter *interfacetype
	_type *_type
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
	fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

itab是接口类型的核心。

首先,它嵌入_type,它是运行时内任何Go类型的内部表示。

_type描述类型的每个方面:其名称,其特征(例如大小,对齐方式...),以及某种程度上的行为方式(例如比较,哈希...)!

在本示例下,_type字段描述了接口保存的值的类型,即data指针指向的值。

其次,我们找到一个指向interfacetype的指针,它只是_type的包装,其中包含一些特定于接口的额外信息。

如您所料,inter字段描述了接口本身的类型。

最后,fun数组包含构成接口的虚拟/调度表的函数指针。

请注意,// variable sized的注释,这意味着声明此数组的大小无关紧要。我们将在本章后面看到,编译器负责分配支持该数组的内存,并且独立于此处指示的大小进行分配。 同样,运行时始终使用原始指针访问此数组,因此边界检查不适用于此处。

_type数据结构


// Needs to be in sync with ../cmd/link/internal/ld/decodesym.go:/^func.commonsize,
// ../cmd/compile/internal/gc/reflect.go:/^func.dcommontype and
// ../reflect/type.go:/^type.rtype.
type _type struct {
	size       uintptr
	ptrdata    uintptr // size of memory prefix holding all pointers
	hash       uint32
	tflag      tflag
	align      uint8
	fieldalign uint8
	kind       uint8
	alg        *typeAlg
	// gcdata stores the GC type data for the garbage collector.
	// If the KindGCProg bit is set in kind, gcdata is a GC program.
	// Otherwise it is a ptrmask bitmap. See mbitmap.go for details.
	gcdata    *byte
	str       nameOff
	ptrToThis typeOff
}

如上所述,_type结构给出了Go类型的完整描述。值得庆幸的是,这些字段大多数都是不言而喻的。

nameOfftypeOff类型是链接器嵌入到最终可执行文件中的元数据的int32偏移量。该元数据在运行时加载到runtime.moduledata结构中,如果您曾经查看过ELF文件的内容,那么它应该看起来非常相似。

运行时提供了一些帮助程序,这些帮助程序实现了必要的逻辑,以便通过moduledata结构跟踪这些偏移量,例如resolveNameOffresolveTypeOff.

func resolveNameOff(ptrInModule unsafe.Pointer, off nameOff) name {}
func resolveTypeOff(ptrInModule unsafe.Pointer, off typeOff) *_type {}

即,假设t_type,则调用resolveTypeOff(t,t.ptrToThis)返回t的副本。

interfacetype结构体:

type interfacetype struct {
	typ     _type
	pkgpath name
	mhdr    []imethod
}

type imethod struct {
	name nameOff
	ityp typeOff
}

如前所述,interfacetype只是一个_type的包装器,在其上添加了一些额外的特定于接口的元数据。

在当前的实现中,此元数据主要由偏移量列表组成,这些偏移量指向接口([]imethod)公开的方法的相应名称和类型。

这是iface内联所有子类型表示时的外观的概述。 希望这将有助于连接所有的点:

type iface struct { // `iface`
    tab *struct { // `itab`
        inter *struct { // `interfacetype`
            typ struct { // `_type`
                size       uintptr
                ptrdata    uintptr
                hash       uint32
                tflag      tflag
                align      uint8
                fieldalign uint8
                kind       uint8
                alg        *typeAlg
                gcdata     *byte
                str        nameOff
                ptrToThis  typeOff
            }
            pkgpath name
            mhdr    []struct { // `imethod`
                name nameOff
                ityp typeOff
            }
        }
        _type *struct { // `_type`
            size       uintptr
            ptrdata    uintptr
            hash       uint32
            tflag      tflag
            align      uint8
            fieldalign uint8
            kind       uint8
            alg        *typeAlg
            gcdata     *byte
            str        nameOff
            ptrToThis  typeOff
        }
        hash uint32
        _    [4]byte
        fun  [1]uintptr
    }
    data unsafe.Pointer
}

本节介绍构成接口的不同数据类型,以帮助我们开始构建涉及整个机械的各种齿轮的思维模型,以及它们如何相互配合。

在下一节中,我们将学习如何实际计算这些数据结构。

3.3 创建一个接口

现在,我们已经快速浏览了所有涉及的数据结构,我们将集中讨论如何实际分配和初始化它们。

package main


type Mather interface {
    Add(a, b int32) int32
    Sub(a, b int64) int64
}

type Adder struct{
	id int32
}
//go:noinline
func (adder Adder) Add(a, b int32) int32 {
	return a + b
}
//go:noinline
func (adder Adder) Sub(a, b int64) int64 {
	return a - b
}

func main() {
    m := Mather(Adder{id: 6754})

    // 这个调用仅仅确定接口是使用的,
    // 没有这个调用,连接器将看到接口是定义了的,但是事实上并没有被使用。
    // 并因此会被优化掉
    m.Add(10, 32)
}
注意:接下来,我们将使用<I,T>标识一个持有T类型的接口I,例如Mather(Adder{id:6754})实例一个iface为<Mather,Adder>

让我们放大一下的实例化iface<Mather, Adder>:

m := Mather(Adder{id: 6754})

这行Go代码实际上引起了相当多的麻烦,因为编译器生成的汇编清单可以证明:

	0x001d 00029 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVL	$0, ""..autotmp_1+28(SP)
	0x0025 00037 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVL	$6754, ""..autotmp_1+28(SP)
	0x002d 00045 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVL	$6754, (SP)
	0x0034 00052 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	CALL	runtime.convT32(SB)
	0x0039 00057 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	PCDATA	$2, $1
	0x0039 00057 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVQ	8(SP), AX
	0x003e 00062 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVQ	AX, ""..autotmp_2+32(SP)
	0x0043 00067 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	PCDATA	$2, $2
	0x0043 00067 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	LEAQ	go.itab."".Adder,"".Mather(SB), CX
	0x004a 00074 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVQ	CX, "".m+40(SP)
	0x004f 00079 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVQ	AX, "".m+48(SP)

我们分为三部分来说明

  • 1.分配接受者
	0x0025 00037 (/Users/zhaojunwei/workspace/src/just.for.test/interface/simpletest/demo2.go:16)	MOVL	$6754, ""..autotmp_1+28(SP)

常数十进制值6754(与我们的AdderID对应)存储在当前堆栈帧的开头。它存储在此处,以便编译器以后可以通过其地址引用它。 我们将在第3部分中了解原因。

  • 2.设置itab
	0x0043 00067 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	LEAQ	go.itab."".Adder,"".Mather(SB), CX
	0x004a 00074 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	PCDATA	$0, $1
	0x004a 00074 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVQ	CX, "".m+40(SP)

看起来编译器已经创建必要的itab代表iface<Mater,Adder>接口,并使它通过一个全局的代码go.itab."".Adder,"".Mather,让我们可以使用。

我们正在构建iface <Mather,Adder>接口,为此,我们正在加载此全局go.itab."".Adder,"".Mather符号的有效地址在当前堆栈帧的顶部。再一次,我们将在第3部分中看到原因。

从语义上讲,这为我们提供了以下伪代码的含义:

tab := getSymAddr(`go.itab.main.Adder,main.Mather`).(*itab)

那就是我们接口的一半!

现在,在我们探讨它的同时,让我们更深入地了解一下go.itab."".Adder,"".Mather 像往常一样,编译器的-S标志可以告诉我们很多信息:

go.itab."".Adder,"".Mather SRODATA dupok size=40
	0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
	0x0010 8a 3d 5f 61 00 00 00 00 00 00 00 00 00 00 00 00  .=_a............
	0x0020 00 00 00 00 00 00 00 00                          ........
	rel 0+8 t=1 type."".Mather+0
	rel 8+8 t=1 type."".Adder+0
	rel 24+8 t=1 "".(*Adder).Add+0
	rel 32+8 t=1 "".(*Adder).Sub+0

整齐。 让我们逐一分析。

第一部分声明该符号及其属性:

go.itab."".Adder,"".Mather SRODATA dupok size=40

和往常一样,由于我们直接查看由编译器生成的中间目标文件(即,链接器尚未运行),因此符号名称仍缺少程序包名称。 在这方面没有新内容。

除此之外,我们在这里得到的是一个40字节的全局对象符号,该符号将存储在二进制文件的.rodata节中。

请注意dupok指令,该指令告诉链接器该符号在链接时多次出现是合法的:链接器将不得不任意选择其中一个。

第二部分是与符号关联的40字节数据的十六进制转储。 即,它是itab结构的序列化表示形式:

	0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
	0x0010 8a 3d 5f 61 00 00 00 00 00 00 00 00 00 00 00 00  .=_a............
	0x0020 00 00 00 00 00 00 00 00                          ........

如您所见,此时大多数数据只是一堆零。 稍后我们会看到,链接器将负责填充它们。

请注意,在所有这些零中,实际上是如何设置4个字节的,偏移量为0x10 + 4。 如果我们回顾一下itab结构的声明并注释其字段的各个偏移量:

type itab struct { // 40 bytes on a 64bit arch
    inter *interfacetype // offset 0x00 ($00)
    _type *_type	 // offset 0x08 ($08)
    hash  uint32	 // offset 0x10 ($16)
    _     [4]byte	 // offset 0x14 ($20)
    fun   [1]uintptr	 // offset 0x18 ($24)
			 // offset 0x20 ($32)
}

我们看到偏移量0x10 + 4与哈希uint32字段匹配:即对应于我们main.Adder类型的哈希值已经在目标文件中了。

第三部分也是最后一部分列出了链接器的一堆重定位指令:

	rel 0+8 t=1 type."".Mather+0
	rel 8+8 t=1 type."".Adder+0
	rel 24+8 t=1 "".(*Adder).Add+0
	rel 32+8 t=1 "".(*Adder).Sub+0

rel 0+8 t=1 type."".Mather+0告诉链接器使用全局对象符号type."".Mather的地址填充首八个字节的内容。

rel 8+8 t=1 type."".Adder+0 使用type."".Adder的地址填充接下来的8字节。等等等等

链接器完成其工作并遵循所有这些指令后,我们40字节序列化的itab将完成。总体而言,我们现在正在研究类似于以下伪代码的内容:

tab := getSymAddr(`go.itab.main.Adder,main.Mather`).(*itab)

// 注意:在构建可执行程序时,链接器将去除符号的`type.`前缀,
所以在二进制.rodata部分符号名将是`main.Mather``main.Adder`
// 而不是`type.main.Mather` 和 `type.main.Adder`.
// 在玩转objdump时不要被这个绊倒。
tab.inter = getSymAddr(`type.main.Mather`).(*interfacetype)
tab._type = getSymAddr(`type.main.Adder`).(*_type)

tab.fun[0] = getSymAddr(`main.(*Adder).Add`).(uintptr)
tab.fun[1] = getSymAddr(`main.(*Adder).Sub`).(uintptr)

我们已经准备好了一个易于使用的itab,现在,如果我们只附带一些数据,那将是一个不错的,完整的接口。

  • 3.设置数据
	0x001d 00029 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVL	$0, ""..autotmp_1+28(SP)
	0x0025 00037 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVL	$6754, ""..autotmp_1+28(SP)
	0x002d 00045 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVL	$6754, (SP)
	0x0034 00052 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	CALL	runtime.convT32(SB)
	0x0039 00057 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	PCDATA	$0, $1
	0x0039 00057 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVQ	8(SP), AX
	0x003e 00062 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVQ	AX, ""..autotmp_2+32(SP)
	0x0043 00067 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	PCDATA	$0, $2
	0x0043 00067 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	PCDATA	$1, $1
	0x0043 00067 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	LEAQ	go.itab."".Adder,"".Mather(SB), CX
	0x004a 00074 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	PCDATA	$0, $1
	0x004a 00074 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVQ	CX, "".m+40(SP)
	0x004f 00079 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	PCDATA	$0, $0
	0x004f 00079 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:22)	MOVQ	AX, "".m+48(SP)
	0x0054 00084 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	"".m+40(SP), AX

在第二部分中,我们已经将一个十进制常数$6754存储到""..autotmp_1+28(SP)。这个值将作为参数传递给runtime.convT32,看一下这个函数

func convT32(val uint32) (x unsafe.Pointer) {
	if val == 0 {
		x = unsafe.Pointer(&zeroVal[0])
	} else {
		x = mallocgc(4, uint32Type, false)
		*(*uint32)(x) = val
	}
	return
}

从可执行文件重建Itab

在上一节中,我们转储了go.itab."".Adder,"".Mather直接从编译器生成的目标文件中查看最终大部分为零的blob(散列值除外):

go.itab."".Adder,"".Mather SRODATA dupok size=40
	0x0000 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
	0x0010 8a 3d 5f 61 00 00 00 00 00 00 00 00 00 00 00 00  .=_a............
	0x0020 00 00 00 00 00 00 00 00                          ........

为了更好地了解如何将数据布局到链接器生成的最终可执行文件中,我们将遍历生成的ELF文件并手动重建构成iface <Mather,Adder>的itab的字节。 希望这将使我们能够在链接器完成工作后观察itab的外观。

首先,让我们构建iface二进制文件:GOOS = linux GOARCH = amd64 go build -o iface.bin iface.go

  • 1.寻找.rodata

让我们打印部分标题以搜索.rodatareadelf可以帮助您:

interfacetest GOOS=linux GOARCH=amd64 go build -o main.bin main.gointerfacetest readelf -St -W main.bin                             
There are 25 section headers, starting at offset 0x1c8:

节头:
  [号] 名称
       Type            Address          Off    Size   ES   Lk Inf Al
       旗标
  [ 0] 
       NULL            0000000000000000 000000 000000 00   0   0  0
       [0000000000000000]: 
  [ 1] .text
       PROGBITS        0000000000401000 001000 0517ae 00   0   0 16
       [0000000000000006]: ALLOC, EXEC
  [ 2] .rodata
       PROGBITS        0000000000453000 053000 030b00 00   0   0 32
       [0000000000000002]: ALLOC

我们真正需要的是该部分的(十进制)偏移量,因此让我们应用一些pipe-foo:

➜  interfacetest readelf -St -W main.bin | \ 
  grep -A 1 .rodata | \
  tail -n +2 | \
  awk '{print "ibase=16;"toupper($3)}' | \
  bc
339968

这意味着将315392字节存储到二进制文件中应将我们放在.rodata节的开头。

现在,我们要做的就是将此文件位置映射到虚拟内存地址。

  • 2.查找.rodata的虚拟内存地址(VMA)

VMA是虚拟地址,一旦二进制文件已由OS加载到内存中,该节将被映射到该虚拟地址。 也就是说,这是我们在运行时用来引用符号的地址。

➜  interfacetest readelf -St -W main.bin | \ 
  grep -A 1 .rodata | \
  tail -n +2 | \
  awk '{print "ibase=16;"toupper($2)}' | \
  bc
4534272

在这种情况下,我们关心VMA的原因是我们无法直接向readelf或objdump请求特定符号(AFAIK)的偏移量。 另一方面,我们所能做的就是索要特定符号的VMA。

结合一些简单的数学运算,我们应该能够在VMA和偏移量之间建立映射,并最终找到所需符号的偏移量。

因此,这就是到目前为止我们所知道的:.rodata节位于ELF文件中的偏移$ 315392(= 0x04d000)处,它将在运行时映射到虚拟地址$ 4509696(= 0x44d000)

现在,我们需要VMA以及所需符号的大小:

- 它的VMA将(间接)允许我们在可执行文件中定位它。
- 一旦找到正确的偏移量,它的大小将告诉我们要提取多少数据。
  • 3.查找VMA和go.itab."".Adder,"".Mather大小

objdump为我们提供了那些。

首先,找到符号:

➜  simpletest objdump -t -j .rodata iface.bin | grep "go.itab.main.Adder,main.Mather"
000000000047dcc0 g     O .rodata	0000000000000028 go.itab.main.Adder,main.Mather

然后,以十进制形式获取其VMA:

➜  simpletest objdump -t -j .rodata iface.bin | \
  grep "go.itab.main.Adder,main.Mather" | \
  awk '{print "ibase=16;"toupper($1)}' | \
  bc
4709568

最后,以十进制形式获取其大小:

➜  simpletest objdump -t -j .rodata iface.bin | \
  grep "go.itab.main.Adder,main.Mather" | \
  awk '{print "ibase=16;"toupper($5)}' | \
  bc
40

因此go.itab.main.Adder,main.Mather在运行时将映射到虚拟地址$ 4673856(= 0x475140),大小为40个字节(我们已经知道,因为它是itab结构的大小)。

  • 4.查找并提取go.itab.main.Adder,main.Mather

这提醒了我们到目前为止所知道的:

.rodata offset: 0x04d000 == $339968
.rodata VMA: 0x44d000 == $4534272

go.itab.main.Adder,main.Mather VMA: 0x475140 == $4709568
go.itab.main.Adder,main.Mather size: 0x24 = $40

现在,我们有了定位go.itab.main.Adder,main.Mather在二进制文件中所需的所有元素。

If 315392(.rodatasoffset)mapsto315392 (.rodata's offset) maps to 4509696 (.rodata's VMA) and go.itab.main.Adder,main.Mather's VMA is 4673856,thengo.itab.main.Adder,main.Mathersoffsetwithintheexecutableis:sym.offset=sym.vmasection.vma+section.offset=4673856, then go.itab.main.Adder,main.Mather's offset within the executable is: sym.offset = sym.vma - section.vma + section.offset = 4673856 - 4509696+4509696 + 315392 = $479552.

如果$339968(.rodata的偏移量)映射到$4534272(.rodata的VMA)和go.itab.main.Adder,main.Mather的VMA是$4709568,之后,go.itab.main.Adder,main.Mather在可执行文件的偏移量为sym.offset = sym.vma - section.vma + section.offset = $4709568 - $4534272 + $339968 = $515264

既然我们已经知道了数据的偏移量和大小,我们就可以取出dd并直接从可执行文件中提取原始字节:

➜  simpletest dd if=iface.bin of=/dev/stdout bs=1 count=40 skip=515264 2>/dev/null | hexdump
0000000 20 01 46 00 00 00 00 00 60 3b 46 00 00 00 00 00
0000010 8a 3d 5f 61 00 00 00 00 d0 fa 44 00 00 00 00 00
0000020 50 fb 44 00 00 00 00 00
0000028

小结: 我们已经为iface <Mather,Adder>接口重构了完整的itab。 它全部存在于可执行文件中,只是在等待使用,并且已经包含了运行时使接口表现出我们所期望的所有信息。

当然,由于itab主要由指向其他数据结构的一堆指针组成,因此我们必须遵循通过dd提取的内容中存在的虚拟地址,以重建完整图片。

说到指针,我们现在可以清楚地查看iface <Mather,Adder>;的虚拟表。 这是go.itab.main.Adder,main.Mather内容的带注释版本:

➜  simpletest dd if=iface.bin of=/dev/stdout bs=1 count=40 skip=515264 2>/dev/null | hexdump
0000000 20 01 46 00 00 00 00 00 60 3b 46 00 00 00 00 00
0000010 8a 3d 5f 61 00 00 00 00 d0 fa 44 00 00 00 00 00
#                               -----------------------
#                               offset 0x18+8: itab.fun[0]
0000020 50 fb 44 00 00 00 00 00
#       -----------------------
#       offset 0x20+8: itab.fun[1]
0000028
➜  simpletest objdump -t -j .text iface.bin | grep 000000000044fad0
000000000044fad0 g     F .text	0000000000000079 main.(*Adder).Add
➜  simpletest objdump -t -j .text iface.bin | grep 000000000044fb50
000000000044fb50 g     F .text	000000000000007f main.(*Adder).Sub

毫无意外,iface<Mather, Adder>的虚表包含两个方法指针:main.(*Adder).Add和主要。main.(*Adder).Sub

4.动态调度

在本节中,我们最终将介绍接口的主要功能:动态调度。

具体来说,我们将研究动态调度是如何在后台进行的,以及我们需要为此付出多少。

  • 接口上的间接方法调用
package main 


type Mather interface {
    Add(a, b int32) int32
    Sub(a, b int64) int64
}

type Adder struct{
	id int32
}
//go:noinline
func (adder Adder) Add(a, b int32) int32 {
	return a + b
}
//go:noinline
func (adder Adder) Sub(a, b int64) int64 {
	return a - b
}

func main() {
    m := Mather(Adder{id: 6754})

    // This call just makes sure that the interface is actually used.
    // Without this call, the linker would see that the interface defined above
    // is in fact never used, and thus would optimize it out of the final
    // executable.
    m.Add(10, 32)
}

我们已经对这段代码中的大部分操作进行了更深入的研究:iface <Mather,Adder>接口是如何创建的,如何在最终的exectutable中进行布局,以及最终如何在运行时加载 。

我们只剩下一件事要看,那就是随后的实际间接方法调用:m.Add(10,32)

为了刷新我们的记忆,我们将放大接口的创建以及方法调用本身:

m := Mather(Adder{id: 6754})
m.Add(10, 32)

值得庆幸的是,我们已经有了由第一行的实例化生成的程序集的完整注释版本(m:= Mather(Adder {id:6754})):

	0x0054 00084 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	"".m+40(SP), AX
	0x0059 00089 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	TESTB	AL, (AX)
	0x005b 00091 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	24(AX), AX
	0x005f 00095 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	PCDATA	$0, $3
	0x005f 00095 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	PCDATA	$1, $0
	0x005f 00095 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	"".m+48(SP), CX
	0x0064 00100 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	PCDATA	$0, $0
	0x0064 00100 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	CX, (SP)
	0x0068 00104 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	$137438953482, CX
	0x0072 00114 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	CX, 8(SP)
	0x0077 00119 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	CALL	AX

借助前几节中积累的知识,这几条说明应该易于理解。

	0x005b 00091 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	24(AX), AX

通过解引用AX并向前偏移24个字节,我们到达i.tab.fun,它对应于虚拟表的第一个条目。这提醒了itab的偏移量表是什么样的:

type itab struct {
	inter *interfacetype
	_type *_type
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
	fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

如上一节所述,我们直接从可执行文件中重建了最终的itabiface.tab.fun [0]是指向main.(*Adder).add的指针,是编译器生成的包装器 -包装我们原始的值接收器main.Adder.add方法的方法。

	0x0068 00104 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	$137438953482, CX
	0x0072 00114 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	MOVQ	CX, 8(SP)

我们在堆栈的顶部存储10和32,作为参数#2和#3。

	0x0077 00119 (/Users/zhaojunwei/go/src/workspace/interfacetest/main.go:28)	CALL	AX

最后,设置好所有堆栈后,我们便可以进行实际的调用。

现在,我们对接口和虚拟方法调用正常工作所需的整个机器有了清晰的了解。

5.interface有哪些常见的特殊情况和使用技巧?

本节将回顾我们在处理接口时每天遇到的一些最常见的特殊情况。

5.1 空接口

空接口的数据结构是您直觉上会想到的:没有itab的iface。

有两个原因:

  • 由于空接口没有方法,因此可以安全地从数据结构中删除与动态调度有关的所有内容。
  • 随着虚拟表的消失,空接口本身的类型(不要与它所保存的数据的类型混淆)始终是相同的
注意:类似于用于iface的表示法,我们将表示类型T的空接口表示为eface <T>

eface长这样

type eface struct {
	_type *_type
	data  unsafe.Pointer
}

其中_type保存数据指向的值的类型信息。与预期的一样,itab已完全删除。

虽然空接口只能重用iface数据结构(毕竟它是eface的超集),但是运行时选择区分这两个原因是出于两个主要原因:空间效率和代码清晰度。

在本章的前面(接口的解剖),我们已经提到,即使将简单的标量类型(例如整数)存储到接口中,也会导致堆分配。

现在该是我们了解原因以及方式的时候了。

package main_test

import (
    "testing"
    "fmt"
)

func BenchmarkEfaceScalar(b *testing.B) {
    var Uint uint32
    b.Run("uint32", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            Uint = uint32(i)
        }
    })
    fmt.Println(Uint)
    var Eface interface{}
    b.Run("eface32", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            Eface = uint32(i)
        }
    })
    fmt.Println(Eface)
}
➜  simpletest go test -benchmem -bench=. ./demo3_test.go
goos: darwin
goarch: amd64
BenchmarkEfaceScalar/uint32-4         	2000000000	         0.34 ns/op	       0 B/op	       0 allocs/op
1999999999
BenchmarkEfaceScalar/eface32-4        	100000000	        15.9 ns/op	       4 B/op	       1 allocs/op
99999999
PASS
ok  	command-line-arguments	2.335s
  • 对于简单的分配操作,这是性能的2个数量级差异,并且
  • 我们可以看到第二个基准测试必须在每次迭代中分配4个额外的字节。

显然,在第二种情况下,一些隐藏的重操作正在被启动:我们需要看一下生成的汇编。

对于第一个基准测试,编译器将生成与赋值操作完全一样的预期结果:

	0x000d 00013 (demo3_test.go:12)	MOVL	DX, (AX)

但是,在第二个基准测试中,事情变得更加复杂:

0x003d 00061 (demo3_test.go:18)	CMPQ	264(DX), CX
	0x0044 00068 (demo3_test.go:18)	JLE	129
	0x0046 00070 (demo3_test.go:18)	MOVQ	CX, "".i+16(SP)
	0x004b 00075 (demo3_test.go:19)	MOVL	CX, (SP)
	0x004e 00078 (demo3_test.go:19)	CALL	runtime.convT32(SB)
	0x0053 00083 (demo3_test.go:19)	PCDATA	$2, $2
	0x0053 00083 (demo3_test.go:19)	MOVQ	8(SP), AX
	0x0058 00088 (demo3_test.go:19)	PCDATA	$2, $3
	0x0058 00088 (demo3_test.go:19)	LEAQ	type.uint32(SB), CX
	0x005f 00095 (demo3_test.go:19)	PCDATA	$2, $4
	0x005f 00095 (demo3_test.go:19)	MOVQ	"".&Eface+24(SP), DX

虽然在实践中通常不会发生将标量值固定在接口中的情况,但是由于各种原因,它可能是一项昂贵的操作,因此,了解其背后的机制非常重要。

说到成本,我们已经提到编译器实现了各种技巧,以避免在某些特定情况下进行分配。 我们将在本节中快速介绍其中3种技巧。

  • 接口技巧1:字节大小的值
    var Eface interface{}
    b.Run("eface32", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            Eface = uint8(i)
        }
    })
➜  simpletest go test -benchmem -bench=. ./demo3_test.go
goos: darwin
goarch: amd64
BenchmarkEfaceScalar/uint32-4         	2000000000	         0.34 ns/op	       0 B/op	       0 allocs/op
1999999999
BenchmarkEfaceScalar/eface32-4        	2000000000	         1.03 ns/op	       0 B/op	       0 allocs/op
255
PASS
ok  	command-line-arguments	2.883s
	0x0041 00065 (demo3_test.go:19)	LEAQ	runtime.staticbytes(SB), R8

我们注意到,在使用字节大小的值的情况下,编译器避免了调用runtime.convT32和关联的堆分配,而是重新使用了已保存的运行时公开的全局变量的地址。 我们正在寻找的1个字节的值LEAQ runtime.staticbytes(SB), R8.

  • 2.接口技巧2:静态推理
    var Eface interface{}
    b.Run("eface32", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            Eface = uint64(65)
        }
    })
➜  simpletest go test -benchmem -bench=. ./demo3_test.go
goos: darwin
goarch: amd64
BenchmarkEfaceScalar/uint32-4         	2000000000	         0.34 ns/op	       0 B/op	       0 allocs/op
1999999999
BenchmarkEfaceScalar/eface32-4        	2000000000	         0.90 ns/op	       0 B/op	       0 allocs/op
65
PASS
ok  	command-line-arguments	2.632s
	0x0034 00052 (demo3_test.go:19)	LEAQ	type.uint64(SB), BX
	0x003b 00059 (demo3_test.go:19)	PCDATA	$2, $3
	0x003b 00059 (demo3_test.go:19)	MOVQ	BX, (CX)
	0x003e 00062 (demo3_test.go:19)	PCDATA	$2, $-2
	0x003e 00062 (demo3_test.go:19)	PCDATA	$0, $-2
	0x003e 00062 (demo3_test.go:19)	CMPL	runtime.writeBarrier(SB), $0
	0x0045 00069 (demo3_test.go:19)	JNE	84
	0x0047 00071 (demo3_test.go:19)	LEAQ	"".statictmp_0(SB), SI
	0x004e 00078 (demo3_test.go:19)	MOVQ	SI, 8(CX)
	0x0052 00082 (demo3_test.go:19)	JMP	40
	0x0054 00084 (demo3_test.go:19)	LEAQ	8(CX), DI
	0x0058 00088 (demo3_test.go:18)	MOVQ	AX, SI
	0x005b 00091 (demo3_test.go:19)	LEAQ	"".statictmp_0(SB), AX
	0x0062 00098 (demo3_test.go:19)	CALL	runtime.gcWriteBarrier(SB)

从生成的程序集中我们可以看到,编译器完全优化了对runtime.conv64的调用,相反,它通过加载已经保存了我们要查找的值的自动生成的全局变量的地址来直接构造空接口:LEAQ "".statictmp_0(SB),SI(请注意(SB)部分,指示全局变量)。

  • 接口技巧3:零值

对于此最后的技巧,请考虑以下基准,该基准从零值实例化eface <uint32>

    var Eface interface{}
    b.Run("eface32", func(b *testing.B) {
        for i := 0; i < b.N; i++ {
            Eface = uint64(i-i)
        }
    })
➜  simpletest go test -benchmem -bench=. ./demo3_test.go
goos: darwin
goarch: amd64
BenchmarkEfaceScalar/uint32-4         	2000000000	         0.37 ns/op	       0 B/op	       0 allocs/op
1999999999
BenchmarkEfaceScalar/eface32-4        	500000000	         3.02 ns/op	       0 B/op	       0 allocs/op
0
PASS
ok  	command-line-arguments	2.636s

首先,请注意我们如何使用uint32(i-i)而不是uint32(0)来防止编译器退回到优化#2(静态推断)。

正如我们在剖析runtime.convT32时早先提到的那样,可以使用类似于#1(字节大小的值)的技巧来优化此处的分配:当某些代码需要引用持有零值的变量时 ,编译器只给它提供运行时公开的全局变量的地址,该变量的值始终为零。

const maxZero = 1024 // must match value in cmd/compile/internal/gc/walk.go
var zeroVal [maxZero]byte

6.关于零值的一句话

正如我们已经看到的,当要由结果接口保存的数据恰好引用零值时,runtime.convT2 *系列函数避免了堆分配。

这种优化并非特定于接口,实际上是Go运行时所做的一项广泛工作,以确保在需要指向零值的指针时,通过获取特殊的,始终为-的地址来避免不必要的分配。 运行时公开的零变量。

package main

import (
	"fmt"
	"unsafe"
)
//go:linkname zeroVal runtime.zeroVal
var zeroVal uintptr

type eface struct{ 
	_type, 
	data unsafe.Pointer 
}

func main() {
    x := 42
    var i interface{} = x - x // outsmart the compiler (avoid static inference)

    fmt.Printf("zeroVal = %p\n", &zeroVal)
    fmt.Printf("      i = %p\n", ((*eface)(unsafe.Pointer(&i))).data)
}
➜  simpletest go run zero_value.go
zeroVal = 0x118e8c0
      i = 0x118e8c0

7.零大小变量的切线

与零值类似,Go程序中一个非常常见的技巧是依赖于以下事实:实例化大小为0的对象(例如struct {} {})不会导致分配。

官方的Go规范(在本章末尾链接)以说明此内容的注释结尾:

如果结构或数组类型不包含大小大于零的字段(或元素),则其大小为零。 两个不同的零大小变量在内存中可能具有相同的地址。

“可能在内存中具有相同的地址”中的“可能”表示编译器不保证这个事实是正确的,尽管在官方Go编译器的当前实现中一直如此并且继续如此(gc )。

func main() {
    var s struct{}
    var a [42]struct{}

    fmt.Printf("s = % p\n", &s)
    fmt.Printf("a = % p\n", &a)
}
➜  simpletest go run zero_value.go
s =  0x118dfd0
a =  0x118dfd0

如果我们想知道该地址后面隐藏着什么,我们可以简单地查看一下二进制文件:

➜  simpletest objdump -t zerobase.bin | grep 118dfd0
000000000118dfd0 l       0e SECT   0c 0000 [__DATA.__noptrbss] runtime.zerobase

runtime/malloc.go

// base address for all 0-byte allocations
var zerobase uintptr
package main

import (
	"fmt"
	"unsafe"
)
//go:linkname zerobase runtime.zerobase
var zerobase uintptr

func main() {
    var s struct{}
    var a [42]struct{}

    fmt.Printf("zerobase = %p\n", &zerobase)
    fmt.Printf("       s = %p\n", &s)
    fmt.Printf("       a = %p\n", &a)
    fmt.Println(unsafe.Pointer(&a))
}
➜  simpletest go run zero_value.go
zerobase = 0x118dfd0
       s = 0x118dfd0
       a = 0x118dfd0
0x118dfd0

8.断言

我们将从实现和成本的角度来看待类型断言

8.1.类型断言

package main

import (
	"fmt"
)

func main() {
	var j uint32
	var Eface interface{} // outsmart compiler (avoid static inference)

    i := uint64(42)
    Eface = i
    j = Eface.(uint32)
    fmt.Println(j)
}

	0x001d 00029 (zero_value.go:13)	LEAQ	type.uint64(SB), AX
	0x0024 00036 (zero_value.go:13)	PCDATA	$2, $0
	0x0024 00036 (zero_value.go:13)	MOVQ	AX, (SP)
	0x0028 00040 (zero_value.go:13)	PCDATA	$2, $1
	0x0028 00040 (zero_value.go:13)	LEAQ	type.uint32(SB), AX
	0x002f 00047 (zero_value.go:13)	PCDATA	$2, $0
	0x002f 00047 (zero_value.go:13)	MOVQ	AX, 8(SP)
	0x0034 00052 (zero_value.go:13)	PCDATA	$2, $1
	0x0034 00052 (zero_value.go:13)	LEAQ	type.interface {}(SB), AX
	0x003b 00059 (zero_value.go:13)	PCDATA	$2, $0
	0x003b 00059 (zero_value.go:13)	MOVQ	AX, 16(SP)
	0x0040 00064 (zero_value.go:13)	CALL	runtime.panicdottypeE(SB)
	0x0045 00069 (zero_value.go:13)	UNDEF
// panicdottypeE is called when doing an e.(T) conversion and the conversion fails.
// have = the dynamic type we have.
// want = the static type we're trying to convert to.
// iface = the static type we're converting from.
func panicdottypeE(have, want, iface *_type) {
	panic(&TypeAssertionError{iface, have, want, ""})
}

8.2 类型switch

package main

import (
	"fmt"
)

func main() {
	var j uint32
	var Eface interface{} // outsmart compiler (avoid static inference)

    i := uint32(42)
    Eface = i
    switch v := Eface.(type) {
    case uint16:
        j = uint32(v)
    case uint32:
        j = v
    }
    fmt.Println(j)
}

	0x002f 00047 (zero_value.go:8)	MOVL	$0, "".j+56(SP)
	0x0037 00055 (zero_value.go:9)	XORPS	X0, X0
	0x003a 00058 (zero_value.go:9)	MOVUPS	X0, "".Eface+88(SP)
	0x003f 00063 (zero_value.go:11)	MOVL	$42, "".i+60(SP)
	0x0047 00071 (zero_value.go:12)	MOVL	$42, ""..autotmp_6+68(SP)
	0x004f 00079 (zero_value.go:12)	PCDATA	$2, $1
	0x004f 00079 (zero_value.go:12)	LEAQ	type.uint32(SB), AX
	0x0056 00086 (zero_value.go:12)	MOVQ	AX, "".Eface+88(SP)
	0x005b 00091 (zero_value.go:12)	PCDATA	$2, $2
	0x005b 00091 (zero_value.go:12)	LEAQ	""..autotmp_6+68(SP), CX
	0x0060 00096 (zero_value.go:12)	MOVQ	CX, "".Eface+96(SP)
	0x0065 00101 (zero_value.go:13)	PCDATA	$0, $1
	0x0065 00101 (zero_value.go:13)	MOVQ	AX, ""..autotmp_7+104(SP)
	0x006a 00106 (zero_value.go:13)	PCDATA	$2, $1
	0x006a 00106 (zero_value.go:13)	MOVQ	CX, ""..autotmp_7+112(SP)
	0x006f 00111 (zero_value.go:13)	JMP	113
	0x0071 00113 (zero_value.go:13)	PCDATA	$2, $0
	0x0071 00113 (zero_value.go:13)	TESTB	AL, (AX)
	0x0073 00115 (zero_value.go:13)	MOVL	type.uint32+16(SB), AX
	0x0079 00121 (zero_value.go:13)	MOVL	AX, ""..autotmp_9+64(SP)
	0x007d 00125 (zero_value.go:13)	CMPL	AX, $-800397251
	0x0082 00130 (zero_value.go:13)	JEQ	137
	0x0084 00132 (zero_value.go:13)	JMP	462
	0x0089 00137 (zero_value.go:13)	MOVL	$0, "".v+52(SP)
	0x0091 00145 (zero_value.go:13)	PCDATA	$2, $1
	0x0091 00145 (zero_value.go:13)	MOVQ	""..autotmp_7+112(SP), AX
	0x0096 00150 (zero_value.go:13)	PCDATA	$2, $2
	0x0096 00150 (zero_value.go:13)	LEAQ	type.uint32(SB), CX
	0x009d 00157 (zero_value.go:13)	PCDATA	$2, $1
	0x009d 00157 (zero_value.go:13)	CMPQ	""..autotmp_7+104(SP), CX
	0x00a2 00162 (zero_value.go:13)	JEQ	169
	0x00a4 00164 (zero_value.go:13)	JMP	453
	0x00a9 00169 (zero_value.go:13)	PCDATA	$2, $0
	0x00a9 00169 (zero_value.go:13)	MOVL	(AX), AX
	0x00ab 00171 (zero_value.go:13)	MOVL	$1, CX
	0x00b0 00176 (zero_value.go:13)	JMP	178
	0x00b2 00178 (zero_value.go:13)	MOVL	AX, "".v+52(SP)
	0x00b6 00182 (zero_value.go:13)	MOVB	CL, ""..autotmp_8+49(SP)
	0x00ba 00186 (zero_value.go:13)	TESTB	CL, CL
	0x00bc 00188 (zero_value.go:13)	JNE	195
	0x00be 00190 (zero_value.go:13)	JMP	353
	0x00c3 00195 (zero_value.go:16)	PCDATA	$2, $-2
	0x00c3 00195 (zero_value.go:16)	PCDATA	$0, $-2
	0x00c3 00195 (zero_value.go:16)	JMP	197
	0x00c5 00197 (zero_value.go:17)	PCDATA	$2, $0
	0x00c5 00197 (zero_value.go:17)	PCDATA	$0, $0
	0x00c5 00197 (zero_value.go:17)	MOVL	"".v+52(SP), AX
	0x00c9 00201 (zero_value.go:17)	MOVL	AX, "".j+56(SP)
	0x00cd 00205 (zero_value.go:13)	JMP	207
	0x00cf 00207 (zero_value.go:19)	MOVL	"".j+56(SP), AX
	0x00d3 00211 (zero_value.go:19)	MOVL	AX, (SP)
	0x00d6 00214 (zero_value.go:19)	CALL	runtime.convT32(SB)
	0x00db 00219 (zero_value.go:19)	PCDATA	$2, $1
	0x00db 00219 (zero_value.go:19)	MOVQ	8(SP), AX
	0x00e0 00224 (zero_value.go:19)	PCDATA	$2, $0
	0x00e0 00224 (zero_value.go:19)	PCDATA	$0, $2
	0x00e0 00224 (zero_value.go:19)	MOVQ	AX, ""..autotmp_10+80(SP)
	0x00e5 00229 (zero_value.go:19)	PCDATA	$0, $3
	0x00e5 00229 (zero_value.go:19)	XORPS	X0, X0
	0x00e8 00232 (zero_value.go:19)	MOVUPS	X0, ""..autotmp_5+120(SP)
	0x00ed 00237 (zero_value.go:19)	PCDATA	$2, $1
	0x00ed 00237 (zero_value.go:19)	PCDATA	$0, $2
	0x00ed 00237 (zero_value.go:19)	LEAQ	""..autotmp_5+120(SP), AX
	0x00f2 00242 (zero_value.go:19)	MOVQ	AX, ""..autotmp_12+72(SP)
	0x00f7 00247 (zero_value.go:19)	TESTB	AL, (AX)
	0x00f9 00249 (zero_value.go:19)	PCDATA	$2, $2
	0x00f9 00249 (zero_value.go:19)	PCDATA	$0, $0
	0x00f9 00249 (zero_value.go:19)	MOVQ	""..autotmp_10+80(SP), CX
	0x00fe 00254 (zero_value.go:19)	PCDATA	$2, $3
	0x00fe 00254 (zero_value.go:19)	LEAQ	type.uint32(SB), DX
	0x0105 00261 (zero_value.go:19)	PCDATA	$2, $2
	0x0105 00261 (zero_value.go:19)	MOVQ	DX, ""..autotmp_5+120(SP)
	0x010a 00266 (zero_value.go:19)	PCDATA	$2, $1
	0x010a 00266 (zero_value.go:19)	MOVQ	CX, ""..autotmp_5+128(SP)
	0x0112 00274 (zero_value.go:19)	TESTB	AL, (AX)
	0x0114 00276 (zero_value.go:19)	JMP	278

注意1:布局

  • 我们找到了一个初始指令块,该指令块加载了我们感兴趣的变量的_type,并检查了nil指针,以防万一。
  • 然后,我们得到N个逻辑块,每个逻辑块对应于原始switch语句中描述的情况之一。
  • 最后,最后一个块定义了一种间接跳转表,该表允许控制流从一种情况跳转到另一种情况,同时确保在途中正确重置脏寄存器。

尽管事后看来很明显,但第二点非常重要,因为这意味着类型切换语句生成的指令数量纯粹是它描述的案例数量的一个因素。

在实践中,这可能会导致令人惊讶的性能问题,例如,带有大量情况的大规模类型转换语句可能生成大量指令,并且如果在错误的路径上使用L1i缓存,最终会破坏它们。

关于上面的简单切换语句的布局,另一个有趣的事实是在生成的代码中设置案例的顺序。 在我们原始的Go代码中,案例uint16首先出现,然后是案例uint32。 但是,在由编译器生成的程序集中,它们的顺序已颠倒了,现在的情况是uint32,而第二个是uint16。

在这种特殊情况下,这种重新排序对我们是一个净赢,仅是运气,AFAICT。 实际上,如果您花时间对类型开关进行一些试验,尤其是两个以上的情况,您会发现编译器总是使用某种确定性启发式方法来对情况进行混洗。

注意2:时间复杂度

其次,请注意控制流如何盲目地从一种情况跳到另一种情况,直到它落在评估为true的情况下或最终到达switch语句的末尾。

再一次,虽然显而易见的是,当人们实际上停止考虑它时(“它还能如何工作?”),但是在更高层次的推理中,这很容易被忽略。 在实践中,这意味着评估类型切换语句的成本随其案例数线性增加:它是O(n)。

同样,有效评估具有N个案例的类型转换语句与评估N个类型声明具有相同的时间复杂性。 正如我们所说的,这里没有魔术。

注意3:类型hash和指针比较

最后,请注意在每种情况下如何始终在两个阶段中进行类型比较:

  • 比较类型的哈希值(_type.hash),然后
  • 如果它们匹配,则直接比较每个_type指针的各自的内存地址。

由于每个_type结构都是由编译器生成的,并存储在.rodata节的全局变量中,因此我们可以确保为每种类型在程序的生命周期内分配一个唯一的地址。

在这种情况下,进行额外的指针比较是有意义的,以确保成功的匹配不只是哈希冲突的结果。但这会引发一个明显的问题:为什么不直接在指针中比较指针? 首先,完全放弃类型散列的概念吗? 尤其是在我们前面已经看到的简单类型断言中,根本不使用类型哈希。

说到类型哈希,我们怎么知道$ -800397251对应于type.uint32.hash,而$ -269349216对应于type.uint16.hash,您可能想知道? 当然很难

package main

import (
	"fmt"
	"unsafe"
)

// simplified definitions of runtime's eface & _type types
type eface struct {
    _type *_type
    data  unsafe.Pointer
}
type _type struct {
    size    uintptr
    ptrdata uintptr
    hash    uint32
    /* omitted lotta fields */
}

var Eface interface{}
func main() {
    Eface = uint32(42)
    fmt.Printf("eface<uint32>._type.hash = %d\n",
        int32((*eface)(unsafe.Pointer(&Eface))._type.hash))

    Eface = uint16(42)
    fmt.Printf("eface<uint16>._type.hash = %d\n",
        int32((*eface)(unsafe.Pointer(&Eface))._type.hash))
}
➜  simpletest go run zero_value.go
eface<uint32>._type.hash = -800397251
eface<uint16>._type.hash = -269349216

注:本文主要内容来自xargin.com/go-and-inte…