.NET 中的併發程式設計

王者天涯發表於2023-02-16

今天我們購買的每臺電腦都有一個多核心的 CPU,允許它並行執行多個指令。作業系統透過將程式排程到不同的核心來發揮這個結構的優點。
然而,還可以透過非同步 I/O 操作和並行處理來幫助我們提高單個應用程式的效能。
在.NET Core中,任務 (tasks) 是併發程式設計的主要抽象表述,但還有其他支撐類可以使我們的工作更容易。

併發程式設計 - 非同步 vs. 多執行緒程式碼

並行程式設計是一個廣泛的術語,我們應該透過觀察非同步方法和實際的多執行緒之間的差異展開探討。
儘管 .NET Core 使用了任務來表達同樣的概念,一個關鍵的差異是內部處理的不同。
呼叫執行緒在做其他事情時,非同步方法在後臺執行。這意味著這些方法是 I/O 密集型的,即他們大部分時間用於輸入和輸出操作,例如檔案或網路訪問。
只要有可能,使用非同步 I/O 方法代替同步操作很有意義。相同的時間,呼叫執行緒可以在處理桌面應用程式中的使用者互動或處理伺服器應用程式中的同時處理其他請求,而不僅僅是等待操作完成。

你可以在我的文章 Asynchronous Programming in C# using Async Await – Best Practices 中閱讀更多關於使用 async 和 await 呼叫非同步方法。該文章來自 DNC Magazine (9月刊) 。

計算密集型的方法要求 CPU 週期工作,並且只能執行在他們專用的後臺執行緒中。CPU 的核心數限制了並行執行時的可用執行緒數量。作業系統負責在剩餘的執行緒之間切換,使他們有機會執行程式碼。
這些方法仍然被併發地執行,卻不必被並行地執行。儘管這意味著方法不是同時執行,卻可以在其他方法暫停的時候執行。

本文將在最後一段中重點介紹 在 .NET Core中多執行緒併發程式設計。

任務並行庫

.NET Framework 4 引入了任務並行庫 (TPL) 作為編寫併發程式碼的首選 API。.NET Core採用相同的程式設計模式。
要在後臺執行一段程式碼,需要將其包裝成一個 任務:

var backgroundTask = Task.Run(() => DoComplexCalculation(42));

// do other work

var result = backgroundTask.Result;

當需要返回結果時,Task.Run 方法接收一個 函式 (Func) ;當不需要返回結果時,方法 Task.Run 接收一個 動作 (Action) 。當然,所有的情況下都可以使用 lambda 表示式,就像我上面例子中呼叫帶一個引數的長時間方法。

執行緒池中的某個執行緒將會處理任務。.NET Core 的執行時包含一個預設排程程式,使用執行緒池來處理佇列並執行任務。您可以透過派生 TaskScheduler 類實現自己的排程演算法,代替預設的,但這超過本文的討論範圍。
正如我們之前所見,我使用 Result 屬性來合併被呼叫的後臺執行緒。對於不需要返回結果的執行緒,我可以呼叫 Wait() 來代替。這兩種方式都將被堵塞到後臺任務完成。
為了避免堵塞呼叫執行緒 ( 如在ASP.NET Core應用程式中) ,可以使用 await 關鍵字:

var backgroundTask = Task.Run(() => DoComplexCalculation(42));

// do other work

var result = await backgroundTask;

這樣被呼叫的執行緒將被釋放以便處理其他傳入請求。一旦任務完成,一個可用的工作執行緒將會繼續處理請求。當然,控制器動作方法必須是非同步的:

public async Task<iactionresult> Index() {     // method body }

 

處理異常

將兩個執行緒合併在一起的時候,任務丟擲的任何異常將被傳遞到呼叫執行緒中:

  • 如果使用 Result 或 Wait() ,它們將被打包到 AggregateException 中。實際的異常將被丟擲並儲存在其 InnerException 屬性中。

  • 如果您使用 await,原來的異常將不會被打包。

在這兩種情況下,呼叫堆疊的資訊將保持不變。

取消任務

由於任務是可以長時間執行的,所以你可能想要有一個可以提前取消任務的選項。實現這個選項,需要在任務建立的時候傳入取消的令牌 (token),之後再使用令牌觸發取消任務:

var tokenSource = new CancellationTokenSource();

var cancellableTask = Task.Run(() =>

{

    for (int i = 0; i < 100; i++)

    {

        if (tokenSource.Token.IsCancellationRequested)

        {

            // clean up before exiting

            tokenSource.Token.ThrowIfCancellationRequested();

        }

        // do long-running processing

    }

    return 42;

}, tokenSource.Token);

// cancel the task

tokenSource.Cancel();

try

{

    await cancellableTask;

}

catch (OperationCanceledException e)

{

    // handle the exception

} 

實際上,為了提前取消任務,你需要檢查任務中的取消令牌,並在需要取消的時候作出反應:在執行必要的清理操作後,呼叫 ThrowIfCancellationRequested() 退出任務。這個方法將會丟擲 OperationCanceledException,以便在呼叫執行緒中執行相應的處理。

協調多工

如果你需要執行多個後臺任務,這裡有些方法可以幫助到你。
要同時執行多個任務,只需連續啟動它們並收集它們的引用,例如在陣列中:

var backgroundTasks = new []
{
    Task.Run(() => DoComplexCalculation(1)),
    Task.Run(() => DoComplexCalculation(2)),
    Task.Run(() => DoComplexCalculation(3))
};

 

現在你可以使用 Task 類的靜態方法,等待他們被非同步或者同步執行完畢。

// wait synchronously
Task.WaitAny(backgroundTasks);
Task.WaitAll(backgroundTasks);
// wait asynchronously
await Task.WhenAny(backgroundTasks);
await Task.WhenAll(backgroundTasks);

實際上,這兩個方法最終都會返回所有任務的自身,可以像任何其他任務一樣再次操作。為了獲取對應任務的結果,你可以檢查該任務的 Result 屬性。

處理多工的異常有點棘手。方法 WaitAll 和 WhenAll 不管哪個任務被收集到異常時都會丟擲異常。不過,對於 WaitAll ,將會收集所有的異常到對應的 InnerExceptions 屬性;對於 WhenAll ,只會丟擲第一個異常。為了確認哪個任務丟擲了哪個異常,您需要單獨檢查每個任務的 Status 和 Exception 屬性。
在使用 WaitAny 和 WhenAny 時必須足夠小心。他們會等到第一個任務完成 (成功或失敗),即使某個任務出現異常時也不會丟擲任何異常。他們只會返回已完成任務的索引或者分別返回已完成的任務。你必須等到任務完成或訪問其 result 屬性時捕獲異常,例如:

var completedTask = await Task.WhenAny(backgroundTasks);
try
{
    var result = await completedTask;
}
catch (Exception e)
{
    // handle exception
}

如果你想連續執行多個任務,代替併發任務,可以使用延續 (continuations)的方式:

var compositeTask = Task.Run(() => DoComplexCalculation(42))
    .ContinueWith(previous => DoAnotherComplexCalculation(previous.Result),
        TaskContinuationOptions.OnlyOnRanToCompletion)

ContinueWith() 方法允許你把多個任務一個接著一個執行。這個延續的任務將獲取到前面任務的結果或狀態的引用。你仍然可以增加條件判斷是否執行延續任務,例如只有在前面任務成功執行或者丟擲異常時。對比連續等待多個任務,提高了靈活性。

當然,您可以將延續任務與之前討論的所有功能相結合:異常處理、取消和並行執行任務。這就有了很大的表演空間,以不同的方式進行組合:

var multipleTasks = new[]
{
    Task.Run(() => DoComplexCalculation(1)),
    Task.Run(() => DoComplexCalculation(2)),
    Task.Run(() => DoComplexCalculation(3))
};
var combinedTask = Task.WhenAll(multipleTasks);
 
var successfulContinuation = combinedTask.ContinueWith(task =>
        CombineResults(task.Result), TaskContinuationOptions.OnlyOnRanToCompletion);
var failedContinuation = combinedTask.ContinueWith(task =>
        HandleError(task.Exception), TaskContinuationOptions.NotOnRanToCompletion);
 
await Task.WhenAny(successfulContinuation, failedContinuation);

任務同步

如果任務是完全獨立的,那麼我們剛才看到的協調方法就已足夠。然而,一旦需要同時共享資料,為了防止資料損壞,就必須要有額外的同步。
兩個以及更多的執行緒同時更新一個資料結構時,資料很快就會變得不一致。就好像下面這個示例程式碼一樣:

var counters = new Dictionary< int, int >();
 
if (counters.ContainsKey(key))
{
    counters[key] ++;
}
else
{
    counters[key] = 1;
}

當多個執行緒同時執行上述程式碼時,不同執行緒中的特定順序執行指令可能導致資料不正確,例如:

  • 所有執行緒將會檢查集合中是否存在同一個 key

  • 結果,他們都會進入 else 分支,並將這個 key 的值設為1

  • 最後結果將會是1,而不是2。如果是接連著執行程式碼的話,將會是預期的結果。

上述程式碼中,臨界區 (critical section) 一次只允許一個執行緒可以進入。在C# 中,可以使用 lock 語句來實現:

var counters = new Dictionary< int, int >();
 
lock (syncObject)
{
    if (counters.ContainsKey(key))
    {
        counters[key]++;
    }
    else
    {
        counters[key] = 1;
    }
}

在這個方法中,所有執行緒都必須共享相同的的 syncObject 。作為最佳做法,syncObject 應該是一個專用的 Object 例項,專門用於保護對一個獨立的臨界區的訪問,避免從外部訪問。

在 lock 語句中,只允許一個執行緒訪問裡面的程式碼塊。它將阻止下一個嘗試訪問它的執行緒,直到前一個執行緒退出。這將確保執行緒完整執行臨界區程式碼,而不會被另一個執行緒中斷。當然,這將減少並行性並減慢程式碼的整體執行速度,因此您最好最小化臨界區的數量並使其儘可能的短。

使用 Monitor 類來簡化 lock 宣告:

var lockWasTaken = false;
var temp = syncObject;
try
{
    Monitor.Enter(temp, ref lockWasTaken);
    // lock statement body
}
finally
{
    if (lockWasTaken)
    {
        Monitor.Exit(temp);
    }
}

儘管大部分時間您都希望使用 lock 語句,但 Monitor 類可以在需要時給予額外的控制。例如,您可以使用 TryEnter() 而不是 Enter(),並指定一個限定時間,避免無止境地等待鎖釋放。

其他同步基元

Monitor 只是 .NET Core 中眾多同步基元的一員。根據實際情況,其他基元可能更適合。

Mutex 是 Monitor 更重量級的版本,依賴於底層的作業系統,提供跨多個程式同步訪問資源[1], 是針對 Mutex 進行同步的推薦替代方案。

SemaphoreSlim 和 Semaphore 可以限制同時訪問資源的最大執行緒數量,而不是像 Monitor 一樣只能限制一個執行緒。SemaphoreSlim 比 Semaphore 更輕量,但僅限於單個程式。如果可能,您最好使用 SemaphoreSlim 而不是 Semaphore。

ReaderWriterLockSlim 可以區分兩種對訪問資源的方式。它允許無限數量的讀取器 (readers) 同時訪問資源,並且限制同時只允許一個寫入器 (writers) 訪問鎖定資源。讀取時執行緒安全,但修改資料時需要獨佔資源,很好地保護了資源。

AutoResetEvent、ManualResetEvent 和 ManualResetEventSlim 將堵塞傳入的執行緒,直到它們接收到一個訊號 (即呼叫 Set() )。然後等待中的執行緒將繼續執行。AutoResetEvent 在下一次呼叫 Set() 之前,將一直阻塞,並只允許一個執行緒繼續執行。ManualResetEvent 和 ManualResetEventSlim 不會堵塞執行緒,除非 Reset() 被呼叫。ManualResetEventSlim 比前兩者更輕量,更值得推薦。

Interlocked 提供一種選擇——原子操作,這是替代 locking 和其他同步基元更好的選擇(如果適用):

// non-atomic operation with a lock
lock (syncObject)
{
    counter++;
}
// equivalent atomic operation that doesn't require a lock
Interlocked.Increment(ref counter);

併發集合

當一個臨界區需要確保對資料結構的原子訪問時,用於併發訪問的專用資料結構可能是更好和更有效的替代方案。例如,使用 ConcurrentDictionary 而不是 Dictionary,可以簡化 lock 語句示例:

var counters = new ConcurrentDictionary< int, int >();
 
counters.TryAdd(key, 0);
lock (syncObject)
{
    counters[key]++;
}

自然地,也有可能像下面一樣:

counters.AddOrUpdate(key, 1, (oldKey, oldValue) => oldValue + 1);

因為 update 的委託是臨界區外面的方法,因此,第二個執行緒可能在第一個執行緒更新值之前,讀取到同樣的舊值,使用自己的值有效地覆蓋了第一個執行緒的更新值,這就丟失了一個增量。錯誤使用併發集合也是無法避免多執行緒帶來的問題。

併發集合的另一個替代方案是 不變的集合 (immutable collections)。
類似於併發集合,同樣是執行緒安全的,但是底層實現是不一樣的。任何關改變資料結構的操作將不會改變原來的例項。相反,它們返回一個更改後的副本,並保持原始例項不變:

var original = new Dictionary< int, int >().ToImmutableDictionary();
var modified = original.Add(key, value);

因此在一個執行緒中對集合任何更改對於其他執行緒來說都是不可見的。因為它們仍然引用原來的未修改的集合,這就是不變的集合本質上是執行緒安全的原因。

當然,這使得它們對於解決不同集合的問題很有效。最好的情況是多個執行緒在同一個輸入集合的情況下,獨立地修改資料,在最後一步可能為所有執行緒合併變更。而使用常規集合,需要提前為每個執行緒建立集合的副本。

並行LINQ (PLINQ)

並行LINQ (PLINQ) 是 Task Parallel Library 的替代方案。顧名思義,它很大程度上依賴於 LINQ(語言整合查詢)功能。對於在大集合中執行相同的昂貴操作的場景是很有用的。與所有操作都是順序執行的普通 LINQ to Objects 不同的是,PLINQ可以在多個CPU上並行執行這些操作。
發揮優勢所需要的程式碼改動也是極小的:

// sequential execution
var sequential = Enumerable.Range(0, 40)
    .Select(n => ExpensiveOperation(n))
    .ToArray();
 
// parallel execution
var parallel = Enumerable.Range(0, 40)
    .AsParallel()
    .Select(n => ExpensiveOperation(n))
    .ToArray();

如你所見,這兩個程式碼片段的不同僅僅是呼叫 AsParallel()。這將IEnumerable 轉換為 ParallelQuery,導致查詢的部分並行執行。要切換為回順序執行,您可以呼叫 AsSequential(),它將再次返回一個IEnumerable。

預設情況下,PLINQ 不保留集合中的順序,以便讓程式更有效率。但是當順序很重要時,可以呼叫 AsOrdered():

var parallel = Enumerable.Range(0, 40)
    .AsParallel()
    .AsOrdered()
    .Select(n => ExpensiveOperation(n))
    .ToArray();

同理,你可以透過呼叫 AsUnordered() 切換回來。

在完整的 .NET Framework 中併發程式設計

由於 .NET Core 是完整的 .NET Framework 的簡化實現,所以 .NET Framework 中所有並行程式設計方法也可以在.NET Core 中使用。唯一的例外是不變的集合,它們不是完整的 .NET Framework 的組成部分。它們作為單獨的 NuGet 軟體包(System.Collections.Immutable)分發,您需要在專案中安裝使用。

結論:

每當應用程式包含可以並行執行的 CPU 密集型程式碼時,利用併發程式設計來提高效能並提高硬體利用率是很有意義的。
.NET Core 中的 API 抽象了許多細節,使編寫併發程式碼更容易。然而需要注意某些潛在的問題, 其中大部分涉及從多個執行緒訪問共享資料。
如果可以的話,你應該完全避免這種情況。如果不行,請確保選擇最合適的同步方法或資料結構。

.NET 中的併發程式設計

相關文章