4

.NET性能优化-快速遍历List集合

 2 years ago
source link: https://www.cnblogs.com/InCerry/p/Fast-Enumerate-List.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

.NET性能优化-快速遍历List集合

System.Collections.Generic.List<T>是.NET中的泛型集合类,可以存储任何类型的数据,因为它的便利和丰富的API,在我们平时会广泛的使用到它,可以说是使用最多的集合类。

在代码编写中,我们经常需要遍历一个List<T>集合,获取里面的得元素进行一些业务的处理。通常情况下,集合内的元素不是很多,遍历起来非常快。但是对于一些大数据处理,统计,实时计算等动辄数万、十万数据的List<T>集合,如何快速的遍历它呢?这就是今天需要和大家分享的内容。

我们来看看不同遍历方式的性能表现,构建了如下一个性能基准测试,使用不同数量级的集合遍历来看看不同方式的性能表现。代码片段如下所示:

public class ListBenchmark
{
    private List<int> _list = default!;

    // 分别测试10、1千、1万、10万及100万数据时的表现
    [Params(10, 1000, 1_0000, 10_0000, 100_0000)]
    public int Size { get; set; }

    [GlobalSetup]
    public void Setup()
    {
        // 提前创建好数组
        _list = new List<int>(Size);
        for (var i = 0; i < Size; i++)
        {
            _list.Add(i);
        }
    }
}

使用foreach语句

foreach是我们遍历集合是最常用的方式了,它是一个语法糖实现了迭代器模式,它也是作为我们本次的基准。

[Benchmark(Baseline = true)]  
public void Foreach()  
{  
    foreach (var item in _list)  
    {  
    }
}

因为foreach语句是一个语法糖,所以最终编译器会使用while循环调用GetEnumerator()MoveNext()来实现功能。编译后的代码如下所示:

997046-20220815210542499-1024449808.png


其中MoveNext()方法实现中会确保在迭代中不会有其它线程修改集合,如果发生了修改则会抛出InvalidOperationException异常,另外它会有溢出检查,检查当前索引是不是合法的,还需要将对应的元素赋值给enumerator.Current属性,所以其实它的性能并不是最好的,代码片段如下所示:

997046-20220815210542262-442035738.png


我们来看看它在不同集合大小的性能怎么样,结果如下所示:

997046-20220815210541992-762887612.png


可以看到在Size不同的情况下,耗时程线性增长关系,就算是没有任何处理逻辑的遍历100w的数据,则需要至少1s。

使用List的ForEach方法

另外一个比较常用的方式就是使用List<T>.ForEach()方法,这个方法允许你传入一个Action<T>委托,它会在遍历元素时调用Action<T>委托。

[Benchmark]  
public void List_Foreach()  
{  
    _list.ForEach(_ => { });  
}

它是List<T>内部实现的方法,所以能直接访问私有数组,另外能避免掉溢出检查;按照理论上来说它应该会很快速;但是在我们的场景中只有一个空方法,可能表现并不会有完全内联调用的foreach方法好。
下面是ForEach方法的源码,可以看到它没有了溢出检查,不过还保留了并发的版本号检查。

997046-20220815210541638-474540185.png


另外由于需要给ForEach方法传递委托,所以在调用代码中,每一次都会检查闭包生成类中的委托对象是否为空,如果不为空则new Action<T>(),如下所示:
997046-20220815210541370-1856877471.png
我们来看看它与foreach关键字相比性能上有什么差别吧。下图是基准测试的结果:

997046-20220815210541105-330037530.png


从测试结果来看,要比直接使用foreach关键字慢40%,看来如非必要,直接使用foreach是比较好的选择,那么还有没有什么更快的方式呢?

for循环遍历

回到了我们最古老的方式,就是使用for关键字来遍历集合。它应该是目前来说性能最好的遍历方式,因为它不需要像之前的那几种方式一样有一些多余的代码(不过索引器同样有检查,防止溢出),另外很显然它不会检查版本号,所以在多线程环境下集合被改变,使用for不会有异常抛出。测试代码如下所示:

public void For()  
{  
    for (var i = 0; i < _list.Count; i++)  
    {  
        // 如果是空循环的话,会被编译器优化
        // 我们加一行代码使其不会被编译器优化
        _ = _list[i];  
    }  
}

来看看它的结果吧。

997046-20220815210540750-1533875291.png


这看来就是我们所期待的方式了,直接使用for循环要比foreach60%,原本需要1秒才能遍历完的集合,现在只需要400毫秒。那么还有没有更快的方式呢?

使用CollectionsMarshal

在.NET5以后,dotnet社区为了让集合操作性能更好,从而实现了CollectionsMarshal类;这个类里面实现了对于集合类型的原生数组的访问方式(如果你看过我的【.NET性能优化-你应该为集合类型设置初始大小】文章,就知道很多数据结构的底层实现都是数组)。所以它能跳过各种检测,直接访问原始的数组,应该是最快速的。代码如下所示:

// 为了测试编译器有没有针对foreach span优化
// 同时测试for span
public void Foreach_Span()  
{  
    foreach (var item in CollectionsMarshal.AsSpan(_list))  
    {  
    }
}  
  

public void For_Span()  
{  
    var span = CollectionsMarshal.AsSpan(_list);  
    for (int i = 0; i < span.Length; i++)  
    {  
        _ = span[i];  
    }  
}

可以看到编译器生成的代码是非常高效的。

997046-20220815210540397-159286256.png

直接访问底层数组是非常危险的,你一定要清楚自己每一行代码在做什么,并且有足够的测试
基准测试结果如下所示:

997046-20220815210539931-1374297530.png


Wow,使用CollectionsMarshal比使用foreach要快79%,不过应该是JIT优化的原因,使用foreachfor关键字循环Span没有很大的差别。

今天和大家聊了聊如何快速的遍历List集合,在大多数的情况下推荐大家使用foreach关键字,它既有溢出检查也有多线程下版本号的控制,可以让我们更容易的写出正确的代码。

如果在需要高性能和大数据量的场景,那么推荐直接使用forCollectionsMarshal.AsSpan来遍历集合;当然,使用CollectionsMarshal.AsSpan一定要注意使用方式。

本文源码链接:https://github.com/InCerryGit/BlogCodes/tree/main/Fast-Enumerate-List


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK