深入解析C# List<T>的原始碼

發表於2023-11-30
  前面的文章中解釋了Array的初始化和元素插入,以及陣列整體的儲存結構(《深度分析C#中Array的儲存結構》)。這裡我們再來詳細的瞭解另一種儲存結構List<T>, List <T>是 ArrayList 泛型版本,是一個泛型集合類,用於表示動態大小的陣列。List<T>應該是我們在開發過程中使用的頻率最高的資料結構了,那麼List<T>內部的具體的實現邏輯是怎樣的呢?如何做到的高效元素插入、記憶體擴容、元素的排序、元素的反轉等等呢?
  這次我們還是藉助一個簡單的樣例開始今天的原始碼解讀。
 1 public class ListExample
 2 {
 3     public static void Main()
 4     {
 5        List<string> dinosaurs = new List<string>(){"A1-0"};
 6        dinosaurs.Add("C3-2");
 7        dinosaurs.Insert(2, "B2-1"});
 8        dinosaurs.Sort();
 9        dinosaurs.Reverse();
10     }
11  }

  以上的樣例中,我們對List進行初始化、使用Add()/Insert()方法對集合進行了元素的插入、藉助Sort()對集合進行了排序操作、最後使用了Reverse()對整個集合的元素進行了反轉。接下來我們將這幾個角度對List<T>進行一個整體的分析。

  在前文中我們介紹說明瞭List<T>是ArrayList的泛型版本,並且在絕大數的場景中,都是推薦使用List<T>這個類,那我們就要多問一個為什麼了,泛型版本的集合為什麼適合絕大數的場景中?可能已經有同學已經很快的就回答了說是"為了保障效能的最優",其實泛型的優勢不僅僅在效能方面,還有在以下的幾個方面:
(1)、型別安全:在編譯時進行型別檢查,可以在編寫程式碼時捕獲型別錯誤。
(
2)、程式碼重用:可以編寫與型別無關的程式碼,從而提高了程式碼的可重用性。
(
3)、效能最佳化:可以避免裝箱和拆箱的開銷,這些操作會引入效能開銷,但使用泛型可以避免這些問題。
(
4)、更好的可讀性和維護性:使程式碼更加抽象,因此更容易理解和維護。
(
5)、集合類的強大支援:較多的集合類(如 List、Dictionary、Queue 等)都是使用泛型實現的。
(
6)、編寫更靈活的演演算法:可以編寫更靈活、更通用的演演算法,這些演演算法不再依賴於特定的資料型別。

  泛型有以上幾種優勢,那麼泛型是如何在CoreCLR的底層中實現的呢?接下類我們藉助List<T>內部的底層實現邏輯,來具體看一下泛型是如何在內部夠完成的建立和維護的,對於List<T>資料結構,在其維護一個泛型陣列。在CoreCLR的內部中,泛型實現的一些關鍵邏輯:

(1)、泛型型別擦除:在執行時,泛型型別的例項不會保留其型別引數的資訊,泛型型別的例項在JIT編譯時被生成為特定型別的程式碼,其中型別引數被替換為實際的型別。          

(2)、通用模板:泛型型別和方法被定義為通用模板,通用模板包含泛型引數,在JIT 編譯時被具體化,CoreCLR為每種型別生成專門的程式碼,同時確保型別安全性。

(3)、泛型共享程式碼:如果兩個具體的泛型型別例項具有相同的執行時表示,CoreCLR將盡可能地共享生成的程式碼,從而減小記憶體佔用和提高效能。

(4)、泛型程式碼的延遲生成:泛型程式碼不會在程式載入時就被全部生成,而是在執行時根據實際使用情況進行生成。有助於減小程式集的大小,因為只有實際用到的泛型型別和方法才會被生成。

(5)、泛型約束: 泛型約束允許在使用泛型型別時對型別引數進行限制。這有助於提供更多的型別安全性,併為 JIT 編譯器提供了生成更有效程式碼的機會。

   以上的CoreCLR對泛型型別管理的基礎實現細節發現,泛型型別在 .NET 中是在編譯時建立,在執行時確定型別,這樣可以保障程式碼的重用性和型別安全性。由於型別擦除,泛型在 .NET 中的實現相對高效,因為它避免了在執行時維護多個相似型別的開銷。

  介紹完了List<T>的初始化和泛型型別的管理策略,接下來我們再來看一下如何往List<T>插入元素,這裡重點介紹一下Add()/Insert()兩個方法,其中Add()是直接在陣列的最後一個位置進行元素的插入,Insert()是往指定的位置插入元素。雖然兩個方法都是插入元素,但是兩個方法還是有比較大的差異的,無論是使用的場景還是其底層實現的邏輯。

  首先我們看一下Add()方法對陣列元素的插入原始碼(以下程式碼進行過刪減,刪除部分非核心程式碼)。

 1         public void Add(T item)
 2         {
 3             T[] array = _items;
 4             int size = _size;
 5             //獲取當前陣列和大小的引用,檢查是否還有足夠的空間來新增元素。
 6             if ((uint)size < (uint)array.Length)
 7             {
 8                 //如果有足夠的空間,直接在陣列中新增元素。
 9                 _size = size + 1;
10                 array[size] = item;
11             }
12             else
13             {
14                 //對陣列進行擴容
15                 AddWithResize(item);
16             }
17         }
18 
19         //用於在需要擴容時新增元素
20         private void AddWithResize(T item)
21         {
22             int size = _size;
23             Grow(size + 1);
24             _size = size + 1;
25             _items[size] = item;
26         }
27         
28         //用於調整陣列的容量
29         internal void Grow(int capacity)
30         {
31             int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;
32 
33             if ((uint)newCapacity > Array.MaxLength) newCapacity = Array.MaxLength;
34 
35             if (newCapacity < capacity) newCapacity = capacity;
36 
37             Capacity = newCapacity;
38         }            

  以上的三段程式碼中說明瞭C#中List<T>的Add()方法是如何完成對元素的新增,Add()用於向動態陣列新增元素,檢查是否還有足夠的空間來新增元素,如果空間不足時,使用AddWithResize() 方法用於在需要擴容時新增元素。當陣列的容量不足時,呼叫Grow() 方法擴容陣列,擴容後的容量是當前容量的2倍,然後基於擴容後的陣列大小,檢查是否新容量超過了陣列的最大長度限制,如果超過了,將容量設為最大長度。

  對於採用擴容為2倍容量的方案存在如下的優劣勢:

1、優勢:
    (1)、均攤複雜度低:擴容為當前容量的兩倍,均攤每次新增的複雜度較低。擴容操作並不是每次都觸發的,而是在陣列達到一定容量時才執行。 
(
2)、減少頻繁擴容:擴容為兩倍的策略減少了頻繁擴容的次數,每次擴容都需要重新分配記憶體並複製元素。
2、劣勢: (1)、空間浪費:導致記憶體浪費,在陣列大小不斷接近容量極限時,如果陣列的大小不一定會迅速接近容量極限,會導致記憶體空間的浪費。
(
2)、潛在浪費:如果陣列的實際大小相對較小,而容量很大,那麼陣列可能會浪費大量的記憶體。
(3)、引起碎片化:擴容可能導致記憶體分配的碎片化,因為需要為新的陣列分配一塊較大的連續記憶體。

  以上描述了C#對於陣列採用了2倍的擴容方案的優劣勢,該方案相對簡單,並且容易實現,均攤複雜度相對較低,但是也會引起記憶體的浪費,其實在整個計算機的體系記憶體在著以下的幾種擴容方案,每種方案都有其優劣勢。

1、倍增策略:(優勢)簡單、易於實現,均攤複雜度較低。(劣勢)會引起記憶體浪費,特別是在陣列大小與容量之間有較大波動時。 
2、增量策略:(優勢)按一定的增量進行擴容,減小記憶體浪費。(劣勢)需要更多的記憶體重新分配次數,增加了一些開銷。
3、動態調整策略:(優勢)根據實際使用情況動態調整容量,避免了一些固定倍增的缺點。(劣勢)增加了一些複雜性,難以確定最佳的調整策略。
4、預分配策略:(優勢)根據應用的預期負載預先分配足夠的容量,避免頻繁擴容。(劣勢)如果預測不準確,可能導致記憶體浪費。
5、緩慢增長策略:(優勢)初始容量較小,每次擴容容量不會增長得太快,更適用於節省記憶體。(劣勢) 可能導致頻繁的擴容操作,影響效能。
6、無限制擴容策略:(優勢)採用動態記憶體分配,不限制容量大小。(劣勢)可能存在資源耗盡的風險,適用於記憶體充足的情況。

  對於不同的場景,可以選擇不同的擴容方案以滿足對應的需求。【其中java擴容的策略是將當前容量乘以一個固定的倍數,預設情況下是 1.5 倍。】我們在具體的開發過程過程中,可以提前分析資料的增長趨勢進行分析。如果可以提前預測到陣列對應的容量,則能夠更好的提升陣列的效能優勢。

  上文中介紹了Add()方法插入元素的操作,以及陣列的擴容策略,接下來,我們來具體看看另一個元素的插入方法Insert(),該方法表示將元素插入陣列中的某個位置。可能有同學會問,為什麼需要對List<T>的Insert()方法進行詳細討論呢?這是因為在C#中對於Add()和Insert()方法的適用場景和實現策略都有所不同,其中Insert()可以向某個具體的位置進行元素的具體插入。
 1         public void Insert(int index, T item)
 2         {
 3             if (_size == _items.Length) Grow(_size + 1);
 4             if (index < _size)
 5             {
 6                 Array.Copy(_items, index, _items, index + 1, _size - index);
 7             }
 8             _items[index] = item;
 9             _size++;
10         }

  以上的程式碼中,對於Grow()方法就不做具體的介紹了,我們來具體看一下Array.Copy()方法的實現邏輯,對於Lis<T>的底層實現,都是藉助於Array物件的底層操作進行實現,那麼我們來具體看一下其核心的實現邏輯。(部分非核心程式碼已做刪減)

 1         public static unsafe void Copy(Array sourceArray, Array destinationArray, int length)
 2         {
 3             MethodTable* pMT = RuntimeHelpers.GetMethodTable(sourceArray);
 4             if (MethodTable.AreSameType(pMT, RuntimeHelpers.GetMethodTable(destinationArray)) &&
 5                 !pMT->IsMultiDimensionalArray &&
 6                 (uint)length <= sourceArray.NativeLength &&
 7                 (uint)length <= destinationArray.NativeLength)
 8             {
 9                 nuint byteCount = (uint)length * (nuint)pMT->ComponentSize;
10                 ref byte src = ref Unsafe.As<RawArrayData>(sourceArray).Data;
11                 ref byte dst = ref Unsafe.As<RawArrayData>(destinationArray).Data;
12 
13                 if (pMT->ContainsGCPointers)
14                     Buffer.BulkMoveWithWriteBarrier(ref dst, ref src, byteCount);
15                 else
16                     Buffer.Memmove(ref dst, ref src, byteCount);
17                     return;
18             }
19 
20             CopyImpl(sourceArray, sourceArray.GetLowerBound(0), destinationArray, destinationArray.GetLowerBound(0), length, reliable: false);
21         }

  以上程式碼中,我們先來看第一行的程式碼邏輯:MethodTable* pMT = RuntimeHelpers.GetMethodTable(sourceArray);該方法獲取給定物件的型別的 MethodTable(方法表),該方法主要用於獲取物件的MethodTable、獲取物件的型別資訊、獲取物件的底層執行時型別資訊。

1、獲取 MethodTable: 該方法用於獲取物件的 MethodTable,以便在執行時獲取有關物件型別的資訊。 
2、物件型別資訊: MethodTable 包含與物件型別相關的資訊,包括方法指標、欄位資訊、基類資訊等。
3、用於高階程式設計: 通常在高階程式設計或與非託管程式碼進行互動時可能會使用該方法,以獲取物件的底層執行時型別資訊。

  對於MethodTable 相關的一些實現細節和解釋,該結構有CoreCLR來進行維護:

1、型別資訊:MethodTable 包含有關特定型別的資訊,包括其方法定義、欄位、基本型別以及其他相關後設資料。 
2、方法指標:MethodTable 包含指向該型別的方法實現的指標,允許高效呼叫方法。
3、介面實現:對於每個型別實現的介面,MethodTable 中有一個指向該介面實際方法實現的槽位。
4、繼承層次結構:MethodTable 還包含指向基本型別的 MethodTable 的指標,建立繼承層次結構。
5、虛方法表(VTable):在繼承和多型的上下文中,每種型別都有一個對應的 VTable,它實質上是一個指向虛方法的指標表。
6、垃圾回收:MethodTable 由垃圾回收器用於管理記憶體並跟蹤各種型別的物件。
7、方法分派:MethodTable 在執行時幫助進行方法分派,確保根據物件的實際型別呼叫正確的方法實現。
8、效能最佳化:MethodTable 允許高效的方法呼叫和與型別相關的操作,有助於提高 .NET 執行時的效能。

  我們介紹完畢MethodTable的結構和通途後,接下來我們再來分析一下Copy()方法的其他核心邏輯。Unsafe.As 將 sourceArray和destinationArray分別視為RawArrayData 型別,然後獲取它們的Data 屬性的引用。如果陣列包含垃圾收集指標(GC Pointers),則使用 Buffer.BulkMoveWithWriteBarrier 進行移動,這會在行動資料時處理寫入屏障,確保垃圾收集器正確地識別物件引用。否則使用 Buffer.Memmove 進行高效的記憶體移動。

  接下來我們具體看一下Buffer.BulkMoveWithWriteBarrier和Buffer.Memmove的特徵和使用場景:
1、Buffer.BulkMoveWithWriteBarrier:在行動資料的過程中涉及到緩衝區操作,並進行寫入屏障(WriteBarrier)處理。 
(1)、寫入屏障:用於確保垃圾回收器在進行垃圾收集時能正確識別物件引用的機制。寫入屏障記錄在物件的欄位或元素中進行寫操作,
        以便垃圾回收器能夠在必要時更新其內部資料結構,確保準確地跟蹤物件引用。

(2)、緩衝區操作:由於具體的實現可能對資料進行了某種最佳化,例如使用 SIMD(Single Instruction, Multiple Data)指令集來加速資料移動。
2、Buffer.Memmove:用於在記憶體中高效移動一塊資料的標準實現。
(1)、記憶體移動:使用底層平臺提供的高效記憶體移動操作,通常是使用處理器指令集中的最佳化指令,如rep movsb。
(2)、無寫入屏障:在使用這個方法時,開發人員需要確保沒有潛在的垃圾回收相關問題,例如可能導致懸空引用的情況。

  Copy方法中不需要使用 GC.KeepAlive(sourceArray) 來保持物件的存活狀態。相反,透過保持 sourceArray 活躍,物件的 MethodTable (pMT) 會自動保持存活狀態。

  上面我們介紹了Add()和Insert()兩種對List集合進行陣列元素的新增操作,其中涉及擴容機制、陣列元素的Copy機制、陣列的記憶體移動、緩衝區操作等具體的操作邏輯。接下來我們再來看一下List<T>中的排序實現方式。(部分原始碼進行了刪減)
 1         public static void Sort<T>(T[] array)
 2         {
 3             if (array.Length > 1)
 4             {
 5                 var span = new Span<T>(ref MemoryMarshal.GetArrayDataReference(array), array.Length);
 6                 ArraySortHelper<T>.Default.Sort(span, null);
 7             }
 8         }
 9         
10         internal static void IntrospectiveSort(Span<TKey> keys, Span<TValue> values, IComparer<TKey> comparer)
11         {
12             if (keys.Length > 1)
13             {
14                 IntroSort(keys, values, 2 * (BitOperations.Log2((uint)keys.Length) + 1), comparer);
15             }
16         }

  在C#中對於List<T>中的Sort()方法,其內部使用了一種混合排序(Hybrid Sorting)的方法,結合了快速排序(QuickSort)、堆排序(HeapSort)、插入排序(InsertionSort)三種演演算法,以提高效能。接下來我們按照以上的排序實現程式碼來逐一進行介紹分析。

1、時間複雜度(asymptotic time complexity):大 O 時間複雜度實際上並不具體表示程式碼真正的執行時間,而是表示程式碼執行時間隨資料規模增長的變化趨勢,而不關心具體的常數因子或低階項。
  常見的複雜度並不多,從低階到高階有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2 )。 2、空間複雜度(asymptotic spacecomplexity):全稱就是漸進空間複雜度,表示演演算法的儲存空間與資料規模之間的增長關係。
  常見的空間複雜度就是 O(1)、O(n)、O(n2), O(logn)、O(nlogn)。
  上面簡單的介紹了一下演演算法的基礎概念,我們接下來看一下C#對於陣列的排序實現得到邏輯程式碼。
  首先,來看一下Sort()方法中的邏輯程式碼。
    (1)、new Span(ref MemoryMarshal.GetArrayDataReference(array), array.Length);Span 是 .NET 中用於表示連續記憶體塊的結構,它提供了一種非託管記憶體訪問的安全方式。  
    (2)、MemoryMarshal.GetArrayDataReference 方法用於獲取陣列的首地址。
  在 .NET 中,陣列的元素是儲存在一塊連續的記憶體中的,這個方法返回陣列的起始地址。透過 Span,可以在不進行復制的情況下對陣列進行安全、高效的操作,例如切片、排序等。
  IntrospectiveSort()方法的實現邏輯中IntroSort(keys, values, 2 * (BitOperations.Log2((uint)keys.Length) + 1), comparer);用於進行混合排序。2 * (BitOperations.Log2((uint)keys.Length) + 1)用於混合排序時用於控制遞迴深度的引數。(uint)keys.Length將keys.Length強制轉換為無符號整數。BitOperations.Log2((uint)keys.Length)計算以 2 為底的 keys.Length 的對數。(BitOperations.Log2((uint)keys.Length) + 1)加 1 是為了增加一些餘地,確保混合排序的遞迴深度不至於過淺。最後乘以 2 是為了提高混合排序的效率,使遞迴深度稍微大一些。
 1         private static void IntroSort(Span<TKey> keys, Span<TValue> values, int depthLimit, IComparer<TKey> comparer)
 2         {
 3             int partitionSize = keys.Length;
 4             while (partitionSize > 1)
 5             {
 6                 if (partitionSize <= Array.IntrosortSizeThreshold)
 7                 {
 8                     if (partitionSize == 2)
 9                     {
10                         SwapIfGreaterWithValues(keys, values, comparer, 0, 1);
11                         return;
12                     }
13 
14                     if (partitionSize == 3)
15                     {
16                         SwapIfGreaterWithValues(keys, values, comparer, 0, 1);
17                         SwapIfGreaterWithValues(keys, values, comparer, 0, 2);
18                         SwapIfGreaterWithValues(keys, values, comparer, 1, 2);
19                         return;
20                     }
21                     // 使用插入排序
22                     InsertionSort(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer);
23                     return;
24                 }
25 
26                 if (depthLimit == 0)
27                 {
28                     // 使用堆排序
29                     HeapSort(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer);
30                     return;
31                 }
32                 depthLimit--;
33                 // 使用快速排序,獲取新的分割槽點 p
34                 int p = PickPivotAndPartition(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer);
35                 // 對右半部分進行遞迴排序
36                 IntroSort(keys[(p+1)..partitionSize], values[(p+1)..partitionSize], depthLimit, comparer);
37                 partitionSize = p;
38             }
39         }

  IntroSort ()方法是混合排序的核心實現。在迴圈中,首先檢查當前分割槽的大小,如果小於等於閾值 Array.IntrosortSizeThreshold,則使用插入排序;如果遞迴深度達到限制 depthLimit,則使用堆排序;否則,使用快速排序找到新的分割槽點 p,然後對右半部分進行遞迴排序。

  上面的程式碼中,我們發現其中有幾部分為核心的程式碼邏輯,分別為 SwapIfGreaterWithValues()、InsertionSort()、HeapSort()、PickPivotAndPartition()、IntroSort()。我們依次來看一下這些方法的內部實現,其中是對應的幾種排序演演算法的時間複雜度分析和適用場景。
 1         private static void SwapIfGreaterWithValues<TKey, TValue>(Span<TKey> keys, Span<TValue> values, IComparer<TKey> comparer, int i, int j)
 2         {
 3             if (i != j && comparer.Compare(keys[i], keys[j]) > 0)
 4             {
 5                 TKey key = keys[i];
 6                 keys[i] = keys[j];
 7                 keys[j] = key;
 8 
 9                 if (!values.IsEmpty)
10                 {
11                     TValue value = values[i];
12                     values[i] = values[j];
13                     values[j] = value;
14                 }
15             }
16         }

  以上的程式碼中,展示了SwapIfGreaterWithValues()方法的核心邏輯,該方法是用於比較陣列中的元素,並在需要時進行交換,這個方法被用於快速排序(QuickSort)過程中。這個方法的目的是確保在排序過程中,當發現當前元素的鍵大於另一個元素的鍵時,進行交換,從而保證排序的正確性。這是排序演演算法中常見的元素交換操作,用於維護排序的穩定性和順序。在這裡,透過泛型引數的使用,可以同時對關聯的值進行交換,以保持鍵值對的關聯性。對於InsertionSort()、HeapSort()、PickPivotAndPartition()、IntroSort()這幾種排序演演算法在C#中的實現程式碼就不做展示,感興趣的同學可以具體看一下對應的實現代碼。(以上排序演演算法在C#中實現的方式相對較為簡單,這裡就不做具體的展開)

  在這裡我們具體分析一下這幾種演演算法的基礎特性:
1、InsertionSort() :插入排序。
      優勢: 
          (1)、在小型陣列上表現良好,具有較低的常數因子。 
          (2)、對於部分有序的陣列,插入排序的效能相對較好。 
      劣勢: 
          (1)、在大型陣列上的效能較差,其時間複雜度為O(n^2)。 
          (2)、不適用於大規模或完全無序的陣列。 
2、HeapSort() :堆排序。   優勢:   (1)、在最壞情況下也能保證 O(n log n) 的時間複雜度。

     (2)、不需要額外的空間,是一種原地排序演演算法。
  (3)、對於大規模資料集和外部排序等場景具有一定優勢。
   劣勢:
     (1)、由於對記憶體的隨機訪問較多,可能會導致快取未命中,效能相對較差。

3、IntroSort() :"引入排序" 或 "介紹排序"。
   優勢:
     (1)、綜合了快速排序、堆排序、插入排序,充分利用各自的優勢。
     (2)、在大多數情況下,IntroSort 的效能比單一排序演演算法更好。
   劣勢:
     (1)、對於小型陣列,插入排序的效能可能更好,而 IntroSort 還需要一些額外的開銷。
     (2)、需要額外的遞迴深度控制引數,這可能需要進行一些經驗性的調優。

  "引入排序" 或 "介紹排序"的基本思路:在每一次遞迴時,都會檢查遞迴深度是否超過了一定的閾值(通常為 log(N)),如果超過了,則切換到堆排序,以避免快速排序在最壞情況下的效能問題。對於以上介紹的幾種演演算法,有幾項簡單的總結:

1、對於小型陣列或部分有序的陣列,插入排序可能是一個不錯的選擇。
2、堆排序適用於大規模資料集,而且是原地排序。

3、快速排序在平均情況下效能較好,但在最壞情況下的效能可能較差。
4、IntroSort 綜合了多種排序演演算法的優勢,通常在各種輸入情況下都表現較好。
  上面說明瞭C#的Sort()方法中的幾種常用排序演演算法,我們看一下這段程式碼PickPivotAndPartition(keys.Slice(0, partitionSize), values.Slice(0, partitionSize), comparer);該方法會選擇一個樞紐,將陣列分成兩部分,左邊的元素小於樞紐,右邊的元素大於樞紐,並返回樞紐的最終位置。這樣,演演算法就可以遞迴地對樞紐的兩側進行排序。
  以上介紹了List<T>中的底層實現的幾種常用排序演演算法的實現,以及這些演演算法的優劣勢,但是沒有對其實現的程式碼進行具體的介紹,因為其實現的核心是較為通用的排序演演算法實現。接下來我們來看一下List<T>的集合元素操作方法Reverse(),由於其內部核心實現為Array的Reverse()方法,我們來具體看一下其核心實現程式碼。
 1         public static void Reverse(ref int buf, nuint length)
 2         {
 3             nint remainder = (nint)length;
 4             nint offset = 0;
 5             
 6             //檢查硬體是否支援相應的SIMD操作。
 7             if (Vector512.IsHardwareAccelerated && remainder >= Vector512<int>.Count * 2)
 8             {
 9                 nint lastOffset = remainder - Vector512<int>.Count;
10                 do
11                 {
12                     Vector512<int> tempFirst = Vector512.LoadUnsafe(ref buf, (nuint)offset);
13                     Vector512<int> tempLast = Vector512.LoadUnsafe(ref buf, (nuint)lastOffset);
14                     tempFirst = Vector512.Shuffle(tempFirst, Vector512.Create(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0));
15                     tempLast = Vector512.Shuffle(tempLast, Vector512.Create(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0));
16 
17                     tempLast.StoreUnsafe(ref buf, (nuint)offset);
18                     tempFirst.StoreUnsafe(ref buf, (nuint)lastOffset);
19 
20                     offset += Vector512<int>.Count;
21                     lastOffset -= Vector512<int>.Count;
22                 } while (lastOffset >= offset);
23 
24                 remainder = lastOffset + Vector512<int>.Count - offset;
25             }
26             else if (Avx2.IsSupported && remainder >= Vector256<int>.Count * 2)
27             {
28                 nint lastOffset = remainder - Vector256<int>.Count;
29                 do
30                 {
31                     Vector256<int> tempFirst = Vector256.LoadUnsafe(ref buf, (nuint)offset);
32                     Vector256<int> tempLast = Vector256.LoadUnsafe(ref buf, (nuint)lastOffset);
33 
34                     tempFirst = Avx2.PermuteVar8x32(tempFirst, Vector256.Create(7, 6, 5, 4, 3, 2, 1, 0));
35                     tempLast = Avx2.PermuteVar8x32(tempLast, Vector256.Create(7, 6, 5, 4, 3, 2, 1, 0));
36 
37                     tempLast.StoreUnsafe(ref buf, (nuint)offset);
38                     tempFirst.StoreUnsafe(ref buf, (nuint)lastOffset);
39 
40                     offset += Vector256<int>.Count;
41                     lastOffset -= Vector256<int>.Count;
42                 } while (lastOffset >= offset);
43 
44                 remainder = lastOffset + Vector256<int>.Count - offset;
45             }
46             else if (Vector128.IsHardwareAccelerated && remainder >= Vector128<int>.Count * 2)
47             {
48                 nint lastOffset = remainder - Vector128<int>.Count;
49                 do
50                 {
51                     Vector128<int> tempFirst = Vector128.LoadUnsafe(ref buf, (nuint)offset);
52                     Vector128<int> tempLast = Vector128.LoadUnsafe(ref buf, (nuint)lastOffset);
53 
54                     tempFirst = Vector128.Shuffle(tempFirst, Vector128.Create(3, 2, 1, 0));
55                     tempLast = Vector128.Shuffle(tempLast, Vector128.Create(3, 2, 1, 0));
56 
57                     tempLast.StoreUnsafe(ref buf, (nuint)offset);
58                     tempFirst.StoreUnsafe(ref buf, (nuint)lastOffset);
59 
60                     offset += Vector128<int>.Count;
61                     lastOffset -= Vector128<int>.Count;
62                 } while (lastOffset >= offset);
63 
64                 remainder = lastOffset + Vector128<int>.Count - offset;
65             }
66 
67             if (remainder > 1)
68             {
69                 ReverseInner(ref Unsafe.Add(ref buf, offset), (nuint)remainder);
70             }
71         }

  對於陣列的反轉操作是相對比較耗時,我們接下來基於其實現反轉操作的程式碼來看看是如何耗時,重點的實現邏輯在何處。該方法使用了 SIMD(SingleInstruction, Multiple Data)指令集,充分發揮現代處理器的平行計算能力,提高陣列反轉的速度。

  【備註:SIMD,即 Single Instruction, Multiple Data(單指令多資料),是一種平行計算的指令集架構。它允許一條指令同時處理多個資料元素,從而在同一時鍾週期內執行多個操作,提高了計算效率。SIMD 主要用於處理大規模資料集的平行計算,例如圖形處理、訊號處理、科學計算等領域。基本思想是將一條指令應用於多個資料元素,以一次性處理它們,而不是對每個資料元素逐個執行相同的指令。這對於涉及相同計算操作的資料集非常有效,因為它能夠充分利用硬體的並行性。在 SIMD 指令集中,通常有一些特殊的暫存器,被稱為向量暫存器,用於儲存多個資料元素。指令被設計為同時在這些向量暫存器上執行操作。通常包含如下幾種:SSE(Streaming SIMD Extensions);AVX(Advanced Vector Extensions);NEON:MMX(Multimedia Extensions)】
1、根據硬體支援情況,選擇適當的 SIMD 操作進行陣列反轉:
(1)、如果硬體支援 512 位向量(Vector512則使用 512 位的 SIMD 指令集進行反轉。
(2)、如果不支援 512 位向量,但支援 256 位向量(Vector256),則使用 256 位的 SIMD 指令集進行反轉。
(3)、如果不支援 256 位向量,但支援 128 位向量(Vector128),則使用 128 位的 SIMD 指令集進行反轉。

2、在每個 SIMD 操作的迴圈中:
(1)、透過 Vector.LoadUnsafe方法載入向量的值。
(2)、使用適當的指令(Vector.Shuffle或Avx2.PermuteVar8x32將向量中的元素進行反轉。
(3)、透過 Vector.StoreUnsafe方法將反轉後的向量值儲存回陣列中。

3、如果硬體不支援 SIMD 或陣列長度不足以使用 SIMD:
(1)、呼叫 ReverseInner方法,該方法使用普通的迴圈來反轉剩餘的陣列元素。

  對於List<T>中的Reverse()實現陣列的反轉方法,透過充分利用硬體的 SIMD 指令集,以高效的方式對陣列進行反轉。在逐個元素反轉的情況下,傳統的迴圈操作會變得相對慢,而 SIMD 指令集可以同時處理多個元素,提高了反轉的效率。這對於處理大型陣列時,可以顯著提升效能。

  本文截止到當前對List<T>集合的初始化、元素插入(Add、Insert)、集合元素的排序、集合元素的反轉等幾個視角進行了簡單的介紹。我們從上面的描述和C#中的List<T>實現原始碼中不難發現,無論是什麼資料結構,其內部的實現都是由相對簡單的方式和巧妙的技巧維護著高效的效能。

  以上內容是對C#List<T>原始碼的簡單解讀,如錯漏的地方,還望指正。

相關文章