我一直在做其他实验,直到这种奇怪的行为引起了我的注意。

代码在x64版本中编译。

如果键入1,,则List方法的第3次运行比前2个花费40%的时间。输出是

List costs 9312
List costs 9289
Array costs 12730
List costs 11950

如果键入2,,则数组方法的第3次运行比前2个花费30%的时间。输出是
Array costs 8082
Array costs 8086
List costs 11937
Array costs 12698

您可以看到该模式,下面附有完整的代码(只需编译并运行):
{所提供的代码对于运行测试而言是最少的。用于获得可靠结果的实际代码更加复杂,我对方法进行了包装,并在适当的预热后对其进行了100多次测试}
class ListArrayLoop
{
    readonly int[] myArray;
    readonly List<int> myList;
    readonly int totalSessions;

    public ListArrayLoop(int loopRange, int totalSessions)
    {
        myArray = new int[loopRange];
        for (int i = 0; i < myArray.Length; i++)
        {
            myArray[i] = i;
        }
        myList = myArray.ToList();
        this.totalSessions = totalSessions;
    }
    public  void ArraySum()
    {
        var pool = myArray;
        long sum = 0;
        for (int j = 0; j < totalSessions; j++)
        {
            sum += pool.Sum();
        }
    }
    public void ListSum()
    {
        var pool = myList;
        long sum = 0;
        for (int j = 0; j < totalSessions; j++)
        {
            sum += pool.Sum();
        }
    }

}
class Program
{
    static void Main(string[] args)
    {
        Stopwatch sw = new Stopwatch();
        ListArrayLoop test = new ListArrayLoop(10000, 100000);

        string input = Console.ReadLine();


        if (input == "1")
        {
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}",sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
        }
        else
        {
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ListSum();
            sw.Stop();
            Console.WriteLine("List costs {0}", sw.ElapsedMilliseconds);
            sw.Reset();
            sw.Start();
            test.ArraySum();
            sw.Stop();
            Console.WriteLine("Array costs {0}", sw.ElapsedMilliseconds);
        }

        Console.ReadKey();
    }
}

最佳答案

简短答案:这是因为CRL对接口(interface)类型上调用的调度方法进行了优化。只要特定接口(interface)的方法调用是在相同类型(实现此接口(interface))上进行的,CLR就会使用快速分派(dispatch)例程(仅3条指令),该例程仅检查实例的实际类型,并且在匹配的情况下,它会直接跳至特定对象的预计算地址方法。但是,当在另一种类型的实例上进行相同接口(interface)的方法调用时,CLR会将调度切换到较慢的例程(后者可以为任何实际实例类型调度方法)。

详细回答:
首先,看一下System.Linq.Enumerable.Sum()方法的声明方式(我省略了对源参数的有效性检查,因为在这种情况下它并不重要):

public static int Sum(this IEnumerable<int> source)
{
    int num = 0;
    foreach (int num2 in source)
        num += num2;
    return num;
}

因此,所有实现IEnumerable< int >的类型都可以调用此扩展方法,包括int []和List 。关键字foreach只是通过IEnumerable .GetEnumerator()获取枚举数并迭代所有值的缩写。因此,此方法实际上是这样做的:
    public static int Sum(this IEnumerable<int> source)
    {
        int num = 0;
        IEnumerator<int> Enumerator = source.GetEnumerator();
        while(Enumerator.MoveNext())
            num += Enumerator.Current;
        return num;
    }

现在您可以清楚地看到,该方法主体包含对接口(interface)类型变量的三个方法调用:GetEnumerator(),MoveNext()和Current(尽管Current实际上是属性,而不是方法,从属性读取值只是调用了相应的getter方法)。

GetEnumerator()通常会创建某个辅助类的新实例,该实例实现IEnumerator ,因此能够一个一个地返回所有值。重要的是要注意,在使用int []和List 的情况下,这两个类的GetEnumerator()返回的枚举器类型是不同的。如果参数source是int []类型,则GetEnumerator()返回SZGenericArrayEnumerator 类型的实例,如果source是List 类型的实例,则它返回List + Enumerator 类型的实例。

在紧缩循环中反复调用其他两个方法(MoveNext()和Current),因此它们的速度对于整体性能至关重要。不幸的是,对接口(interface)类型变量(例如IEnumerator )的调用方法不像普通实例方法调用那样简单。 CLR必须动态地找出变量中对象的实际类型,然后再找出哪个对象的方法实现了相应的接口(interface)方法。

CLR尝试避免在每次调用时对进行这种耗时的查找,但要有一些技巧。首次调用特定方法(例如MoveNext())时,CLR将查找在其上进行调用的实例的实际类型(例如,如果您在int []上调用Sum,则为SZGenericArrayEnumerator )并查找以下地址:方法,为该类型实现相应的方法(即方法SZGenericArrayEnumerator .MoveNext()的地址)。然后,它使用此信息生成辅助调度方法,该方法仅检查实际实例类型是否与首次调用时相同(即SZGenericArrayEnumerator ),如果是,则直接跳转至先前找到的方法地址。因此,在后续调用中,只要实例类型保持不变,就不会进行复杂的方法查找。但是,当对不同类型的枚举数进行调用时(例如,在计算List 的总和的情况下,如List + Enumerator ),CLR不再使用此快速分派(dispatch)方法。而是使用另一种(通用)且速度较慢的调度方法。

因此,只要仅在数组上调用Sum(),CLR就会使用快速方法将调用分派(dispatch)到GetEnumerator(),MoveNext()和Current。当在列表上也调用Sum()时,CLR切换到较慢的调度方法,因此性能降低。

如果您关心性能,则对要调用Sum()的每种类型实现自己的单独Sum()扩展方法。这样可以确保CLR使用快速分配方法。例如:
public static class FasterSumExtensions
{
    public static int Sum(this int[] source)
    {
        int num = 0;
        foreach (int num2 in source)
            num += num2;
        return num;
    }

    public static int Sum(this List<int> source)
    {
        int num = 0;
        foreach(int num2 in source)
            num += num2;
        return num;
    }
}

甚至更好的是,完全避免使用IEnumerable 接口(interface)(因为它仍然带来明显的开销)。例如:
public static class EvenFasterSumExtensions
{
    public static int Sum(this int[] source)
    {
        int num = 0;
        for(int i = 0; i < source.Length; i++)
            num += source[i];
        return num;
    }

    public static int Sum(this List<int> source)
    {
        int num = 0;
        for(int i = 0; i < source.Count; i++)
            num += source[i];
        return num;
    }
}

这是我的计算机的结果:
  • 您的原始程序:9844、9841、12545, 14384
  • FasterSumExtensions:6149、6445、754, 6145
  • EvenFasterSum扩展名:1557,1561,553, 1574
  • 09-30 15:42
    查看更多