當多個背景工作共用同一個 .NET 行程時,沒有明顯的 lock,仍可能因執行名額、連線池、執行緒池與下游儲存層而彼此等待。這篇文章把問題拆成可觀察的層次,並示範如何以 SemaphoreSlim 建立有界併發、保留取消能力、確保例外後歸還名額,再用最小測試專案驗證行為。核心目標不是追求最大平行度,而是讓等待的位置、容量與失敗條件都能被解釋。

為什麼沒有 lock,工作仍會互相拖慢

沒有應用程式鎖只代表程式未明確序列化入口,不代表工作擁有獨立資源;只要共用執行緒池、連線池、磁碟或資料表,等待仍會在較下層發生。

判讀排程延遲時,第一個問題不該是「程式有沒有鎖」,而是「從認領到完成之間,哪些容量是共用的」。一個工作可能很快取得排程器的執行資格,卻在開啟連線、等待遠端回應、取得資料列鎖或排入執行緒池後停住。若只看工作總耗時,這些等待會被混成同一個模糊的「很慢」,最後往往調高全域併發,反而讓下游競爭更嚴重。

建議把時間線至少切成四段:排隊等待、取得應用程式名額、執行核心工作、提交結果。每段都使用單調遞增計時器記錄持續時間,並把工作類型與結果分類寫入可查詢的結構化事件。這樣才能辨認「還沒開始」與「已開始但卡在共享資源」的差異。不要把某個方法回傳所需時間直接當成處理器使用 CPU 的時間;非同步等待期間未必占用執行緒,但仍占用工作生命週期與其他有限資源。

共享資源也有不同性質。執行緒池是行程級資源,同步阻塞會讓 continuation 難以及時執行;資料庫連線池通常依連線設定分組,過多同時請求會在開啟連線時排隊;儲存層則可能因鎖、輸入輸出或快取壓力延遲。先標出資源邊界,再選擇限制位置,才不會用一個全域閘門掩蓋所有問題。

有界併發應放在哪一層

有界併發應貼近真正稀缺的共享資源,而不是任意包住整個背景工作;按工作類型或資源分池,能防止一種大量工作吃完所有名額。

若所有任務共用單一閘門,設定雖然簡單,但批次量大的類型可能長時間占據名額,使短工作無法開始。更穩健的模型是先設全域安全上限,再針對昂貴資源設專用上限。例如資料庫密集工作使用一個閘門,純計算工作使用另一個閘門;兩者仍受全域容量保護,但不會因同一批次互相吞掉全部配額。這種分池是隔離,不是保證公平,仍要觀察各類型的排隊時間分布。

限制範圍同樣重要。若工作先取得閘門,接著做大量不需要稀缺資源的解析,名額會被白白占住。較好的做法是完成輸入驗證與可在本機進行的準備後,僅在真正存取共享資源前呼叫 WaitAsync,離開資源區段立即 Release。相反地,也不能把範圍縮到失去原子性;若兩個寫入必須維持一致,應由儲存層交易或明確的一致性設計處理,而不是假設行程內閘門能保護所有呼叫者。

容量數字必須來自量測。先以保守上限開始,在測試資料與 sandbox 中觀察排隊時間、執行時間、錯誤分類、連線取得時間與執行緒池計數器,再逐步調整。不要從處理器核心數直接推導輸入輸出工作上限,也不要引用沒有相同工作負載與相依條件的外部效能數字。本文不宣稱特定吞吐量;可驗證的結論只有閘門會把同時進入臨界區的工作限制在指定數量。

SemaphoreSlim 的正確非同步模式

正確模式是以 WaitAsync 非同步取得名額,成功後才進入 try,並在 finally 精確歸還一次;取消發生在取得前時,不可呼叫 Release。

SemaphoreSlim 適合行程內的輕量容量控制。它不提供跨行程協調,也不應被當成持久工作佇列。下面的範例鎖定 .NET 10 測試專案,將十二個安全的延遲工作限制為最多三個同時進入。Interlocked 只用來維護測試計數,不代表實務工作一定需要共享計數器。

var gate = new SemaphoreSlim(3, 3);
var active = 0;

async Task RunAsync(int id, CancellationToken cancellationToken)
{
    await gate.WaitAsync(cancellationToken);
    try
    {
        Interlocked.Increment(ref active);
        await Task.Delay(30, cancellationToken);
        Console.WriteLine($"completed:{id}");
    }
    finally
    {
        Interlocked.Decrement(ref active);
        gate.Release();
    }
}

WaitAsync 拋出取消例外時,呼叫者尚未取得名額,因此 try/finally 必須放在等待成功之後。若把等待也包進同一個 try,再於 finally 無條件釋放,取消路徑就可能憑空增加計數。另一個常見錯誤是工作內發生例外後略過釋放,造成名額永久流失;所有後續工作會逐步變成等待,表面看起來像隨機效能問題。

Task.WhenAll 用來等待整組工作完成,不會自行限制併發。先建立數千個立即啟動的工作,再讓它們全部等待同一個閘門,仍會產生排隊物件與生命週期成本。大量或持續輸入宜使用有容量的通道或固定數量的消費者,讓尚未輪到的項目留在佇列中。選型的判準是輸入是否有限、是否需要背壓、是否需要持久化,以及停止時如何處理尚未開始的項目。

取消、逾時與例外不能混為一談

取消表示呼叫端不再要求工作繼續,逾時是由政策決定的時間界線,例外則表示執行失敗;三者要分開記錄、處理與決定是否重試。

把所有非成功結果都轉成同一個布林值,會讓操作人員看不出工作是在等名額時被取消、進入後超過期限,還是核心邏輯真的失敗。等待閘門與執行工作可以使用同一個停止訊號,也可以依需求使用不同期限,但事件中必須標明階段。排隊逾時通常反映容量或流量問題;執行逾時則較可能反映下游延遲或工作量異常,兩者需要不同處置。

重試也不能由「逾時」自動推出。呼叫端停止等待,不等於下游操作一定已停止;若工作可能已產生部分效果,直接重跑可能形成重工或順序錯亂。安全設計應先定義冪等鍵、狀態轉換與可重入邊界,並在 sandbox 驗證重複輸入的結果。對本文的延遲測試而言,取消只終止本機 Task.Delay,不涉及外部狀態,因此可安全重現,但這個條件不能外推到真實資料寫入。

例外路徑的最低要求是釋放名額、保留原始堆疊、記錄失敗階段,並讓上層決定結果。不要在底層捕捉後回傳假成功,也不要為了維持批次完成而吞掉錯誤。若需要收集多工作結果,為每一項建立明確狀態,最後再彙整成功、取消與失敗數量,避免只看到 WhenAll 的整體例外而失去項目脈絡。

驗證與重現:固定 .NET 10 的三項安全測試

驗證應同時覆蓋容量上限、等待取消與例外後歸還名額;本文已在隔離的 .NET 10.0.400 主控台專案執行,三項觀察條件均通過。

  1. 建立 .NET 10 主控台測試專案,加入十二個延遲工作、上限為三的 SemaphoreSlim,並以原子計數記錄尖峰同時工作數。
  2. 先占滿三個名額,再以五十毫秒的 CancellationTokenSource 等待第四個名額;觀察到 OperationCanceledException 才算通過。
  3. 取得一個名額後刻意拋出本機測試例外,透過 finally 釋放;最後確認 CurrentCount 回到三,且程序結束碼為零。
dotnet --version
dotnet new console --framework net10.0 -n SchedulerVerification
dotnet run --project SchedulerVerification --configuration Release

排程環境的真實結果為 SDK 10.0.400;十二項工作全部完成,觀察到的尖峰為三,結束時可用名額為三;取消測試進入預期例外分支;刻意失敗後名額仍回到三。這些結果只證明範例的容量與清理語意,不是效能基準,也不代表特定資料庫或服務的建議容量。讀者重現時若尖峰大於三、取消分支未發生、名額不是三或程序非零結束,任一項都應判定失敗。

為避免計時偶發差異影響結論,測試不要求工作完成順序,也不對總毫秒數設門檻。併發程式的排程順序本來就不應成為未明示的契約。真正要固定的是安全不變量:同時進入數不超過容量、未取得者可取消、每個已取得名額恰好釋放一次。

如何診斷執行緒池飢餓與下游等待

診斷要先用計時與執行階段定位等待,再使用官方診斷工具觀察執行緒池;只有證據指向同步阻塞時,才調整程式而非盲目放大容量。

Microsoft 的官方診斷文件說明,執行緒池飢餓常見表徵是執行緒數持續增加,而 CPU 使用率明顯低於可用容量,請求或工作延遲卻上升。這是診斷線索,不是單一判決。使用 dotnet-counters 觀察執行中的測試服務時,還要同步查看工作輸入速率與下游等待,否則連線池耗盡也可能呈現相似的上層延遲。

實務上的排查順序應固定:先比較排隊時間與執行時間;再看取得連線或其他資源的階段計時;接著觀察執行緒池計數與 CPU;最後才取得 trace 分析阻塞堆疊。若問題只在單一工作類型,先檢查該類型是否執行同步輸入輸出或持有名額過久。若所有類型一起變慢,則檢查全域共用池與行程層資源。這套順序能降低「看到慢就加執行緒」造成的次生問題。

資料庫等待必須到資料庫端查證,不能由應用程式 log 猜測。應用程式能提供的是連線取得、命令開始、命令結束與結果分類;鎖等待、執行計畫及輸入輸出壓力要由相應的 sandbox 資料庫工具觀察。公開範例應使用測試資料與可丟棄環境,不帶入任何環境專屬值。這也是本文刻意只驗證行程內閘門,而不虛構外部系統結果的原因。

設計檢查清單與結論

可靠的有界併發設計必須回答容量保護誰、等待在哪裡、取消何時生效、名額如何歸還、錯誤如何觀察,以及各工作類型能否彼此隔離。

  • 先畫出排程器、執行緒池、連線池與儲存層的資源邊界。
  • 分別量測排隊、取得名額、核心執行與提交結果的時間。
  • 將閘門放在稀缺資源周圍,不要過早取得或過晚釋放。
  • WaitAsync 成功後才進入 try/finally,每次取得只釋放一次。
  • 讓等待與工作本身都能接收停止訊號,並區分取消、逾時與失敗。
  • 對大量持續輸入採用有容量佇列或固定消費者,建立背壓。
  • 以每類型排隊時間檢查飢餓;有證據後再分池或調整容量。
  • 測試例外與取消路徑,不只測試全部成功的理想情境。

最重要的技術結論是:沒有 lock 並不等於沒有等待,而提高平行度也不等於提高有效吞吐。先把共享資源與等待階段變得可見,再以有界併發保護真正的瓶頸;最後用安全不變量驗證取消與清理路徑。這樣得到的是能被操作、能被回歸測試的排程契約,而不是一個靠猜測維持的併發數字。