Async

在雲端深處,棲息著一個無形的池子,我們稱它為 ThreadPool——它不眠不休,日夜吞吐無數任務,是城市裡最沉默卻最可靠的工廠。每一個呼叫 Task.Run 的瞬間,就像把工作單投入池子,任它激起層層漣漪,有人接手、有人閒置、有人待命。

我們在這裡低聲詠唱,希望當高峰來臨,池子能無限擴張,當潮水退去,池子也能自我收束,忠實而溫順,既不餓死任務,也不浪費資源。這是我們寫給池子的詠歌,也是給未來的自己一段溫柔的提醒 —— 當你想起這座池子,請記得,它從未離開,只是默默在背後,調度著一切的並行與秩序。


🎵 Task.Run 觀察 ThreadPool 的重複使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void Main()
{
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());

Thread.Sleep(1000);
Console.WriteLine("稍微等等後繼續.......");
Thread.Sleep(1000);

Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
Task.Run(() => DoSomething());
}


public static void DoSomething()
{
Console.WriteLine($"dooooooooo.........ThreadId: {Thread.CurrentThread.ManagedThreadId}");
}


// dooooooooo.........ThreadId: 16
// dooooooooo.........ThreadId: 18
// dooooooooo.........ThreadId: 19
// dooooooooo.........ThreadId: 17
// dooooooooo.........ThreadId: 21
// 稍微等等後繼續.......
// dooooooooo.........ThreadId: 21
// dooooooooo.........ThreadId: 21
// dooooooooo.........ThreadId: 19
// dooooooooo.........ThreadId: 18
// dooooooooo.........ThreadId: 15

結果告訴了我們

1️⃣ Task.Run 在背後其實也是呼叫 ThreadPool 取得執行緒,因此一樣會看到重複使用相同的 Thread ID(例如 10, 9, 11)。

2️⃣ 執行多個 Task 時,ThreadPool 會並行排程任務,將可用的執行緒分配給尚未完成的工作。

3️⃣ 稍微 Sleep 一段時間後再丟新的 Task,多數情況下會看到相同的 Thread ID 被再次使用,表示執行緒沒有立刻銷毀,而是維持一段時間等待可重用。

4️⃣ 實際執行下,Thread ID 分配不會每次都相同,因為 ThreadPool 會根據當下系統負載、佇列狀況動態調整。



🎵 ThreadPool 如何自己調節 Thread 數量


Starvation-Avoidance Mechanism (怕你餓機制)

從前面的例子我們知道,ThreadPool 的核心價值在於「有限資源的重複利用」。然而,如果所有可用的執行緒(Threads)都被某些長時間執行或阻塞的任務佔用,就會導致後續新任務無法被及時處理,甚至可能引發 資源競爭死鎖(Deadlock) 或 任務飢餓(Starvation)。

為了解決這種情況,ThreadPool 內建了 Starvation-Avoidance Mechanism(飢餓避免機制),你可以把它想成是一種「怕你餓機制」,只要它發現工作佇列(Queue)裡的等待項目持續無法減少,就會考慮動態 擴充額外的 Worker Threads,嘗試打破當前的阻塞局面。



生活情境比喻:智慧餐廳的臨時增援

想像我們的智慧餐廳,平時有 5 位大廚輪流接單、備餐、送餐、結帳,每位大廚就是 ThreadPool 的一個 Thread。
突然有一批大型外送團單湧入,所有大廚都在廚房裡等設備、忙著備料,卻沒有人能接待新客人或出門送外賣,結果:

  • 廚房裡有人卡著設備動不了 → 共享資源阻塞
  • 新來的客人排隊排到馬路上 → 隊列累積未消化

如果此時沒有任何機制,整間餐廳就會陷入死結。但因為有「怕你餓機制」,餐廳會臨時叫來外包大廚或臨時工幫忙處理外送、結帳或排隊接單的流程。這些額外的大廚(新增的 Threads)就可能:

  • ✅ 處理非廚房工作,繞過當下阻塞的瓶頸
  • ✅ 執行一些輔助任務(例如清理空間、補充食材),間接釋放被卡住的資源
  • ✅ 處理超時訂單,強制中斷某些過度佔用設備的流程


🎵 Climbing Heuristic:ThreadPool 的爬山捷思法

在上一段提到 ThreadPool 會透過 Starvation-Avoidance Mechanism 避免工作餓死,但光是臨時增加 Threads 並不代表每次都能達到最佳效能。為了進一步找到「恰到好處」的執行緒數量,ThreadPool 採用了來自最佳化領域的概念——Hill-Climbing Heuristic(爬山捷思法)。所謂 Hill-Climbing,就像在一座未知地形的山上尋找最高點:

  • 山的高度代表系統的 Throughput(吞吐量)。
  • 你不知道最高點在哪裡,只能根據當下位置和變化趨勢,一步步嘗試往更高處移動。

Hill-Climbing 的核心

在 ThreadPool 中,這個算法會持續觀察:

  • 目前有多少任務在排隊?
  • Worker Threads 的數量是多少?
  • 調整 Threads 後,整體 Throughput 有沒有變化?

具體來說:

當佇列中有等待工作,且執行已持續一段時間(例如超過 0.5 秒),ThreadPool 可能會 增加一個或多個 Worker Threads 來嘗試提高吞吐量。如果觀察到 Throughput 確實上升,則代表「往山頂爬對了方向」,系統可能會繼續增加 Threads。如果 Throughput 開始下降或維持不變,代表「已超過最佳點或卡在局部高峰」,此時就會嘗試減少 Threads,避免額外的資源開銷。

例如餐廳的最佳人力配置,延續前面的餐廳比喻,你想要找到 最適合的人手數量,讓出餐速度最快,又不會因為人太多而相互干擾、搶設備。當你多請了幾位臨時工,如果發現平均出餐速度提高了,就代表這筆人事開銷是值得的,你會保留這些人手。
但如果請的人太多,廚房反而擠得水泄不通,大家互相卡位,結果平均出餐速度不升反降,這時就該減少多餘的人力,避免浪費。



為什麼增加 Threads 並不一定帶來更高 Throughput?

1️⃣ Context Switching(上下文切換)成本
當 Threads 數量增加超過 CPU 核心數時,CPU 必須不停在多個執行緒之間切換,每次切換都要保存和恢復執行緒的狀態(如暫存器、堆疊),這些額外操作會吃掉寶貴的 CPU 週期。

想像一位廚師同時接了 5 個訂單,每隔幾分鐘就要放下手上的刀具,去處理下一個客人,來回切換太頻繁,反而沒辦法專注把一份菜切完。這就是多執行緒切換的開銷。

2️⃣ 有限資源的競爭與記憶體壓力
每個 Thread 都需要佔用 CPU、記憶體(例如堆疊空間)及快取,當 Threads 太多時,這些資源的競爭會導致快取失效率增加、記憶體管理成本上升,甚至可能觸發額外的 Garbage Collection 或分頁換出(paging),讓效能反而變差。



🎵 .NET ThreadPool 執行緒數量管理實驗


實驗設計者


🧪 實驗一. 塞入 200 個工作,每個工作要設定 1 min 完成,觀察 Threads 數量增長情形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

void Main()
{
bool stop = false;
ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads);
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletePortThreads);
Console.WriteLine($"ThreadPool Min : minWorkerThreads {minWorkerThreads}, minCompletionPortThreads {minCompletionPortThreads}");
Console.WriteLine($"ThreadPool Max : MaxWorkerThreads {maxWorkerThreads}, MaxCompletionPortThreads {maxCompletePortThreads}");

const int totalTasksCount = 200;
var remainingCount = totalTasksCount;
var running = 0;
var startDatetime = DateTime.Now;

// 固定的監控排程
Task.Factory.StartNew(() => {
Console.WriteLine("Time | Threads | Running | Pending ");
Console.WriteLine("-----+---------+---------+---------");
while(stop == false)
{
Console.WriteLine($"{(DateTime.Now - startDatetime).TotalSeconds,3:n0}s | {ThreadPool.ThreadCount,7} | {running,7} | {ThreadPool.PendingWorkItemCount,7}");
Thread.Sleep(1000);
}
});


// 丟任務給 ThreadPool
Enumerable.Range(0, totalTasksCount).ToList().ForEach(i =>
{
Task.Run(() => {
Interlocked.Increment(ref running);
Thread.Sleep(60000);
Interlocked.Decrement(ref remainingCount);
Interlocked.Decrement(ref running);
});
});


while(remainingCount > 0)
{
Thread.Sleep(100);
}

stop = true;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
ThreadPool Min : minWorkerThreads 20, minCompletionPortThreads 1
ThreadPool Max : MaxWorkerThreads 32767, MaxCompletionPortThreads 1000
Time | Threads | Running | Pending
-----+---------+---------+---------
0s | 11 | 8 | 191
1s | 21 | 20 | 181
2s | 23 | 22 | 180
3s | 24 | 23 | 180
4s | 25 | 24 | 179
5s | 27 | 26 | 177
6s | 28 | 27 | 176
7s | 30 | 29 | 174
8s | 31 | 30 | 173
9s | 32 | 31 | 172
10s | 34 | 33 | 170
11s | 34 | 33 | 170
12s | 36 | 35 | 168
13s | 37 | 36 | 167
14s | 38 | 37 | 166
15s | 39 | 38 | 165
16s | 40 | 39 | 164
17s | 41 | 40 | 163
18s | 43 | 42 | 161
19s | 44 | 43 | 160
20s | 46 | 45 | 158
21s | 47 | 46 | 157
22s | 48 | 47 | 156
23s | 49 | 48 | 155
24s | 51 | 50 | 153
25s | 52 | 51 | 152
26s | 53 | 52 | 151
27s | 54 | 53 | 150
28s | 56 | 55 | 148
29s | 57 | 56 | 147
30s | 58 | 57 | 146
31s | 60 | 59 | 144
32s | 61 | 60 | 143
33s | 62 | 61 | 142
34s | 63 | 62 | 141
35s | 65 | 64 | 139
36s | 66 | 65 | 138
37s | 68 | 67 | 136
38s | 69 | 68 | 135
39s | 71 | 70 | 133
40s | 73 | 72 | 131
41s | 74 | 73 | 130
42s | 75 | 74 | 129
43s | 76 | 75 | 128
44s | 77 | 76 | 127
45s | 78 | 77 | 126
46s | 80 | 79 | 124
48s | 81 | 80 | 123
49s | 82 | 81 | 122
50s | 83 | 82 | 121
51s | 84 | 83 | 120
52s | 85 | 84 | 119
53s | 87 | 86 | 117
54s | 88 | 87 | 116
55s | 90 | 89 | 114
56s | 91 | 90 | 113
57s | 92 | 91 | 112
58s | 93 | 92 | 111
59s | 95 | 94 | 109
60s | 96 | 95 | 108
61s | 97 | 96 | 88
62s | 97 | 96 | 86
63s | 98 | 97 | 84
64s | 99 | 98 | 82
65s | 99 | 98 | 80
66s | 99 | 98 | 78
67s | 100 | 99 | 76
68s | 100 | 99 | 74
69s | 101 | 100 | 72
70s | 102 | 101 | 70
71s | 103 | 102 | 68
72s | 103 | 102 | 66
73s | 104 | 103 | 64
74s | 105 | 104 | 62
75s | 106 | 105 | 60
76s | 107 | 106 | 58
77s | 108 | 107 | 56
78s | 108 | 107 | 54
79s | 109 | 108 | 52
80s | 109 | 108 | 50
81s | 110 | 109 | 48
82s | 111 | 110 | 46
83s | 111 | 110 | 45
84s | 112 | 111 | 42
85s | 113 | 112 | 41
86s | 114 | 113 | 39
87s | 115 | 114 | 37
88s | 115 | 114 | 35
89s | 116 | 115 | 33
90s | 116 | 115 | 31
91s | 117 | 116 | 28
92s | 118 | 117 | 26
93s | 118 | 117 | 25
94s | 119 | 118 | 23
95s | 120 | 119 | 20
96s | 120 | 119 | 19
97s | 121 | 120 | 17
98s | 121 | 120 | 15
99s | 122 | 121 | 13
100s | 122 | 121 | 11
101s | 123 | 122 | 9
102s | 124 | 123 | 7
103s | 125 | 124 | 5
104s | 126 | 125 | 3
105s | 126 | 123 | 0
106s | 126 | 122 | 0
107s | 126 | 121 | 0
108s | 126 | 120 | 0
109s | 126 | 119 | 0
110s | 126 | 118 | 0
111s | 126 | 117 | 0
112s | 126 | 115 | 0
113s | 126 | 113 | 0
114s | 126 | 112 | 0
115s | 126 | 111 | 0
116s | 126 | 110 | 0
117s | 126 | 109 | 0
118s | 126 | 107 | 0
119s | 126 | 106 | 0
120s | 126 | 85 | 0
121s | 126 | 84 | 0
122s | 126 | 82 | 0
123s | 126 | 80 | 0
124s | 126 | 78 | 0
125s | 126 | 76 | 0
126s | 126 | 74 | 0
127s | 126 | 72 | 0
128s | 126 | 70 | 0
129s | 122 | 68 | 0
130s | 121 | 66 | 0
131s | 120 | 64 | 0
132s | 119 | 62 | 0
133s | 118 | 60 | 0
134s | 115 | 58 | 0
135s | 115 | 56 | 0
136s | 113 | 54 | 0
137s | 111 | 52 | 0
138s | 111 | 50 | 0
139s | 109 | 48 | 0
140s | 92 | 46 | 0
141s | 89 | 44 | 0
142s | 88 | 42 | 0
143s | 86 | 40 | 0
144s | 84 | 38 | 0
145s | 82 | 37 | 0
146s | 80 | 34 | 0
147s | 79 | 32 | 0
149s | 76 | 31 | 0
150s | 75 | 28 | 0
151s | 73 | 26 | 0
152s | 69 | 25 | 0
153s | 67 | 22 | 0
154s | 65 | 20 | 0
155s | 63 | 18 | 0
156s | 60 | 16 | 0
157s | 58 | 14 | 0
158s | 56 | 13 | 0
159s | 53 | 11 | 0
160s | 51 | 9 | 0
161s | 50 | 7 | 0
162s | 48 | 5 | 0
163s | 46 | 3 | 0
164s | 45 | 1 | 0


🔍 觀察結果

1️⃣ 初始設置

  • ThreadPool 最小工作 Thread 數(MinWorkerThreads)是 20
  • 最大工作 Thread 數(MaxWorkerThreads)是 32767
  • 最小 I/O 完成埠 Thread 數(MinCompletionPortThreads)是 1
  • 最大 I/O 完成埠 Thread 數(MaxCompletionPortThreads)是 1000

ThreadPool 就像開了一家工廠,規定至少要有 20 個工人 stand by,最多可以請到 32767 個人來做事。I/O Thread 則是處理檔案或網路之類的工作,跟 CPU 工作 Thread 分開算。

2️⃣ 開始執行時,ThreadPool Thread 從多少到多少?

一開始從大概 11 條 Thread(主執行緒也算在內),很快增加到 21、23、24 條 Thread ,接著持續增加,最後大概在 105 秒達到最高峰 126 條 Thread 。ThreadPool 有一個保護機制:當有太多任務排隊時,會慢慢增加 Thread ,每秒大概只能多生 1~2 條 Thread ,避免突然開太多,電腦被榨乾。

3️⃣ 任務 Queue 狀況

一開始 Pending(排隊任務)有 191 件。前 60 秒,Pending 慢慢減少,但因為每個任務都要做 60 秒,所以前期沒有任務完成,新的 Thread 只能慢慢補充。60 秒後開始有任務做完,Pending 開始明顯下降。前 60 秒很像一堆客人進餐廳,工人數量不夠,要慢慢叫外場工讀生加班,但廚房還沒煮完第一批,沒人下班,排隊還是很多。

4️⃣ 為什麼增長速度會在 60 秒後開始放慢?

前 60 秒,任務執行時間固定是 60 秒(Sleep)。所以一開始 ThreadPool 只能靠 Starvation 機制偵測「工作都卡在隊列沒人做」→ 觸發每秒生 1~2 條 Thread 。到第 60 秒,第一批任務做完,有 Thread 釋放回池子,新的任務就用這些人手, Thread 數成長速度自然變慢。

5️⃣ Thread 峰值觀察

大概 97~105 秒時,Pending 已經到 0, Thread 數還在最高點 126 條。Queue 沒有新任務進來,ThreadPool 就不再增加 Thread 。這時 Running 也逐漸下降,代表任務執行中也慢慢收尾。這就像工廠訂單做到一個段落,沒新訂單,就不會再找更多工人,但老闆也不會馬上解雇人,會慢慢等人閒到沒事做再請他們回家。

6️⃣ 任務做完後的回收行為

從 105 秒以後,Pending 一直是 0。 Thread 數從 126 條開始慢慢減少,經過 20~60 秒逐步降到 100 以下。最後 Thread 數會慢慢收斂到一個比較合理的水位(可能接近最小工作 Thread 或系統內部的 Idle Thread 策略)。

我們發現了,ThreadPool 真的是慢慢擴充,慢慢釋放,不是瞬間滿載瞬間消失。如果瞬間爆量(200 個任務同時來),預設的 MinThreads(20 條)明顯不夠,會導致很多任務必須排隊等待。每秒只能多生 1~2 條 Thread ,對短任務影響更大,因為排隊時間比執行時間還長,效能就會差。



🧪 實驗二.開頭即設定 200 條最低執行緒數量

1
2
3

ThreadPool.SetMinThreads(200, 1)

=> 結果時間縮短為 60 秒左右,也就是 200 個任務等於一個任務所需要完成的時間



🧪 實驗三.每個任務時間縮短到 2 秒,每隔 30 秒塞 200 筆工作

200 件工作做完後約 20 秒,Thread 數由 202 降到 4,第 30 秒又來 200 件工作時,Thread 數瞬問回到 200 條,與預期相符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
void Main()
{
bool stop = false;
ThreadPool.SetMinThreads(200, 1);
ThreadPool.GetMinThreads(out int minWorkerThreads, out int minCompletionPortThreads);
ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxCompletePortThreads);
Console.WriteLine($"ThreadPool Min : minWorkerThreads {minWorkerThreads}, minCompletionPortThreads {minCompletionPortThreads}");
Console.WriteLine($"ThreadPool Max : MaxWorkerThreads {maxWorkerThreads}, MaxCompletionPortThreads {maxCompletePortThreads}");

const int totalTasksCount = 200;
var remainingCount = totalTasksCount;
var running = 0;
var startDatetime = DateTime.Now;

// 固定的監控排程
Task.Factory.StartNew(() => {
Console.WriteLine("Time | Threads | Running | Pending ");
Console.WriteLine("-----+---------+---------+---------");
while(stop == false)
{
Console.WriteLine($"{(DateTime.Now - startDatetime).TotalSeconds,3:n0}s | {ThreadPool.ThreadCount,7} | {running,7} | {ThreadPool.PendingWorkItemCount,7}");
Thread.Sleep(1000);
}
});


//// 丟任務給 ThreadPool
//Enumerable.Range(0, totalTasksCount).ToList().ForEach(i =>
//{
// Task.Run(() => {
// Interlocked.Increment(ref running);
// Thread.Sleep(60000);
// Interlocked.Decrement(ref remainingCount);
// Interlocked.Decrement(ref running);
// });
//});


var insertTask = Task.Factory.StartNew(() => {

for(int i = 0; i < 3;i++)
{
Console.WriteLine($"Insert batch : {i} at {(DateTime.Now - startDatetime).TotalSeconds,3:n0}");

Enumerable.Range(1, 200).ToList().ForEach(i => {
Interlocked.Increment(ref remainingCount);
Task.Run(() =>
{
Interlocked.Increment(ref running);
Thread.Sleep(2000); // 單筆執行 2 秒
Interlocked.Decrement(ref running);
Interlocked.Decrement(ref remainingCount);
});
});


Thread.Sleep(30000); // 間隔 30 秒
}
});


while(insertTask.Status != TaskStatus.RanToCompletion || remainingCount > 0)
{
Thread.Sleep(100);
}

stop = true;

Console.WriteLine("All done.");
}



🔍 觀察實驗結果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
ThreadPool Min : minWorkerThreads 200, minCompletionPortThreads 1
ThreadPool Max : MaxWorkerThreads 32767, MaxCompletionPortThreads 1000
Time | Threads | Running | Pending
-----+---------+---------+---------
Insert batch : 0 at 0
0s | 4 | 0 | 0
1s | 201 | 199 | 2
2s | 202 | 32 | 0
3s | 202 | 1 | 0
4s | 202 | 0 | 0
5s | 202 | 0 | 0
6s | 202 | 0 | 0
7s | 202 | 0 | 0
8s | 202 | 0 | 0
9s | 202 | 0 | 0
10s | 202 | 0 | 0
11s | 202 | 0 | 0
12s | 202 | 0 | 0
13s | 202 | 0 | 0
14s | 202 | 0 | 0
15s | 202 | 0 | 0
16s | 202 | 0 | 0
17s | 202 | 0 | 0
18s | 202 | 0 | 0
19s | 202 | 0 | 0
20s | 202 | 0 | 0
21s | 202 | 0 | 0
22s | 7 | 0 | 0
23s | 7 | 0 | 0
24s | 7 | 0 | 0
25s | 7 | 0 | 0
26s | 7 | 0 | 0
27s | 7 | 0 | 0
28s | 7 | 0 | 0
29s | 7 | 0 | 0
Insert batch : 1 at 30
30s | 200 | 198 | 2
31s | 202 | 200 | 1
32s | 202 | 2 | 0
33s | 202 | 0 | 0
34s | 202 | 0 | 0
35s | 202 | 0 | 0
36s | 202 | 0 | 0
37s | 202 | 0 | 0
38s | 202 | 0 | 0
39s | 202 | 0 | 0
40s | 202 | 0 | 0
41s | 202 | 0 | 0
42s | 202 | 0 | 0
43s | 202 | 0 | 0
44s | 202 | 0 | 0
45s | 202 | 0 | 0
46s | 202 | 0 | 0
47s | 202 | 0 | 0
48s | 202 | 0 | 0
49s | 202 | 0 | 0
50s | 202 | 0 | 0
51s | 202 | 0 | 0
52s | 9 | 0 | 0
53s | 9 | 0 | 0
54s | 9 | 0 | 0
55s | 9 | 0 | 0
56s | 9 | 0 | 0
57s | 9 | 0 | 0
58s | 7 | 0 | 0
59s | 7 | 0 | 0
Insert batch : 2 at 60
60s | 200 | 198 | 2
61s | 202 | 200 | 1
62s | 203 | 2 | 0
63s | 203 | 0 | 0
64s | 203 | 0 | 0
65s | 203 | 0 | 0
66s | 203 | 0 | 0
67s | 203 | 0 | 0
68s | 203 | 0 | 0
69s | 203 | 0 | 0
70s | 203 | 0 | 0
71s | 203 | 0 | 0
73s | 203 | 0 | 0
74s | 203 | 0 | 0
75s | 203 | 0 | 0
76s | 203 | 0 | 0
77s | 203 | 0 | 0
78s | 203 | 0 | 0
79s | 203 | 0 | 0
80s | 203 | 0 | 0
81s | 203 | 0 | 0
82s | 203 | 0 | 0
83s | 9 | 0 | 0
84s | 8 | 0 | 0
85s | 8 | 0 | 0
86s | 8 | 0 | 0
87s | 8 | 0 | 0
88s | 8 | 0 | 0
89s | 8 | 0 | 0
90s | 8 | 0 | 0
91s | 8 | 0 | 0
92s | 8 | 0 | 0
93s | 8 | 0 | 0
94s | 8 | 0 | 0
95s | 8 | 0 | 0
96s | 8 | 0 | 0
97s | 8 | 0 | 0
98s | 8 | 0 | 0
99s | 8 | 0 | 0
...

1️⃣ 剛開始插入一批 200 筆工作,我們看到一開始 ThreadPool.ThreadCount 很少(例如 4),當任務進來後,執行緒數量瞬間往上飆到 200+,running 很快達到 200,Pending 幾乎歸零,原因是 ThreadPool 看到要執行的工作量超過可用執行緒,就會依照 MinThreads 迅速建立新執行緒來滿足需求。

2️⃣ 2 秒後大部分工作做完,工作做完後 running 很快降到 0,但 ThreadPool.ThreadCount 還是維持在 200 多,並沒有馬上下降,因為 ThreadPool 預設不會馬上銷毀多餘的執行緒,它會先讓這些執行緒空閒一段時間,等到超過「空閒逾時(idle timeout)」才開始回收。

3️⃣ 約 20 秒後開始收執行緒,觀察到大約第 20 ~ 22 秒之後,執行緒數量從 200 多慢慢掉回到大概 7 ~ 8 條,因為 .NET ThreadPool 的預設執行緒空閒逾時(Idle Timeout)大約是 15~20 秒(不同版本的 .NET 有些微差異)超過這段時間沒工作用到執行緒時,ThreadPool 才會逐步把多餘的執行緒回收。

4️⃣ 30 秒後又丟新的 200 筆,新一批工作丟進去後,執行緒數量又瞬間從 7 條跳回 200 多條



🎵 結語

親愛的執行緒池,當任務如潮水般湧入,你勇敢撐起更多人手,不讓任何一件工作餓死在角落。當任務散去、喧囂褪去,你收起過剩的資源,不浪費一絲一毫的運算力,也不枉費每一個核心的跳動。Starvation-Avoidance 會替我們打破死結,Hill-Climbing 替我們尋找效能的山巔。所以我們一次次把任務丟進池子,看著執行緒擴張、收斂、重複、再生,這是有限資源最溫柔的重複利用