Go语言实例 | 类型系统_类型_模块_地址

Go语言一共提供了26种类型种类: 一个布尔型,包含uintptr在内一共11种整型,两种浮点类型,两种复数类型,一个字符串类型,指针、数组、切片、map和struct共5种常用复合类型,以及chan、func、interface和unsafe.Pointer这4种特殊类型。这26种类型是Go语言整个类型系统的基础,任何更复杂的类型都由这些类型组合而来,即使用户自定义的类型有着各种各样的名称,它们的种类也不会超出这26种的范畴。

至此,我们已经知道类型元数据是用runtime._type结构表示的,那么这些数据是如何组织起来的,以及运行阶段又是如何解析的呢? 带着这个问题,下面就深入runtime的源码中去找答案。

01

类型信息的萃取

提到反射和类型,很自然地就会想起reflect包中用于获取类型信息的TypeOf函数,该函数有一个interface{}类型的参数,可以接受传入任意类型。函数的返回值类型是reflect.Type,这是个接口类型,提供了一系列方法来从类型元数据中提取信息。TypeOf函数所做的事情如图5-21所示,就是找到传入参数的类型元数据,并以reflect.Type形式返回。

展开全文

■ 图5-21 由一个*_type和一个*itab组建一个iface

TypeOf函数代码如下:

funcTypeOf( iinterface{}) Type{

eface := *(*emptyInterface)(unsafe. Pointer(&i))

return toType(eface.typ)

第2行代码相当于把传入的参数i强制转换成了emptyInterface类型,emptyInterface类型和5.1节介绍过的eface类型在内存布局上等价,emptyInterface类型定义的代码如下:

typeemptyInterface struct{

typ *rtype

word unsafe.Pointer

其中的rtype类型与runtime._type类型在内存布局方面也是等价的,只不过因为无法使用其他包中未导出的类型定义,所以需要在reflect包中重新定义一下。代码中的eface.typ实际上就是从interface{}变量中提取出的类型元数据地址,再来看一下toType函数,代码如下:

functoType(t *rtype)Type{

ift == nil{

returnnil

returnt

先判断了一下传入的rtype指针是否为nil,如果不为nil就把它作为Type类型返回,否则返回nil。从这里可以知道*rtype类型肯定实现了Type接口,之所以要加上这个nil判断,需要考虑到Go的接口类型是个双指针结构,一个指向itab,另一个指向实际的数据对象。如图5-22所示,只有在两个指针都为nil的时候,接口变量才等于nil。

■ 图5-22 萃取前为什么要判断非空

用一段更直观的代码加以说明,代码如下:

//第5章/code_5_12.go

varrw io.ReadWriter

ifrw == nil{

println( 1)

varf *os.File

rw = f

ifrw == nil{

println( 2)

在上述代码中第1个if处判断结果为真,所以会打印出1。第2个if处rw不再为nil,所以不会打印2。这里需要注意一下,f本身为nil,赋值给rw之后却不再为nil,这是因为接口的双指针结构,其中数据指针为nil,itab指针不为空。也就是说nil指针也是有类型的,所以在赋值给interface{}和一般的非空接口变量时要格外注意。toType函数中前置的nil检测就是为了避免返回一个itab指针不为nil,而数据指针为nil的Type变量,使上层代码无法通过nil检测区分返回值是否有效,由此带来诸多不便和隐患。

综上所述,TypeOf函数所做的事情就是从interface{}中提取出类型元数据地址,然后在地址不为nil的时候将其作为Type类型返回。并没有太神奇的逻辑,而interface{}中的类型元数据地址是从哪里来的呢? 当然是在编译阶段由编译器赋值的,实际的地址可能是由链接器填写的,也就是说源头还是要追溯到最初的源码中。

02

类型系统的初始化

迄今为止,见过的所有基于类型元数据的特性都少不了interface的影子,通过反射实现类型信息的萃取也要依赖于interface参数,然而对于interface{}和非空接口,其中用到的类型元数据,论及源头都是在编译阶段由编译器赋值的。这样一来,整个类型系统给人的感觉就像是一个KV 存储,只能在获得某个key的前提下去查询对应的value,有没有一个地方能够遍历所有的key呢? 下面就带着这个问题去研究runtime的源码。

通过buildmode=plugin可以把Go项目构建成一个动态链接库,后续以插件的形式被程序的主模块按需加载,这样一来运行阶段就需要加载多个二进制模块。由于每个模块中都有自己的一组类型元数据,所以就会出现类型信息不一致的问题,像类型断言这样的特性,底层通过比较元数据地址实现,也就无法正常工作了。保证类型系统中的类型唯一性至关重要,因此Go语言的runtime会在类型系统的初始化阶段进行去重操作,如图5-23所示。

■ 图5-23 类型系统初始化利用typemap去重

下面从源码层面看一下具体的实现,用来初始化类型系统的就是untime.typelinksinit函数,代码如下:

functypelinksinit{

iffirstmoduledata.next == nil{

return

typehash := make( map[ uint32][]*_type, len(firstmoduledata.typelinks))

modules := activeModules

prev := modules[ 0]

for_, md := rangemodules[ 1:] {

//把前一个模块中的类型收集到typehash中

collect:

for_, tl := rangeprev.typelinks {

vart *_type

ifprev.typemap == nil{

t = (*_type)(unsafe.Pointer(prev.types + uintptr(tl)))

} else{

t = prev.typemap[typeOff(tl)]

//已经有的就不重复添加了

tlist := typehash[t.hash]

for_, tcur := rangetlist {

iftcur == t {

continuecollect

typehash[t.hash] = append(tlist, t)

ifmd.typemap == nil{

//如果当前模块typelinks中的某种类型与某个前驱模块中的某类型一致,

//那就通过当前模块的typemap将其映射到前驱模块中的对应类型

tm := make( map[typeOff]*_type, len(md.typelinks))

pinnedTypemaps = append(pinnedTypemaps, tm)

md.typemap = tm

for_, tl := rangemd.typelinks {

t := (*_type)(unsafe.Pointer(md.types + uintptr(tl)))

for_, candidate := rangetypehash[t.hash] {

seen := map[_typePair] struct{}{}

iftypesEqual(t, candidate, seen) {

t = candidate

break

md.typemap[typeOff(tl)] = t

prev = md

在类型系统内部,元数据间通过typeOff互相引用,typeOff实际上就是个int32。类型元数据在二进制文件中是存放在一起的,单独占据了一段空间,moduledata结构的types字段和etypes字段就是这段空间的起始地址和结束地址。typeOff表示的就是目标类型的元数据距离起始地址types的偏移。梳理一下这个函数的大致逻辑:

(1) 分配了一个map[uint32][]*_type类型的变量typehash,用来收集所有模块中的类型信息,用类型的hash作为map的key,收集的是类型元数据_type结构的地址,把hash相同的类型的地址放到同一个slice中。

(2) 通过activeModules函数得到当前活动模块的列表,也就是所有能够正常使用的Go二进制模块,然后从第2个模块开始向后遍历。

(3) 每次循环中通过前一个模块的typelinks 字段,收集模块内的类型信息,将typehash中尚未包含的类型添加进去,注意是收集前一个模块的类型信息。这样一来,typehash中包含的类型信息都是该类型在整个模块列表中首次出现时的那个地址。假如按照A、B、C的顺序遍历模块列表,而类型T在B和C中都出现过,typehash中只会包含B模块中T的地址。

(4) 如果当前模块的typemap为nil,就分配一个新的map并填充数据。遍历当前模块的typelinks,对于其中所有的类型,先去typehash中查找,优先使用typehash中的类型地址,typehash中没有的类型才使用当前模块自身包含的地址,把地址添加到typemap中。pinnedTypemaps主要是避免GC回收掉typemap,因为模块列表对于GC不可见。

这样当整个循环执行完成后,所有模块中的typemap中的任何一种类型都是该类型在整个模块列表中第一次出现时的地址,也就实现了类型信息的唯一化,而每个模块的typelinks字段就相当于遍历该模块所有类型的入口,虽然并不能从这里找到所有类型信息(有些闭包的类型信息就不会包含)。后续通过typeOff引用类型元数据时,会先从typemap中查找,如果找不到才会把当前模块的types加上typeOff作为结果返回,5.4.2节会更详细地分析讲解。

经过typelinksinit之后,用于反射的类型元数据实现了唯一化,跨多个模块的reflect不会出现不一致现象了,但是回过头来继续看一看5.3.1节的类型断言的实现原理,底层直接比较类型元数据的地址,不会用到模块的typemap字段,所以上述唯一化操作应该无法解决这类问题。

类型断言所用到的元数据地址是由编译器直接编码在指令中的,下面先来研究一下编译器是如何确定类型元数据地址的,代码如下:

funcIsBool(a interface{}) bool{

_, ok := a.( bool)

returnok

用compile命令将上述代码编译成OBJ文件,然后进行反编译,得到的汇编代码如下:

$ gotool compile -p gom -o assert.o assert. go

$ gotool objdump -S -s 'IsBool'assert.o

TEXT gom.IsBool(SB) gofile../home/kylin/ go/src/fengyoulin.com/gom/assert. go

_, ok := a.( bool)

0x422488b442408 MOVQ 0x8(SP), AX

0x427488d0d00000000 LEAQ 0(IP), CX [ 3: 7]R_PCREL: type. bool

0x42e4839c8 CMPQ CX, AX

returnok

0x4310f94442418 SETE 0x18(SP)

0x436c3 RET

第2条汇编指令LEAQ用于获取bool类型元数据的地址,第1个操作数0(IP)中的0是个偏移量,编译阶段只预留了4字节的空间,所以在OBJ文件中是0,等到链接器填写了实际的偏移量后可执行文件中就会有值了。LEAQ offset(IP), CX的含义就是把当前指令指针IP的值加上offset,把结果存入CX寄存器中。这种计算方式是以当前指令位置为基址,然后加上32位的偏移来得到目标地址。32位偏移能够覆盖-2GB~2GB的偏移范围,多用于单个二进制文件内部的寻址,因为单个二进制文件的大小一般不会超过2GB。

对于模块间的地址引用,这种相对地址的计算方式就不能很好地支持了。因为64位地址空间中两个模块间的距离可能会超过2GB,所以需要直接使用64位宽度的地址。还是使用IsBool函数,这次编译的时候加上一个dynlink参数,实际上在以plugin方式构建项目时工具链会自动添加这个编译参数。再反编译得到的OBJ文件,汇编代码如下:

$ gotool compile -dynlink -p gom -o assert.o assert. go

$ gotool objdump -S -s 'IsBool'assert.o

TEXT gom.IsBool(SB) gofile../home/kylin/ go/src/fengyoulin.com/gom/assert. go

_, ok := a.( bool)

0x44d488b442408 MOVQ 0x8(SP), AX

0x452488b0d00000000 MOVQ 0(IP), CX [ 3: 7]R_GOTPCREL: type. bool

0x4594839c8 CMPQ CX, AX

returnok

0x45c0f94442418 SETE 0x18(SP)

0x461c3 RET

唯一的不同就是原来的LEAQ 变成了MOVQ,含义也发生了很大变化,LEAQ 与MOVQ的区别如图5-24所示。LEAQ 直接把当前指令地址加上偏移用作元数据地址,而MOVQ从当前指令地址加上偏移处取出一个64位整型,用作类型元数据的地址。也就是MOVQ不直接计算元数据地址,而是又多了一层中转,也就是又多了一层灵活性。

■ 图5-24 LEAQ与MOVQ的区别

进一步分析会发现,MOVQ 读取地址的地方是ELF文件中一个叫.got的节区,.got节中有一个全局偏移表(Global Offset Table),表中的一系列重定位项会在ELF文件被加载的时候由操作系统的动态链接器完成赋值。像类型断言这种,代码中直接使用元数据地址的场景,其中的类型唯一性问题在二进制模块加载的时候就被动态链接器处理掉了,如图5-25所示。

■ 图5-25 地址被动态链接重定位直接使用类型元数据

精彩回顾

Go语言实例:闭包的实现原理

Go语言实例:堆内存管理之heapArena

03

参考书籍

书名:深度探索Go语言——对象模型与runtime的原理、特性及应用

扫码京东优惠购书

特别声明

本文仅代表作者观点,不代表本站立场,本站仅提供信息存储服务。

分享:

扫一扫在手机阅读、分享本文