.Net 开发人员的 5 个性能提示

大家好!今天我想和大家分享一些 .Net 5 性能技巧与基准测试!

我的系统:

  • BenchmarkDotNet=v0.13.0, OS=Windows 10.0.19042.985 (20H2/October2020Update)
  • Intel Core i7-9750H CPU 2.60GHz,1 个 CPU,12 个逻辑内核和 6 个物理内核
  • .NET SDK=5.0.104

我将以 100% 是最快结果的百分比提供基准测试结果。

1.用于连接的StringBuilder

您可能知道,字符串是不可变的。因此,每当您连接字符串时,都会分配一个新的字符串对象,填充内容,并最终收集垃圾。所有这些都是昂贵的,这就是为什么 StringBuilder 将始终具有更好的性能。

基准示例:

private static StringBuilder sb = new();

[Benchmark]
public void Concat3() => ExecuteConcat(3);  
[Benchmark]
public void Concat5() => ExecuteConcat(5);  
[Benchmark]
public void Concat10() => ExecuteConcat(10);  
[Benchmark]
public void Concat100() => ExecuteConcat(100);  
[Benchmark]
public void Concat1000() => ExecuteConcat(1000);    

[Benchmark]
public void Builder3() => ExecuteBuilder(3);   
[Benchmark]
public void Builder5() => ExecuteBuilder(5);   
[Benchmark]
public void Builder10() => ExecuteBuilder(10);   
[Benchmark]
public void Builder100() => ExecuteBuilder(100);   
[Benchmark]
public void Builder1000() => ExecuteBuilder(1000);

public void ExecuteConcat(int size)
{
    string s = "";
    for (int i = 0; i < size; i++)
    {
        s += "a";
    }
}

public void ExecuteBuilder(int size)
{
    sb.Clear();
    for (int i = 0; i < size; i++)
    {
        sb.Append("a");
    }
}

结果:

  1. 3 个字符串连接 – 218% (35.21 ns)
  2. 3 个 StringBuilder 连接 – 100% (16.09 ns)
  1. 5 个字符串连接 – 277% (66.99 ns)
  2. 5 个 StringBuilder 连接 – 100% (24.16 ns)
  1. 10 个字符串连接 – 379% (160.69 ns)
  2. 10 个 StringBuilder 连接 – 100% (42.37 ns)
  1. 100 个字符串连接 – 711% (2,796.63 ns)
  2. 100 个 StringBuilder 连接 – 100% (393.12 ns)
  1. 1000 个字符串连接 – 3800% (144,100.46 ns)
  2. 1000 个 StringBuilder 连接 – 100% (3,812.22 ns)

2. 动态集合的初始大小

.NET 提供了很多集合,例如 List<T>、Dictionary<T> 和 HashSet<T>。所有这些集合都具有动态大小容量。当您添加更多项目时,它们会自动扩大其大小。

当集合达到它的大小限制时,它将分配一个新的更大的内存缓冲区(通常是一个大小翻倍的数组)。这意味着额外的分配和释放。

基准示例:

[Benchmark]
public void ListDynamicCapacity()
{
    List<int> list = new List<int>();
    for (int i = 0; i < Size; i++)
    {
        list.Add(i);
    }
}
[Benchmark]
public void ListPlannedCapacity()
{
    List<int> list = new List<int>(Size);
    for (int i = 0; i < Size; i++)
    {
        list.Add(i);
    }
}

在第一种方法中,List 集合以默认容量开始并扩展大小。在第二个基准测试中,初始容量设置为它将拥有的项目数。

对于 1000 个项目,结果是:

  1. 列出动态容量 – 140% (2.490 us)
  2. 列出计划容量 – 100% (1.774 us)

Dictionary 和 HashSet 的基准测试:

  1. 字典动态容量 – 233% (20.314 us)
  2. 字典计划容量 – 100% (8.702 us)
  1. HashSet 动态容量 – 223% (17.004 us)
  2. HashSet 计划容量 – 100% (7.624 us)

3. ArrayPool 用于短命大数组

阵列的分配和不可避免的解除分配可能非常昂贵。以高频率执行这些分配将导致 GC 压力并损害性能。一个优雅的解决方案是在 Systems.Buffers NuGet中找到的 System.Buffers.ArrayPool 类 。

这个想法与 ThreadPool 非常相似。为数组分配了一个共享缓冲区,您可以在不实际分配和取消分配内存的情况下重复使用该缓冲区。基本用法是调用 ArrayPool<T>.Shared.Rent(size)。这将返回一个常规数组,您可以随意使用它。完成后,调用 ArrayPool<int>.Shared.Return(array) 将缓冲区返回到共享池。

基准示例:

[Benchmark]
public void RegularArray()
{
    int[] array = new int[ArraySize];
}
[Benchmark]
public void SharedArrayPool()
{
    var pool = ArrayPool<int>.Shared;
    int[] array = pool.Rent(ArraySize);
    pool.Return(array);
}

ArraySize = 1000 的结果:

  1. 常规阵列 – 2270% (440.41 ns)
  2. 共享 ArrayPool – 100% (19.40 ns)

4.结构而不是类

 在解除分配方面,结构有几个好处

  • 当结构不是类的一部分时,它们被分配在堆栈上,根本不需要垃圾收集。
  • 当结构是类(或任何引用类型)的一部分时,结构将存储在堆上。在这种情况下,它们被内联存储并在包含类型被释放时被释放。内联意味着结构的数据按原样存储。与引用类型相反,其中指针与实际数据一起存储到堆上的另一个位置。这在集合中特别有意义,其中结构集合的释放成本要低得多,因为它只是一个内存缓冲区。
  • 结构比引用类型占用更少的内存,因为它们没有 ObjectHeader 和 MethodTable。

根据指南决定是否使用 struct 。

基准示例:

class VectorClass
{
    public int X { get; set; }
    public int Y { get; set; }
}

struct VectorStruct
{
    public int X { get; set; }
    public int Y { get; set; }
}

private const int ITEMS = 10000;


[Benchmark]
public void WithClass()
{
    VectorClass[] vectors = new VectorClass[ITEMS];
    for (int i = 0; i < ITEMS; i++)
    {
        vectors[i] = new VectorClass();
        vectors[i].X = 5;
        vectors[i].Y = 10;
    }
}

[Benchmark]
public void WithStruct()
{
    VectorStruct[] vectors = new VectorStruct[ITEMS];
    // At this point all the vectors instances are already allocated with default values
    for (int i = 0; i < ITEMS; i++)
    {
        vectors[i].X = 5;
        vectors[i].Y = 10;
    }
}

结果:

  1. 有类 – 742% (88.83 us)
  2. 带结构 – 100% (11.97 us)

5. StackAlloc 用于短期数组分配

C# 中的 StackAlloc 关键字允许非常快速地分配和释放非托管内存。也就是说,类不起作用,但支持原语、结构和数组。

基准示例:

struct VectorStruct
{
    public int X { get; set; }
    public int Y { get; set; }
}

[Benchmark]
public void WithNew()
{
    VectorStruct[] vectors = new VectorStruct[5];
    for (int i = 0; i < 5; i++)
    {
        vectors[i].X = 5;
        vectors[i].Y = 10;
    }
}

[Benchmark]
public unsafe void WithStackAlloc() // Note that unsafe context is required
{
    VectorStruct* vectors = stackalloc VectorStruct[5];
    for (int i = 0; i < 5; i++)
    {
        vectors[i].X = 5;
        vectors[i].Y = 10;
    }
}

[Benchmark]
public void WithStackAllocSpan() // When using Span, no need for unsafe context
{
    Span<VectorStruct> vectors = stackalloc VectorStruct[5];
    for (int i = 0; i < 5; i++)
    {
        vectors[i].X = 5;
        vectors[i].Y = 10;
    }
}

结果:

  1. 新 – 303% (10.870 ns)
  2. 使用 StackAlloc – 102% (3.643 ns)
  3. 使用 StackAllocSpan – 100% (3.580 ns)

6. ConcurrentQueue<T> 代替 ConcurrentBag<T>

切勿在未进行基准测试的情况下使用 ConcurrentBag<T>。这个集合是为非常特定的用例而设计的(大多数情况下,一个项目被加入队列的线程出队),如果以其他方式使用,则会出现重要的性能问题。如果需要并发集合,则首选 ConcurrentQueue<T>。

基准示例:

private static int Size = 1000;

[Benchmark]
public void Bag()
{
    ConcurrentBag<int> bag = new();
    for (int i = 0; i < Size; i++)
    {
        bag.Add(i);
    }
}

[Benchmark]
public void Queue()
{
    ConcurrentQueue<int> bag = new();
    for (int i = 0; i < Size; i++)
    {
        bag.Enqueue(i);
    }
}

结果:

  1. ConcurrentBag – 165% (24.21 us)
  2. 并发队列 – 100% (14.64 us)