List<T>与IEnumerable<T>的区别

6
我运行了以下控制台应用程序:
class Program
{
    static void Main(string[] args)
    {
        int n = 10000;

        Stopwatch s = new Stopwatch();
        s.Start();
        List<int> numbers = GetListNumber(n);
        foreach (var number in numbers)
        {

        }
        s.Stop();
        Console.WriteLine(s.Elapsed);
        Console.WriteLine();

        s.Restart();
        foreach (var number in GetEnumerator(n))
        {

        }
        s.Stop();
        Console.WriteLine(s.Elapsed);
        Console.ReadKey();
    }

    static List<int> GetListNumber(int n)
    {
        List<int> numbers = new List<int>();
        for (int i = 0; i < n; i++)
            numbers.Add(i);
        return numbers;
    }

    static IEnumerable<int> GetEnumerator(int n)
    {
        for (int i = 0; i < n; i++)
            yield return i;
    }
}

为了比较使用List还是IEnumerable构建集合,我们需要迭代每个元素并比较时间。令人惊讶的是,List的结果是00:00:00.0005504,而IEnumerable的结果是00:00:00.0016900。我原本预期第二种方式IEnumerable会更快,因为这些值是即时创建的,我们无需像在List中一样逐个添加每个项目,然后迭代它们。
有人能解释一下这种差异吗?为什么会出现这种行为而不是相反的行为。
提前感谢任何帮助!

4
你知道你所做的测量是没有意义的吗?这是1.5毫秒对0.55毫秒的最大值——全部都太小了。如果我在你的情况下,会重新编写和运行一个更有意义的测试(使运行时间约为一秒),然后查看错误是否仍然存在。 - TomTom
我正在使用Enumerable获得更好的结果。 - Rafa Paez
好的,这样会更好。更有意义 - 至少两秒钟让它变得更好了。你运行了分析器并检查了时间花在哪里吗? - TomTom
你尝试启用了优化吗?例如,在发布配置中。 - Dmitry
1
@Dmitry 有个奇怪的事情:我之前在调试配置下运行我的代码,然后我改成了发布配置。现在第一个情况下我得到了1.73秒,而第二个情况下是1.67秒!!!我搞不明白。 - Christos
显示剩余5条评论
3个回答

4
首先,你进行测试的方式无法给出有用的性能差异印象。迭代10000个项目的次数实在太短了;你已经可以看到微秒级别的结果。相反,你应该尝试获得多个秒级的测试结果。此外,你应该连续运行多次相同的测试,然后取平均值。这样可以消除随机影响并获得更稳定的结果(也可以参见大数定律)。
但是,迭代生成器函数可能会比列表慢。这是因为:首先,由于从暂停执行的函数中获取项,因此你最终会得到大量的上下文切换。我不确定生成器函数在这方面是否进行了优化,但无论如何,你仍然必须以某种方式处理它们,因此你确实有一个处罚。
其次,列表内部使用动态调整大小的数组。因此,当你在迭代列表时,你实际上正在迭代一个数组。你正在按顺序迭代内存中的一系列数字。这将始终比其他任何东西都快。
重要的区别是让你考虑使用生成器函数而不是完整列表的内存方面。当你创建列表时,你快速生成所有项目,将它们放入内存中,然后再次快速迭代它们。但是你也将全部放入内存中。因此,根据项目数量的不同,这可能意味着很大的成本。特别是当你只需要访问一个项目一次时,通常不值得。另一方面,生成器函数仅需要记忆单个项目,因此在内存方面非常高效。
最后,虽然存在速度差异,但这很可能永远不会太重要。应用程序变慢的情况很少是因为你决定在某个地方使用生成器函数。更有可能的是,应用程序的瓶颈在其他地方,最可能是在I/O或网络操作方面,因此,在它成为问题之前,你真的不需要关心它。

在这种情况下,接口方法调用可能会对性能产生影响。首先,调用 MoveNext()Current 都涉及到虚函数表查找或类似操作,这在微基准测试中非常重要。此外,接口方法可能无法被 JITter 内联。 - GregRos

0

简单回答。

列表使用了大量的内存,会导致缓存超载。该方法不会占用太多内存,因此可以在处理器的一级缓存中运行。这是至少一个可能的解释。特别是当您处理1000个以上的数字时。


0
可能的差异也可能是因为底层使用了不同的枚举器。例如,用于枚举 List<T> 的 IL 如下所示:
callvirt    System.Collections.Generic.List<System.Int32>.GetEnumerator
stloc.s     04 // CS$5$0000
br.s        IL_0030
ldloca.s    04 // CS$5$0000
call        System.Collections.Generic.List<System.Int32>+Enumerator.get_Current
stloc.3     // number
nop         
nop         
ldloca.s    04 // CS$5$0000
call        System.Collections.Generic.List<System.Int32>+Enumerator.MoveNext
stloc.s     05 // CS$4$0001
ldloc.s     05 // CS$4$0001
brtrue.s    IL_0026
leave.s     IL_004E
ldloca.s    04 // CS$5$0000
constrained. System.Collections.Generic.List<>.Enumerator
callvirt    System.IDisposable.Dispose
nop         
endfinally  

对于遍历 IEnumerable<T>,可以使用 IL 代码实现如下:

callvirt    System.Collections.Generic.IEnumerable<System.Int32>.GetEnumerator
stloc.s     06 // CS$5$0002
br.s        IL_008E
ldloc.s     06 // CS$5$0002
callvirt    System.Collections.Generic.IEnumerator<System.Int32>.get_Current
stloc.3     // number
nop         
nop         
ldloc.s     06 // CS$5$0002
callvirt    System.Collections.IEnumerator.MoveNext
stloc.s     05 // CS$4$0001
ldloc.s     05 // CS$4$0001
brtrue.s    IL_0084
leave.s     IL_00B1
ldloc.s     06 // CS$5$0002
ldnull      
ceq         
stloc.s     05 // CS$4$0001
ldloc.s     05 // CS$4$0001
brtrue.s    IL_00B0
ldloc.s     06 // CS$5$0002
callvirt    System.IDisposable.Dispose
nop         
endfinally  

正如您所看到的,前者在CurrentMoveNext调用中使用了call,而后者则使用了callvirt。这是因为一个正在迭代List<T>.Enumerator,它不能被继承,另一个正在使用IEnumerator<T>,其中还必须考虑继承(例如,您可以返回自己的枚举器,它实际上会从另一个枚举器继承)。

更多关于callcallvirt的阅读可以参考:call and callvirt


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