Cancelando uma tarefa pendente de forma síncrona no encadeamento da interface do usuário

Às vezes, depois de solicitar o cancelamento de uma tarefa pendente com CancellationTokenSource.Cancel , preciso garantir que a tarefa tenha atingido adequadamente o estado cancelado antes que eu possa continuar. Na maioria das vezes, enfrento essa situação quando o aplicativo está sendo encerrado e desejo cancelar todas as tarefas pendentes normalmente. No entanto, ele também pode ser um requisito da especificação do stream de trabalho da interface do usuário, quando o novo processo em segundo plano só pode ser iniciado se o pendente atual tiver sido totalmente cancelado ou alcançado naturalmente.

Eu apreciaria se alguém compartilhasse sua abordagem ao lidar com essa situação. Eu estou falando sobre o seguinte padrão:

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

Como é, é conhecido por ser capaz de causar facilmente um deadlock quando usado no thread da interface do usuário. No entanto, nem sempre é possível usar uma espera assíncrona (por exemplo, await task ; por exemplo, aqui está um dos casos em que é possível). Ao mesmo tempo, é um cheiro de código simplesmente solicitar o cancelamento e continuar sem realmente observar seu estado.

Como um exemplo simples que ilustra o problema, talvez seja necessário certificar-se de que a tarefa DoWorkAsync a seguir tenha sido totalmente cancelada dentro do manipulador de events FormClosing . Se eu não esperar pela _task dentro de MainForm_FormClosing , talvez nem veja o rastreio "Finished work item N" para o item de trabalho atual, pois o aplicativo é encerrado no meio de uma subtarefa pendente (que é executada em um fio da piscina). Se eu esperar, isso resulta em um deadlock:

 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); } } } 

Isso acontece porque o loop de mensagens do thread de interface do usuário tem que continuar bombeando mensagens, portanto, a continuação assíncrona dentro DoWorkAsync (que está agendada no WindowsFormsSynchronizationContext do thread) tem uma chance de ser executada e, eventualmente, ter atingido o estado cancelado. No entanto, a bomba é bloqueada com _task.Wait() , que leva ao deadlock. Este exemplo é específico para WinForms, mas o problema também é relevante no contexto do WPF.

Nesse caso, não vejo nenhuma outra solução a não ser organizar um loop de mensagem nested, enquanto aguardo o _task . De um modo distante, é semelhante ao Thread.Join , que mantém as mensagens bombeando enquanto aguarda o término de um thread. A estrutura não parece oferecer uma API de tarefa explícita para isso, então, finalmente, desenvolvi a seguinte implementação do 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; } } } 

Acredito que o cenário descrito deve ser bastante comum para os aplicativos de interface do usuário, mas eu encontrei muito pouco material sobre este assunto. Idealmente, o processo de tarefa em segundo plano deve ser projetado da maneira que não requer uma bomba de mensagens para suportar o cancelamento síncrono , mas não acho que isso seja sempre possível.

Estou esquecendo de algo? Existem outras formas / padrões talvez mais portáveis ​​para lidar com isso?

Portanto, não queremos fazer uma espera síncrona, pois isso estaria bloqueando o thread da interface do usuário e, possivelmente, o deadlocking.

O problema de lidar com isso de forma assíncrona é simplesmente que o formulário será fechado antes de você estar “pronto”. Isso pode ser consertado; simplesmente cancele o fechamento do formulário se a tarefa assíncrona ainda não estiver concluída e feche-a novamente “para real” quando a tarefa for concluída.

O método pode se parecer com algo assim (tratamento de erros omitido):

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

Note que, para tornar o tratamento de erros mais fácil, você poderia neste momento tornar o método async também, ao invés de usar continuações explícitas.

Eu discordo que é um cheiro de código para emitir uma solicitação de cancelamento sem esperar que o cancelamento entre em vigor. Na maioria das vezes, a espera não é necessária.

De fato, em cenários de interface do usuário, eu diria que essa é a abordagem comum. Se você precisar evitar efeitos colaterais (por exemplo, depurar impressões, ou mais realisticamente, IProgress.Report ou uma declaração de return ), basta inserir uma verificação explícita para cancelamento antes de executá-las:

 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); 

Isso é particularmente útil em um contexto de interface do usuário porque não há condições de corrida em torno do cancelamento.

Inspirado pela resposta de @Servy , aqui está outra idéia: mostrar uma checkbox de modal dialog temporária com uma mensagem “Aguarde por favor …” e utilizar seu loop de mensagem modal para aguardar de forma assíncrona pela tarefa pendente. O diálogo desaparece automaticamente quando a tarefa foi totalmente cancelada.

Isso é o que ShowModalWaitMessage faz abaixo, chamado de MainForm_FormClosing . Eu acho que essa abordagem é um pouco mais fácil de usar.

Caixa de 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; } } } 

Que tal usar o caminho mais antigo:

  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"); } 

Você pode fazer mais modificações para manter o usuário ocupado com uma boa animação