🧵 多執行緒與平行處理
📌 什麼是多執行緒?
想像你在一家餐廳工作。單執行緒就像只有一個廚師,要一道一道菜做。多執行緒就像有多個廚師,可以同時做不同的菜,大幅提升效率。
📌 Thread 基礎
// 建立一個新的執行緒(雇一個新廚師)
Thread thread = new Thread(() => // 用 Lambda 定義要執行的工作
{
// 模擬耗時的工作
for (int i = 0; i < 5; i++) // 迴圈 5 次
{
Console.WriteLine($"背景執行緒:{i}"); // 印出目前進度
Thread.Sleep(1000); // 暫停 1 秒(模擬耗時工作)
}
});
thread.IsBackground = true; // 設定為背景執行緒(主程式結束時會自動停止)
thread.Start(); // 啟動執行緒(新廚師開始工作)
// 主執行緒繼續做自己的事
Console.WriteLine("主執行緒繼續執行"); // 這行會馬上印出
thread.Join(); // 等待背景執行緒完成(等新廚師做完才繼續)
Console.WriteLine("所有工作完成"); // 背景執行緒做完後才印出
📌 ThreadPool 與 Task.Run
// ThreadPool:由系統管理的執行緒池(像是外包的廚師團隊)
ThreadPool.QueueUserWorkItem(state => // 把工作丟給執行緒池
{
// 在執行緒池中執行工作
Console.WriteLine($"ThreadPool 執行緒 ID:{Thread.CurrentThread.ManagedThreadId}");
});
// Task.Run:現代化的做法(推薦使用)
Task task = Task.Run(() => // 把工作丟給背景執行緒執行
{
// 模擬耗時計算
int result = 0; // 初始化結果
for (int i = 0; i < 1000000; i++) // 計算一百萬次
{
result += i; // 累加
}
Console.WriteLine($"計算結果:{result}"); // 印出結果
});
// Task.Run 搭配回傳值
Task<int> taskWithResult = Task.Run(() => // 泛型版本可以回傳值
{
// 模擬耗時計算
Thread.Sleep(2000); // 暫停 2 秒
return 42; // 回傳計算結果
});
int answer = taskWithResult.Result; // 取得結果(會阻塞直到完成)
Console.WriteLine($"答案是:{answer}"); // 印出 42
📌 同步機制:保護共享資源
lock 關鍵字
// 沒有 lock 的危險情況:多個廚師搶同一把刀
public class Counter
{
private int _count = 0; // 共享的計數器
private readonly object _lockObj = new object(); // 鎖定物件(像是一把鑰匙)
// 安全的遞增方法
public void SafeIncrement()
{
lock (_lockObj) // 進入前先拿到鑰匙(一次只有一個執行緒能進入)
{
_count++; // 安全地修改共享資源
} // 離開時自動歸還鑰匙
}
// 取得目前計數
public int GetCount()
{
lock (_lockObj) // 讀取時也要鎖定,確保讀到正確的值
{
return _count; // 回傳計數值
}
}
}
// 使用範例
Counter counter = new Counter(); // 建立計數器
// 建立多個 Task 同時遞增
Task[] tasks = new Task[10]; // 準備 10 個任務
for (int i = 0; i < 10; i++) // 迴圈建立任務
{
tasks[i] = Task.Run(() => // 每個任務都會遞增 1000 次
{
for (int j = 0; j < 1000; j++) // 迴圈 1000 次
{
counter.SafeIncrement(); // 安全地遞增
}
});
}
Task.WaitAll(tasks); // 等待所有任務完成
Console.WriteLine($"最終計數:{counter.GetCount()}"); // 應該印出 10000
Monitor、Mutex、SemaphoreSlim
// Monitor:跟 lock 類似,但提供更多控制
object monitorObj = new object(); // 建立監視器物件
bool lockTaken = false; // 追蹤是否成功取得鎖定
try
{
Monitor.TryEnter(monitorObj, TimeSpan.FromSeconds(5), ref lockTaken); // 嘗試在 5 秒內取得鎖定
if (lockTaken) // 如果成功取得鎖定
{
// 安全地存取共享資源
Console.WriteLine("已取得鎖定,執行工作中...");
}
else // 如果 5 秒內沒取得鎖定
{
Console.WriteLine("無法取得鎖定,跳過"); // 超時處理
}
}
finally
{
if (lockTaken) // 如果有取得鎖定
{
Monitor.Exit(monitorObj); // 釋放鎖定
}
}
// SemaphoreSlim:限制同時存取的數量(像是停車場的車位)
SemaphoreSlim semaphore = new SemaphoreSlim(3); // 最多允許 3 個執行緒同時進入
async Task AccessResource(int id)
{
await semaphore.WaitAsync(); // 等待取得一個「車位」
try
{
Console.WriteLine($"執行緒 {id} 進入(剩餘車位:{semaphore.CurrentCount})"); // 印出進入訊息
await Task.Delay(2000); // 模擬使用資源 2 秒
}
finally
{
semaphore.Release(); // 離開時歸還「車位」
Console.WriteLine($"執行緒 {id} 離開"); // 印出離開訊息
}
}
📌 Parallel 與 PLINQ
// Parallel.ForEach:平行處理集合中的每個元素
List<string> urls = new List<string> // 要處理的 URL 清單
{
"https://example1.com", // 第一個網址
"https://example2.com", // 第二個網址
"https://example3.com" // 第三個網址
};
Parallel.ForEach(urls, url => // 平行處理每個 URL
{
// 每個 URL 會在不同的執行緒上同時處理
Console.WriteLine($"正在處理 {url}(執行緒 {Thread.CurrentThread.ManagedThreadId})");
// 模擬下載
Thread.Sleep(1000); // 模擬耗時 1 秒
});
// PLINQ:用 LINQ 語法做平行查詢
List<int> numbers = Enumerable.Range(1, 1000000).ToList(); // 建立一百萬個數字
// 用 AsParallel() 把 LINQ 變成平行版本
var evenSquares = numbers
.AsParallel() // 啟用平行處理
.Where(n => n % 2 == 0) // 篩選偶數(平行執行)
.Select(n => n * n) // 計算平方(平行執行)
.ToList(); // 收集結果
Console.WriteLine($"偶數平方的數量:{evenSquares.Count}"); // 印出結果數量
📌 async vs 多執行緒的差異
// 多執行緒(Threading):建立新的工人去做事
// 適合:CPU 密集的計算(壓縮、加密、數學運算)
Task.Run(() =>
{
// 這裡會在另一個執行緒上執行
// 適合做大量計算
double result = 0;
for (int i = 0; i < 10000000; i++) // 大量計算
{
result += Math.Sqrt(i); // CPU 密集的工作
}
});
// async/await:不建立新工人,而是讓現有工人在等待時去做別的事
// 適合:I/O 操作(網路請求、檔案讀寫、資料庫查詢)
async Task<string> FetchDataAsync()
{
using HttpClient client = new HttpClient(); // 建立 HTTP 客戶端
// await 時執行緒可以去做別的事(不會閒置等待)
string data = await client.GetStringAsync("https://api.example.com/data"); // 等待網路回應
return data; // 回傳資料
}
// 簡單比喻:
// 多執行緒 = 雇更多廚師來做菜(增加人力)
// async/await = 一個廚師在等水燒開的時候去切菜(提高效率)
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:死結(Deadlock)
// ❌ 錯誤寫法:兩個執行緒互相等待對方的鎖
object lockA = new object(); // 鎖 A
object lockB = new object(); // 鎖 B
// 執行緒 1:先拿 A 再拿 B
Task task1 = Task.Run(() =>
{
lock (lockA) // 取得鎖 A
{
Thread.Sleep(100); // 短暫等待,增加死結機率
lock (lockB) // 嘗試取得鎖 B → 💥 但鎖 B 被執行緒 2 拿走了!
{
Console.WriteLine("執行緒 1 完成"); // 永遠不會執行
}
}
});
// 執行緒 2:先拿 B 再拿 A(順序相反)
Task task2 = Task.Run(() =>
{
lock (lockB) // 取得鎖 B
{
Thread.Sleep(100); // 短暫等待
lock (lockA) // 嘗試取得鎖 A → 💥 但鎖 A 被執行緒 1 拿走了!
{
Console.WriteLine("執行緒 2 完成"); // 永遠不會執行
}
}
});
// 兩個執行緒互相等待,永遠不會結束 → 死結!
// ✅ 正確寫法:統一鎖定順序
object lockA = new object(); // 鎖 A
object lockB = new object(); // 鎖 B
// 所有執行緒都按照相同順序取得鎖(先 A 後 B)
Task task1 = Task.Run(() =>
{
lock (lockA) // 先取得鎖 A
{
lock (lockB) // 再取得鎖 B
{
Console.WriteLine("執行緒 1 完成"); // ✅ 可以正常執行
}
}
});
Task task2 = Task.Run(() =>
{
lock (lockA) // ✅ 也是先取得鎖 A(相同順序)
{
lock (lockB) // 再取得鎖 B
{
Console.WriteLine("執行緒 2 完成"); // ✅ 可以正常執行
}
}
});
解釋: 死結就像兩個人在窄巷中面對面,都堅持對方先讓路,結果誰都走不了。解決方法:規定大家都靠右走(統一順序)。
❌ 錯誤 2:競爭條件(Race Condition)
// ❌ 錯誤寫法:多個執行緒同時修改共享變數,沒有保護
int sharedCounter = 0; // 共享的計數器
Task[] tasks = new Task[10]; // 建立 10 個任務
for (int i = 0; i < 10; i++) // 迴圈建立任務
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 10000; j++) // 每個任務遞增一萬次
{
sharedCounter++; // ❌ 不是原子操作,會產生競爭條件
}
});
}
Task.WaitAll(tasks); // 等待所有任務完成
Console.WriteLine($"結果:{sharedCounter}"); // ❌ 結果會小於 100000!
// ✅ 正確寫法:使用 Interlocked 進行原子操作
int sharedCounter = 0; // 共享的計數器
Task[] tasks = new Task[10]; // 建立 10 個任務
for (int i = 0; i < 10; i++) // 迴圈建立任務
{
tasks[i] = Task.Run(() =>
{
for (int j = 0; j < 10000; j++) // 每個任務遞增一萬次
{
Interlocked.Increment(ref sharedCounter); // ✅ 原子操作,保證執行緒安全
}
});
}
Task.WaitAll(tasks); // 等待所有任務完成
Console.WriteLine($"結果:{sharedCounter}"); // ✅ 正確印出 100000
解釋: ++ 操作其實分成三步:讀取、加一、寫回。兩個執行緒可能同時讀到相同的值,各自加一後寫回,導致只加了一次。就像兩個人同時從提款機領錢,可能只扣了一次帳。
❌ 錯誤 3:在背景執行緒存取 UI
// ❌ 錯誤寫法(WinForms/WPF):從背景執行緒直接修改 UI
Task.Run(() =>
{
// 在背景執行緒做完計算後...
string result = "計算完成"; // 計算結果
label1.Text = result; // 💥 InvalidOperationException!不能從非 UI 執行緒修改控制項
});
// ✅ 正確寫法:使用 Invoke 切回 UI 執行緒
Task.Run(() =>
{
// 在背景執行緒做完計算後...
string result = "計算完成"; // 計算結果
// 使用 Invoke 切回 UI 執行緒來更新介面
this.Invoke(() =>
{
label1.Text = result; // ✅ 在 UI 執行緒上安全地修改控制項
});
});
// 或者更好的做法:使用 async/await(自動回到 UI 執行緒)
async void Button_Click(object sender, EventArgs e)
{
// await 之後會自動回到 UI 執行緒
string result = await Task.Run(() =>
{
// 背景計算
return "計算完成"; // 回傳結果
});
label1.Text = result; // ✅ 自動在 UI 執行緒上執行
}
解釋: UI 控制項只能由建立它的執行緒(UI 執行緒)來修改。就像餐廳的點餐系統只能由前台操作,廚師(背景執行緒)做好菜要透過前台(Invoke)才能送到客人桌上。