1.前言
最近趁着項目的一段平穩期研讀了不少書籍,其中《C#並發編程經典實例》給我的印象還是比較深刻的。當然,這可能是由於近段日子看的書大多嘴炮大於實際,如《Head First設計模式》《Cracking the coding interview》等,所以陡然見到一本打着“實例”旗號的書籍,還是挺讓我覺得耳目一新。本着分享和加深理解的目的,我特地整理了一些筆記(主要是Web開發中容易涉及的內容,所以部分章節如數據流,RX等我看了看就直接跳過了),以供審閱學習。語言和技術的魅力,真是不可捉摸
2.開宗明義
一直以來都有一種觀點是實現底層架構,編寫驅動和引擎,或者是框架和工具開發的才是高級開發人員,做上層應用的人僅僅是“碼農”,其實能夠利用好平台提供的相關類庫,而不是全部采用底層技術自己實現,開發出高質量,穩定的應用程序,對技術能力的考驗並不低於開發底層庫,如TPL,async,await等。
3.開發原則和要點
(1)並發編程概述
- 並發:同時做多件事情
- 多線程:並發的一種形式,它采用多個線程來執行程序
- 並行處理:把正在執行的大量的任務分割成小塊,分配給多個同時運行的線程
- 並行處理是多線程的一種,而多線程是並發的一種處理形式
- 異步編程:並發的一種形式,它采用future模式或者callback機制,以避免產生不必要的線程
- 異步編程的核心理念是異步操作:啟動了的操作會在一段時間后完成。這個操作正在執行時,不會阻塞原來的線程。啟動了這個操作的線程,可以繼續執行其他任務。當操作完成后,會通知它的future,或者調用回調函數,以便讓程序知道操作已經結束
- await關鍵字的作用:啟動一個將會被執行的Task(該Task將在新線程中運行),並立即返回,所以await所在的函數不會被阻塞。當Task完成后,繼續執行await后面的代碼
- 響應式編程:並發的一種基於聲明的編程方式,程序在該模式中對事件作出反應
- 不要用 void 作為 async 方法的返回類型! async 方法可以返回 void,但是這僅限於編寫事件處理程序。一個普通的 async 方法如果沒有返回值,要返回 Task,而不是 void
- async 方法在開始時以同步方式執行。在 async 方法內部,await 關鍵字對它的參數執行一個異步等待。它首先檢查操作是否已經完成,如果完成了,就繼續運行 (同步方式)。否則,它會暫停 async 方法,並返回,留下一個未完成的 task。一段時間后, 操作完成,async
方法就恢復運行。 - await代碼中拋出異常后,異常會沿着Task方向前進到引用處
- 你一旦在代碼中使用了異步,最好一直使用。調用 異步方法時,應該(在調用結束時)用 await 等待它返回的 task 對象。一定要避免使用 Task.Wait 或 Task
.Result 方法,因為它們會導致死鎖 - 線程是一個獨立的運行單元,每個進程內部有多個線程,每個線程可以各自同時執行指令。 每個線程有自己獨立的棧,但是與進程內的其他線程共享內存
- 每個.NET應用程序都維護着一個線程池,這種情況下,應用程序幾乎不需要自行創建新的線程。你若要為 COM interop 程序創建 SAT 線程,就得 創建線程,這是唯一需要線程的情況
- 線程是低級別的抽象,線程池是稍微高級一點的抽象
- 並發編程用到的集合有兩類:並發變成+不可變集合
- 大多數並發編程技術都有一個類似點:它們本質上都是函數式的。這里的函數式是作為一種基於函數組合的編程模式。函數式的一個編程原則是簡潔(避免副作用),另一個是不變性(指一段數據不能被修改)
- .NET 4.0 引入了並行任務庫(TPL),完全支持數據並行和任務並行。但是一些資源較少的 平台(例如手機),通常不支持 TPL。TPL 是 .NET 框架自帶的
(2)異步編程基礎
- 指數退避是一種重試策略,重試的延遲時間會逐 次增加。在訪問 Web 服務時,最好的方式就是采用指數退避,它可以防止服務器被太多的重試阻塞
static async Task<string> DownloadStringWithRetries(string uri)
{
using (var client = new HttpClient())
{
// 第 1 次重試前等 1 秒,第 2 次等 2 秒,第 3 次等 4 秒。
var nextDelay = TimeSpan.FromSeconds(1);
for (int i = 0; i != 3; ++i)
{
try
{
return await client.GetStringAsync(uri);
}
catch
{ }
await Task.Delay(nextDelay);
nextDelay = nextDelay + nextDelay;
}
// 最后重試一次,以便讓調用者知道出錯信息。
return await client.GetStringAsync(uri);
}
}
- Task.Delay 適合用於對異步代碼進行單元測試或者實現重試邏輯。要實現超時功能的話, 最好使用 CancellationToken
- 如何實現一個具有異步簽名的同步方法。如果從異步接口或基類繼承代碼,但希望用同步的方法來實現它,就會出現這種情況。解決辦法是可以使用 Task.FromResult 方法創建並返回一個新的 Task
對象,這個 Task 對象是已經 完成的,並有指定的值 - 使用 IProgress
和 Progress 類型。編寫的 async 方法需要有 IProgress 參數,其 中 T 是需要報告的進度類型,可以展示操作的進度 - Task.WhenALl可以等待所有任務完成,而當每個Task拋出異常時,可以選擇性捕獲異常
- Task.WhenAny可以等待任一任務完成,使用它雖然可以完成超時任務(其中一個Task設為Task.Delay),但是顯然用專門的帶有取消標志的超時函數處理比較好
- 第一章提到async和上下文的問題:在默認情況下,一個 async 方法在被 await 調用后恢復運行時,會在原來的上下文中運行。而加上擴展方法ConfigureAwait(false)后,則會在await之后丟棄上下文
(3)並行開發的基礎
- Parallel 類有一個簡單的成員 Invoke,可用於需要並行調用一批方法,並且這些方法(大部分)是互相獨立的
static void ProcessArray(double[] array)
{
Parallel.Invoke(
() => ProcessPartialArray(array, 0, array.Length / 2),
() => ProcessPartialArray(array, array.Length / 2,array.Length));
}
static void ProcessPartialArray(double[] array, int begin, int end)
{
// 計算密集型的處理過程 ...
}
- 在並發編程中,Task類有兩個作用:作為並行任務,或作為異步任務。並行任務可以使用 阻塞的成員函數,例如 Task.Wait、Task.Result、Task.WaitAll 和 Task.WaitAny。並行任務通常也使用 AttachedToParent 來建立任務之間的“父 / 子”關系。並行任務的創建需要 用 Task.Run 或者 Task.Factory.StartNew。
- 相反的,異步任務應該避免使用阻塞的成員函數,而應該使用 await、Task.WhenAll 和 Task. WhenAny。異步任務不使用 AttachedToParent,但可以通過 await 另一個任務,建立一種隱 式的“父 / 子”關系。
(4)測試技巧
- MSTest從Visual Studio2012 版本開始支持 async Task 類型的單元測試
- 如果單元測試框架不支持 async Task 類型的單元測試,就需要做一些額外的修改才能等待異步操作。其中一種做法是使用 Task.Wait,並在有錯誤時拆開 AggregateException 對象。我的建議是使用 NuGet 包 Nito.AsyncEx 中的 AsyncContext 類
這里附上一個ABP中實現的可操作AsyncHelper類,就是基於AsyncContext實現
/// <summary>
/// Provides some helper methods to work with async methods.
/// </summary>
public static class AsyncHelper
{
/// <summary>
/// Checks if given method is an async method.
/// </summary>
/// <param name="method">A method to check</param>
public static bool IsAsyncMethod(MethodInfo method)
{
return (
method.ReturnType == typeof(Task) ||
(method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
);
}
/// <summary>
/// Runs a async method synchronously.
/// </summary>
/// <param name="func">A function that returns a result</param>
/// <typeparam name="TResult">Result type</typeparam>
/// <returns>Result of the async operation</returns>
public static TResult RunSync<TResult>(Func<Task<TResult>> func)
{
return AsyncContext.Run(func);
}
/// <summary>
/// Runs a async method synchronously.
/// </summary>
/// <param name="action">An async action</param>
public static void RunSync(Func<Task> action)
{
AsyncContext.Run(action);
}
}
- 在 async 代碼中,關鍵准則之一就是避免使用 async void。我非常建議大家在對 async void 方法做單元測試時進行代碼重構,而不是使用 AsyncContext。
(5)集合
-
線程安全集合是可同時被多個線程修改的可變集合。線程安全集合混合使用了細粒度鎖定和無鎖技術,以確保線程被阻塞的時間最短(通常情況下是根本不阻塞)。對很多線程安全集合進行枚舉操作時,內部創建了該集合的一個快照(snapshot),並對這個快照進行枚舉操作。線程安全集合的主要優點是多個線程可以安全地對其進行訪問,而代碼只會被阻塞很短的時間,或根本不阻塞
-
ConcurrentDictionary<TKey, TValue>是數據結構中的精品,它是線程安全的,混合使用了細粒度鎖定和無鎖技術,以確保絕大多數情況下能進行快速訪問.
-
ConcurrentDictionary<TKey, TValue> 內置了AddOrUpdate, TryRemove, TryGetValue等方法。如果多個線程讀寫一個共享集合,使用ConcurrentDictionary<TKey, TValue>是最合適的,如果不會頻繁修改,那就更適合使用ImmutableDictionary<TKey, TValue>。而如果是一些線程只添加元素,一些線程只移除元素,最好使用生產者/消費者集合
(6)函數式OOP
- 異步編程是函數式的(functional),.NET 引入的async讓開發者進行異步編程的時候也能用過程式編程的思維來進行思考,但是在內部實現上,異步編程仍然是函數式的
偉人說過,世界既是過程式的,也是函數式的,但是終究是函數式的
-
可以用await等待的是一個類(如Task
對象),而不是一個方法。可以用await等待某個方法返回的Task,無論它是不是async方法。 -
類的構造函數里是不能進行異步操作的,一般可以使用如下方法。相應的,我們可以通過
var instance=new Program.CreateAsync();
class Program
{
private Program()
{
}
private async Task<Program> InitializeAsync()
{
await Task.Delay(TimeSpan.FromSeconds(1));
return this;
}
public static Task<Program> CreateAsync()
{
var result = new Program();
return result.InitializeAsync();
}
}
- 在編寫異步事件處理器時,事件參數類最好是線程安全的。要做到這點,最簡單的辦法就 是讓它成為不可變的(即把所有的屬性都設為只讀)
(7)同步
-
同步的類型主要有兩種:通信和數據保護
-
如果下面三個條件都滿足,就需要用同步來保護共享的數據
- 多段代碼正在並發運行
- 這幾段代碼在訪問(讀或寫)同一個數據
- 至少有一段代碼在修改(寫)數據
- 觀察以下代碼,確定其同步和運行狀態
class SharedData
{
public int Value { get; set; }
}
async Task ModifyValueAsync(SharedData data)
{
await Task.Delay(TimeSpan.FromSeconds(1));
data.Value = data.Value + 1;
}
// 警告:可能需要同步,見下面的討論。
async Task<int> ModifyValueConcurrentlyAsync()
{
var data = new SharedData();
// 啟動三個並發的修改過程。
var task1 = ModifyValueAsync(data);
var task2 = ModifyValueAsync(data);
var task3 = ModifyValueAsync(data);
await Task.WhenAll(task1, task2, task3);
return data.Value;
}
本例中,啟動了三個並發運行的修改過程。需要同步嗎?答案是“看情況”。如果能確定 這個方法是在 GUI 或 ASP.NET 上下文中調用的(或同一時間內只允許一段代碼運行的任 何其他上下文),那就不需要同步,因為這三個修改數據過程的運行時間是互不相同的。 例如,如果它在 GUI 上下文中運行,就只有一個 UI 線程可以運行這些數據修改過程,因 此一段時間內只能運行一個過程。因此,如果能夠確定是“同一時間只運行一段代碼”的 上下文,那就不需要同步。但是如果從線程池線程(如 Task.Run)調用這個方法,就需要同步了。在那種情況下,這三個數據修改過程會在獨立的線程池線程中運行,並且同時修改 data.Value,因此必須同步地訪問 data.Value。
-
不可變類型本身就是線程安全的,修改一個不可變集合是不可能的,即便使用多個Task.Run向集合中添加數據,也並不需要同步操作
-
線程安全集合(例如 ConcurrentDictionary)就完全不同了。與不可變集合不同,線程安 全集合是可以修改的。線程安全集合本身就包含了所有的同步功能
-
關於鎖的使用,有四條重要的准則
- 限制鎖的作用范圍(例如把lock語句使用的對象設為私有成員)
- 文檔中寫清鎖的作用內容
- 鎖范圍內的代碼盡量少(鎖定時不要進行阻塞操作)
- 在控制鎖的時候絕不運行隨意的代碼(不要在語句中調用事件處理,調用虛擬方法,調用委托)
-
如果需要異步鎖,請嘗試 SemaphoreSlim
-
不要在 ASP. NET 中使用 Task.Run,這是因為在 ASP.NET 中,處理請求的代碼本來就是在線程池線程中運行的,強行把它放到另一個線程池線程通常會適得其反
(7) 實用技巧
- 程序的多個部分共享了一個資源,現在要在第一次訪問該資源時對它初始化
static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
new Lazy<Task<int>>(() =>
Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
}));
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Value;
}