Edit

Share via


How to handle cross-thread operations with controls

Multithreading can improve the performance of Windows Forms apps, but access to Windows Forms controls isn't inherently thread-safe. Multithreading can expose your code to serious and complex bugs. Two or more threads manipulating a control can force the control into an inconsistent state and lead to race conditions, deadlocks, and freezes or hangs. If you implement multithreading in your app, be sure to call cross-thread controls in a thread-safe way. For more information, see Managed threading best practices.

There are two ways to safely call a Windows Forms control from a thread that didn't create that control. Use the System.Windows.Forms.Control.Invoke method to call a delegate created in the main thread, which in turn calls the control. Or, implement a System.ComponentModel.BackgroundWorker, which uses an event-driven model to separate work done in the background thread from reporting on the results.

Unsafe cross-thread calls

It's unsafe to call a control directly from a thread that didn't create it. The following code snippet illustrates an unsafe call to the System.Windows.Forms.TextBox control. The Button1_Click event handler creates a new WriteTextUnsafe thread, which sets the main thread's TextBox.Text property directly.

private void button2_Click(object sender, EventArgs e)
{
    WriteTextUnsafe("Writing message #1 (UI THREAD)");
    _ = Task.Run(() => WriteTextUnsafe("Writing message #2 (OTHER THREAD)"));
}

private void WriteTextUnsafe(string text) =>
    textBox1.Text += $"{Environment.NewLine}{text}";
Private Sub Button2_Click(sender As Object, e As EventArgs) Handles Button2.Click
    WriteTextUnsafe("Writing message #1 (UI THREAD)")
    Task.Run(Sub() WriteTextUnsafe("Writing message #2 (OTHER THREAD)"))
End Sub

Private Sub WriteTextUnsafe(text As String)
    TextBox1.Text += $"{Environment.NewLine}{text}"
End Sub

The Visual Studio debugger detects these unsafe thread calls by raising an InvalidOperationException with the message, Cross-thread operation not valid. Control accessed from a thread other than the thread it was created on. The InvalidOperationException always occurs for unsafe cross-thread calls during Visual Studio debugging, and might occur at app runtime. You should fix the issue, but you can disable the exception by setting the Control.CheckForIllegalCrossThreadCalls property to false.

Safe cross-thread calls

Windows Forms applications follow a strict contract-like framework, similar all other Windows UI frameworks: all controls must be created and accessed from the same thread. This is important because Windows requires applications to provide a single dedicated thread to deliver system messages to. Whenever the Windows Window Manager detects an interaction to an application window, such as a key press, a mouse click, or resizing the window, it routes that information to the thread that created and manages the UI, and turns it into actionable events. This thread is known as the UI thread.

Because code running on another thread can't access controls created and managed by the UI thread, Windows Forms provides ways to safely work with these controls from another thread, as demonstrated in the following code examples:

Example: Use Control.InvokeAsync (.NET 9 and later)

Starting with .NET 9, Windows Forms includes the InvokeAsync method, which provides async-friendly marshaling to the UI thread. This method is useful for async event handlers and eliminates many common deadlock scenarios.

Note

Control.InvokeAsync is only available in .NET 9 and later. It isn't supported in .NET Framework.

Understanding the difference: Invoke vs InvokeAsync

Control.Invoke (Sending - Blocking):

  • Synchronously sends the delegate to the UI thread's message queue.
  • The calling thread waits until the UI thread processes the delegate.
  • Can lead to UI freezes when the delegate marshaled to the message queue is itself waiting for a message to arrive (deadlock).
  • Useful when you have results ready to display on the UI thread, for example: disabling a button or setting the text of a control.

Control.InvokeAsync (Posting - Non-blocking):

  • Asynchronously posts the delegate to the UI thread's message queue instead of waiting for the invoke to finish.
  • Returns immediately without blocking the calling thread.
  • Returns a Task that can be awaited for completion.
  • Ideal for async scenarios and prevents UI thread bottlenecks.

Advantages of InvokeAsync

Control.InvokeAsync has several advantages over the older Control.Invoke method. It returns a Task that you can await, making it work well with async and await code. It also prevents common deadlock problems that can happen when mixing async code with synchronous invoke calls. Unlike Control.Invoke, the InvokeAsync method doesn't block the calling thread, which keeps your apps responsive.

The method supports cancellation through CancellationToken, so you can cancel operations when needed. It also handles exceptions properly, passing them back to your code so you can deal with errors appropriately. .NET 9 includes compiler warnings (WFO2001) that help you use the method correctly.

For comprehensive guidance on async event handlers and best practices, see Events overview.

Choosing the right InvokeAsync overload

Control.InvokeAsync provides four overloads for different scenarios:

Overload Use Case Example
InvokeAsync(Action) Sync operation, no return value. Update control properties.
InvokeAsync<T>(Func<T>) Sync operation, with return value. Get control state.
InvokeAsync(Func<CancellationToken, ValueTask>) Async operation, no return value.* Long-running UI updates.
InvokeAsync<T>(Func<CancellationToken, ValueTask<T>>) Async operation, with return value.* Async data fetching with result.

*Visual Basic doesn't support awaiting a ValueTask.

The following example demonstrates using InvokeAsync to safely update controls from a background thread:

private async void button1_Click(object sender, EventArgs e)
{
    button1.Enabled = false;
    
    try
    {
        // Perform background work
        await Task.Run(async () =>
        {
            for (int i = 0; i <= 100; i += 10)
            {
                // Simulate work
                await Task.Delay(100);
                
                // Create local variable to avoid closure issues
                int currentProgress = i;
                
                // Update UI safely from background thread
                await loggingTextBox.InvokeAsync(() =>
                {
                    loggingTextBox.Text = $"Progress: {currentProgress}%";
                });
            }
        });

        loggingTextBox.Text = "Operation completed!";
    }
    finally
    {
        button1.Enabled = true;
    }
}
Private Async Sub Button1_Click(sender As Object, e As EventArgs) Handles button1.Click
    button1.Enabled = False

    Try
        ' Perform background work
        Await Task.Run(Async Function()
                           For i As Integer = 0 To 100 Step 10
                               ' Simulate work
                               Await Task.Delay(100)

                               ' Create local variable to avoid closure issues
                               Dim currentProgress As Integer = i

                               ' Update UI safely from background thread
                               Await loggingTextBox.InvokeAsync(Sub()
                                                                    loggingTextBox.Text = $"Progress: {currentProgress}%"
                                                                End Sub)
                           Next
                       End Function)

        ' Update UI after completion
        Await loggingTextBox.InvokeAsync(Sub()
                                             loggingTextBox.Text = "Operation completed!"
                                         End Sub)
    Finally
        button1.Enabled = True
    End Try
End Sub

For async operations that need to run on the UI thread, use the async overload:

private async void button2_Click(object sender, EventArgs e)
{
    button2.Enabled = false;
    try
    {
        loggingTextBox.Text = "Starting operation...";

        // Dispatch and run on a new thread, but wait for tasks to finish
        // Exceptions are rethrown here, because await is used
        await Task.WhenAll(Task.Run(SomeApiCallAsync),
                           Task.Run(SomeApiCallAsync),
                           Task.Run(SomeApiCallAsync));

        // Dispatch and run on a new thread, but don't wait for task to finish
        // Exceptions are not rethrown here, because await is not used
        _ = Task.Run(SomeApiCallAsync);
    }
    catch (OperationCanceledException)
    {
        loggingTextBox.Text += "Operation canceled.";
    }
    catch (Exception ex)
    {
        loggingTextBox.Text += $"Error: {ex.Message}";
    }
    finally
    {
        button2.Enabled = true;
    }
}

private async Task SomeApiCallAsync()
{
    using var client = new HttpClient();
    
    // Simulate random network delay
    await Task.Delay(Random.Shared.Next(500, 2500));

    // Do I/O asynchronously
    string result = await client.GetStringAsync("https://github.com/dotnet/docs/raw/refs/heads/main/README.md");

    // Marshal back to UI thread
    await this.InvokeAsync(async (cancelToken) =>
    {
        loggingTextBox.Text += $"{Environment.NewLine}Operation finished at: {DateTime.Now:HH:mm:ss.fff}";
    });

    // Do more async I/O ...
}
Private Async Sub Button2_Click(sender As Object, e As EventArgs) Handles button2.Click
    button2.Enabled = False
    Try
        loggingTextBox.Text = "Starting operation..."

        ' Dispatch and run on a new thread, but wait for tasks to finish
        ' Exceptions are rethrown here, because await is used
        Await Task.WhenAll(Task.Run(AddressOf SomeApiCallAsync),
                           Task.Run(AddressOf SomeApiCallAsync),
                           Task.Run(AddressOf SomeApiCallAsync))

        ' Dispatch and run on a new thread, but don't wait for task to finish
        ' Exceptions are not rethrown here, because await is not used
        Call Task.Run(AddressOf SomeApiCallAsync)

    Catch ex As OperationCanceledException
        loggingTextBox.Text += "Operation canceled."
    Catch ex As Exception
        loggingTextBox.Text += $"Error: {ex.Message}"
    Finally
        button2.Enabled = True
    End Try
End Sub

Private Async Function SomeApiCallAsync() As Task
    Using client As New HttpClient()

        ' Simulate random network delay
        Await Task.Delay(Random.Shared.Next(500, 2500))

        ' Do I/O asynchronously
        Dim result As String = Await client.GetStringAsync("https://github.com/dotnet/docs/raw/refs/heads/main/README.md")

        ' Marshal back to UI thread
        ' Extra work here in VB to handle ValueTask conversion
        Await Me.InvokeAsync(DirectCast(
                Async Function(cancelToken As CancellationToken) As Task
                    loggingTextBox.Text &= $"{Environment.NewLine}Operation finished at: {DateTime.Now:HH:mm:ss.fff}"
                End Function,
            Func(Of CancellationToken, Task)).AsValueTask() 'Extension method to convert Task
        )

        ' Do more Async I/O ...
    End Using
End Function

Note

If you're using Visual Basic, the previous code snippet used an extension method to convert a ValueTask to a Task. The extension method code is available on GitHub.

Example: Use the Control.Invoke method

The following example demonstrates a pattern for ensuring thread-safe calls to a Windows Forms control. It queries the System.Windows.Forms.Control.InvokeRequired property, which compares the control's creating thread ID to the calling thread ID. If they're different, you should call the Control.Invoke method.

The WriteTextSafe enables setting the TextBox control's Text property to a new value. The method queries InvokeRequired. If InvokeRequired returns true, WriteTextSafe recursively calls itself, passing the method as a delegate to the Invoke method. If InvokeRequired returns false, WriteTextSafe sets the TextBox.Text directly. The Button1_Click event handler creates the new thread and runs the WriteTextSafe method.

private void button1_Click(object sender, EventArgs e)
{
    WriteTextSafe("Writing message #1");
    _ = Task.Run(() => WriteTextSafe("Writing message #2"));
}

public void WriteTextSafe(string text)
{
    if (textBox1.InvokeRequired)
        textBox1.Invoke(() => WriteTextSafe($"{text} (NON-UI THREAD)"));

    else
        textBox1.Text += $"{Environment.NewLine}{text}";
}
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

    WriteTextSafe("Writing message #1")
    Task.Run(Sub() WriteTextSafe("Writing message #2"))

End Sub

Private Sub WriteTextSafe(text As String)

    If (TextBox1.InvokeRequired) Then

        TextBox1.Invoke(Sub()
                            WriteTextSafe($"{text} (NON-UI THREAD)")
                        End Sub)

    Else
        TextBox1.Text += $"{Environment.NewLine}{text}"
    End If

End Sub

For more information on how Invoke differs from InvokeAsync, see Understanding the difference: Invoke vs InvokeAsync.

Example: Use a BackgroundWorker

An easy way to implement multi-threading scenarios while guaranteeing that the access to a control or form is performed only on the main thread (UI thread), is with the System.ComponentModel.BackgroundWorker component, which uses an event-driven model. The background thread raises the BackgroundWorker.DoWork event, which doesn't interact with the main thread. The main thread runs the BackgroundWorker.ProgressChanged and BackgroundWorker.RunWorkerCompleted event handlers, which can call the main thread's controls.

Important

The BackgroundWorker component is no longer the recommended approach for asynchronous scenarios in Windows Forms applications. While we continue supporting this component for backwards compatibility, it only addresses offloading processor workload from the UI thread to another thread. It doesn't handle other asynchronous scenarios like file I/O or network operations where the processor might not be actively working.

For modern asynchronous programming, use async methods with await instead. If you need to explicitly offload processor-intensive work, use Task.Run to create and start a new task, which you can then await like any other asynchronous operation. For more information, see Example: Use Control.InvokeAsync (.NET 9 and later) and Cross-thread operations and events.

To make a thread-safe call by using BackgroundWorker, handle the DoWork event. There are two events the background worker uses to report status: ProgressChanged and RunWorkerCompleted. The ProgressChanged event is used to communicate status updates to the main thread, and the RunWorkerCompleted event is used to signal that the background worker has completed. To start the background thread, call BackgroundWorker.RunWorkerAsync.

The example counts from 0 to 10 in the DoWork event, pausing for one second between counts. It uses the ProgressChanged event handler to report the number back to the main thread and set the TextBox control's Text property. For the ProgressChanged event to work, the BackgroundWorker.WorkerReportsProgress property must be set to true.

private void button1_Click(object sender, EventArgs e)
{
    if (!backgroundWorker1.IsBusy)
        backgroundWorker1.RunWorkerAsync(); // Not awaitable
}

private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
    int counter = 0;
    int max = 10;

    while (counter <= max)
    {
        backgroundWorker1.ReportProgress(0, counter.ToString());
        System.Threading.Thread.Sleep(1000);
        counter++;
    }
}

private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e) =>
    textBox1.Text = (string)e.UserState;
Private Sub Button1_Click(sender As Object, e As EventArgs) Handles Button1.Click

    If (Not BackgroundWorker1.IsBusy) Then
        BackgroundWorker1.RunWorkerAsync() ' Not awaitable
    End If

End Sub

Private Sub BackgroundWorker1_DoWork(sender As Object, e As ComponentModel.DoWorkEventArgs) Handles BackgroundWorker1.DoWork

    Dim counter = 0
    Dim max = 10

    While counter <= max

        BackgroundWorker1.ReportProgress(0, counter.ToString())
        System.Threading.Thread.Sleep(1000)

        counter += 1

    End While

End Sub

Private Sub BackgroundWorker1_ProgressChanged(sender As Object, e As ComponentModel.ProgressChangedEventArgs) Handles BackgroundWorker1.ProgressChanged
    TextBox1.Text = e.UserState
End Sub