CLR对接口成员进行虚拟方法调用的实现

35

出于好奇:CLR如何将虚方法调用分派给接口成员以正确实现?

我知道CLR为每个类型维护一个VTable,其中包含每个方法的方法槽,并且对于每个接口,它还有一个附加的方法槽列表,指向相关的接口方法实现。但我不明白以下内容:CLR如何有效地确定要从类型的VTable中选择哪个接口方法槽列表?

来自MSDN杂志2005年5月号的文章深入了解.NET Framework内部,以查看CLR如何创建运行时对象谈到了一个由接口ID索引的进程级映射表IVMap。这是否意味着同一进程中的所有类型都具有指向相同IVMap的相同指针?

它还说明:

如果MyInterface1由两个类实现,则IVMap表中将有两个条目。该条目将指回嵌入在MyClass方法表中的子表的开头。

CLR如何知道要选择哪个条目?它会进行线性搜索以找到与当前类型匹配的条目吗?还是二进制搜索?或者某种直接索引并具有可能存在许多空条目的映射?

我也阅读了《CLR via C# 3rd edition》中关于接口的章节,但它没有涉及到这一点。因此,这个问题的其他答案 的答案并不能解答我的问题。


问题中的链接不再指向特定的文章。请问您能告诉我们这篇文章出现在哪一期(月份和年份)吗? - user1023602
1
@buffjape 这是2005年5月份的MSDN杂志。我更新了链接,指向互联网档案馆。 - Daniel A.A. Pelsmaeker
1
请不要将大量的赏金授予一个过时的、复制粘贴的答案,即使在发布时也是无关紧要的。这对任何人都没有帮助。 - Hans Passant
这个问题的答案中包含了很多有趣的信息:https://softwareengineering.stackexchange.com/questions/373806/implementation-of-pure-abstract-classes-and-interfaces#373810 - tigrou
3个回答

29
该文章已经十多年了,自那时以来,发生了很多变化。
IVMaps现已被Virtual Stub Dispatch所取代。
虚拟存根调度(VSD)是使用存根进行虚拟方法调用的技术,而不是传统的虚拟方法表。过去,接口分派需要接口具有进程唯一标识符,并且每个加载的接口都添加到全局接口虚拟表映射中。
去阅读那篇文章,它有你需要了解的更多细节。它来自运行时之书 Book of the Runtime,这是CLR开发人员为CLR开发人员编写的文档,但现在已经对所有人发布。它基本上描述了运行时的内部结构。
我没有重复文章的必要,但我会陈述主要观点和其含义。
当JIT看到对接口成员的调用时,它将其编译为“查找存根”。这是一段代码,将调用“通用解析器”。
“通用解析器”是一个函数,它将找出要调用的方法。这是最通用的、因此最慢的调用接口方法的方式。当从“查找存根”首次调用时,它将把该存根(在运行时重写其代码)改为“分配存根”。它还为后续使用生成一个“解析存根”。此时,“查找存根”消失了。
“分配存根”是调用接口成员的最快方式,但有一个问题:它对调用始终解析为相同具体类型的情况进行了优化,这意味着它针对接口调用始终解析为相同具体类型的情况进行了优化。它比较对象的方法表(即具体类型),并调用缓存方法(其地址也是硬编码的),如果比较成功,则失败则回退到“解析存根”。
“解析存根”处理多态调用(一般情况)。它使用缓存来查找要调用的方法。如果缓存中没有该方法,则调用“通用解析器”(也会写入此缓存)。
以下是翻译:

这里有一点重要的考虑,直接来自于文章:

当调度存根失败的次数足够多时,调用站点被认为是多态的,解析存根将会回溯调用站点,直接指向解析存根,以避免持续失败的调度存根的开销。在同步点(目前是 GC 的末尾),多态站点将随机升级回单态调用站点,假设调用站点的多态属性通常是临时的。如果对于任何特定的调用站点,这个假设是不正确的,它将很快触发回溯,将其降级为多态。

运行时对单态调用站点非常乐观,在实际代码中这非常合理,并且会尽可能地避免使用解析存根。


25

.NET Stack

如果你看一下链接站点上的图表,它可能会更容易理解。

这是不是意味着同一进程中的所有类型都具有指向相同IVMap的指针?

是的,因为它在域级别上,这意味着该AppDomain中的所有内容都具有相同的IVMap。

CLR如何知道要选择哪个条目?它是否执行线性搜索以查找与当前类型匹配的条目?还是二进制搜索?或者进行某种直接索引并具有其中可能有许多空条目的映射?

类是通过偏移量布局的,因此每个东西都具有相对固定的区域。当查找方法时,这使事情变得更容易。它将搜索IVMap表,并从接口中查找该方法。从那里,它转到MethodSlotTable并使用该类对接口的实现。该类的接口映射保存元数据,但是实现与任何其他方法一样处理。

再次来自你链接的网站:

每个接口实现都将在IVMap中具有一个条目。如果MyInterface1由两个类实现,则IVMap表中将有两个条目。该条目将指向嵌入在MyClass方法表中的子表的开头。

这意味着每次实现接口时,它在IVMap中都有唯一记录,该记录指向MethodSlotTable,后者又指向实现。因此,根据调用它的类,它知道要选择哪个实现,因为IVMap记录指向调用该方法的类中的MethodSlotTable。因此,我想它只是通过IVMap进行线性搜索以找到正确的实例,然后就可以运行了。


编辑:提供有关IVMap的更多信息。

同样来自OP中的链接:

第一个InterfaceInfo条目的前4个字节指向MyInterface1的TypeHandle(参见图9和图10)。下一个字(2个字节)由Flags占用(其中0从父项继承,1在当前类中实现)。 Flags之后的WORD是Start Slot,它由类加载器用于布置接口实现子表。

因此,这里我们有一个表格,其中数字是字节的偏移量。这只是IVMap中的一个记录:

+----------------------------------+
| 0 - InterfaceInfo                |
+----------------------------------+
| 4 - Parent                       |
+----------------------------------+
| 5 - Current Class                |
+----------------------------------+
| 6 - Start Slot (2 Bytes)         |
+----------------------------------+

假设在该 AppDomain 中有 100 个接口记录,我们需要为每一个记录找到实现。我们只需比较第5个字节,以查看它是否与我们当前的类匹配,如果匹配,则跳转到第6个字节的代码。由于每个记录长8个字节,因此我们需要进行以下操作:(伪代码)

findclass :
   if (!position == class) 
      findclass adjust offset by 8 and try again

虽然它仍然是一种线性搜索,但实际上,由于要迭代的数据规模并不是很大,所以不会花费太长时间。希望这能有所帮助。


编辑2:

所以,在查看图表并想知道为什么类图中没有Slot 1在IVMap中之后,我重新阅读了该部分并找到了以下内容:

  

IVMap是根据方法表中嵌入的接口映射信息创建的。接口映射是在MethodTable布局过程中基于类的元数据创建的。一旦类型加载完成,只使用IVMap进行方法调度。

因此,类的IVMap仅加载其继承的接口。它似乎从域IVMap复制,但仅保留指向的接口。这引出了另一个问题,如何?很可能它与C++的vtable相同,其中每个条目都具有偏移量,并且接口映射提供要包括在IVMap中的偏移量列表。

如果我们看一下可能适用于整个域的IVMap:

+-------------------------+
| Slot 1 - YourInterface  |
+-------------------------+
| Slot 2 - MyInterface    |
+-------------------------+
| Slot 3 - MyInterface2   |
+-------------------------+
| Slot 4 - YourInterface2 |
+-------------------------+
假设在此领域中只有4个Interface Map的实现。每个插槽都会有一个偏移量(类似于我之前发布的IVMap记录),并且该类的IVMap将使用这些偏移量来访问IVMap中的记录。
假设每个插槽为8字节,插槽1从0开始,因此如果我们想要得到插槽2和3,我们可以这样做:
mov ecx,edi
mov eax, dword ptr [ecx]
mov eax, dword ptr [ecx+08h] ; slot 2
; do stuff with slot 2
mov eax, dword ptr [ecx+10h] ; slot 3
; do stuff with slot 3
请原谅我的x86知识不是很熟悉,但我尝试复制了与链接文章中相同的内容。

我不遵循使用偏移量布置的方式,因此每个东西都有一个相对固定的区域。但是,是的,我可以想象CLR会通过我的帖子中提到的IVMap进行线性搜索。但我想知道它在实践中是如何工作的。毫无疑问,对于像接口方法调用这样经常发生的事情,进行线性搜索似乎非常幼稚。 - Daniel A.A. Pelsmaeker
@Virtlink 如果您查看上面的图像,您会注意到基于堆的方法表中有一个数字。因此,GCInfo位于-12,基本实例大小位于4等。使用这些设置的标准化偏移量,您将能够通过执行类似于“dword ptr [eax+0Ch]”这样的操作来查找字段。 - Jetti
1
@Virtlink,请查看我的编辑。我希望我已经澄清了事情。如果还有问题,请告诉我。 - Jetti
1
@Virtlink x86的示例并未展示如何找到类,它只展示了如何执行某个类对接口的实现,这并不是你所问的。我不确定如何找到它,但我敢打赌微软的某个人能够给出100%的具体细节,但我不知道他们是否会这样做。我只是指出基于IVMap布局的线性搜索在理论上并不像看起来那么糟糕。 - Jetti
@Virtlink 谢谢,我已经再次编辑了。也许会更加清晰明了。 - Jetti
显示剩余2条评论

0

从您提供的第一篇文章中:

如果MyInterface1由两个类实现,则IVMap表中将有两个条目。该条目将指向嵌入在MyClass方法表中的子表的开头,如图9所示。

以及

ClassLoader遍历当前类、父类和接口的元数据,并创建方法表。在布局过程中,它替换任何被覆盖的虚拟方法,替换任何被隐藏的父类方法,创建新的插槽,并根据需要复制插槽。复制插槽是必要的,以创建每个接口都有自己的迷你vtable的假象。但是,复制的插槽指向与类自己的vtable条目相同的物理实现。

这让我想到接口的IVMap具有由类名(或某个等效项)键控的条目,指向类的vtable的子部分,该子部分基本上具有实现该接口的类的每个方法的重复实现,由指向与类自己的vtable条目相同的物理实现的指针支持。

不过,我的理解可能完全错误。


1
如果你完整地阅读了我的帖子,你会发现我已经引用了你引用的同样的语句,并且我知道VTable有一个子部分,其中包含指向方法实现的指针。我的问题是CLR如何知道使用哪个子部分。 - Daniel A.A. Pelsmaeker

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接