1. 穩健多執行緒程式碼的祕訣
如果把多執行緒比作一隊同時修車的工人,很容易理解:只要其中一個人去拿別人的工具,維修就卡住了。程式碼也是一樣:對共享資料處理不慎會導致隱藏錯誤,只有在「實戰」時才會出現。
在本講中會說明如何寫出不會像紙牌屋一樣倒的多執行緒程式碼,以及當事情出錯時有哪些工具可以幫忙。
1. 將臨界區(lock)最小化
臨界區內的程式碼越少越好。當一個執行緒持有鎖時,其他執行緒都要等。
範例:
// 不好:整個業務邏輯都在 lock 裡面 — 所有執行緒都在等
lock(_locker)
{
// 長時間操作(與共享資源無關)
Thread.Sleep(500);
counter++;
}
// 好:只在 lock 裡做必要的操作
// 重的工作放在 lock 外面
Thread.Sleep(500);
lock(_locker)
{
counter++;
}
實務:如果在鎖內呼叫網路或做長時間計算,效能會大幅下降。
2. 不要用通用物件當作鎖的 key
寫 lock(this) 或 lock(typeof(MyClass)) 並不是好主意。
為什麼? 如果別的程式也用同一個物件來鎖,會導致 deadlock 或難以發現的 Bug。應該永遠使用獨立的 private 物件:
private readonly object _locker = new object();
lock(_locker)
{
// 你的操作
}
禁止:string(string)、public 欄位、值類型物件。
3. 始終用 try...finally 釋放已抓到的資源
任何抓取 Mutex、Semaphore、ReaderWriterLockSlim 的操作,都應該在 finally 中釋放。
_mutex.WaitOne();
try
{
// 臨界區
}
finally
{
_mutex.ReleaseMutex();
}
4. 不要過度同步
只對真正的共享資源(例如集合)進行同步,而不是每個「小動作」都上鎖。過多的鎖會讓程式變成等待隊列。
5. 使用 thread-safe 的集合與類型
.NET 提供了適用於多執行緒場景的專用集合:ConcurrentDictionary、ConcurrentQueue、ConcurrentBag、BlockingCollection 等。它們內部已經做好保護。
using System.Collections.Concurrent;
ConcurrentDictionary<int, string> users = new ConcurrentDictionary<int, string>();
users.TryAdd(1, "Vasya");
users[2] = "Petya";
6. 當心 deadlock(互相鎖住)
典型陷阱是以不同順序取得多個鎖。
// 執行緒 1
lock(obj1)
{
lock(obj2)
{
// 做點什麼
}
}
// 執行緒 2
lock(obj2)
{
lock(obj1)
{
// 做點什麼
}
}
建議:在所有執行緒中都按相同順序取得鎖。
7. 盡可能使用不可變狀態(immutable state)
如果物件在建立後不會改變狀態,就可以安全地從任何執行緒讀取。例子:string、Tuple、DateTime,以及只讀的自訂 DTO。
2. 多執行緒問題的診斷工具
同步錯誤很狡猾:發生頻率低而且不可預測。使用能幫你發現與分析它們的工具與方法。
1. 記錄事件與執行緒
記錄當前 Thread.ManagedThreadId 與關鍵操作,是理解「誰在什麼時候」進入/離開臨界區的簡單方式。
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 進入臨界區");
// ...
Console.WriteLine($"[{Thread.CurrentThread.ManagedThreadId}] 離開臨界區");
對於真實應用,使用 Microsoft.Extensions.Logging、NLog、Serilog。
2. Thread Sanitizer & Race Detector
在 .NET 裡沒有一個完美內建的 ThreadSanitizer,但有些有用的工具:
- JetBrains ReSharper — 檢查能抓到部分危險模式。
- Roslyn Analyzers — 靜態程式碼分析。
- Concurrency Visualizer — 分析等待/鎖與執行緒負載。
3. Visual Studio Diagnostics Tools
Visual Studio 的 profiler 幫你看見:
- 應用中有哪些執行緒;
- 哪些執行緒處於等待(waiting);
- 哪裡有鎖定與競爭;
- 何時發生 deadlock 與 contention。
收集 trace 以獲得鎖使用的詳細圖譜。
4. Dump 分析與 WinDbg
如果伺服器「掛掉」了,抓取進程 dump 用 WinDbg 或 dotnet-dump 打開。從 stack trace 可以看出執行緒卡在哪、誰持有哪些鎖。
堆疊分析範例:
0:000> !syncblk
Index SyncBlock MonitorHeld Recursion Owning Thread Info SyncBlock Owner
1 000001d4b6f90e08 1 1 000001d4b5c941c0 000001d4b6f03458
(通常只有部署經驗豐富的「絕地武士」會去看 dump — 別害怕,這是個強大的工具。)
5. 壓力測試的單元測試(stress testing)
在並行環境下把你的多執行緒程式跑幾百到幾千次迭代——這樣比較容易發現稀有的 race。
[Test]
public void Counter_IsThreadSafe()
{
var counter = 0;
var locker = new object();
var tasks = new List<Task>();
for (int i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (int j = 0; j < 10000; j++)
{
lock (locker)
{
counter++;
}
}
}));
}
Task.WaitAll(tasks.ToArray());
Assert.AreEqual(100 * 10000, counter);
}
6. 使用 assert 與專門檢查
在偵錯時加入保證狀態正確的檢查。例如在同一執行緒重複取得資源時用 Debug.Assert。
3. 結論與建議
視覺化示意:危險區域與安全
graph TD
A[共用資源] -- 無同步 --> B(競爭狀態)
A -- 鎖定 (lock/Mutex) --> C[安全存取:臨界區]
C -- "過多的鎖" --> D(效能下降)
A -- ReaderWriterLockSlim --> E{多讀者 / 單寫者}
E -- "讀取" --> F[多個執行緒可同時讀取]
E -- "寫入" --> G[只有一個寫,其他等待]
同步原語與用途
| 原語 | 用途 | 允許多少執行緒 | 跨程序 | 效能 | 使用場景 |
|---|---|---|---|---|---|
|
簡單的臨界區 | 1 | 否 | 非常高 | 99% 的情況 |
|
類似的臨界區,但跨進程 | 1 | 是 | 中等 | 檔案、IPC |
|
不超過 N 個執行緒 | N | 是 | 中等 | 資源池 |
|
相同用途,但在進程內更快 | N | 否 | 高 | 程式內的池 |
|
多讀者、單寫者 | 多/1 | 否 | 高 | 快取、設定 |
GO TO FULL VERSION