1. 介紹
今天我們會看看如何用你電腦(或伺服器)上可用的所有處理器核心,盡可能快地處理大量資料。為此我們會用到 System.Threading.Tasks.Parallel 裡的類別,主要是 Parallel.For 與 Parallel.ForEach 方法。
如果任務是純 CPU-bound,要怎麼做?
傳統的 for 或 foreach 會一個接一個地處理元素,簡單可靠。但在多核心處理器上,這樣的迴圈只會用到一個核心,其他核心閒置。何不把陣列的不同片段分配給不同核心,讓它們同時處理呢?
範例:
// 計算從 1 到 N 的平方和
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
sum += i * i;
}
這段程式很簡單,但是順序執行的。如果把工作分散到多個核心會怎樣?
認識家族成員:Parallel.For 與 Parallel.ForEach
這是什麼?
- Parallel.For — 類似一般的 for,但會把工作切成多個部份並自動分配到不同的 thread,利用所有可用的核心。
- Parallel.ForEach — 跟一般的 foreach 一樣處理集合,但會以並行方式執行。
官方文件:
為什麼這很方便?
你不需要自己建立、啟動和管理 threads。framework 幫你處理繁重的工作。你寫的程式看起來像普通迴圈,但底下自動處理並行化。
2. 語法:基礎範例
Parallel.For
long total = 0;
Parallel.For(1, 1_000_001, i =>
{
// 這個 lambda 可能會被不同的 thread 同時執行
Interlocked.Add(ref total, i * i); // 避免 race condition
});
Console.WriteLine($"平方和:{total}");
注意:我們透過 Interlocked.Add 更新變數 total,以避免資料競爭。
Parallel.ForEach
var numbers = Enumerable.Range(1, 10_000_000).ToArray();
long sum = 0;
Parallel.ForEach(numbers, num =>
{
Interlocked.Add(ref sum, num * num); // 安全的加法
});
Console.WriteLine($"平方和:{sum}");
內部運作(視覺示意)
+-------------------+
|集合/範圍 |
+---------+---------+
|
v
+----------------------+
| Parallel.ForEach |
+----------+-----------+
|
+----+----+----+----+
| | |
v v v
Task #1 Task #2 Task #3 ... (可用核心)
| | |
+--+----+ +--+-----+ +--+-----+
|處理中 | |處理中 | |處理中 |
+-------+ +--------+ +--------+
\ | /
+--------+--------+
|
v
結果
3. 大檔案分析(CPU-bound 處理)
假設我們有一個包含數萬行的文字檔,每行都是一個數字。需要讀取檔案,把每個數字平方,並計算平方和。
同步版本
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
foreach (var line in lines)
{
if (long.TryParse(line, out long n))
{
sum += n * n;
}
}
Console.WriteLine($"平方和:{sum}");
使用 Parallel.For 的並行版本
string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;
Parallel.For(0, lines.Length, i =>
{
if (long.TryParse(lines[i], out long n))
{
Interlocked.Add(ref sum, n * n);
}
});
Console.WriteLine($"平方和:{sum}");
改變在哪:我們把普通迴圈換成了並行迴圈,並透過 Interlocked.Add 安全地增加 sum,避免多執行緒衝突。
4. 底層發生了什麼?
當你呼叫 Parallel.For 或 Parallel.ForEach 時,.NET 會自動把工作切成片段並分配到可用的處理器核心,使用 thread pool。每個片段會在自己的 thread 中獨立處理。
優點:如果你有 4 個核心,工作在理想情況下可以接近 4 倍加速(前提是任務不受外部資源或其他瓶頸限制,如記憶體或磁碟讀取速度)。
比較執行時間
var numbers = Enumerable.Range(1, 100_000_000).ToArray();
long sumSync = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();
foreach (var n in numbers)
sumSync += n * n;
sw.Stop();
Console.WriteLine($"Sync: {sw.ElapsedMilliseconds} ms, 和:{sumSync}");
long sumParallel = 0;
sw.Restart();
Parallel.ForEach(numbers, n =>
Interlocked.Add(ref sumParallel, n * n)
);
sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, 和:{sumParallel}");
自己試試看! 在高效能機器上,加速可能很明顯,但實際效果取決於任務特性與瓶頸。
5. 有用的細節
控制並行度
有時候限制使用的 threads 數是有意義的(例如避免把系統搞得太忙)。可以使用 MaxDegreeOfParallelism:
using System.Threading.Tasks;
long sum = 0;
var options = new ParallelOptions {
MaxDegreeOfParallelism = 2
};
Parallel.For(0, 100, options, i =>
{
Interlocked.Add(ref sum, i * i);
});
Console.WriteLine($"平方和:{sum}");
什麼情況下有用:如果部分計算主要負載在磁碟而不是 CPU,設定較少的 threads 可能會得到更好的整體效能。
何時該用並行迴圈
| 普通 for | Parallel.For/Parallel.ForEach | |
|---|---|---|
| 處理器 | 使用單一核心 | 使用所有核心 |
| 順序性 | 有順序保證 | 無順序保證 |
| 速度 | 通常較慢 | 通常顯著較快 |
| 簡單性 | 非常簡單 | 需要考慮 thread-safe |
| 最佳應用 | 小資料量、I/O-bound | 大資料量、CPU-bound |
延伸:Parallel 還能做什麼?
Parallel.Invoke() — 同時啟動多個獨立方法:
static void DoTask1() => Console.WriteLine("任務 1 完成");
static void DoTask2() => Console.WriteLine("任務 2 完成");
static void DoTask3() => Console.WriteLine("任務 3 完成");
Parallel.Invoke(
() => DoTask1(),
() => DoTask2(),
() => DoTask3()
);
每個方法會盡可能在自己的核心上執行。
實務應用
- 影像處理:同時處理不同區塊(例如套用濾鏡)。
- 陣列計算:金融計算、模擬(例如不同情境的投資組合評估)。
- 大規模日誌處理:在多核心上同時搜尋與彙總。
- 機器學習:把工作切成獨立任務(資料 batch、feature engineering)。
而且在面試時你不只可以解釋什麼是並行迴圈,還能實事求是地評估它們的優缺點。
6. 在使用 Parallel.For 與 Parallel.ForEach 時常見錯誤
錯誤 #1:忽視資料競爭。
在沒有使用 Interlocked 或 lock 的情況下更新共享變數會導致錯誤結果,因為多個 thread 同時存取。
錯誤 #2:用在 I/O-bound 任務。
並行迴圈不會加速受磁碟或網路限制的任務,甚至可能因為額外開銷而變慢。
錯誤 #3:假設有執行順序。
並行迴圈不保證元素處理的順序,如果邏輯依賴順序,會出錯。
錯誤 #4:忽視副作用。
在並行迴圈中改變共用狀態(例如集合)會出問題,除非使用 thread-safe 的結構或同步機制。
GO TO FULL VERSION