调用和Callvirt

65
CIL指令中的“Call”和“Callvirt”有什么区别?
6个回答

63

当运行时执行 call 指令时,它是在调用一个确切的代码片段(方法)。没有关于它存在的问题。一旦中间语言(IL)被即时编译(JIT),调用站点上的结果机器码是无条件的 jmp 指令。

相比之下,callvirt 指令用于以多态方式调用虚方法。必须为每次调用确定方法代码的确切位置。JIT后生成的代码涉及通过虚表结构的某些间接性。因此,调用执行较慢,但更具灵活性,因为它允许进行多态调用。

请注意,编译器可以为虚方法发出 call 指令。例如:

sealed class SealedObject : object
{
   public override bool Equals(object o)
   {
      // ...
   }
}

考虑以下代码:

SealedObject a = // ...
object b = // ...

bool equal = a.Equals(b);

虽然 System.Object.Equals(object) 是一个虚方法,但在此用法中,不存在重载 Equals 方法的方式。而 SealedObject 是一个密封类,不能有子类。

因此,.NET 的 sealed 类可以比其非密封类拥有更好的方法调度性能。

编辑:事实证明我是错的。C# 编译器无法对该方法进行无条件跳转,因为对象的引用(方法内的 this 值)可能为空。因此,它发出 callvirt 指令来进行空检查,并在必要时抛出异常。

这实际上解释了我在 .NET 框架中使用反编译器找到的一些奇怪代码:

if (this==null) // ...

编译器可以生成可验证的代码,并将 this 指针(local0)设置为 null 值,但 csc 不会这样做。

因此,我猜想 call 只用于类静态方法和结构体。

根据这些信息,我认为 sealed 只对 API 安全有用。我发现另一个问题似乎暗示封闭类并不能带来性能优势。

编辑2: 这个问题还有更多细节。例如,下面的代码会生成一个call指令:

new SealedObject().Equals("Rubber ducky");

显然,在这种情况下,对象实例不可能为 null。

有趣的是,在 DEBUG 构建中,以下代码会发出 callvirt

var o = new SealedObject();
o.Equals("Rubber ducky");
这是因为您可以在第二行设置断点并修改o的值。在发布版本中,我想这个调用将会是一个call而非callvirt
不幸的是,我的电脑目前出了一些问题,但是一旦它恢复正常,我会进行实验。

3
封装属性在通过反射查找时肯定更快,但除此之外,我不知道还有没有其他你没有提到的好处。 - TraumaPony

54

call 用于调用非虚方法、静态方法或超类方法,即调用目标不会被覆盖。而 callvirt 则用于调用虚方法(如果 this 是子类且覆盖了该方法,则调用子类版本)。


42
如果我没记错的话,call 在执行调用之前不会检查指针是否为 null,而 callvirt 显然需要这样做。这就是为什么编译器有时会生成 callvirt,即使调用的是非虚方法。 - dalle
2
啊,谢谢指出来(我不是 .NET 人)。我使用的类比是 call => invokespecial,以及 callvirt => invokevirtual,在 JVM 字节码中。在 JVM 的情况下,这两个指令都会检查 "this" 是否为 null(我刚写了一个测试程序来检查)。 - C. K. Young
3
在你的回答中,你可能需要提到性能差异,这也是存在“调用”指令的原因。请注意保持原意并使语言更加通俗易懂。 - Nick Johnson
1
@AngshumanAgarwal 关于C#总是使用callvirt,这很公平。无论如何,我对callcallvirt的描述(据我所知)仍然是正确的,而且我的评论关于JVM字节码的工作方式仍然成立——使用super.foobar()的Java代码确实仍然会使用invokespecial(而不是invokevirtual)。 - C. K. Young
4
“call”指令确实会被时不时地生成。以下是一个测试C#程序的ILDasm:IL_0026: ldstr "This was expected behavior. Will exit with code 100." IL_002b: *call* void [mscorlib]System.Console::WriteLine(string) IL_0030: ldc.i4.s 100``` - Jduv
显示剩余2条评论

12
由于这个原因,“.NET”的密封类比其非密封类能够具有更好的方法调度性能并不正确。不幸的是,callvirt还有另外一个有用的功能,即当对象上调用方法时,它会检查对象是否存在,如果不存在,则会抛出NullReferenceException异常。而call则会直接跳转到内存位置,即使对象引用不存在,也会尝试执行该位置上的字节。这意味着在C#编译器(不确定VB)中,始终使用callvirt来调用类的方法,并且对于结构体始终使用call(因为它们永远不会为null或子类化)。编辑:回应Drew Noakes的评论:是的,似乎可以让编译器针对任何类发出调用命令,但仅限于以下非常特定的情况:
public class SampleClass
{
    public override bool Equals(object obj)
    {
        if (obj.ToString().Equals("Rubber Ducky", StringComparison.InvariantCultureIgnoreCase))
            return true;

        return base.Equals(obj);
    }

    public void SomeOtherMethod()
    {
    }

    static void Main(string[] args)
    {
        // This will emit a callvirt to System.Object.Equals
        bool test1 = new SampleClass().Equals("Rubber Ducky");

        // This will emit a call to SampleClass.SomeOtherMethod
        new SampleClass().SomeOtherMethod();

        // This will emit a callvirt to System.Object.Equals
        SampleClass temp = new SampleClass();
        bool test2 = temp.Equals("Rubber Ducky");

        // This will emit a callvirt to SampleClass.SomeOtherMethod
        temp.SomeOtherMethod();
    }
}

注意,这个功能不需要将类密封。

因此,如果以下所有条件均为真,则编译器似乎会发出调用:

  • 方法调用紧随对象创建之后
  • 该方法未在基类中实现

这很有道理。我曾经学习过CIL并对编译器的行为做出了一些假设。感谢您澄清了这一点。我会更新我的答案。 - Drew Noakes
1
其实我不相信你在这里是100%正确的。我已经更新了我的帖子(编辑2)。 - Drew Noakes
嗨,Cameron。你能否澄清一下,是否对此代码的分析是在发布版本上执行的? - Drew Noakes
在调试版本和发布版本中,调用之间没有任何区别。唯一的区别在于调用之间的堆栈操作代码。在第一个调用中使用CallVirt,因为它不是调用SampleClass.Equals而是Object.Equals。 - Cameron MacFarland
只是要指出,如果方法是静态的,编译器也会发出call - Antony Thomas

8
根据MSDN: Call
调用指令使用传递给指令的方法描述符调用指定的方法。方法描述符是一个元数据标记,指示要调用的方法...元数据标记携带足够的信息来确定调用是静态方法、实例方法、虚拟方法还是全局函数。在所有这些情况下,目标地址完全由方法描述符确定(与调用虚拟方法的Callvirt指令形成对比,在该指令中,目标地址还取决于在Callvirt之前推送的实例引用的运行时类型)。 CallVirt
callvirt指令在对象上调用后期绑定方法。也就是说,方法是根据obj的运行时类型选择的,而不是方法指针中可见的编译时类。Callvirt可用于调用虚拟和实例方法。
因此,基本上采取不同的路线来调用对象的实例方法,无论是否重写:

调用:变量 -> 变量的 类型对象 -> 方法

虚方法调用:变量 -> 对象实例 -> 对象的 类型对象 -> 方法


6
或许有必要补充一下前面回答的内容,相对于“IL call”只有一种执行方式,而“IL callvirt”有两种执行方式。
看看这个示例设置。
    public class Test {
        public int Val;
        public Test(int val)
            { Val = val; }
        public string FInst () // note: this==null throws before this point
            { return this == null ? "NO VALUE" : "ACTUAL VALUE " + Val; }
        public virtual string FVirt ()
            { return "ALWAYS AN ACTUAL VALUE " + Val; }
    }
    public static class TestExt {
        public static string FExt (this Test pObj) // note: pObj==null passes
            { return pObj == null ? "NO VALUE" : "VALUE " + pObj.Val; }
    }

首先,FInst()和FExt()的CIL主体是完全相同的,从操作码到操作码都是一样的 (除了一个被声明为“实例”,另一个被声明为“静态”) - 但是FInst()将使用“callvirt”调用,而FExt()将使用“call”调用。

其次,尽管其中一个是虚拟的,而另一个不是, 但FInst()和FVirt()都将使用“callvirt”进行调用 - 但实际执行的“callvirt”并不是完全相同的。

以下是JIT编译后大致发生的情况:

    pObj.FExt(); // IL:call
    mov         rcx, <pObj>
    call        (direct-ptr-to) <TestExt.FExt>

    pObj.FInst(); // IL:callvirt[instance]
    mov         rax, <pObj>
    cmp         byte ptr [rax],0
    mov         rcx, <pObj>
    call        (direct-ptr-to) <Test.FInst>

    pObj.FVirt(); // IL:callvirt[virtual]
    mov         rax, <pObj>
    mov         rax, qword ptr [rax]  
    mov         rax, qword ptr [rax + NNN]  
    mov         rcx, <pObj>
    call        qword ptr [rax + MMM]  

"call"和"callvirt[instance]"之间唯一的区别是,"callvirt[instance]"在调用实例函数的直接指针之前有意尝试从*pObj访问一个字节(以可能立即抛出异常)。因此,如果你对于多次编写检查部分感到烦恼,请使用"callvirt[instance]"。
var d = GetDForABC (a, b, c);
var e = d != null ? d.GetE() : ClassD.SOME_DEFAULT_E;

你不能将 "if (this==null) return SOME_DEFAULT_E;" 推进到 ClassD.GetE() 中 (因为 "IL callvirt[instance]" 语义不允许这样做), 但是如果你将 .GetE() 移动到某个扩展函数中,你可以将其推入 .GetE() 中 (因为 "IL call" 语义允许这样做,但是会失去访问私有成员的能力等)。
即便如此,"callvirt[instance]" 的执行与 "call" 更相似, 而不是 "callvirt[virtual]",因为后者可能需要执行三次间接寻址以找到函数的地址。 (间接寻址到 typedef base,然后到 base-vtab 或某个接口,最后到实际槽位)
希望这能帮到你, Boris

1

除了以上回答,我认为早在很久以前就已经进行了更改,使得对于所有实例方法都会生成Callvirt IL指令,而对于静态方法则会生成Call IL指令。

参考资料:

Pluralsight课程“C#语言内部-第1部分”(由Bart De Smet制作的视频-- CLR IL中的调用指令和调用堆栈概述)

以及https://blogs.msdn.microsoft.com/ericgu/2008/07/02/why-does-c-always-use-callvirt/


2
call 也被用于基础调用。此外,我相信,如果Roslyn编译器可以轻松确定对象永远不会为空(例如,从空条件运算符生成的调用),则它将为密封/非虚拟方法生成 call 指令。 - Brian Reichle

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