WinForms 控件非線程安全,必須在創建它們的 UI 線程上訪問。耗時操作(計算、IO、網絡)若在 UI 線程執行會導致界面凍結、無法重繪(如文章中的 Pi 計算示例)。
// 簽名與 UI 更新方法一致
private delegate void ShowProgressDelegate(string text, int total, int current);
private void CalcPi(int digits)
{
var sb = new StringBuilder("3", digits + 2);
var showProgress = new ShowProgressDelegate(ShowProgress);
// 初始進度
this.BeginInvoke(showProgress, sb.ToString(), digits, 0);
if (digits > 0)
{
sb.Append(".");
for (int i = 0; i < digits; i += 9)
{
// ... 計算 9 位數字 ...
int nineDigits = ComputeNineDigits(i + 1);
int count = Math.Min(digits - i, 9);
sb.Append(nineDigits.ToString($"D9").Substring(0, count));
// 異步更新 UI,不阻塞工作線程
this.BeginInvoke(showProgress, sb.ToString(), digits, i + count);
}
}
}
private void ShowProgress(string pi, int totalDigits, int digitsSoFar)
{
// 如果已在 UI 線程,直接執行;否則 BeginInvoke 會排隊執行此方法
if (this.InvokeRequired)
{
// 理論上 BeginInvoke 已保證在 UI 線程,此處作為防禦性編程
this.BeginInvoke(new ShowProgressDelegate(ShowProgress), pi, totalDigits, digitsSoFar);
return;
}
// 安全更新控件
txtPi.Text = pi;
progressBar.Maximum = totalDigits;
progressBar.Value = digitsSoFar;
}
private void btnCalc_Click(object sender, EventArgs e)
{
int digits = (int)numDigits.Value;
// 方式一:ThreadPool (推薦簡短任務)
ThreadPool.QueueUserWorkItem(_ => CalcPi(digits));
// 方式二:Task (現代首選,.NET 4.0+)
// Task.Run(() => CalcPi(digits));
// 方式三:BackgroundWorker (舊版組件,含進度/取消事件)
// bgWorker.RunWorkerAsync(digits);
}
| 特性 | Invoke (同步) | BeginInvoke (異步) |
|---|---|---|
| 調用線程 | 阻塞等待 UI 線程執行完成 | 立即返回,UI 線程稍後執行 |
| 返回值 | 可獲取委托返回值 | 需 EndInvoke 獲取 (易忘導致洩漏) |
| 死鎖風險 | 高 (若 UI 線程正等待工作線程) | 低 |
| 異常傳播 | 直接拋回調用者 | 在 UI 線程拋出,需 Application.ThreadException 捕獲 |
| 適用場景 | 必須等待 UI 更新完成再繼續 | 火燄忘、進度報告、非關鍵更新 |
private async void btnCalc_Click(object sender, EventArgs e)
{
btnCalc.Enabled = false;
try
{
// Progress 自動捕獲 UI 同步上下文,回調在 UI 線程執行
var progress = new Progress(p =>
{
txtPi.Text = p.CurrentText;
progressBar.Value = p.DigitsDone;
});
await Task.Run(() => CalcPiAsync((int)numDigits.Value, progress));
}
finally
{
btnCalc.Enabled = true;
}
}
private void CalcPiAsync(int digits, IProgress progress)
{
var sb = new StringBuilder("3", digits + 2);
progress.Report(new PiProgress(sb.ToString(), digits, 0));
if (digits > 0)
{
sb.Append(".");
for (int i = 0; i < digits; i += 9)
{
// 計算...
sb.Append(/* 9位數字 */);
progress.Report(new PiProgress(sb.ToString(), digits, i + 9));
}
}
}
record PiProgress(string CurrentText, int TotalDigits, int DigitsDone);
優勢:無需手動 Invoke、自動上下文捕獲、異常自然冒泡、代碼線性易讀、支持取消 (CancellationToken)。
// 錯誤:直接在工作線程讀取 txtInput.Text 可能拋異常或讀到舊值
// 正確:Invoke 獲取
string input = (string)this.Invoke(() => txtInput.Text);
文章強調:工作線程與 UI 線程間傳遞 值類型、不可變對象、或深拷貝副本,避免共享可變對象(如 StringBuilder、List)導致競態條件。若必須共享,使用 `lock`、`ConcurrentQueue`、`ImmutableArray` 等線程安全結構。
WinForms 多線程核心鐵律:耗時操作放後台,UI 更新回主線程。從 `Invoke/BeginInvoke` 到 `BackgroundWorker`,再到現代 `async/await + Progress