Compartir a través de


Cómo controlar las operaciones entre subprocesos con controles

Multithreading puede mejorar el rendimiento de las aplicaciones de Windows Forms, pero el acceso a los controles de Windows Forms no es inherentemente seguro para subprocesos. Multithreading puede exponer el código a errores graves y complejos. Dos o más subprocesos que manipulan un control pueden forzar el mismo a un estado incoherente y provocar condiciones de carrera, interbloqueos y congelaciones. Si implementa multihilo en su aplicación, asegúrese de controlar acciones entre hilos de manera segura. Para obtener más información, consulte prácticas recomendadas de hilos administrados.

Hay dos maneras de llamar de forma segura a un control de Windows Forms desde un subproceso que no creó ese control. Use el System.Windows.Forms.Control.Invoke método para llamar a un delegado creado en el subproceso principal, que a su vez llama al control. O bien, implemente un System.ComponentModel.BackgroundWorker, que usa un modelo controlado por eventos para separar el trabajo realizado en el subproceso en segundo plano de informar sobre los resultados.

Llamadas no seguras entre subprocesos

No es seguro llamar a un control directamente desde un subproceso que no lo creó. En el fragmento de código siguiente se muestra una llamada no segura al System.Windows.Forms.TextBox control . El Button1_Click controlador de eventos crea un nuevo WriteTextUnsafe subproceso, que establece directamente la propiedad del TextBox.Text subproceso principal.

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

El depurador de Visual Studio detecta estas llamadas a subprocesos no seguras mediante la generación de un InvalidOperationException elemento con el mensaje Operación entre subprocesos no válida. Control al que se accede desde un subproceso distinto del subproceso en el que se creó.InvalidOperationException Siempre se produce para las llamadas entre subprocesos no seguras durante la depuración de Visual Studio y pueden producirse en el tiempo de ejecución de la aplicación. Debe corregir el problema, pero puede deshabilitar la excepción estableciendo la propiedad Control.CheckForIllegalCrossThreadCalls en false.

Llamadas seguras a través de hilos

Las aplicaciones de Windows Forms siguen un marco similar a un contrato estricto, similar a todos los demás marcos de interfaz de usuario de Windows: se debe crear y acceder a todos los controles desde el mismo subproceso. Esto es importante porque Windows requiere que las aplicaciones proporcionen un único subproceso dedicado para entregar mensajes del sistema. Cada vez que el Administrador de ventanas de Windows detecta una interacción con una ventana de aplicación, como una pulsación de tecla, un clic del mouse o el cambio de tamaño de la ventana, enruta esa información al subproceso que creó y administra la interfaz de usuario y la convierte en eventos accionables. Este subproceso se conoce como subproceso de interfaz de usuario.

Dado que el código que se ejecuta en otro subproceso no puede acceder a los controles creados y administrados por el subproceso de interfaz de usuario, Windows Forms proporciona maneras de trabajar de forma segura con estos controles desde otro subproceso, como se muestra en los ejemplos de código siguientes:

Ejemplo: Usar Control.InvokeAsync (.NET 9 y versiones posteriores)

A partir de .NET 9, Windows Forms incluye el InvokeAsync método , que proporciona serialización asincrónica al subproceso de la interfaz de usuario. Este método es útil para los controladores de eventos asincrónicos y elimina muchos escenarios comunes de interbloqueo.

Nota:

Control.InvokeAsync solo está disponible en .NET 9 y versiones posteriores. No se admite en .NET Framework.

Descripción de la diferencia: Invocar frente a InvokeAsync

Control.Invoke (envío - bloqueo):

  • Envía de forma sincrónica el delegado a la cola de mensajes del subproceso de la interfaz de usuario.
  • El subproceso que llama espera hasta que el subproceso de interfaz de usuario procesa el delegado.
  • Puede provocar que la interfaz de usuario se inmoviliza cuando el delegado serializado a la cola de mensajes está esperando a que llegue un mensaje (interbloqueo).
  • Resulta útil cuando tiene resultados listos para mostrarse en el subproceso de la interfaz de usuario, por ejemplo: deshabilitar un botón o establecer el texto de un control.

Control.InvokeAsync (Contabilización: no bloqueo):

  • Publica de forma asincrónica el delegado en la cola de mensajes del subproceso de la interfaz de usuario en lugar de esperar a que finalice la invocación.
  • Devuelve inmediatamente sin bloquear el subproceso que realiza la llamada.
  • Devuelve un Task objeto que se puede esperar para completarse.
  • Ideal para escenarios asincrónicos y evita cuellos de botella de subprocesos de interfaz de usuario.

Ventajas de InvokeAsync

Control.InvokeAsync tiene varias ventajas sobre el método anterior Control.Invoke . Devuelve un Task que puede esperar, lo que hace que funcione bien con código asincrónico y await. También evita problemas comunes de interbloqueo que pueden producirse al mezclar código asincrónico con llamadas de invocación sincrónicas. A diferencia Control.Invokede , el InvokeAsync método no bloquea el subproceso que realiza la llamada, lo que mantiene la capacidad de respuesta de las aplicaciones.

El método admite la cancelación a través CancellationTokende , por lo que puede cancelar las operaciones cuando sea necesario. También controla correctamente las excepciones y las devuelve al código para que pueda tratar correctamente los errores. .NET 9 incluye advertencias del compilador (WFO2001) que le ayudan a usar el método correctamente.

Para obtener instrucciones completas sobre los controladores de eventos asincrónicos y los procedimientos recomendados, consulte Introducción a los eventos.

Elección de la sobrecarga InvokeAsync adecuada

Control.InvokeAsync proporciona cuatro sobrecargas para diferentes escenarios:

Sobrecarga Caso de uso Example
InvokeAsync(Action) Operación de sincronización, sin valor devuelto. Actualizar las propiedades del control.
InvokeAsync<T>(Func<T>) Operación de sincronización, con valor devuelto. Obtiene el estado del control.
InvokeAsync(Func<CancellationToken, ValueTask>) Operación asincrónica, sin valor devuelto.* Actualizaciones de la interfaz de usuario de larga duración.
InvokeAsync<T>(Func<CancellationToken, ValueTask<T>>) Operación asincrónica, con valor devuelto.* Captura de datos asincrónicos con el resultado.

*Visual Basic no admite la espera de .ValueTask

En el ejemplo siguiente se muestra cómo usar InvokeAsync para actualizar controles de forma segura desde un subproceso en segundo plano:

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

Para las operaciones asincrónicas que deben ejecutarse en el subproceso de la interfaz de usuario, use la sobrecarga asincrónica:

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

Nota:

Si usa Visual Basic, el fragmento de código anterior usó un método de extensión para convertir en ValueTask .Task El código del método de extensión está disponible en GitHub.

Ejemplo: Usar el método Control.Invoke

En el ejemplo siguiente se muestra un patrón para garantizar llamadas seguras para subprocesos a un control de Windows Forms. Consulta la System.Windows.Forms.Control.InvokeRequired propiedad, que compara el identificador de subproceso de creación del control con el identificador de subproceso que hace la llamada. Si son diferentes, debería llamar al método Control.Invoke.

WriteTextSafe permite establecer un nuevo valor para la propiedad TextBox del control Text. El método consulta InvokeRequired. Si InvokeRequired devuelve true, WriteTextSafe se llama recursivamente a sí mismo, pasando el método como delegado al Invoke método . Si InvokeRequired devuelve false, WriteTextSafe establece directamente TextBox.Text . El Button1_Click controlador de eventos crea el nuevo subproceso y ejecuta el WriteTextSafe método .

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

Para obtener más información sobre cómo Invoke difiere de InvokeAsync, vea Descripción de la diferencia: Invocar frente a InvokeAsync.

Ejemplo: Usar un BackgroundWorker

Una manera fácil de implementar escenarios de varios subprocesos, al tiempo que garantiza que el acceso a un control o formulario solo se realiza en el subproceso principal (subproceso de interfaz de usuario), es con el System.ComponentModel.BackgroundWorker componente , que usa un modelo controlado por eventos. El subproceso en segundo plano genera el BackgroundWorker.DoWork evento , que no interactúa con el subproceso principal. El hilo principal ejecuta los controladores de eventos BackgroundWorker.ProgressChanged y BackgroundWorker.RunWorkerCompleted, los cuales pueden llamar a los controles del hilo principal.

Importante

El BackgroundWorker componente ya no es el enfoque recomendado para escenarios asincrónicos en aplicaciones de Windows Forms. Aunque seguimos admitiendo este componente para la compatibilidad con versiones anteriores, solo se dirige a la descarga de la carga de trabajo del procesador del subproceso de la interfaz de usuario a otro subproceso. No controla otros escenarios asincrónicos, como la E/S de archivos o las operaciones de red en las que es posible que el procesador no funcione activamente.

Para la programación asincrónica moderna, use async métodos con await en su lugar. Si necesita descargar explícitamente el trabajo intensivo del procesador, use Task.Run para crear e iniciar una nueva tarea, que puede esperar como cualquier otra operación asincrónica. Para obtener más información, vea Ejemplo: Usar Control.InvokeAsync (.NET 9 y versiones posteriores) yoperaciones y eventos entre subprocesos.

Para realizar una llamada segura para subprocesos mediante BackgroundWorker, controle el evento DoWork. Hay dos eventos que utiliza el trabajador en segundo plano para notificar el estado: ProgressChanged y RunWorkerCompleted. El ProgressChanged evento se usa para comunicar las actualizaciones de estado al subproceso principal y el RunWorkerCompleted evento se usa para indicar que el trabajo en segundo plano se ha completado. Para iniciar el subproceso en segundo plano, llame a BackgroundWorker.RunWorkerAsync.

El ejemplo cuenta de 0 a 10 durante el evento DoWork, pausando durante un segundo entre cuentas. Utiliza el ProgressChanged controlador de eventos para comunicar el número de regreso al subproceso principal y establecer la propiedad TextBox del control Text. Para que el evento ProgressChanged funcione, la propiedad BackgroundWorker.WorkerReportsProgress debe establecerse en 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