在很久以前,我认为现在的计算资源是过剩的,写代码的时候比较肆无忌惮,没有过多考虑性能,只要能实现功能就行,各种模式随便用,怎么方便怎么来,不会去深入考虑各种数据结构背后的实现逻辑,也很少比较不同实现方法之间的差异。但最近几年发现,在有些应用场景下,不同的实现方式对性能有很大的影响,从而变得谨慎,胆小,甚至如履薄冰,觉得只要有一点性能影响就不考虑,走向了另外一个极端。比如最近在重构代码,之前心里一直对虚方法存在一定的偏见,认为它会影响性能,所以代码中避免使用虚方法或者接口方法,从而使得代码存在重复的地方。但跟实例方法相比究竟会有多大的性能损耗一直没有进行过测试。

   多态,就是需要在运行时而不是编译时才能判断该具体调用哪个方法,这就是常说的晚绑定(late-binding)。在C#中,可以在基类中定义虚方法或者定义接口方法来实现,但是与直接调用实例方法或者静态方法相比,虚方法或接口方法会带来额外的性能开销。在某些情况下,这是需要考虑的问题。

    本文通过简单的例子,对比了这几种方法的耗时差别,从而对不同的方法在纯粹函数调用方面的耗时差别在心里上有了一个基本的认知。

直接看代码


    废话不多说,直接看代码。这里定义了静态方法、接口方法、虚方法、实例方法、抽象方法。然后使用CodeTimer这个简单的性能计数器进行了测试(当然可以用更专业的Benchmark.Dotnet)。

interface IInterfaceExample
{
    int Calculate(int i);
}

class ImplementsInterface : IInterfaceExample
{
    public int Calculate(int i)
    {
        return Foo.Calculate(i);
    }
}

class BaseExample
{
    public virtual int Calculate(int i) { return i * 2; }
}

class DerivesBase : BaseExample
{
    public override int Calculate(int i)
    {
        return Foo.Calculate(i);
    }
    public int DirectCalculate(int i)
    {
        return Foo.Calculate(i);
    }

    public static int StaticCalculate(int i)
    {
        return Foo.Calculate(i);
    }
}

abstract class AbstractExample
{

    public abstract int Calculate(int i);
}

class DerivesAbstract : AbstractExample
{
    public override int Calculate(int i)
    {
        return Foo.Calculate(i);
    }
}

static class Foo
{
    //[MethodImpl(MethodImplOptions.NoInlining)]
    public static int Calculate(int i) { return i * 2; }
}

internal class Program
{
    static void Main(string[] args)
    {
        int count = 10000000;
        IInterfaceExample interfaceCall = new ImplementsInterface();
        AbstractExample abstractCall = new DerivesAbstract();
        BaseExample virtualCall = new DerivesBase();
        DerivesBase directCall = new DerivesBase();
        interfaceCall.Calculate(1);
        virtualCall.Calculate(1);
        abstractCall.Calculate(1);
        CodeTimer.Initialize();
        CodeTimer.Time("static call", count, () => { DerivesBase.StaticCalculate(1); });
        CodeTimer.Time("direct call", count, () => { directCall.DirectCalculate(1); });
        CodeTimer.Time("virtual call", count, () => { virtualCall.Calculate(1); });
        CodeTimer.Time("abstract call", count, () => { abstractCall.Calculate(1); });
        CodeTimer.Time("interface call", count, () => { interfaceCall.Calculate(1); });
        Console.ReadLine();
    }
}

    运行结果如下:

static call
        Time Elapsed:   18ms
        CPU Cycles:     66,934,630
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

direct call
        Time Elapsed:   15ms
        CPU Cycles:     54,106,838
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

virtual call
        Time Elapsed:   27ms
        CPU Cycles:     99,081,860
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

abstract call
        Time Elapsed:   34ms
        CPU Cycles:     125,566,736
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

interface call
        Time Elapsed:   34ms
        CPU Cycles:     125,167,052
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

    当明确指定Foo方法的Calculate方法不允许内联后,再次运行会发现结果有些不同。

static call
        Time Elapsed:   30ms
        CPU Cycles:     105,875,706
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

direct call
        Time Elapsed:   30ms
        CPU Cycles:     108,100,828
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

virtual call
        Time Elapsed:   41ms
        CPU Cycles:     147,654,398
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

abstract call
        Time Elapsed:   42ms
        CPU Cycles:     151,094,366
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

interface call
        Time Elapsed:   42ms
        CPU Cycles:     151,889,826
        Gen 0:          0
        Gen 1:          0
        Gen 2:          0

    可以看到,在方法调用了1000万次后:

  1. 当允许内联时,实例方法速度最快,其次是静态方法,再次是重载的虚方法,最慢是抽象方法和接口方法(这两者耗时相近)。
  2. 当不允许方法内联时,实例方法和静态方法基本一样快,虚方法、抽象方法和接口方法一样快。但总体而言前者比后者要更快。
  3. 在两种不同的情况下,可以看到,不管是否允许内联,实例方法比虚方法在调用1000万次会慢11~12ms,如果换算到1次调用的差距,其实可以忽略不计。所以跟方法的调用内部的运算耗时相比,实例方法和虚方法之间的纯粹的调用时间耗时可以忽略不计。

原因分析


    那么实例方法和虚方法为什么会有差距呢,最直观的感受就是,实例方法以及静态方法,这两种方法,编译器在编译阶段就能够知道调用该方法的对象,所以能够进行更多的编译器优化,比如内联,比如常量展开等等,这样能减少诸如函数调用的开销。

   而接口方法,虚方法,抽象方法这些,在编译阶段是不知道具体该调用那个具体对象的,它需要在运行时才能确定到底是调用的那个对象的实现,所以编译器能做的优化的空间不大。

    从代码层面看,在C#生成的IL中,对方法的调用有call和callvirtual。

  • call可以调用静态方法,实例方法和虚方法。call指令通常以非虚的方式调用虚方法,call指令不会检查当前对象是否为空,它默认不为空。
  • callvirutal可以调用实例方法和虚方法。callvirtual通常用来调用非虚方法,虽然使用的是callvirutal,但编译器知道某个方法是非虚方法,所以直接以非虚的方式调用。
  • 这两个方法在内部实现中,第一个参数是"this",即被调用方法所在的对象。在callvirutal中,方法会检查this对象是否为空,如果为空就会抛出NullReferenceException的异常而call指令则默认假定this不为空,不做是否为空的检查这个额外是否为空的检查,会增加一点点性能开销,但是这种差别非常小

    虚方法跟实例方法相比要慢,主要原因在于:

  • 首先调用虚方法比非虚方法相比要多一个非空检查,本身就慢。
  • 最重要的是,编译器无法对虚方法进行一些能在虚方法中进行的优化操作,比如内联、比如常数展开等优化。内联能减少函数调用开销。对于那些频繁调用,且函数体非常短小的适合内联的方法,这种差别最明显,而对于那些函数体内操作比较多的本身不适合进行内联优化的情况,差别则没有那么明显。

结论


    实例方法或静态方法对比虚方法、抽象方法、接口方法,在单纯的函数调用方面要快一点,但是相比函数内部逻辑代码的执行时间,这种快的程度基本可以忽略不计。所以基本不用考虑这两者的差异,更不能因为这一点小小的差异而放弃虚方法、抽象方法、接口方法这类面向对象技术带来的能够使代码变得更简洁、更具有扩展性的技术。

 

  1. https://learn.microsoft.com/en-us/archive/blogs/ericgu/why-does-c-always-use-callvirt
  2. https://softwareengineering.stackexchange.com/questions/234473/how-are-virtual-methods-slower-in-c 
  3. https://www.aloneguid.uk/posts/2021/05/csharp-method-call-performance/