CodeGym /課程 /C# SELF /鎖定: lock 與類別

鎖定: lock 與類別 Monitor

C# SELF
等級 56 , 課堂 1
開放

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

方式 防錯能力 較易撰寫 彈性
lock(obj)
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 的黑盒子吞掉。

在高負載系統中會用到更進階的同步機制,但對於簡單情況 lockMonitor 仍然是黃金標準。

7. 使用鎖的注意事項與常見錯誤

最常見的錯誤是「忘了」對同一份資料使用相同的鎖。例如:

void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }

如果兩個方法操作相同的變數,但 ab 不同,那你只是做了假保護 — 執行緒仍會同時操作那個變數!

結論:保護同一份資料時,務必使用同一個鎖物件。

另一種錯誤是使用過於「寬範圍」的鎖。例如在普通類別內使用 lock (this),如果不確定外部沒有人也會用這個物件來鎖,可能會產生死鎖或其他古怪問題。

最後一點:不要在 lock 裡面執行長時間或外部操作(檔案、網路)。你會把其他執行緒鎖住很久,影響效能。臨界區應只包含真正不能同時執行的那小部分程式碼!

2
任務
C# SELF, 等級 56, 課堂 1
上鎖
使用 `lock` 進行最簡單的同步
使用 `lock` 進行最簡單的同步
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION