若要平行處理數據源上的作業,其中一個基本步驟是將來源 分割 成多個區段,讓多個線程可以並行存取。 PLINQ 和工作平行連結庫 (TPL) 會在您撰寫平行查詢或 ForEach 迴圈時提供透明運作的預設分割器。 針對更進階的情境,您可以使用自己的分區器。
分割種類
有許多方法可以分割數據源。 在最有效率的方法中,多個線程會合作處理原始來源序列,而不是實際將來源分隔成多個子序列。 對於陣列和其他索引來源,例如 IList 事先知道長度的集合, 範圍分割 是最簡單的數據分割類型。 每個執行緒都會接收唯一的開始和結束索引,以便能在不覆蓋其他執行緒或被其他執行緒覆蓋的情況下,處理其來源範圍。 範圍分割的唯一額外負荷是建立範圍的初始工作;之後不需要進行其他同步處理。 因此,只要工作負載平均分割,就可以提供良好的效能。 範圍分割的缺點是,如果某個線程提前完成,則無法協助其他線程完成其工作。
對於未知長度的連結清單或其他集合,您可以使用 區塊分割。 在區塊分割中,平行迴圈或查詢中的每個線程或工作都會取用一個區塊中的一些來源元素、處理它們,然後返回以擷取其他元素。 分割器可確保所有元素已被分配,且不存在重複元素。 區塊可以是任何大小。 例如, 如何:實作動態分割 區中所示範的數據分割器會建立只包含一個元素的區塊。 只要區塊不太大,這種數據分割本身就具備負載均衡功能,因為元素指派給線程的過程並沒有預先決定。 不過,每次線程需要取得另一個區塊時,分割器就會產生同步處理額外負荷。 在這些情況下產生的同步處理量與區塊大小成反比。
一般而言,若委派的執行時間為小到中度,且來源包含大量元素,並且每個分割區的總工作大致相等時,範圍分割的速度會更快。 因此,在大部分情況下,區塊分割通常較快。 在元素數量較少或委派執行時間較長的來源上,區塊分割和範圍分區的效能大約相等。
TPL 分割器也支援動態數目的分割。 這表示他們可以即時建立分割區,例如當 ForEach 迴圈產生新的工作時。 這項功能可讓分割器與迴圈本身一起調整。 動態分割器本質上具有負載平衡的特性。 當您建立自訂分區器時,必須支援動態分區,以便從 ForEach 迴圈取用。
設定 PLINQ 的負載平衡分割器
某些Partitioner.Create方法的重載允許您為陣列或IList來源創建分割器,並指定它是否應該嘗試在線程間平衡工作負載。 當分割器設定為負載平衡時,會使用區塊分割,而且元素會按照要求以小區塊形式交替傳送到每個分割區。 此方法可協助確保所有分割區都有元素要處理,直到整個迴圈或查詢完成為止。 額外的重載可用來為任何 IEnumerable 來源提供負載平衡分區。
一般而言,負載平衡需要分區相對頻繁地向分區器請求元素。 相較之下,執行靜態數據分割的分割器可以使用範圍或區塊分割,將元素一次指派給每個分割器。 這需要比負載平衡更少的額外負荷,但如果一個線程最終的工作量明顯高於其他線程,則執行時間可能會更長。 預設情況下,當傳遞 IList 或陣列時,PLINQ 一律使用範圍分割,而不進行負載平衡。 若要啟用 PLINQ 的負載平衡,請使用 Partitioner.Create 方法,如下列範例所示。
// Static partitioning requires indexable source. Load balancing
// can use any IEnumerable.
var nums = Enumerable.Range(0, 100000000).ToArray();
// Create a load-balancing partitioner. Or specify false for static partitioning.
Partitioner<int> customPartitioner = Partitioner.Create(nums, true);
// The partitioner is the query's data source.
var q = from x in customPartitioner.AsParallel()
select x * Math.PI;
q.ForAll((x) =>
{
ProcessData(x);
});
' Static number of partitions requires indexable source.
Dim nums = Enumerable.Range(0, 100000000).ToArray()
' Create a load-balancing partitioner. Or specify false For Shared partitioning.
Dim customPartitioner = Partitioner.Create(nums, True)
' The partitioner is the query's data source.
Dim q = From x In customPartitioner.AsParallel()
Select x * Math.PI
q.ForAll(Sub(x) ProcessData(x))
判斷是否在任何指定案例中使用負載平衡的最佳方式,是實驗和測量在代表性負載和計算機設定下完成作業所需的時間。 例如,靜態數據分割可能會在只有少數核心的多核心計算機上提供顯著的加速,但可能會導致具有相對多核心之計算機上的速度變慢。
下表列出 Create 方法的可用多載。 這些分割器不限於只搭配 PLINQ 或 Task使用。 它們也可以與任何自定義平行建構搭配使用。
| 超載 | 使用負載平衡 |
|---|---|
| Create<TSource>(IEnumerable<TSource>) | 永遠 |
| Create<TSource>(TSource[], Boolean) | 當布爾自變數指定為 true 時 |
| Create<TSource>(IList<TSource>, Boolean) | 當布爾自變數指定為 true 時 |
| Create(Int32, Int32) | 從不 |
| Create(Int32, Int32, Int32) | 從不 |
| Create(Int64, Int64) | 從不 |
| Create(Int64, Int64, Int64) | 從不 |
設定 Parallel.ForEach 的靜態範圍分割器
在 For 迴圈中,迴圈主體會作為委託提供給方法。 調用該委派的成本大致上與虛擬方法調用相同。 在某些情況下,平行迴圈的主體可能小到使得每次迴圈迭代的委派調用成本都變得相當重要。 在這種情況下,您可以使用其中 Create 一個多載,在來源元素上建立 IEnumerable<T> 範圍分割區。 然後,您可以將此範圍集合傳遞至主體中包含一般ForEach迴圈的方法。 此方法的優點是委任呼叫成本只會在每個範圍發生一次,而不是每個元素發生一次。 下列範例示範基本模式。
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
class Program
{
static void Main()
{
// Source must be array or IList.
var source = Enumerable.Range(0, 100000).ToArray();
// Partition the entire source array.
var rangePartitioner = Partitioner.Create(0, source.Length);
double[] results = new double[source.Length];
// Loop over the partitions in parallel.
Parallel.ForEach(rangePartitioner, (range, loopState) =>
{
// Loop over each range element without a delegate invocation.
for (int i = range.Item1; i < range.Item2; i++)
{
results[i] = source[i] * Math.PI;
}
});
Console.WriteLine("Operation complete. Print results? y/n");
char input = Console.ReadKey().KeyChar;
if (input == 'y' || input == 'Y')
{
foreach(double d in results)
{
Console.Write("{0} ", d);
}
}
}
}
Imports System.Threading.Tasks
Imports System.Collections.Concurrent
Module PartitionDemo
Sub Main()
' Source must be array or IList.
Dim source = Enumerable.Range(0, 100000).ToArray()
' Partition the entire source array.
' Let the partitioner size the ranges.
Dim rangePartitioner = Partitioner.Create(0, source.Length)
Dim results(source.Length - 1) As Double
' Loop over the partitions in parallel. The Sub is invoked
' once per partition.
Parallel.ForEach(rangePartitioner, Sub(range, loopState)
' Loop over each range element without a delegate invocation.
For i As Integer = range.Item1 To range.Item2 - 1
results(i) = source(i) * Math.PI
Next
End Sub)
Console.WriteLine("Operation complete. Print results? y/n")
Dim input As Char = Console.ReadKey().KeyChar
If input = "y"c Or input = "Y"c Then
For Each d As Double In results
Console.Write("{0} ", d)
Next
End If
End Sub
End Module
迴圈中的每個線程都會接收自己的 Tuple<T1,T2>,其中包含指定子範圍中的開始和結束索引值。 內部 for 迴圈會使用 fromInclusive 和 toExclusive 值來遍歷陣列或直接遍歷 IList。
其中一個 Create 多載可讓您指定分割區的大小,以及分割區的數目。 此重載可用於每個元素的處理工作非常少的情況,甚至每個元素的一次虛擬方法呼叫都會對效能產生明顯的影響。
自定義分割器
在某些情況下,實現您自己的分區器可能是值得的,甚至是必要的。 例如,您可能有一個自定義集合類別,並可以根據您對類別內部結構的了解,比預設分割器更高效地進行分割。 或者,您可能想要根據您對處理來源集合中不同位置元素所需時間的了解,建立不同大小的範圍分割。
若要建立基本的自定義分割器,請從 System.Collections.Concurrent.Partitioner<TSource> 衍生類別並覆寫虛擬方法,如下表所述。
| 方法 | 說明 |
|---|---|
| GetPartitions | 主線程會呼叫此方法一次,並傳回 IList(IEnumerator(TSource))。 循環或查詢中的每個工作線程都可以在清單上呼叫GetEnumerator ,以從不同的區域擷取IEnumerator<T>。 |
| SupportsDynamicPartitions | 如果您實作 true,則傳回 GetDynamicPartitions,否則,傳回 false。 |
| GetDynamicPartitions | 如果 SupportsDynamicPartitions 是 true,則可以選擇性地呼叫這個方法,而不是呼叫 GetPartitions。 |
如果結果必須可排序,或您需要對元素進行索引存取,請衍生自 System.Collections.Concurrent.OrderablePartitioner<TSource> 並覆寫其虛擬方法,如下表所述。
| 方法 | 說明 |
|---|---|
| GetPartitions | 主線程會呼叫這個方法一次,並傳 IList(IEnumerator(TSource))回 。 循環或查詢中的每個工作線程都可以在清單上呼叫GetEnumerator ,以從不同的區域擷取IEnumerator<T>。 |
| SupportsDynamicPartitions | 如果您實作 true,則傳回 GetDynamicPartitions;否則,傳回 false。 |
| GetDynamicPartitions | 通常只需呼叫 GetOrderableDynamicPartitions。 |
| GetOrderableDynamicPartitions | 如果 SupportsDynamicPartitions 是 true,則可以選擇性地呼叫這個方法,而不是呼叫 GetPartitions。 |
下表提供關於三種負載平衡分割器如何實作OrderablePartitioner<TSource>類別的更多詳細資訊。
| 方法/屬性 | 沒有負載平衡的 IList / 陣列 | 具有負載平衡的 IList / Array | IEnumerable |
|---|---|---|---|
| GetOrderablePartitions | 使用範圍分割法 | 使用針對清單優化的區塊分割來進行指定的分割數量。 | 使用區塊分割法,藉由建立固定數目的分割區。 |
| OrderablePartitioner<TSource>.GetOrderableDynamicPartitions | 擲回不支援的例外狀況 | 使用針對清單和動態數據分割優化的區塊分割 | 藉由建立動態數目分區,使用塊狀分割策略。 |
| KeysOrderedInEachPartition | 傳回 true。 |
傳回 true。 |
傳回 true。 |
| KeysOrderedAcrossPartitions | 傳回 true。 |
傳回 false。 |
傳回 false。 |
| KeysNormalized | 傳回 true。 |
傳回 true。 |
傳回 true。 |
| SupportsDynamicPartitions | 傳回 false。 |
傳回 true。 |
傳回 true。 |
動態分區
如果您希望分區處理器在 ForEach 方法中使用,那麼您必須能夠傳回動態數目的分區。 這表示分割器在迴圈執行期間可以隨需提供新分割區的列舉器。 基本上,每當迴圈加入新的平行工作時,就會要求該工作的新分割區。 如果您需要使數據可排序,請從 System.Collections.Concurrent.OrderablePartitioner<TSource> 派生,為每個分區中的每個專案指派唯一的索引。
如需詳細資訊和範例,請參閱 如何:實作動態數據分割。
分配者的合約
當您實作自定義分割器時,請遵循下列指導方針,協助確保與 PLINQ 和 ForEach TPL 中的正確互動:
如果 GetPartitions 被呼叫時,其參數
partitionsCount小於或等於零,則拋出 ArgumentOutOfRangeException。 雖然 PLINQ 和 TPL 永遠不會傳入partitionCount等於 0,但我們還是建議您防範這種可能性。GetPartitions 和 GetOrderablePartitions 應該一律傳回
partitionsCount分割區數目。 如果分割器耗盡數據,而且無法根據要求建立足夠的數據分割,則方法應該會針對每一個剩餘的分割傳回空的枚舉器。 否則,PLINQ 和 TPL 都會丟擲 InvalidOperationException。GetPartitions、 GetOrderablePartitions、 GetDynamicPartitions和 GetOrderableDynamicPartitions 不應該傳回
null(Nothing在 Visual Basic 中)。 如果這樣做,PLINQ / TPL 會拋出InvalidOperationException。傳回數據分割的方法應該一律傳回可以完整且唯一列舉數據源的數據分割。 除非數據分割器的設計特別需要,否則數據源或略過的專案不應重複。 如果未遵循此規則,則輸出順序可能會混亂。
下列布爾值 getter 必須始終正確地返回以下值,以確保輸出順序不會混亂。
KeysOrderedInEachPartition:每個分割區都會傳回具有遞增鍵值索引的元素。KeysOrderedAcrossPartitions:針對傳回的所有分割區,分割 區 i 中的索引鍵索引高於分割區 i-1 中的索引鍵索引。KeysNormalized:所有關鍵索引從零開始,且是連續單調遞增的,沒有間隙。
所有索引都必須是唯一的。 可能沒有重複的索引。 如果未遵循此規則,則輸出順序可能會混亂。
所有索引都必須是非負值。 如果未遵循此規則,PLINQ/TPL 可能會拋出異常。