WinForms 控制項具有執行緒親和性,只能在建立它們的 UI 執行緒存取。背景執行緒直接操作會導致競爭條件、繪圖錯誤或例外。本文詳細比較 Invoke/BeginInvoke 傳統模式與現代 async/await + SynchronizationContext 模式,並提供最佳實務模板。
InvalidOperationException ("Cross-thread operation not valid")
private void UpdateLabelFromThread(string text)
{
if (label1.InvokeRequired)
{
// 封送委派到 UI 執行緒,阻塞直到完成
label1.Invoke(new Action(UpdateLabelFromThread), text);
return;
}
label1.Text = text; // UI 執行緒執行
}
缺點:背景執行緒阻塞,若 UI 執行緒忙碌 (如 MessageBox) 會死結。
private void UpdateLabelAsync(string text)
{
if (label1.InvokeRequired)
{
label1.BeginInvoke(new Action(UpdateLabelAsync), text);
return;
}
label1.Text = text;
}
// 帶回傳值
private string GetTextFromUi()
{
if (textBox1.InvokeRequired)
{
var result = textBox1.BeginInvoke(new Func(GetTextFromUi));
return result.EndInvoke() as string; // 這裡會阻塞等待
}
return textBox1.Text;
}
// 無參數無回傳值的簡潔寫法
label1.Invoke(new MethodInvoker(() => label1.Text = "Hello"));
label1.BeginInvoke(new MethodInvoker(() => label1.Text = "Hello"));
SynchronizationContext.Current = WindowsFormsSynchronizationContextawait 預設捕獲當前 SynchronizationContext,續作排程回原執行緒Task.Run 將工作排程到 ThreadPool 背景執行緒
private async void btnLoad_Click(object sender, EventArgs e)
{
btnLoad.Enabled = false;
lblStatus.Text = "載入中...";
try
{
// 1. 背景執行緒執行耗時工作
var data = await Task.Run(() => LoadDataFromDatabase());
// 2. 自動回到 UI 執行緒 (await 捕獲了 SynchronizationContext)
dataGridView1.DataSource = data;
lblStatus.Text = $"完成,共 {data.Count} 筆";
}
catch (Exception ex)
{
MessageBox.Show($"錯誤:{ex.Message}");
}
finally
{
btnLoad.Enabled = true;
}
}
private List LoadDataFromDatabase()
{
Thread.Sleep(2000); // 模擬耗時
return new List { new Order { Id = 1, Name = "訂單A" } };
}
private async Task ProcessFilesAsync(IEnumerable files)
{
var progress = new Progress(p =>
{
// 自動在 UI 執行緒執行
progressBar1.Value = p;
lblProgress.Text = $"{p}%";
});
await Task.Run(() =>
{
int total = files.Count();
int done = 0;
foreach (var file in files)
{
ProcessFile(file);
done++;
progress.Report(done * 100 / total); // 背景執行緒安全回報
}
});
MessageBox.Show("處理完成");
}
// 類別庫/非 UI 程式碼:不需要回到 UI 執行緒
public async Task GetDataAsync()
{
var json = await httpClient.GetStringAsync(url).ConfigureAwait(false);
return JsonSerializer.Deserialize(json); // 在執行緒池繼續執行
}
// UI 事件處理:需要回到 UI 執行緒 (預設行為)
private async void btnRefresh_Click(object sender, EventArgs e)
{
var data = await GetDataAsync(); // 這裡 await 不加 ConfigureAwait,會回到 UI 執行緒
grid.DataSource = data;
}
| 特性 | Invoke | BeginInvoke | async/await |
|---|---|---|---|
| 阻塞呼叫執行緒 | 是 | 否 | 否 (語法非阻塞) |
| 程式碼可讀性 | 委派嵌套 | 委派 + EndInvoke | 同步風格,最佳 |
| 例外處理 | 直接 try-catch | EndInvoke 捕獲 | 標準 try-catch |
| 回傳值 | 直接回傳 | EndInvoke 取得 | 直接 await 取得 |
| 死結風險 | 高 (UI 忙碌時) | 低 | 低 (ConfigureAwait 控制) |
| 進度回報 | 需自行實作 | 需自行實作 | IProgress 內建支援 |
| 適用 .NET 版本 | 所有 | 所有 | .NET 4.5+ / .NET Core |
| 陷阱 | 現象 | 解決 |
|---|---|---|
| 在建構函式中呼叫 Invoke | 控制項尚未建立控制代碼 | 移至 OnHandleCreated 或 Load 事件 |
| Invoke 死結 | UI 執行緒被 MessageBox/迴圈阻塞 | 改用 BeginInvoke 或 async/await |
| 忘記檢查 InvokeRequired | UI 執行緒重複封送造成效能損耗 | 保留檢查或直接用 async/await |
| lambda 捕獲迴圈變數 | 所有委派執行時看到相同最終值 | 區域變數捕獲:foreach(var f in files) { var file=f; ... } |
| 背景執行緒直接新建控制項 | 控制項父視窗為 null,顯示異常 | 所有控制項建立/操作必須在 UI 執行緒 |
// 1. 事件處理器:async void (唯一允許 async void 的地方)
private async void btnAction_Click(object sender, EventArgs e)
{
SetUiBusy(true);
try
{
await DoWorkAsync();
UpdateUiSuccess();
}
catch (Exception ex)
{
ShowError(ex);
}
finally
{
SetUiBusy(false);
}
}
// 2. 核心邏輯:async Task (可測試、可復用)
private async Task DoWorkAsync()
{
var data = await Task.Run(() => FetchData()); // 背景運算
await UpdateUiWithDataAsync(data); // 更新 UI (可再細分)
}
// 3. 純 UI 更新:分離關注點
private Task UpdateUiWithDataAsync(Data data)
{
// 若已在 UI 執行緒,直接執行;否則封送
if (this.InvokeRequired)
return Task.Factory.StartNew(() => UpdateUiWithDataAsync(data),
CancellationToken.None, TaskCreationOptions.DenyChildAttach,
TaskScheduler.FromCurrentSynchronizationContext());
grid.DataSource = data;
return Task.CompletedTask;
}
async/await + Task.Run + IProgressBeginInvoke 替換 Invoke 降低死結風險await 加上 .ConfigureAwait(false)await 邊界分隔