Async work, threading and progress
async and await inside event handlers, Task.Run for CPU-bound work, progress reporting and cancellation, and the cross-thread exception that comes from touching a control from the wrong thread.
await in an event handler
private async void OnLoadClicked(object sender, EventArgs e)
{
loadButton.Enabled = false;
try
{
var data = await _api.GetOrdersAsync(_customerId); // resumes on the UI thread
orderGrid.DataSource = new BindingList<Order>(data.ToList());
}
catch (HttpRequestException ex)
{
MessageBox.Show(this, ex.Message, "Could not load orders");
}
finally
{
loadButton.Enabled = true;
}
}async voidis correct only for event handlers - it is the one place the framework needs a fire-and-forget signature.- After
awaityou are back on the UI thread because WinForms installs aSynchronizationContext. Directly touching controls is then safe. - An exception thrown after
awaitin anasync voidmethod is not caught by the caller. Wrap the whole body in try/catch. - Do not call
Thread.Sleepor.Resultor.Wait()on the UI thread - both freeze the window and the second one can deadlock.
CPU work and progress
private CancellationTokenSource _cts;
private async void OnImportClicked(object sender, EventArgs e)
{
_cts = new CancellationTokenSource();
cancelButton.Enabled = true;
var progress = new Progress<ImportProgress>(p =>
{
// this callback always runs on the UI thread
statusLabel.Text = $"Row {p.Done} of {p.Total}";
progressBar.Value = (int)(p.Done * 100L / Math.Max(1, p.Total));
});
try
{
var rows = await Task.Run(() => _importer.Import(filePath, progress, _cts.Token), _cts.Token);
MessageBox.Show(this, $"Imported {rows} rows.", "Done");
}
catch (OperationCanceledException)
{
statusLabel.Text = "Cancelled";
}
finally
{
cancelButton.Enabled = false;
_cts.Dispose();
_cts = null;
}
}// inside the worker: never touch a control from here
public int Import(string path, IProgress<ImportProgress> progress, CancellationToken token)
{
var total = CountRows(path);
var done = 0;
foreach (var row in ReadRows(path))
{
token.ThrowIfCancellationRequested();
Save(row);
if (++done % 250 == 0) // report in batches, not per row
progress.Report(new ImportProgress(done, total));
}
return done;
}Cross-thread access and marshalling
Before .NET Core, touching a control from another thread threw InvalidOperationException. Modern WinForms still raises the exception in debug builds but may silently misbehave in release, which is worse. Marshal explicitly.
// called from any thread
void AppendLog(string line)
{
if (logBox.InvokeRequired)
{
logBox.BeginInvoke(new Action<string>(AppendLog), line); // async, no deadlock
return;
}
logBox.AppendText(line + Environment.NewLine);
}| Situation | Use | Why |
|---|---|---|
| I/O bound | await on an async API | No thread is blocked while waiting |
| CPU bound | await Task.Run(...) | Keeps the UI thread free |
| Long loop that must report | IProgress<T> | Marshals to the captured context |
| Called from a background thread | BeginInvoke | Async, so it cannot deadlock |
| Called from the UI thread | Invoke | Synchronous, ordering matters |
| Background loop | BackgroundWorker | Legacy; only for existing code |
💡
Disable the controls a long operation depends on, and re-enable them in a
finally. A user who can press Import twice while the first run is in flight is a bug you will only find in production.FAQ
Is async multi-threaded?
No.
async is about not blocking a thread, not about creating one. Real parallelism needs Task.Run or a library that does it for you.How do I cancel cleanly?
Pass the
CancellationToken down to every layer, call ThrowIfCancellationRequested inside loops, and treat OperationCanceledException as a normal outcome rather than an error.Related
Dialogs, multiple forms and MDI Files, settings and persistence
Last refreshed 2026-09-18.