1. 介紹
Mutex 和 lock 就像咖啡師一次只服務一位客人。但如果不是只有一台咖啡機,而是三台可以同時煮三杯咖啡呢?
例如,你有一家咖啡廳,三台咖啡機。客人(執行緒)來,佔用空的機器,做完咖啡就離開。如果三台都被佔滿,其他人就要等到有一台釋出。
問題: 如何確保同一時間最多只有三位客人在咖啡機前工作,其他人排隊等候?
答案: 使用 信號量!
什麼是信號量?
信號量是經典的同步工具。如果 lock/Mutex 控制「一個進去,其他等候」,那信號量會說:「我允許 N 個同時進來!」。
信號量由 Edsger Dijkstra 在 1965 年提出。名稱來自海上信號:像旗子傳達能否通行一樣,信號量告訴執行緒要進入還是等待。
應用場景
- 限制同時存取某個資源的執行緒數量。
- 限制同時到資料庫的連線數、並行請求數或耗資源的工作數量。
2. 類別概覽: Semaphore 與 SemaphoreSlim
Semaphore
- 重量級類別,使用作業系統核心物件 (kernel objects)。
- 支援跨程序(process)之間的同步。
- 可以指定名稱並在不同程序間共享。
SemaphoreSlim
- 精簡版,只在單一程序內工作。
- 更快、資源消耗更低。
- 如果不需要跨程序同步,幾乎總是優先選擇。
類比:背包(SemaphoreSlim)對比 大型行李箱(Semaphore)。輕裝旅行就帶背包。
比較表
| 類別 | 跨程序 | 效能 | 建議 |
|---|---|---|---|
|
是 | 較慢 | 需要跨程序同步時使用 |
|
否 | 較快 | 在單一程序內的 99% 情況下使用 |
信號量的主要方法與屬性
主要參數
- InitialCount — 初始的許可數量。
- MaxCount — 同時發放的最大許可數。
關鍵方法
- Wait() 或 WaitAsync() — 要求存取(佔用一個許可)。
- Release() — 釋放一個許可。
如何運作
如果呼叫 Wait() 時沒有許可,執行緒會被阻塞並等待別人呼叫 Release()。釋放後,某個等待中的執行緒會繼續執行。
3. 第一個實作範例
在主控台應用裡建立一個有 3 個位子的「停車場」,並嘗試啟動 10 個執行緒。
using System;
using System.Threading;
class Program
{
// 信號量,有 3 個許可(3 個停車位)
static SemaphoreSlim parking = new SemaphoreSlim(3);
static void Main()
{
for (int i = 1; i <= 10; i++)
{
int carNumber = i;
new Thread(() =>
{
Console.WriteLine($"汽車 #{carNumber} 嘗試停車...");
parking.Wait(); // 等待空位
Console.WriteLine($"汽車 #{carNumber} 已停入停車場!");
Thread.Sleep(2000); // 在停車場停 2 秒
Console.WriteLine($"汽車 #{carNumber} 離開停車場.");
parking.Release(); // 釋放車位
}).Start();
}
}
}
- 同一時間最多只有三台汽車停好。
- 其他的會等待有空位為止。
- 輸出會交錯 — 這對多執行緒是正常的。
4. 信號量作為負載限制器
把同時執行的耗時任務(例如下載)限制成 5 個。
static SemaphoreSlim semaphore = new SemaphoreSlim(5); // 最多 5 個同時下載
static void DownloadFile(int fileId)
{
semaphore.Wait();
try
{
Console.WriteLine($"--> 開始下載檔案 {fileId}");
Thread.Sleep(1000 + fileId * 100); // 模擬下載
Console.WriteLine($"<-- 檔案 {fileId} 下載完成");
}
finally
{
semaphore.Release();
}
}
static void Main()
{
for (int i = 1; i <= 12; i++)
{
int localId = i;
new Thread(() => DownloadFile(localId)).Start();
}
}
重點: 把 Wait() 放在 try 之前,並把 Release() 放在 finally,這樣即使發生例外,許可也一定會被釋放。
5. Wait(int millisecondsTimeout) 與非同步方法
可以只等待有限的時間:
if (semaphore.Wait(500))
{
// 成功在半秒內拿到許可!
}
else
{
// 500 ms 內沒等到 — 放棄
}
在現代應用(例如 ASP.NET)裡,請使用非同步版本:await semaphore.WaitAsync()。這樣在等待許可時不會阻塞執行緒。
注意:在非同步程式碼中要用 SemaphoreSlim 及其 WaitAsync,否則可能遇到意外的 deadlock。
6. 錯誤範例與正確做法
常見錯誤是忘記呼叫 Release():許可會「洩漏」,整個系統會停擺。
錯誤
static void SomeWork()
{
semaphore.Wait();
// ... 處理,但忘了 Release()!
}
正確
static void SomeWork()
{
semaphore.Wait();
try
{
// 處理
}
finally
{
semaphore.Release();
}
}
非同步版本
static async Task SomeAsyncWork()
{
await semaphore.WaitAsync();
try
{
// 非同步處理
}
finally
{
semaphore.Release();
}
}
7. 信號量的內部運作(用簡單比喻解釋)
信號量就是一個計數器。Wait() 會把它減少 1。如果它大於 0,執行緒可以通過;如果等於 0,執行緒就會等待。Release() 會把計數器增加並喚醒等待中的執行緒。
+-------------------------------+
| 信號量 (計數器 = 3) |
+-------------------------------+
| [ ] [ ] [ ] | <--- 許可
+----+----+----+----------------+
| | |
執行緒 執行緒 執行緒
8. 有用的細節
與其他同步原語的差異
- lock / Monitor / Mutex — 只允許一個執行緒(獨佔存取)。
- Semaphore/SemaphoreSlim — 允許限制數量的 N 個執行緒同時進入。
信號量不綁定「擁有者」:任何執行緒都可以釋放許可。這是設計使然,不是錯誤。
實際應用
- 限制到服務或資料庫的平行連線數。
- 資源池:最多有 N 個執行緒使用該資源。
- 限制同時處理的 web 請求數量。
- 限制讀寫操作以避免過載。
- 限制對外部 API 的呼叫頻率(並行數)。
錯誤範例(Release 超過 Wait)
var semaphore = new SemaphoreSlim(2);
semaphore.Release(); // 錯誤!計數器變成 3,超過 MaxCount — 會拋出 SemaphoreFullException.
這裡會發生 SemaphoreFullException:計數器超過最大值。
Semaphore 與 SemaphoreSlim 的差別
- SemaphoreSlim — 只在程序內,速度較快且簡單(幾乎總是使用)。
- Semaphore — 用於跨程序同步(罕見需求)。
為何要知道信號量?
面試常問的經典問題:「如何限制同時訪問資源的執行緒數?」——正確答案是:使用 信號量。
- lock — 1 個執行緒。
- Semaphore/SemaphoreSlim — N 個執行緒。
9. 常見錯誤與使用注意事項
錯誤 №1: 忘記呼叫 Release()。 如果執行緒佔用了許可(通過 Wait() 或 WaitAsync()),但沒有釋放,其他人會永遠等待 — 應用會「卡住」。
錯誤 №2: 呼叫 Release() 的次數多於 Wait()。 會產生多餘的許可。對 Semaphore 來說會拋出 SemaphoreFullException,並破壞存取邏輯。
錯誤 №3: 混用不同的同步機制。 在某些地方用 lock,另一些地方用信號量去保護同一資源,會提升 mutual deadlock 的風險。
錯誤 №4: 在非同步程式中使用 Semaphore。 傳統的 Semaphore 不太適合 async/await。非同步場景請使用 SemaphoreSlim 及其 WaitAsync()。
錯誤 №5: 錯誤設定 initialCount 和 maxCount。 值設定不當可能讓限制失效,導致比預期更多的執行緒同時訪問資源。
GO TO FULL VERSION