溫故之.NET 任務並行

JameLee發表於2019-02-12

這篇文章主要講解 .NET 的任務並行,與資料並行不同的是:資料並行以資料為處理單元,而任務並行,則以任務(工作)為單元

關於任務的理解,如果還有疑問,可以參考之前的文章【溫故之.NET 非同步】

任務並行基礎

如果我們想要建立並行的任務,可以通過 Parallel.Invoke 來實現。它可以很方便的幫助我們同時執行多個任務,如下

public static void WorkOne() {
    // 任務一
}
public static void WorkTwo() {
    // 任務二
}
Parallel.Invoke(WorkOne, WorkTwo);

// 我們也可以通過 Lambda 表示式這樣寫
Parallel.Invoke(
    () => {
        // 任務一
    }, () => {
        // 任務二
    }
);
複製程式碼

藉助 Parallel.Invoke ,我們只需表達想同時執行的操作,CLR 會處理所有執行緒排程的具體資訊(包括將執行緒數量自動縮放至計算機上的核心數)

需要特別注意
TPL 在後臺建立的 Task 數量不一定與所提供的操作的數量相等。 因為 TPL 可能會針對操作的數量進行不同程度的優化

因此,對 Parallel.Invoke ,我們可以這樣理解(只是為了理解方便,不表示其內部具體實現也是這樣的)

  • 分配一個具有 4 個執行緒的“執行緒池”(假設計算機處理器為 4 核 4 執行緒)。或者根據指定的 ParallelOptions 中的 MaxDegreeOfParallelism 屬性來確定具體數量
  • 採用 Task.Run 的方式執行每一個任務。每執行一個任務,就從“執行緒池”中取一個空閒的執行緒。如果沒有多餘的空閒執行緒,則等待
  • 直到處理完所有的任務為止

這也可以理解為對其內部實現的一個猜測。如果有興趣,可以使用 .NET Refactor 看一下其原始碼

如果程式有 UI 執行緒,且任務的建立從 UI 執行緒開始,那麼在使用方式上會有變化,如下程式碼所示

Task.Run(() => {
    Parallel.Invoke(
    () => {
        // 任務一
    }, () => {
        // 任務二
    });
});
複製程式碼

這對於其他的並行(如資料並行)也是一樣的。
只要我們需要從 UI 執行緒建立並行,就應該使用 Task.Run 來啟動它們。否則,很有可能產生死鎖(一般出現在當並行程式碼內部需要訪問 UI 的情況下,其他情況我也暫時沒有遇到過)

如果我們分不清當前建立並行的是 UI 執行緒還是其他型別的執行緒。我們可以統一使用 Task 的方式來啟動它們。反正在大部分情況下,使用 Task 來啟動也不會造成什麼效能問題

不過,如果我們需要並行立即啟動,或者儘快啟動,使用 Task 來啟動可能就不太合適,在系統工作量比較重的情況下,我們也不清楚這個 Task 什麼時候能夠執行。
在這種場景下,我們可以新建一個 Thread 來做這件事。因為 ThreadTask 不同,Thread 不以任務為單位,當我們呼叫 Thread.Start() 的時候,執行緒就會立即執行。而 Task,當我們呼叫 Task.Run 的時候,它需要接受 TPL 的排程(Task Scheduler)。因此,其執行時間就不確定了

針對建立並行,有以下建議

  • 在不確定建立並行的是 UI 執行緒還是其他執行緒時,使用 Task.Run 來啟動並行(如前面例子所示)
  • 在系統工作比較重的情況下,如果希望並行能夠立即啟動,我們應該使用 Thread 的方式
  • 否則,在大多數情況下,無論 PC 端、Web 端、還是 WebApi 後臺,我們使用 Task.Run 來啟動並行是比較好的方式

通過 Thread 方式啟動並行,示例如下

Thread thread = new Thread(() => {
    Parallel.Invoke(
    () => {
        Debug.WriteLine("Work 1");
    },() => {
        Debug.WriteLine("Work 2");
    });
});
thread.Start();
複製程式碼

針對並行的建議

前面提到,在多處理器條件下,使用 Parallel 可以顯著提升效能。但事物總有兩面性,因此還是有一些坑需要我們注意

  • 對於任務並行,如果任務間具有強關聯性(即有很多工的執行依賴於其他的任務或者多個任務之間存在資源共享)。個人不建議使用並行庫,因為在以往的經驗中,這樣的處理並沒有為我們帶來特別大的效能提升
  • Parallel.ForParallel.ForEach 以資料並行為主;Parallel.Invoke 以任務並行為主
  • 不要對迴圈進行過度並行化。所謂物極必反,過度的並行化,不但增加了管理的難度,執行緒間的同步以及最後各個分割槽的合併,都會對效能造成影響
  • 如果並行裡面的單次迭代的工作量較小,推薦使用 Partitioner 來手動的對源集合進行分塊
  • 避免在並行程式碼塊內呼叫非執行緒安全的方法,就算是宣告為執行緒安全的方法,也應該儘量少的呼叫
  • 儘量避免在 UI 執行緒上執行並行迴圈。也應儘量避免在並行程式碼中更新 UI,因為這有可能會產生資料損壞或死鎖
  • 在並行迭代中,我們不應該假定每一個迭代順序開始。比如有集合 [1,2,3,4,5,6,7,8],假設分為 4 個分塊 [1,2]、[3,4]、[5,6]、[7,8],我們不應該認為 [1,2] 這個塊要比 [5,6] 這個塊先執行。理解這個很重要,可以防止我們寫出可能產生死鎖的程式碼,示例如【示例A】所示

示例A

ManualResetEventSlim mre = new ManualResetEventSlim();
int processor = Environment.ProcessorCount;
var source = Enumerable.Range(0, processor * 100);
Parallel.ForEach(source, item => {
    if (item == processor) {
        mre.Set();
    } else {
        mre.Wait();
    }
});
複製程式碼

對於這段程式碼,就可能會(可能性非常大)發生死鎖。如前面【針對並行的建議】的最後一點所說,同樣地,此處我們也無法確定 mre.Set()mre.Wait() 到底誰先執行

至此,這篇文章的內容講解完畢。

後話
最近看了一些書籍,決定無論何時,凡是關注了我的朋友,都一律關注回去
源於以下一點:尊重是相互的,學習也是相互的

在此,也感謝在微信公眾號、知乎、簡書、掘金等內容平臺關注我的朋友。歡迎關注公眾號【嘿嘿的學習日記】,所有的文章,都會在公眾號首發,Thank you~

公眾號二維碼

相關文章