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 void is correct only for event handlers - it is the one place the framework needs a fire-and-forget signature.
  • After await you are back on the UI thread because WinForms installs a SynchronizationContext. Directly touching controls is then safe.
  • An exception thrown after await in an async void method is not caught by the caller. Wrap the whole body in try/catch.
  • Do not call Thread.Sleep or .Result or .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);
}
SituationUseWhy
I/O boundawait on an async APINo thread is blocked while waiting
CPU boundawait Task.Run(...)Keeps the UI thread free
Long loop that must reportIProgress<T>Marshals to the captured context
Called from a background threadBeginInvokeAsync, so it cannot deadlock
Called from the UI threadInvokeSynchronous, ordering matters
Background loopBackgroundWorkerLegacy; 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.

Dialogs, multiple forms and MDI Files, settings and persistence

Last refreshed 2026-09-18.