探秘.NET 4和Visual Studio 2010中的多核利用
如果你想利用多核機器的強大計算能力,你需要使用PLINQ(并行LINQ),任務并行庫(Task Parallel Library,TPL)和Visual Studio2010中的新功能創建應用程序。
以前,如果你創建的多線程應用程序有BUG,那要跟蹤起來是很麻煩的,但現在情況完全變了,感謝微軟為我們帶來了Microsoft Parallel Extensions for .NET(.NET并行擴展),它在.NET框架線程模型上提供了一個抽象層。
并行擴展遵循微軟在COM應用程序中建立的事務管理和在數據訪問領域建立的實體框架和LINQ模型,它試圖通過給.NET框架中的復雜過程建立高級支持,以便將先進的技術帶給大眾,隨著多核處理器的普及,開發人員渴望他們的應用程序可以利用所有處理器核心的計算能力。
你可以通過并行LINQ(PLINQ)和任務并行庫(Task Parallel Library,TPL)使用并行擴展的功能,它們都允許你為單核和多核計算機寫一套代碼,依靠.NET框架,最大限度利用代碼執行平臺的計算能力,并防止自行創建多線程應用程序時常見的陷阱。
PLINQ擴展了LINQ查詢,它將單個查詢分解成多個并行運行的子查詢,TPL允許你創建并行運行的循環,而不是一個接一個地運行,雖然PLINQ的聲明語法使創建并行進程更加簡單,但一般情況下,面向TPL的操作比PLINQ查詢更輕量級。
許多時候,選擇TPL還是PLINQ只是一種生活方式,如果喜歡并行循環,而不是并行查詢,那么設計一個TPL解決方案比設計一個PLINQ解決方案更容易。
PLINQ簡介
對于商業應用程序,只要LINQ查詢涉及到多個子查詢時,PLINQ就像金子一樣發光,如果你要連接本地數據庫某張表中的行和另一個遠程數據庫某張表中的行,PLINQ將非常有用,在這種情況下,LINQ必須在每個數據源上獨立運行子查詢,然后調和結果,PLINQ將會把這些子查詢分配給多個處理器核心,這些子查詢就可以同時執行。實際上,你使用的處理器周期不是少了,而是更多了,當然好處就是你可以更早得到結果,請閱讀“并行處理不會讓你的應用程序變得更快”了解更多關于多線程應用程序的行為。
并行處理不會讓你的應用程序變得更快
關于多線程應用程序最常見的一個誤解是,應用程序線程越多,運行速度就越快,多啟動一個線程并不會導致Windows給你的應用程序更多的處理周期,它只是把這些周期劃分給更多線程了,實際上,在單處理器計算機上,開啟多線程只會讓你的應用程序變得更慢。
多線程只是讓你的應用程序響應更快,但它仍然要等待其它阻塞任務完成先,不過在等待期間,你可以利用多線程應用程序的特點讓其它線程做一些別的事情。在單核機器上,如果線程未被阻塞,多個線程只能相互爭奪有限的處理周期。
多核處理器改變了這種狀況,在多核環境中,你可以讓Windows給你的應用程序分配更多的處理周期,你不需要阻塞線程,所有線程都在它們自己的核心上執行。并行擴展提供了編程結構,允許你告訴.NET框架應用程序那些部分可以并行執行。
即使在多核機器上,PLINQ也并不總是并行的查詢,有兩個原因,一是你的應用程序并行運行不會總是更快,第二個原因是,即使你有一個抽象層管理你的線程,在并行處理時總會出現腳步不一致的情況,PLINQ會檢查一些不安全的條件,如果檢測到就不會進行并行查詢。我會指出PLINQ不會檢查的問題和條件,但使用PLINQ出了問題只有你自己負責處理。
處理PLINQ
調用PLINQ很簡單,只需要在你的數據源中添加AsParallel擴展,下面是一個從本地Northwind數據庫連接遠程Northwind數據庫,根據客戶(customer)信息查詢訂單(Orders)的示例:
2. ords = From c In le.Customers.AsParallel Join o In re.Orders.AsParallel
3. On c.CustomerID Equals o.CustomerID
4. Where c.CustomerID = "ALFKI"
5. Select o
因為兩個數據源都標記了AsParallel(在連接時,如果一個數據源使用了AsParallel,另一個也必須使用),因此將會使用PLINQ。
和普通的LINQ查詢一樣,PLINQ查詢使用延遲處理,即等到你要真正使用數據時,它才會開始檢索,這意味著即使LINQ查詢聲明了是并行的,在你要處理結果前不會發生并行處理,除非使用下面這樣的代碼塊:
2. ord.RequiredDate.Value.AddDays(2)
3. Next
在后臺,PLINQ將使用一個線程執行For …Each循環中的代碼,而其它線程可能被用來執行子查詢,最大可以使用64個線程,請閱讀“并行控制”材料了解這種行為的更多信息。
并行控制
本文認為并行LINQ(PLINQ)總是好的,例如,首先選擇是否要并行運行,然后決定如何將多個子查詢分配給多個線程,你可以使用With*擴展控制PLINQ的行為。
在使用調試工具的時候,你會發現PLINQ不是并行執行查詢的,你可以傳遞ParallelExecutionMode .ForceParallelism值給WithExecutionMode方法讓其強制并行執行查詢。
2. WithExecutionMode(ParallelExecutionMode.ForceParallelism)
如果你想指定線程的數量(例如,你想讓一或多個處理核心閑置),你可以使用WithDegreeOfParallelism方法,下面的代碼示例將線程數限制為3。
2. WithDegreeOfParallelism(3)
你也可以使用cancellation結束處理過程,首先創建一個CancellationTokenSource對象,然后將其傳遞給WithCancellation擴展。
2. ords = From o In le.Orders.AsParallel.
3. WithCancellation(ctx.Token)
4. Where o.RequiredDate > Now
5. Select o
6.
7. For Each ord As Order In ords
8. totFreight += ord.Freight
9. If totFreight > FreightChargeLimit Then
10. ctx.Cancel()
11. End If
12. Next
如果你正在處理For…Each循環中的PLINQ查詢結果,調用cancellation會自動退出循環。
如果在一個訂單(Order)上的處理過程不和另一個訂單上的處理過程共享狀態,可以使用ForAll循環進一步提高響應,ForAll可以用于支持Lambda表達式的PLINQ查詢結果集,它和For…Each循環不一樣,For…Each只在程序的主線程中執行的,而傳遞給ForAll方法的操作是在PLINQ查詢產生的獨立查詢線程上執行的。
2. ord.RequiredDate.Value.AddDays(2)
3. End Sub)
此外,For…Each循環是在它自己的線程中串行執行的,而ForAll中的代碼是在檢索訂單的線程上并行執行的。
管理順序
雖然和SQL類似,但PLINQ不保證順序,PLINQ子查詢返回結果的順序依賴于各個線程不可預知的響應時間,例如下面這個查詢是為了獲得將要先發貨的五個訂單。
Where o.RequiredDate > Now
Select o
Take (5)
圖 1 PLINQ給TPL中的功能添加查詢分析和標準查詢操作,TPL提供管理操作系統底層線程需要的基本的結構和調度
如果不保證順序,我將獲得一個隨機的訂單(Orders)數據集,它們可能是(也可能不是)應該先發貨的五個訂單,為了確保得到前五個訂單,我需要在查詢中增加一個Order By子句,按照日期對查詢結果進行排序,當然這樣就會丟掉PLINQ的一些好處。
因為結果來自多個線程,難免不會出現異常,PLINQ不能明白“上一條”和“下一條”的概念,如果在你的循環中剛好要用到下一條項目的值時,完全有可能會遭遇錯誤的處理,為了讓訂單中的項目按照原始數據源中的順序處理,你需要在查詢中增加AsOrdered擴展。
例如,如果我想將低于某一運費的所有訂單打包到一起處理,我可能會寫下面這樣一個循環:
2. totFreight += ord.Freight
3. If totFreight > FreightChargeLimit Then
4. Exit For
5. End If
6. shipOrders.Add(ord)
7. Next
由于并行處理返回的項目順序不可預知,因此進入批處理的訂單可能是隨機的,為了保證按照原始數據源中的順序處理返回的結果,我必須給數據源加上AsOrdered擴展。
2. Where o.RequiredDate > Now
3. Select o
TPL(任務并行庫)介紹
如果你的處理不是由LINQ查詢驅動的,你可以使用借鑒了PLINQ的TPL技術,從根本上看,TPL讓你創建可并行執行的循環,如果你的計算機是四核的,一個循環可能用1/3的時間就完成了。
如果不使用TPL,你可能會像下面這樣處理Orders集合中的所有元素:
2. o.RequiredDate.Value.AddDays(2)
3. Next
如果使用TPL,你調用Parallel類的ForEach方法,通過Lambda表達式來處理集合中的項目:
2. le.Orders, Sub(o)
3. o.RequiredDate.Value.AddDays(2)
4. End Sub)
通過使用Parallel ForEach,每個方法的實例可以在獨立的處理器上同時處理,如果每個操作需要1毫秒,并且有足夠的處理器存在,所有的訂單就可以在1毫秒內處理,而不是1毫秒乘以訂單數量的時間。
任何復雜的處理放在Lambda表達式中都會變得很難閱讀,因此你要經常想到在你的Lambda表達式中調用下面這樣一些方法:
2. le.Orders, Sub(o)
3. ExtendOrders(o)
4. End Sub)
5. ...
6. Sub ExtendOrders(ByVal o As Order)
7. o.RequiredDate.Value.AddDays(2)
8. End Sub
從本質上講,TPL將集合中的成員分配給獨立的任務,這些任務又被分配到所有處理核心上執行,每個任務完成時釋放掉代碼,TPL調度器從執行隊列中取出另一個任務開始執行,你也可以根據索引值使用For方法創建一個循環。
當你創建自定義任務時你才會感覺到TPL的強大之處,任務創建好后使用它的Start方法啟動,但它更容易使用Task類的靜態工廠對象(Factory),它的StartNew方法可以創建并啟動任務(Task),你只需要通過一個Lambda表達式就可以使用StartNew方法,如果你的函數要返回一個值,你可以使用Task對象的Generic版本指定返回的類型。
下面的示例為計算訂單總價的Order Detail對象創建并啟動了一個Task,Task被添加到一個列表(List)中,后面的代碼循環檢索List中的結果,如果我需要一個未計算的結果,第二個循環將會暫停,直到Task完成。
2. Tasks.Task(Of Decimal)
3. Dim CalcTasks As New List(Of System.
4. Threading.Tasks.Task(Of Decimal))
5. For Each ord As Order_Detail In
6. le.Order_Details
7. Dim od As Order_Detail = ord
8. CalcTask = System.Threading.
9. Tasks.Task(Of Decimal).
10. Factory.StartNew(Function() CalcValue(od))
11. CalcTasks.Add(CalcTask)
12. Next
13.
14. Dim totResult As Decimal
15. For Each ct As System.Threading.Tasks.Task(Of Decimal) In CalcTasks
16. totResult += ct.Result
17. Next
如果我足夠幸運,在我需要結果前,Task總是先完成,即使不走運,也要比按順序運行每個Task更早得到結果。
凡是遇到一個Task的輸出要依賴于另一個Task先完成的情況,你可以在Task之間創建依賴或將Task分組,最簡單的辦法是使用Wait方法,但它會導致你的應用程序停止執行,直到所有Task全部完成。
2. Task(Of Decimal).Factory.StartNew(Function()
CalcValue(le.Order_Details(0))),
3. Task(Of Decimal).Factory.StartNew(Function()
CalcValue(le.Order_Details(1)))
4. }
5. System.Threading.Tasks.Task.WaitAll(tsks)
一個更復雜的方法是使用Task對象的ContinueWith方法,當其它Task完成時,它觸發一個Task繼續運行。下面的例子啟動了多個線程,每個都計算訂單明細(Order Detail)的值,但都只有等到訂單明細上的其它操作完成后才能執行。
2. Dim od As Order_Detail = ordd
3. Dim adjustedDiscount As New Task(Sub() AdjustDiscount(od))
4. Dim calcedValue As Task(Of Long) =
5. adjustedDiscount.ContinueWith(Of Long)(Function() CalcValue(od))
6. adjustedDiscount.Start
7. Next
圖 2 并行堆棧窗口提供了一個可視化視圖,顯示了當前執行的線程的附加信息
出錯時如何處理
在多個處理器上同時執行多個線程也會造成異常出現得更頻繁,任何線程上一旦發生異常,整個應用程序都將掛起,給AggregateException對象添加的錯誤處理也會增加,通過這個對象的InnerExceptions屬性允許你查看每個線程的異常。
2. Try
3. 'PLINQ or TPL processing
4. Catch aex As AggregateException
5. For Each ex As Exception In aex.InnerExceptions
6. Messages.Append(ex.Message & "; ")
7. Next
8. End Try
注意這里沒有使用Catch語句,你需要檢查InnerExceptions的類型,確定每個線程究竟拋出的是什么異常。
調試并發線程變得更加有趣,因為異常可能隨一個PLINQ查詢中的循環出現,解決這個問題可能需要重構PLINQ查詢,幸運的是,Visual Studio 2010包括了額外的工具調式并行錯誤。
并行堆棧窗口(Parallel Stacks)超越了舊的線程窗口,線程窗口只能提供一個視圖,而并行堆棧窗口可以顯示所有正在執行的線程,例如,它默認允許你同時查看多個線程的調用堆棧,你可以放大顯示內容,也可以過濾只顯示指定的線程,更重要的是,如果你使用TPL,你可以切換到基于任務的視圖(對應于你代碼中的Task對象),或方法視圖(顯示調用方法的任務),但使用并行任務窗口(Parallel Tasks)可能更有用,因為它圍繞Task組織任務,這個窗口不僅顯示當前運行的任務,已調度和等待運行的任務也會顯示(顯示在狀態[Status]列),你可以通過檢查當前運行的Task是否在等待其它任務,從而確定Task之間的依賴關系。
在早期的Visual Studio版本中,要一步一步調式多線程程序是一場噩夢,因為調試器要從一個線程中的當前語句跳轉到另一個線程的當前語句,并行任務(Parallel Task)允許你凍結或解凍與Task相關的線程,在調試時控制哪一個線程先運行。
一起使用這兩個窗口可以簡化并行處理問題的診斷,例如,Visual Studio現在檢測到一個死鎖時,它會自動打破死鎖,當調式器檢測到兩個或多個Task不能處理時(因為相互都在等待對方釋放鎖定的對象),Visual Studio將實施凍結處理,就好像你遇到一個斷點似的,并行任務窗口將顯示每個Task在等待的對象,以及它占有的線程,并行堆棧窗口的方法視圖可視化顯示了發生死鎖時哪個Task調用了哪個方法。
其它調試功能
除了這些工具外,Visual Studio還包含了其它幾個用于調式并行處理的功能,在遍歷你的代碼時,當你的鼠標移到一個Task對象上時,彈出一個提示窗口,顯示該任務的Id,關聯的方法和它當前的狀態(如,等待執行)等詳細信息,進一步展開該提示,可以看到該Task的屬性值,包括它的結果。在觀察窗口(Watch)中檢查Task的InternalCurrent屬性,可以得到當前正在執行的Task的信息,任務調度器(TaskScheduler)的提示展開后可以看到它管理的所有Task。
合理使用PLINQ,TPL和Visual Studio提供的功能,無論你的應用程序運行在什么計算機上,你都可以利用所有處理器的計算能力。
留言列表