1. 介紹
來看一個在處理執行緒時常見的情況。假設我們在一個很簡單的應用程式中有一個共享的成功計數器。
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // 不是原子操作!
}
}
// 啟動兩個執行緒:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter: {counter}");
多跑幾次這段程式。幾乎永遠看不到 200_000!為什麼?兩個執行緒會互相干擾,有時候會同時讀取變數、各自增加然後寫回相同的結果。結果就是某些遞增「消失」了。
這就是 race condition(競爭狀態)。如果不遵守「排隊」規則,執行緒會為資料互相衝突。
什麼是臨界區?
臨界區 是一段程式碼,必須保證一次只有一個執行緒能執行。用廚房類比:就像一個水槽,如果兩個人同時在那洗臉,會弄得到處都是。約定好一次一人上廁所吧!
在我們的例子中,臨界區就是那一行 counter++。
2. 關鍵字 lock
在 C# 有個簡潔又安全的方法來建立臨界區 — 關鍵字 lock。它幫你封裝了同步原語的複雜性,確保同一時間只有一個執行緒可以進入被保護的程式區塊。
如何使用 lock
語法:
lock (lockerObject)
{
// 只有一個執行緒能同時執行的程式碼
}
lockerObject 是一個在程式生命週期內存在的物件。通常寫法:
private static object locker = new object();
注意:千萬不要用字串、數字或其他可能被外部存取到的物件來當鎖! 只用私有的、你確定不在其他地方使用的物件。
修正我們的範例
private static object locker = new object();
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
lock (locker)
{
counter++; // 現在是原子操作!
}
}
}
現在兩個或十個執行緒會依序進入這段程式。輸出結果會是理想的 200_000。大家都開心!
3. lock 在內部如何運作?類別 Monitor
在底層,關鍵字 lock 其實是操作 System.Threading.Monitor。它像個祕書,只允許拿到通行證的人進入。
等價但較為「裸露」的語法:
Monitor.Enter(locker);
try
{
// 臨界區
}
finally
{
Monitor.Exit(locker);
}
主要差別是你要自己保證呼叫 Monitor.Exit。通常用 try...finally 來確保。如果忘了呼叫 Exit(),那個執行緒會永遠「待在裡面」,其他執行緒會無限等待 — 程式就像舊版 Windows 裝更新時凍住一樣。
比較表: lock vs 手動 Monitor
| 方式 | 防錯能力 | 較易撰寫 | 彈性 |
|---|---|---|---|
|
是 | 是 | 否 |
|
只有在使用 try/finally 時 | 否 | 是 |
在 99% 的情況下使用 lock。只有需要最大彈性(例如想做帶 timeout 的鎖)時才會手動使用 Monitor。
4. lock 的參數:可以與不可以用什麼?
新手常犯的錯誤是用 字串 或其他「可見」物件當鎖。例如:
lock ("mylock") { /*...*/ } // 很糟糕!
問題是字串會被 intern(在整個應用程式中是唯一的),可能和別的函式庫衝突,結果造成「死鎖」。總是使用私有物件:
private readonly object myLock = new object();
lock (myLock)
{
// 只有你自己的程式知道 myLock
}
5. lock:帶 Console 輸出的範例
練習時間!做一個小程式,兩個執行緒印出字串,但把對 Console 的存取也同步化,避免輸出被打亂。
private static object consoleLock = new object();
void PrintMessages(string name)
{
for (int i = 0; i < 5; i++)
{
lock (consoleLock)
{
Console.WriteLine($"{name}: 訊息 {i + 1}");
Thread.Sleep(50); // 模擬處理
}
}
}
Thread t1 = new Thread(() => PrintMessages("執行緒 1"));
Thread t2 = new Thread(() => PrintMessages("執行緒 2"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
結果:每行輸出都是整塊依序列印,不會亂掉。這種方式常用在日誌記錄,避免 log 被打散讀不懂。
6. 有用的小細節
手動控制鎖:進階的 Monitor
當內建的 lock 不夠(例如你希望嘗試進入但不想無限等待),可以用 Monitor.TryEnter。
if (Monitor.TryEnter(locker, 100)) // 等待 100 毫秒
{
try
{
// 臨界區
}
finally
{
Monitor.Exit(locker);
}
}
else
{
Console.WriteLine("在 100 毫秒內無法取得鎖定");
}
這在不希望程式「凍住」時很有用 — 比如我們可以回報給使用者或在等待期間做別的事情。
視覺化:鎖如何工作(流程圖)
flowchart LR
A[執行緒 1: 想進入臨界區]
B[執行緒 2: 想進入臨界區]
C[locker 空閒]
D[執行緒 1 在 lock 內執行程式]
E[執行緒 2 等待]
F[執行緒 1 離開 lock]
G[執行緒 2 取得存取權]
A -- 檢查 locker --> C
C -- locker 空閒 --> D
B -- 檢查 locker --> D
D -- lock 被佔用 --> E
D -- 完成工作 --> F
F -- 釋放 locker --> G
E -- locker 現在空閒 --> G
鎖與效能
鎖的行為很直接:同一時間只有一個執行緒 能執行大括號內的程式。這對資料完整性很好,但如果很多執行緒在排隊,效能會下降。因此同步不是萬靈丹:盡量把臨界區做得小一些。
實用技巧:如果臨界區內只需花費小於毫秒的時間,那很理想。如果裡面有長時間計算、I/O、網路或檔案操作,應把那些慢的工作移到 lock 外面。先計算好、讀好資料,再快速地更新共享狀態。
面試與實際應用
在任何用到多執行緒的嚴肅專案,面試官很可能會問:「如果兩個執行緒同時存取同一個變數怎麼辦?」展示用鎖保護的程式碼,能讓你的簡歷不至於被 HR 的黑盒子吞掉。
在高負載系統中會用到更進階的同步機制,但對於簡單情況 lock 與 Monitor 仍然是黃金標準。
7. 使用鎖的注意事項與常見錯誤
最常見的錯誤是「忘了」對同一份資料使用相同的鎖。例如:
void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }
如果兩個方法操作相同的變數,但 a 和 b 不同,那你只是做了假保護 — 執行緒仍會同時操作那個變數!
結論:保護同一份資料時,務必使用同一個鎖物件。
另一種錯誤是使用過於「寬範圍」的鎖。例如在普通類別內使用 lock (this),如果不確定外部沒有人也會用這個物件來鎖,可能會產生死鎖或其他古怪問題。
最後一點:不要在 lock 裡面執行長時間或外部操作(檔案、網路)。你會把其他執行緒鎖住很久,影響效能。臨界區應只包含真正不能同時執行的那小部分程式碼!
GO TO FULL VERSION