Async

有些風,獨自穿梭林間,沿著樹影與地面爬行,沒有分身,沒有回音,只將時間吹得靜謐。

有些風,輕輕裂成數萬縷絲線,從山脊滲入溪谷,從枝頭滲入土壤,它們彼此不糾纏,卻同時為大地帶來不同的聲響。

也有些種子,懂得在雲後潛伏,等待雨季將它們喚醒;一顆未必只能開一朵花,它可以把等待切碎,併發成無數顆更小的種子,在時間的縫隙裡生根發芽。

當我們談論分割與合流,等待與釋放,這片風與影子的地圖,就是平行之風,併發之歌。
那些藏在核心深處的秘密,終究會在一次又一次的呼吸裡,被我們拆解、重組、散播,直到有一天,學會怎樣用一秒去換取另一秒。

用.NET展現多核威力(1) - 從ThreadPool翻船談起


🧪 實驗

這個實驗想要透過一個簡單的數學運算(Math.Log10),用 單執行緒(Single Thread)、平行迴圈(Parallel.For)、以及 Task-based 併發(async/await + Task.WhenAll) 三種方式,分別執行相同的 100 萬次運算,並多輪重複測量執行時間。

目的是比較:

順序執行 vs. 多核心平行 vs. 多任務併發 在 CPU-bound 工作下的效能差異,瞭解平行化與併發在沒有 I/O 等待、沒有共享資源時,會帶來什麼開銷與優勢,體會 ThreadPool、Context Switching、Task 排程等背後的隱藏成本

這能幫助我們釐清:

  • 什麼情況適合單執行緒?
  • 什麼情況可以用平行迴圈發揮多核心效能?
  • 什麼情況下 Task-based 非但沒好處還可能更慢?

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

async Task Main()
{
const int TIME = 1_000_000;
int Round = 5;

for (int i = 0; i < Round; i++)
{
Console.WriteLine($"This is Round : {i + 1}");

// SingleTread
var singleThreadTime = TimeHelper.MeasureTime(() =>
{
for (int j = 0; j < TIME; j++)
{
double d = Math.Log10(Convert.ToDouble(i));
}
});

Console.WriteLine($"Single Thread : {singleThreadTime} milliSeconds");

//// Parallel.For
var parallelForTime = TimeHelper.MeasureTime(() =>
{
Parallel.For(0, TIME, k => {
double d = Math.Log10(Convert.ToDouble(k + 1));
});
});

Console.WriteLine($"parallelForTime : {parallelForTime} milliSeconds");

//// ASync
var taskRunAsyncTime = await TimeHelper.MeasureTimeAsync(async () =>
{
var tasks = Enumerable.Range(0, TIME).Select(async l => {
double d = Math.Log10(Convert.ToDouble(l + 1));
});

await Task.WhenAll(tasks);
});

Console.WriteLine($"Task-based: {taskRunAsyncTime:N0}ms");
Console.WriteLine();
}
}

public static class TimeHelper
{
public static long MeasureTime(Action action)
{
var stopWatch = new Stopwatch();
stopWatch.Start();
action();
stopWatch.Stop();
return stopWatch.ElapsedMilliseconds;
}

public static async Task<long> MeasureTimeAsync(Func<Task> action)
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();

await action();

stopwatch.Stop();
return stopwatch.ElapsedMilliseconds;
}
}



結果


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
This is Round : 1
Single Thread : 30 milliSeconds
parallelForTime : 8 milliSeconds
Task-based: 69ms

This is Round : 2
Single Thread : 5 milliSeconds
parallelForTime : 6 milliSeconds
Task-based: 73ms

This is Round : 3
Single Thread : 4 milliSeconds
parallelForTime : 6 milliSeconds
Task-based: 72ms

This is Round : 4
Single Thread : 4 milliSeconds
parallelForTime : 9 milliSeconds
Task-based: 85ms

This is Round : 5
Single Thread : 4 milliSeconds
parallelForTime : 11 milliSeconds
Task-based: 83ms


🎵 分析


Single Thread

結果: 單執行緒平均時間第一次 4 但後續皆 ~ 30 毫秒,執行穩定。

單執行緒沒有額外的排程、執行緒切換、或執行緒池分派開銷,CPU 只要照順序完成迴圈裡的計算即可。這種情況下,效能幾乎只受到 CPU 時脈與記憶體快取的影響。適合執行小規模、簡單且不需要分拆的 CPU 任務,像是短時間的批次運算、輕量的邏輯處理,或需要維持執行緒上下文一致性時(例如需要在 UI 執行緒中執行)。



Parallel.For

平均時間約 6 ~ 11 毫秒,明顯比單執行緒快 2~5 倍。

如果你用一般的 for 迴圈,就是一次做一個,一個做完再做下一個。但有些工作是可以 分開同時做 的,像是:

  • 計算一堆獨立的數學題
  • 處理一大堆圖片
  • 分析很多筆資料

如果一次只用一條線(CPU 的一個核心)去做,就很浪費電腦的多核心能力。Parallel.For 會把迴圈工作,切成一小塊一小塊,丟給不同的核心同時去做。

  • 一般 for:一個人做 100 件事。
  • Parallel.For:10 個人分工,每人做 10 件事,大家同時開始。這樣就能用到 CPU 的多核心,達到 平行處理,速度通常會更快。

本質上 Parallel.For 用的是 ThreadPool(執行緒池) 和 Task 的概念。

1️⃣ 把迴圈分段(Partitioning)
Parallel.For 會把迴圈切成多塊,比如 0999 分成 099、100~199、…,每塊給一個執行緒去做。

2️⃣ 排程分配(Thread Scheduling)
它用 ThreadPool,把這些工作分給空閒的執行緒跑,避免你自己開一大堆執行緒(那樣開太多會拖垮效能)。

3️⃣ 動態調整(Work-Stealing)
如果某個執行緒做完了,它還可以幫忙搶別人的工作來做,讓 CPU 不浪費時間。

如果每筆任務非常小,平行化的好處會被 Context Switching、排程、資料同步的成本吃掉。如果每筆任務彼此之間需要共享資源(例如對同一個集合寫入),就必須加鎖(lock),加鎖就會導致多執行緒之間搶鎖,效能會急速下降。



Task-based (Concurrency)

結果: 平均時間約 69 ~ 85 毫秒,遠遠落後於單執行緒與 Parallel.For。

Task 是 併發(Concurrency) 的抽象,它是為了讓一個執行緒可以同時「管理」多個任務進度而存在,而不是要讓每個任務都佔用獨立執行緒。在典型的 I/O Bound 情境(例如呼叫 API、等待檔案寫入),Task 透過 async/await 可以在等待期間「釋放執行緒」,讓執行緒去跑其他工作,等結果回來時再接手後續邏輯,這樣就能極大化 ThreadPool 的效益。

但在這個案例,每次都要建立一個獨立的 Task,對於 100 萬次來說,就會產生 100 萬個 Task 實例!

每個 Task 都需要:

  • 分配記憶體給 Task 物件
  • 加進 ThreadPool 的排程佇列
  • 執行完還要觸發 Completion 回調

重點是,這些任務本身極短,完全沒有 I/O 等待,也沒時間釋放執行緒,反而產生了巨大的排程與 Context Switching 開銷。



🎵 結語

有的風,獨自輕掠,循序而行,穩定、可控;
有的風,裂成無數股氣流,沿著核心四散流動,各自翻湧,互不干擾,卻又匯成更大的動能;
而有些風,懂得在等待裡隱忍,把執行緒還給大地,讓一段封鎖的時間,換取更多並存的可能。

哪陣風適合獨行,哪陣風能劃分成絲線穿梭,哪陣風需要暫時潛伏、留白,等待時機到來。

這就是 平行之風,併發之歌