WeHelp
[.NET] Redis 明明已經回應了,為什麼還是 Timeout?一次搞懂 ThreadPool Starvation
2026-07-20 22:29:42
最近在排查一個 Redis Timeout 問題時,學到了一個以前沒有深入研究過的 .NET 機制:**ThreadPool 最小執行緒數 (`ThreadPool.SetMinThreads`)**。 一開始以為是 Redis 壞掉、連線池設定錯誤,最後才發現真正的原因可能是 **ThreadPool Starvation(執行緒飢餓)**。 這篇就記錄一下這次的排查過程,以及我對 ThreadPool 的理解。 --- # 問題背景 某段時間,Server Log 開始大量出現 Redis Timeout。 但奇怪的是: - Server CPU 正常 - Server Memory 正常 - Redis CPU 正常 - Redis Memory 也沒有異常 代表 Redis 本身看起來並沒有過載。 --- # 一開始的猜測 我的第一個想法是: > Redis Connection Pool 是不是設定太小? 後來我把當時收集到的資訊提供給 AI 工具一起分析。 AI 提出另一個方向: > 有可能不是 Redis 本身,而是 .NET ThreadPool 在瞬間高併發時發生 ThreadPool Starvation。 這讓我開始去研究 `ThreadPool.SetMinThreads()` 到底是什麼 --- # .NET ThreadPool 是什麼? ASP.NET 在處理 Request 時,背後大量依賴 .NET ThreadPool。 例如: - Controller - async / await - Task - Background Work 最終都會由 ThreadPool 的 Worker Thread 負責執行。 為了避免程式一啟動就建立大量 Thread 浪費資源,ThreadPool 預設只會保留少量 Worker Thread。 一般情況下,預設的最小 Worker Thread 數會與 CPU Logical Processor 數量有關,因此通常不會很大。 --- # 為什麼會發生 ThreadPool Starvation? 假設平常只有幾十個 Request: ```text Request │ ▼ ThreadPool │ ▼ Worker Thread ``` 一切都很正常。 但是如果突然湧入大量 Request: ```text 500 個 Request │ ▼ ThreadPool │ ▼ 只有少量 Worker Thread ``` ThreadPool 並不會立刻建立幾百條新的 Thread。 相反地,它會透過 **Hill Climbing Algorithm**,根據 CPU 使用率、工作吞吐量、等待情況等資訊,逐步增加 Worker Thread。 也就是說,ThreadPool 的設計目標是: > 避免建立過多 Thread,導致 Context Switch 和記憶體浪費。 因此在流量突然暴增時,新的 Worker Thread 成長速度可能跟不上 Request 增加速度。 --- # 那 Redis Timeout 又是怎麼發生的? 一開始我一直有個誤解: > Redis 都已經回傳資料了,為什麼還會 Timeout? 後來才理解真正等待的不是 Redis,而是 **Task Continuation**。 流程大概如下: ```text Request │ ▼ Redis GetAsync() │ ▼ Redis 已回傳資料 │ ▼ Task Completion │ ▼ Continuation 排入 ThreadPool │ ▼ 沒有 Worker Thread 可以執行 │ ▼ 等待超過 Timeout │ ▼ Redis Timeout ``` 也就是說: Redis 其實早就把資料送回來了。 但是因為 ThreadPool 沒有空閒的 Worker Thread 去執行後續的 Continuation,最後超過 Timeout,看起來就像 Redis 沒有回應。 --- # `ThreadPool.SetMinThreads()` 做了什麼? 如果知道自己的服務平常就有大量並發,例如: - API Gateway - 即時通知 - Redis 大量存取 - 高流量網站 可以適度提高 ThreadPool 最小 Worker Thread 數,例如: ```csharp ThreadPool.SetMinThreads( workerThreads: 200, completionPortThreads: 200); ``` 這代表: > ThreadPool 會將最小 Worker Thread 門檻提高到 200。在 Worker Thread 尚未達到這個數量前,Runtime 會更積極地建立新的 Worker Thread,而不會過早進入較保守的調節策略,因此能降低突發流量時因 ThreadPool 成長過慢而造成的排隊。 需要注意的是,SetMinThreads() 不是預先建立 200 條 Thread,也不是永久保留 200 條 Thread。它只是提高 ThreadPool 的最小門檻,當流量下降後,閒置的 Thread 仍會由 Runtime 逐步回收。 --- # 是不是設越大越好? 並不是。 Thread 越多: - 記憶體消耗越高 - Context Switch 增加 - CPU 排程成本增加 如果平常只有幾十個 Request,卻設定成: ```csharp ThreadPool.SetMinThreads(1000, 1000); ``` 反而可能降低整體效能。 因此這個設定應該依照: - Server CPU - 平均 QPS - 尖峰流量 - 壓力測試結果 去調整,而不是直接套用別人的數值。 --- # ThreadPool Starvation 不一定就是 Redis Timeout 值得注意的是,Redis Timeout 的原因很多。 例如: - Redis Server 過載 - 網路延遲 - GC Stop-the-World - Sync over Async - ThreadPool Starvation - Connection Multiplexer 設定不當 因此,**`ThreadPool.SetMinThreads()` 並不一定是主要原因。** 真正排查問題時,還是需要搭配 CPU、GC、Redis Slow Log、Network Latency 等資訊一起分析 --- # 心得分享 我覺得這次算是一次中獎,剛好做第一件改動,就修正了問題。 真實排查都是一項一項刪去的,一個一個調整看看哪個才是真正的原因,當然會盡可能一次就正中紅心。 現在有了 AI 協助排查,那麼能否給齊正確的情報,也很重要,可以加速找到原因。 而這算一次,用各種資訊,學習到一項一開始不在自己知識範圍內的事情,這是在先前的時代,要花更多時間才能找到的資訊,現在則是可以透過 AI ,幫助自己更快找到知識點 ( 試著想想看我拿著 Redis Time Out 的 Log 不斷去查詢...幾時能找到這件事情 XD,光想就覺得可能要花很多 Time ) 其實就算 AI 找到根因,能否用自己的言語解釋給所有 Member 聽,也是蠻重要的 按照我的經驗,請 AI 總結然後整包貼給 Member,不一定是什麼好事 XD,很有可能大家都沒看懂 但是用自己的話去解釋,那個人工懶人包有可能會比 AI 懶人包解釋得更清楚 不過語病、修飾自己打出來,念起來不順暢的語意,也是可以請 AI 幫忙 簡單來說大概會直接說: 痾 .NET 一開始預設 Thread Pool 執行緒不多,突然很多 Request 進來,新增的速度太慢,導致 Redis 等不到執行緒幫忙處理資料,調高這個執行緒下限,就可緩解 Request 突然變多,執行緒不夠用的情況 --- # 結語 `ThreadPool.SetMinThreads()` 並不是一個需要預設修改的設定,但對於高併發的 Web API、即時系統或大量使用 `async/await` 的服務而言,它可能是改善 ThreadPool Starvation 的有效手段之一。 如果你的系統曾經遇過: - CPU 很低 - Redis 很正常 - Database 很正常 - 但 Request 卻持續 Timeout 那麼除了檢查 Redis 或 Database 之外,也別忘了觀察是否發生了 **ThreadPool Starvation**。 有時候,真正需要調整的不是 Redis,而是應用程式本身的 ThreadPool。 ## 參考資料 - [Microsoft Q&A — High Queued Items and RedisTimeoutException in .NET 9](https://learn.microsoft.com/en-us/answers/questions/5596101/high-queued-items-and-redistimeoutexception-in-net) 微軟官方診斷的真實案例,情境與本文幾乎相同。 - [黑暗執行緒 — 深入 .NET ThreadPool 執行緒數量管理](https://blog.darkthread.net/blog/threadpool-thread-management/) 用實測數據拆解 ThreadPool 的 Starvation-Avoidance 與 Hill-Climbing 兩種調節機制,並實際驗證 SetMinThreads() 前後完成時間的差異(167 秒 vs 60 秒)。