Cancelar una tarea pendiente de forma sincrónica en el hilo de la interfaz de usuario

Algunas veces, una vez que he solicitado la cancelación de una tarea pendiente con CancellationTokenSource.Cancel , necesito asegurarme de que la tarea ha alcanzado correctamente el estado cancelado , antes de poder continuar. La mayoría de las veces me enfrento a esta situación cuando la aplicación está finalizando y quiero cancelar todas las tareas pendientes correctamente. Sin embargo, también puede ser un requisito de la especificación del flujo de trabajo de la interfaz de usuario, cuando el nuevo proceso de fondo solo puede comenzar si el pendiente actual se ha cancelado por completo o ha llegado a su fin de forma natural.

Agradecería que alguien compartiera su enfoque al lidiar con esta situación. Estoy hablando del siguiente patrón:

 _cancellationTokenSource.Cancel(); _task.Wait(); 

Como es, se sabe que es capaz de causar fácilmente un interlocking cuando se utiliza en el subproceso de interfaz de usuario. Sin embargo, no siempre es posible utilizar una espera asíncrona en su lugar (es decir, await task , por ejemplo, aquí hay uno de los casos en que es posible). Al mismo tiempo, es un olor a código simplemente solicitar la cancelación y continuar sin observar su estado.

Como un ejemplo simple que ilustra el problema, me gustaría asegurarme de que la siguiente tarea DoWorkAsync se haya cancelado por completo dentro del controlador de eventos FormClosing . Si no espero la _task dentro de MainForm_FormClosing , es posible que ni siquiera vea el trazo "Finished work item N" MainForm_FormClosing "Finished work item N" para el elemento de trabajo actual, ya que la aplicación termina en el medio de una subtarea pendiente (que se ejecuta en un hilo de piscina). Sin embargo, si espero, resulta en un punto muerto:

 public partial class MainForm : Form { CancellationTokenSource _cts; Task _task; // Form Load event void MainForm_Load(object sender, EventArgs e) { _cts = new CancellationTokenSource(); _task = DoWorkAsync(_cts.Token); } // Form Closing event void MainForm_FormClosing(object sender, FormClosingEventArgs e) { _cts.Cancel(); try { // if we don't wait here, // we may not see "Finished work item N" for the current item, // if we do wait, we'll have a deadlock _task.Wait(); } catch (Exception ex) { if (ex is AggregateException) ex = ex.InnerException; if (!(ex is OperationCanceledException)) throw; } MessageBox.Show("Task cancelled"); } // async work async Task DoWorkAsync(CancellationToken ct) { var i = 0; while (true) { ct.ThrowIfCancellationRequested(); var item = i++; await Task.Run(() => { Debug.Print("Starting work item " + item); // use Sleep as a mock for some atomic operation which cannot be cancelled Thread.Sleep(1000); Debug.Print("Finished work item " + item); }, ct); } } } 

Eso sucede porque el bucle de mensajes del subproceso UI debe continuar transmitiendo mensajes, por lo que la continuación asincrónica dentro de DoWorkAsync (que está progtwigda en WindowsFormsSynchronizationContext del subproceso) tiene la posibilidad de ejecutarse y, finalmente, llegar al estado cancelado. Sin embargo, la bomba está bloqueada con _task.Wait() , que conduce al punto muerto. Este ejemplo es específico de WinForms, pero el problema también es relevante en el contexto de WPF.

En este caso, no veo ninguna otra solución más que organizar un bucle de mensajes nesteds, mientras espero la _task . De manera distante, es similar a Thread.Join , que sigue transmitiendo mensajes mientras espera que termine un hilo. El marco no parece ofrecer una API de tareas explícita para esto, así que finalmente he presentado la siguiente implementación de WaitWithDoEvents :

 using System; using System.Diagnostics; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; namespace WinformsApp { public partial class MainForm : Form { CancellationTokenSource _cts; Task _task; // Form Load event void MainForm_Load(object sender, EventArgs e) { _cts = new CancellationTokenSource(); _task = DoWorkAsync(_cts.Token); } // Form Closing event void MainForm_FormClosing(object sender, FormClosingEventArgs e) { // disable the UI var wasEnabled = this.Enabled; this.Enabled = false; try { // request cancellation _cts.Cancel(); // wait while pumping messages _task.AsWaitHandle().WaitWithDoEvents(); } catch (Exception ex) { if (ex is AggregateException) ex = ex.InnerException; if (!(ex is OperationCanceledException)) throw; } finally { // enable the UI this.Enabled = wasEnabled; } MessageBox.Show("Task cancelled"); } // async work async Task DoWorkAsync(CancellationToken ct) { var i = 0; while (true) { ct.ThrowIfCancellationRequested(); var item = i++; await Task.Run(() => { Debug.Print("Starting work item " + item); // use Sleep as a mock for some atomic operation which cannot be cancelled Thread.Sleep(1000); Debug.Print("Finished work item " + item); }, ct); } } public MainForm() { InitializeComponent(); this.FormClosing += MainForm_FormClosing; this.Load += MainForm_Load; } } ///  /// WaitHandle and Task extensions /// by Noseratio - https://stackoverflow.com/users/1768303/noseratio ///  public static class WaitExt { ///  /// Wait for a handle and pump messages with DoEvents ///  public static bool WaitWithDoEvents(this WaitHandle handle, CancellationToken token, int timeout) { if (SynchronizationContext.Current as System.Windows.Forms.WindowsFormsSynchronizationContext == null) { // https://stackoverflow.com/a/19555959 throw new ApplicationException("Internal error: WaitWithDoEvents must be called on a thread with WindowsFormsSynchronizationContext."); } const uint EVENT_MASK = Win32.QS_ALLINPUT; IntPtr[] handles = { handle.SafeWaitHandle.DangerousGetHandle() }; // track timeout if not infinite Func hasTimedOut = () => false; int remainingTimeout = timeout; if (timeout != Timeout.Infinite) { int startTick = Environment.TickCount; hasTimedOut = () => { // Environment.TickCount wraps correctly even if runs continuously int lapse = Environment.TickCount - startTick; remainingTimeout = Math.Max(timeout - lapse, 0); return remainingTimeout > 16) != 0) continue; // the message queue is empty, raise Idle event System.Windows.Forms.Application.RaiseIdle(EventArgs.Empty); if (hasTimedOut()) return false; // wait for either a Windows message or the handle // MWMO_INPUTAVAILABLE also observes messages already seen (eg with PeekMessage) but not removed from the queue var result = Win32.MsgWaitForMultipleObjectsEx(1, handles, (uint)remainingTimeout, EVENT_MASK, Win32.MWMO_INPUTAVAILABLE); if (result == Win32.WAIT_OBJECT_0 || result == Win32.WAIT_ABANDONED_0) return true; // handle signalled if (result == Win32.WAIT_TIMEOUT) return false; // timed out if (result == Win32.WAIT_OBJECT_0 + 1) // an input/message pending continue; // unexpected result throw new InvalidOperationException(); } } public static bool WaitWithDoEvents(this WaitHandle handle, int timeout) { return WaitWithDoEvents(handle, CancellationToken.None, timeout); } public static bool WaitWithDoEvents(this WaitHandle handle) { return WaitWithDoEvents(handle, CancellationToken.None, Timeout.Infinite); } public static WaitHandle AsWaitHandle(this Task task) { return ((IAsyncResult)task).AsyncWaitHandle; } ///  /// Win32 interop declarations ///  public static class Win32 { [DllImport("user32.dll")] public static extern uint GetQueueStatus(uint flags); [DllImport("user32.dll", SetLastError = true)] public static extern uint MsgWaitForMultipleObjectsEx( uint nCount, IntPtr[] pHandles, uint dwMilliseconds, uint dwWakeMask, uint dwFlags); public const uint QS_KEY = 0x0001; public const uint QS_MOUSEMOVE = 0x0002; public const uint QS_MOUSEBUTTON = 0x0004; public const uint QS_POSTMESSAGE = 0x0008; public const uint QS_TIMER = 0x0010; public const uint QS_PAINT = 0x0020; public const uint QS_SENDMESSAGE = 0x0040; public const uint QS_HOTKEY = 0x0080; public const uint QS_ALLPOSTMESSAGE = 0x0100; public const uint QS_RAWINPUT = 0x0400; public const uint QS_MOUSE = (QS_MOUSEMOVE | QS_MOUSEBUTTON); public const uint QS_INPUT = (QS_MOUSE | QS_KEY | QS_RAWINPUT); public const uint QS_ALLEVENTS = (QS_INPUT | QS_POSTMESSAGE | QS_TIMER | QS_PAINT | QS_HOTKEY); public const uint QS_ALLINPUT = (QS_INPUT | QS_POSTMESSAGE | QS_TIMER | QS_PAINT | QS_HOTKEY | QS_SENDMESSAGE); public const uint MWMO_INPUTAVAILABLE = 0x0004; public const uint WAIT_TIMEOUT = 0x00000102; public const uint WAIT_FAILED = 0xFFFFFFFF; public const uint INFINITE = 0xFFFFFFFF; public const uint WAIT_OBJECT_0 = 0; public const uint WAIT_ABANDONED_0 = 0x00000080; } } } 

Creo que el escenario descrito debería ser bastante común para las aplicaciones de interfaz de usuario, sin embargo, he encontrado muy poco material sobre este tema. Idealmente, el proceso de la tarea en segundo plano debe diseñarse de la manera en que no requiere una bomba de mensajes para soportar la cancelación sincrónica , pero no creo que esto siempre sea posible.

¿Me estoy perdiendo de algo? ¿Hay otras formas / patrones más manejables para manejarlo?

Así que no queremos hacer una espera sincrónica ya que eso estaría bloqueando el hilo de UI, y posiblemente también el locking.

El problema con el manejo asincrónico es simplemente que el formulario se cerrará antes de que esté “listo”. Eso puede ser arreglado; simplemente cancele el cierre del formulario si la tarea asíncrona aún no ha finalizado, y luego vuelva a cerrarla “de verdad” cuando la tarea finalice.

El método puede parecerse a esto (se omite el manejo de errores):

 void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (!_task.IsCompleted) { e.Cancel = true; _cts.Cancel(); _task.ContinueWith(t => Close(), TaskScheduler.FromCurrentSynchronizationContext()); } } 

Tenga en cuenta que, para facilitar el manejo de errores, en este punto podría hacer que el método también sea async , en lugar de usar continuaciones explícitas.

No estoy de acuerdo con que sea un olor codicioso emitir una solicitud de cancelación sin esperar a que la cancelación surta efecto. La mayoría de las veces, esperar no es necesario.

De hecho, en los escenarios de IU, diría que ese es el enfoque común. Si necesita evitar los efectos secundarios (por ejemplo, impresiones de depuración, o de forma más realista, IProgress.Report o una statement de return ), simplemente inserte una comprobación explícita para la cancelación antes de realizarlos:

 Debug.Print("Starting work item " + item); // use Sleep as a mock for some atomic operation which cannot be cancelled Thread.Sleep(10000); ct.ThrowIfCancellationRequested(); Debug.Print("Finished work item " + item); 

Esto es particularmente útil en un contexto UI porque no hay condiciones de carrera en torno a la cancelación.

Inspirada en la respuesta de @Servy , aquí hay otra idea: mostrar un diálogo modal temporal con un mensaje “Espere …” y utilizar su ciclo de mensaje modal para esperar asíncronamente la tarea pendiente. El diálogo desaparece automáticamente cuando la tarea se ha cancelado por completo.

Eso es lo que hace ShowModalWaitMessage continuación, llamado desde MainForm_FormClosing . Creo que este enfoque es un poco más fácil de usar.

Diálogo de espera

 using System; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; namespace WinformsApp { public partial class MainForm : Form { CancellationTokenSource _cts; Task _task; // Form Load event void MainForm_Load(object sender, EventArgs e) { _cts = new CancellationTokenSource(); _task = DoWorkAsync(_cts.Token); } // Form Closing event void MainForm_FormClosing(object sender, FormClosingEventArgs e) { ShowModalWaitMessage(); } // Show a message and wait void ShowModalWaitMessage() { var dialog = new Form(); dialog.Load += async (s, e) => { _cts.Cancel(); try { // show the dialog for at least 2 secs await Task.WhenAll(_task, Task.Delay(2000)); } catch (Exception ex) { while (ex is AggregateException) ex = ex.InnerException; if (!(ex is OperationCanceledException)) throw; } dialog.Close(); }; dialog.ShowIcon = false; dialog.ShowInTaskbar = false; dialog.FormBorderStyle = FormBorderStyle.FixedToolWindow; dialog.StartPosition = FormStartPosition.CenterParent; dialog.Width = 160; dialog.Height = 100; var label = new Label(); label.Text = "Closing, please wait..."; label.AutoSize = true; dialog.Controls.Add(label); dialog.ShowDialog(); } // async work async Task DoWorkAsync(CancellationToken ct) { var i = 0; while (true) { ct.ThrowIfCancellationRequested(); var item = i++; await Task.Run(() => { Debug.Print("Starting work item " + item); // use Sleep as a mock for some atomic operation which cannot be cancelled Thread.Sleep(1000); Debug.Print("Finished work item " + item); }, ct); } } public MainForm() { InitializeComponent(); this.FormClosing += MainForm_FormClosing; this.Load += MainForm_Load; } } } 

¿Qué tal si usamos la forma anterior?

  public delegate void AsyncMethodCaller(CancellationToken ct); private CancellationTokenSource _cts; private AsyncMethodCaller caller; private IAsyncResult methodResult; // Form Load event private void MainForm_Load(object sender, EventArgs e) { _cts = new CancellationTokenSource(); caller = new AsyncMethodCaller(DoWorkAsync); methodResult = caller.BeginInvoke(_cts.Token, ar => { }, null); } // Form Closing event private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { _cts.Cancel(); MessageBox.Show("Task cancellation requested"); } // async work private void DoWorkAsync(CancellationToken ct) { var i = 0; while (true) { var item = i++; Debug.Print("Starting work item " + item); // use Sleep as a mock for some atomic operation which cannot be cancelled Thread.Sleep(10000); Debug.Print("Finished work item " + item); if (ct.IsCancellationRequested) { return; } } } private void MainForm_FormClosed(object sender, FormClosedEventArgs e) { methodResult.AsyncWaitHandle.WaitOne(); MessageBox.Show("Task cancelled"); } 

Puede hacer algunas modificaciones adicionales para mantener al usuario ocupado con una buena animación