GoLang之动态派发系列二

GoLang之动态派发系列二

1.问题3:如何实现“动态派发”

1.1介绍

在这里插入图片描述

对于动态派发来讲,编译阶段能够确定的有:
(1)要调用的方法的名字;
(2)方法的原型(参数与返回值列表)。
实际上有这些信息就足够了,运行阶段根据这些信息就能完成动态派发。
至于动态派发的代码实现,可以有很多种不同版本。先不去管Go语言到底是如何实现的,如果让我们来设计的话…

1.2可以怎么做?

自定义类型的类型元数据中存有方法集信息,方法集信息是一组method结构构成的数组,通过它可以找到对应方法的方法名、参数和返回值的类型,以及代码的地址。method结构定义如下:


type method struct {
  name nameOff
  mtyp typeOff
  ifn  textOff
  tfn  textOff
}

类型元数据中的method数组是按照方法名升序排列的,可以直接应用二分法查找。运行阶段利用这些信息,就可以根据方法名和原型动态绑定方法地址了。
假如现在有个io.Reader类型的接口变量r,其背后动态类型是*os.File,如下所示:

var r io.Reader = f
n, err := r.Read(buf)

在这里插入图片描述

首先通过r得到其动态类型*os.File的类型元数据,然后根据方法名称Read以二分法查找匹配的method结构,找到后再根据method.mtyp得到方法本身的类型元数据,最后对比方法原型是否一致(参数和返回值的类型、顺序是否一致)。如果原型一致的话,那就找到了目标方法,通过method.ifn字段得到方法的地址,然后就像调用普通函数一样调用就可以了。

1.3这样真的可以吗?

单就动态派发而言,这种方式确实可以实现。但是有一个明显的问题,那就是效率低,或者说性能差。
跟地址静态绑定的方法调用比起来,原本一条CALL指令完成的事情,这里又多出一次二分查找加方法原型匹配,增加的开销不容小觑,可能会造成动态派发的方法比静态绑定的方法多一倍开销甚至更多,所以必须进行优化。

1.4怎么优化?

如果接触过C++,就会发现C++中的虚函数机制跟接口的思想很相似:语言允许父类指针指向子类对象,当通过父类的指针来调用虚函数时,就能实现动态派发。
C++中具体实现原理就是:
(1)编译器为每个包含虚函数的类都生成一张虚函数表,实际上就是个地址数组,按照虚函数声明的顺序存储了各个虚函数的地址。此外还会在类对象的头部安插一个虚指针(GCC安插在头部,其他编译器或有不同),指向类型对应的虚函数表。
(2)运行阶段通过类对象指针调用虚函数时,会先取得对象中的虚指针,进一步找到对象类型对应的虚函数表,然后基于虚函数声明的顺序,以数组下标的方式从表中取得对应函数的地址,整个动态派发过程就完成了。

在这里插入图片描述

参考C++的虚函数表思想,再回过头来看Go语言中接口的设计,如果把这种基于数组的函数地址表应用在接口的实现中,基本就能消除每次查询地址造成的性能开销。

1.5Go语言怎么实现动态派发?

非空接口的数据结构如下:


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

非空接口也存储两个指针,data用来装载数据,itab存储的是类型元数据相关的信息,实现动态派发要使用的函数地址表就存储在itab中。

type itab struct {
  inter *interfacetype
  _type *_type
  hash  uint32
  _     [4]byte
  fun   [1]uintptr
}

(1)itab.inter指向当前接口的类型元数据,记录着接口要求实现的方法列表;
(2)itab._type指向动态类型元数据,从_type到uncommontype,再到[mcount]method,可以找到该动态类型的方法集。

在这里插入图片描述

接口要求实现的方法列表与动态类型的方法集都是有序的,所以经过一次循环对比就可以确定该动态类型是否实现了特定接口:
(1)如果没有实现,那么itab.fun[0]就等于0;
(2)如果实现了,就把动态类型实现的方法地址存储到itab.fun数组中。
有了这样的itab,再通过接口调用方法时,就可以像C++的虚函数那样直接按数组下标读取地址了。

2.关于itab缓存

首先要明确一点:基于一个确定的接口类型和一个确定的具体类型,就能够唯一确定一个itab。所以只要构建一个itab缓存,那么每个itab都只需创建一次。
itabTable就是runtime中itab的全局缓存,它本身是个itabTableType类型的指针,itabTableType的结构定义如下:

type itabTableType struct {
  size    uintptr
  count   uintptr
  entries [itabInitSize]*itab
}

(1)entries是实际的缓存空间;
(2)size字段表示缓存的容量,也就是entries数组的大小;
(3)count表示实际已经缓存了多少个itab。
entries的初始大小是通过itabInitSize指定的,这个常量的值为512。当缓存存满以后,runtime会重新分配整个struct,entries数组是itabTableType的最后一个字段,可以无限增大它的下标来使用超出容量大小的内存,只要在struct之后分配足够的空间就够了,这也是C语言里常用的手法。
itabTableType被实现成一个散列表,有两个方法find和add分别实现查找和插入。
add操作内部不会扩容存储空间,重新分配操作是在外层实现的。因此对于find方法而言,已经插入的内容不会再被修改,所以查找时不需要加锁。add操作需要在加锁的前提下进行,getitab函数是通过调用itabAdd函数来完成添加缓存的,itabAdd内部会按需对缓存进行扩容,然后再调用add方法。因为缓存扩容需要重新分配itabTableType结构,为了并发安全,使用原子操作更新itabTable指针。加锁后立刻再次查询也是出于并发的考虑,避免其他协程已经将同样的itab添加至缓存。
通过persistentalloc分配的内存不会被回收,分配的大小为itab结构的大小加上“接口方法数减一”个指针的大小,因为itab中的fun数组声明长度为1,已经包含了一个指针,分配空间时只需要补齐剩下的就可以了。

假如一个具体类型没有实现某个接口,为什么也要缓存对应的itab?
按照一般的思路,只有具体类型实现了该接口,才能够得到一个itab,进而缓存起来。但这样会有个问题,假如具体类型没有实现该接口,但是运行阶段有大量这样的类型断言,缓存中查不到对应的itab,就会每次都查询元数据的方法列表,从而显著影响性能。所以,Go会把有效、无效的itab都缓存起来,通过fun[0]加以区分。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

GoGo在努力

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值