[.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 秒)。
點擊複製文章連結