.NET的併發程式設計(TPL程式設計)是什麼?

學習中的苦與樂發表於2021-01-13

寫在前面

       優秀軟體的一個關鍵特徵就是具有併發性。過去的幾十年,我們可以進行併發程式設計,但是難度很大。以前,併發性軟體的編寫、除錯和維護都很難,這導致很多開發人員為圖省事放棄了併發程式設計。新版 .NET 中的程式庫和語言特徵,已經讓併發程式設計變得簡單多了。隨著 Visual Studio 2012 的釋出,微軟明顯降低了併發程式設計的門檻。以前只有專家才能做併發程式設計,而今天,每一個開發人員都能夠(而且應該)接受併發程式設計。

解答疑問:.NET Core 同步和非同步的差別

public ActionResult PushFileData([FromBody] Web_PushFileData file) //同步
public async ActionResult PushFileData([FromBody] Web_PushFileData file) //非同步
疑問:對於同步方法,每個請求都是使用同個執行緒嗎?如客戶A請求同步Action,還未執行完畢時,客戶B請求會阻塞。
對於非同步方法,每個請求都是從執行緒池拿空閒執行緒出來執行方法?也就是客戶A和客戶B請求方法,都是在不同子執行緒裡分別執行的。

導航

基本概念

  • 併發程式設計
  • TPL

執行緒基礎

  • windows為什麼要支援執行緒
  • 執行緒開銷
  • CPU的發展
  • 使用執行緒的理由

如何寫一個簡單Parallel.For迴圈

  • 資料並行
  • Parallel.For剖析

資料和任務並行中潛在的缺陷

  • 不要假設並行總是很快
  • 避免寫入共享快取
  • 避免呼叫非執行緒安全的方法

       許多個人電腦和工作站都有多核CPU,可以同時執行多個執行緒。為了充分利用硬體,您可以將程式碼並行化,以便跨多個處理器分發工作。

       在過去,並行需要對執行緒和鎖進行低階操作。Visual Studio和.NET框架通過提供執行時、類庫型別和診斷工具來增強對並行程式設計的支援。這些特性是在.NET Framework 4中引入的,它們使得並行程式設計變得簡單。您可以用自然的習慣用法編寫高效、細粒度和可伸縮的並行程式碼,而無需直接處理執行緒或執行緒池。

下圖展示了.NET框架中並行程式設計體系結構。
在這裡插入圖片描述


1 基本概念

1.1 併發程式設計

併發

同時做多件事情

       這個解釋直接表明了併發的作用。終端使用者程式利用併發功能,在輸入資料庫的同時響應使用者輸入。伺服器應用利用併發,在處理第一個請求的同時響應第二個請求。只要你希望程式同時做多件事情,你就需要併發。

多執行緒

       併發的一種形式,它採用多個執行緒來執行程式。從字面上看,多執行緒就是使用多個執行緒。多執行緒是併發的一種形式,但不是唯一的形式。

並行處理

把正在執行的大量的任務分割成小塊,分配給多個同時執行的執行緒。

       為了讓處理器的利用效率最大化,並行處理(或並行程式設計)採用多執行緒。當現代多核 CPU行大量任務時,若只用一個核執行所有任務,而其他核保持空閒,這顯然是不合理的。

       並行處理把任務分割成小塊並分配給多個執行緒,讓它們在不同的核上獨立執行。並行處理是多執行緒的一種,而多執行緒是併發的一種。

非同步程式設計

併發的一種形式,它採用 future 模式或回撥(callback)機制,以避免產生不必要的執行緒。

       一個 future(或 promise)型別代表一些即將完成的操作。在 .NET 中,新版 future 型別
有 Task 和 Task 。在老式非同步程式設計 API 中,採用回撥或事件(event),而不是
future。非同步程式設計的核心理念是非同步操作:啟動了的操作將會在一段時間後完成。這個操作
正在執行時,不會阻塞原來的執行緒。啟動了這個操作的執行緒,可以繼續執行其他任務。當
操作完成時,會通知它的future,或者呼叫回撥函式,以便讓程式知道操作已經結束。

       NOTE:通常情況下,一個併發程式要使用多種技術。大多數程式至少使用了多執行緒(通過執行緒池)和非同步程式設計。要大膽地把各種併發程式設計形式進行混合和匹配,在程式的各個部分使用
合適的工具。

1.2 TPL

       任務並行庫(TPL)是System.Threading和System.Threading.Tasks名稱空間中的一組公共型別和API。

       TPL動態地擴充套件併發度,以最有效地使用所有可用的處理器。通過使用TPL,您可以最大限度地提高程式碼的效能,同時專注於您的程式碼的業務實現。

從.NET Framework 4開始,TPL是編寫多執行緒和並行程式碼的首選方式。

2 執行緒基礎

2.1 Windows 為什麼要支援執行緒

       在計算機的早期歲月,作業系統沒提供執行緒的概念。事實上,整個系統只執行著一個執行執行緒(單執行緒),其中同時包含作業系統程式碼和應用程式程式碼。只用一個執行執行緒的問題在於,長時間執行的任務會阻止其他任務執行。
例如,在16位Windows的那些日子裡,列印一個文件的應用程式很容易“凍結”整個機器,造成OS和其他應用程式停止響應。有的程式含有bug,會造成死迴圈。遇到這個問題,使用者只好重啟計算機。使用者對此深惡痛絕。

       於是微軟下定決心設計一個新的OS,這個OS必須健壯,可靠,易於是伸縮以安全,同同時必須改進16位windows的許多不足。

       微軟設計這個OS核心時,他們決定在一個程式(Process)中執行應用程式的每個例項。程式不過是應用程式的一個例項要使用的資源的一個集合。每個程式都被賦予一個虛擬地址空間,確保一個程式使用的程式碼和資料無法由另一個程式訪問。這就確保了應用程式例項的健壯性。由於應用程式破壞不了其他應用程式或者OS本身,所以使用者的計算體驗變得更好了。

       聽起來似乎不錯,但CPU本身呢?如果一個應用程式進入無限迴圈,會發生什麼呢?如果機器中只有一個CPU,它會執行無限迴圈,不能執行其它任何東西。所以,雖然資料無法被破壞,而且更安全,但系統仍然可能停止響應。微軟要修復這個問題,他們拿出的方案就是執行緒。作為Windows概念,執行緒的職責是對CPU進行虛擬化。Windows為每個程式都提供了該程式專用的專用的執行緒(功能相當於一個CPU,可將執行緒理解成一個邏輯CPU)。如果應用程式的程式碼進入無限迴圈,與那個程式碼關聯的程式會被“凍結”,但其他程式(他們有自己的執行緒)不會凍結:他們會繼續執行!

2.2 執行緒開銷

       執行緒是一個非常強悍的概念,因為他們使windows即使在執行長時間執行的任務時也能隨時響應。另外,執行緒允許使用者使用一個應用程式(比如“工作管理員”)強制終止似乎凍結的一個應用程式(它也有可能正在執行一個長時間執行的任務)。但是,和一切虛擬化機制一樣,執行緒會產生空間(記憶體耗用)和時間(執行時的執行效能)上的開銷。

       建立執行緒,讓它進駐系統以及最後銷燬它都需要空間和時間。另外,還需要討論一下上下文切換。單CPU的計算機一次只能做一件事情。所以,windows必須在系統中的所有執行緒(邏輯CPU)之間共享物理CPU。

       在任何給定的時刻,Windows只將一個執行緒分配給一個CPU。那個執行緒允許執行一個時間片。一旦時間片到期,Windows就上下文切換到另一個給執行緒。每次上下文切換都要求Windows執行以下操作:

  • 將CPU暫存器中的值儲存到當前正在執行的執行緒的核心物件內部的一個上下文結構中。
  • 從現有執行緒集合中選一個執行緒供排程(切換到的目標執行緒)。如果該執行緒由另一個程式擁有,Window在開始執行任何程式碼或者接觸任何資料之前,還必須切換CPU“看得見”的虛擬地址空間。
  • 將所選上下文結構中的值載入到CPU的暫存器中。

       上下文切換完成後,CPU執行所選的執行緒,直到它的時間片到期。然後,會發生新一輪的上下文切換。Windows大約每30ms執行一次上下文切換。

       上下文切換是淨開銷:也就是說上下文切換所產生的開銷不會換來任何記憶體或效能上的收益。

       根據上述討論,我們的結論是必須儘可能地避免使用執行緒,因為他們要耗用大量的記憶體,而且需要相當多的時間來建立,銷燬和管理。Windows線上程之間進行上下文切換,以及在發生垃圾回收的時候,也會浪費不少時間。然而,根據上述討論,我們還得出一個結論,那就是有時候必須使用執行緒,因為它們使Windows變得更健壯,反應更靈敏。

       應該指出的是,安裝了多個CPU或者一個多核CPU)的計算機可以真正同時執行幾個執行緒,這提升了應用程式的可伸縮性(在少量的時間裡做更多工作的能力)。Windows為每個CPU核心都分配一個執行緒,每個核心都自己執行到其他執行緒的上下文切換。Windows確保單個執行緒不會在多個核心上同時被排程,因為這會代理巨大的混亂。今天,許多計算機都包含了多個CPu,超執行緒CPU或者多核CPU。但是,windows最初設計時,單CPU計算機才是主流,所以Windows設計了執行緒來增強系統的響應能力和可靠性。今天,執行緒還被用於增強應用程式的可伸縮性,但在只有多CPU(或多核CPU)計算機上才有可能發生。

TIP:一個時間片結束時,如果Windows決定再次排程同一個執行緒(而不是切換到另外給一個執行緒),那麼Windows不會執行上下文切換。執行緒將繼續執行,這顯著改進了效能。設計自己的程式碼時注意,上下文切換能避免的就要儘量避免。

2.3 CPU的發展

       過去,CPU速度一直隨著時間在變快。所以,在一臺舊機器上執行得慢的程式在新機器上一般會快些。然而,CPU 廠商沒有延續CPU越來越快的趨勢。由於CPU廠商不能做到一直提升CPU的速度,所以它們側重於將電晶體做得越來越小,使一個晶片上能夠容納更多的電晶體。今天,一個矽晶片可以容納2個或者更多的CPU核心。這樣一來,如果在寫軟體時能利用多個核心,軟體就能執行得更快些。

今天的計算機使用了以下三種多CPU技術。

  1. 多個CPU
  2. 超執行緒晶片
  3. 多核晶片

2.4 使用執行緒的理由

使用執行緒有以下三方面的理由。

  1. 使用執行緒可以將程式碼同其他程式碼隔離
           這將提高應用程式的可靠性。事實上,這正是Windows在作業系統中引入執行緒概念的原因。Windows之所以需要執行緒來獲得可靠性,是因為你的應用程式對於作業系統來說是的第三方元件,而微軟不會在你釋出應用程式之前對這些程式碼進行驗證。如果你的應用程式支援載入由其它廠商生成的元件,那麼應用程式對健壯性的要求就會很高,使用執行緒將有助於滿足這個需求。
  2. 可以使用執行緒來簡化編碼
           有的時候,如果通過一個任務自己的執行緒來執行該任務,或者說單獨一個執行緒來處裡該任務,編碼會變得更簡單。但是,如果這樣做,肯定要使用額外的資源,也不是十分“經濟”(沒有使用盡量少的程式碼達到目的)。現在,即使要付出一些資源作為代價,我也寧願選擇簡單的編碼過程。否則,乾脆堅持一直用機器語言寫程式好了,完全沒必要成為一名C#開發人員。但有的時候,一些人在使用執行緒時,覺得自己選擇了一種更容易的編碼方式,但實際上,它們是將事情(和它們的程式碼)大大複雜化了。通常,在你引入執行緒時,引入的是要相互協作的程式碼,它們可能要求執行緒同步構造知道另一個執行緒在什麼時候終止。一旦開始涉及協作,就要使用更多的資源,同時會使程式碼變得更復雜。所以,在開始使用執行緒之前,務必確定執行緒真的能夠幫助你。
  3. 可以使用執行緒來實現併發執行
           如果(而且只有)知道自己的應用程式要在多CPU機器上執行,那麼讓多個任務同時執行,就能提高效能。現在安裝了多個CPU(或者一個多核CPU)的機器相當普遍,所以設計應用程式來使用多個核心是有意義的。

3 資料並行(Data Parallelism)

3.1 資料並行

       資料並行是指對源集合或陣列中的元素同時(即並行)執行相同操作的情況。在資料並行操作中,源集合被分割槽,以便多個執行緒可以同時在不同的段上操作。

資料並行性是指對源集合或陣列中的元素同時任務並行庫(TPL)通過system.threading.tasks.parallel類支援資料並行。這個類提供了for和for each迴圈的基於方法的並行實現。

您為parallel.for或parallel.foreach迴圈編寫迴圈邏輯,就像編寫順序迴圈一樣。您不必建立執行緒或將工作項排隊。在基本迴圈中,您不必使用鎖。底層工作TPL已經幫你處理。

下面程式碼展示順序和並行:

// Sequential version            
foreach (var item in sourceCollection)
{
    Process(item);
}

// Parallel equivalent
Parallel.ForEach(sourceCollection, item => Process(item));
 
並行迴圈執行時,TPL對資料來源進行分割槽,以便迴圈可以同時在多個部分上執行。在後臺,任務排程程式根據系統資源和工作負載對任務進行分割槽。如果工作負載變得不平衡,排程程式會在多個執行緒和處理器之間重新分配工作。

下面的程式碼來展示如何通過Visual Studio除錯程式碼:

public static void test()
        {
            int[] nums = Enumerable.Range(0, 1000000).ToArray();
            long total = 0;
            
            // Use type parameter to make subtotal a long, not an int
            Parallel.For<long>(0, nums.Length, () => 0, (j, loop, subtotal) =>
            {
                subtotal += nums[j];
                return subtotal;
            },
                (x) => Interlocked.Add(ref total, x)
            );

            Console.WriteLine("The total is {0:N0}", total);
            Console.WriteLine("Press any key to exit");
            Console.ReadKey();
        }
  • 選擇除錯 > 開始除錯,或按F5。
  • 應用在除錯模式下啟動,並會在斷點處暫停。
  • 在中斷模式下開啟執行緒通過選擇視窗除錯 > Windows > 執行緒。 您必須位於一個除錯會話以開啟或請參閱執行緒和其他除錯視窗。
    在這裡插入圖片描述

3.2 Parallel.For剖析

檢視Parallel.For的底層,

public static ParallelLoopResult For<TLocal>(int fromInclusive, int toExclusive, Func<TLocal> localInit, Func<int, ParallelLoopState, TLocal, TLocal> body, Action<TLocal> localFinally);
 

清楚的看到有個func函式,看起來很熟悉。

 [TypeForwardedFrom("System.Core, Version=3.5.0.0, Culture=Neutral, PublicKeyToken=b77a5c561934e089")]
    public delegate TResult Func<out TResult>();
 

原來是定義的委託,有多個過載,具體檢視文件:https://docs.microsoft.com/en-us/dotnet/api/system.func-4?view=netframework-4.7.2

實際上TPL之前,實現併發或多執行緒,基本都要使用委託。

TIP:關於委託,大家可以檢視(https://docs.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/delegates)。或者《細說委託》(https://www.cnblogs.com/laoyu/archive/2013/01/13/2859000.html)

4 資料和任務並行中潛在的缺陷

       在許多情況下,parallel.for和parallel.foreach可以比普通的順序迴圈提供顯著的效能改進。然而,並行迴圈的工作引入了複雜性,這可能會導致在順序程式碼中不常見或根本不會遇到的問題。本主題列舉了一些實踐來幫您避免這些問題,當你在寫並行程式碼的時候。

4.1 不要假設並行總是很快

       在某些情況下,並行迴圈的執行速度可能比其順序等效迴圈慢。基本的經驗法則是,具有很少迭代和快速使用者委託的並行迴圈不太可能加快速度。但是,由於有很多因素會影響效能,我建議您測量實際結果。

4.2 避免寫入共享快取

       在順序程式碼中,讀寫靜態變數或者欄位是很正常的。然而,每當多個執行緒同時訪問這些變數時,就有很大的競爭條件潛力。即使您可以使用鎖來同步對變數的訪問,同步成本也會損害效能。因此,我們建議您儘可能避免或至少限制對並行迴圈中共享狀態的訪問。最好的方式是使用Parallel.For 和 Parallel.ForEach的過載方法,在並行迴圈期間,它們使用System.Threading.ThreadLocal泛型型別的變數來儲存執行緒本地狀態。通過使用並行迴圈,您將產生劃分源集合和同步工作執行緒的開銷。並行化的好處進一步受到計算機上處理器數量的限制。在一個處理器上執行多個計算繫結執行緒並不能加快速度。因此,要注意不要過度使用並行。

過度使用並行最常見的場景發生在巢狀迴圈中。在大多數情況下,最好僅在外層迴圈使用並行,除非以下幾種場景適用:

  • 內層迴圈很長
  • 您正在對每筆訂單執行昂貴的計算。
  • 目標系統有足夠的處理器來處理通過並行處理對客戶訂單的查詢而產生的執行緒數。

在所有情況下,確定最佳查詢形狀的最佳方法都是測試和度量。

4.3 避免呼叫非執行緒安全的方法

       從並行迴圈中寫入非執行緒安全的例項方法可能會導致資料損壞,這在程式中可能會被檢測到,也可能不會被檢測到。它可能導致異常。在以下示例中,多執行緒會嘗試同時呼叫FileStream.WriteByte方法,但是這個是不被支援的。

FileStream fs = File.OpenWrite(path);
byte[] bytes = new Byte[10000000];
// ...
Parallel.For(0, bytes.Length, (i) => fs.WriteByte(bytes[i]));
 

參考文獻:

  1. https://docs.microsoft.com/en-us/dotnet/standard/parallel-programming/
  2. https://docs.microsoft.com/en-us/dotnet/csharp/tour-of-csharp/delegates
  3. https://www.cnblogs.com/laoyu/archive/2013/01/13/2859000.html
  4. 《C#併發經典例項》
  5. 《CLR via C#》第3版
  6. https://www.52interview.com/solutions/38

 

歡迎關注訂閱我的微信公眾平臺【熊澤有話說】,更多好玩易學知識等你來取
作者:熊澤-學習中的苦與樂
公眾號:熊澤有話說
當前出處: https://www.cnblogs.com/xiongze520/p/14271739.html
原文出處: https://www.52interview.com/solutions/38

創作不易,版權歸作者和部落格園共有,轉載或者部分轉載、摘錄,請在文章明顯位置註明作者和原文連結。  

 

相關文章