Annullamento di un’attività in sospeso in modo sincrono sul thread dell’interfaccia utente

A volte, una volta richiesta l’annullamento di un’attività in sospeso con CancellationTokenSource.Cancel , devo assicurarmi che l’attività abbia raggiunto correttamente lo stato annullato , prima che possa continuare. Molto spesso mi trovo ad affrontare questa situazione quando l’app termina e voglio cancellare tutte le attività in sospeso con garbo. Tuttavia, può anche essere un requisito delle specifiche del stream di lavoro dell’interfaccia utente, quando il nuovo processo in background può essere avviato solo se quello corrente in sospeso è stato completamente annullato o ha raggiunto la fine in modo naturale.

Sarei grato se qualcuno condividesse il suo approccio nel trattare questa situazione. Sto parlando del seguente schema:

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

Come è, è noto per essere in grado di causare facilmente un deadlock quando viene utilizzato sul thread dell’interfaccia utente. Tuttavia, non è sempre ansible utilizzare un’attesa asincrona (ad esempio await task , ad esempio, qui è uno dei casi in cui è ansible). Allo stesso tempo, è un odore di codice semplicemente richiedere la cancellazione e continuare senza effettivamente osservarne lo stato.

Come un semplice esempio che illustra il problema, è consigliabile verificare che la seguente attività di DoWorkAsync sia stata completamente annullata all’interno del gestore di eventi FormClosing . Se non aspetto il _task all’interno di MainForm_FormClosing , potrei anche non vedere la traccia "Finished work item N" per l’elemento di lavoro corrente, poiché l’app termina nel mezzo di un’attività secondaria in sospeso (che viene eseguita su un filo della piscina). Se lo aspetto, tuttavia, si verifica un 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); } } } 

Ciò accade perché il ciclo di messaggi del thread dell’interfaccia utente deve continuare a pompare messaggi, quindi la continuazione asincrona all’interno di DoWorkAsync (che è pianificata sul thread WindowsFormsSynchronizationContext ) ha la possibilità di essere eseguita e alla fine ha raggiunto lo stato annullato. Tuttavia, la pompa è bloccata con _task.Wait() , che porta al punto morto. Questo esempio è specifico per WinForms, ma il problema è rilevante anche nel contesto di WPF.

In questo caso, non vedo altre soluzioni se non quella di organizzare un ciclo di messaggi nidificati, mentre aspetto il _task . In un modo distante, è simile a Thread.Join , che continua a pompare messaggi mentre attende la conclusione di un thread. Il framework non sembra offrire un’API di task esplicita per questo, quindi alla fine ho scoperto la seguente implementazione di 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; } } } 

Credo che lo scenario descritto dovrebbe essere abbastanza comune per le app di interfaccia utente, ma ho trovato molto poco materiale su questo argomento. Idealmente, il processo di attività in background dovrebbe essere progettato nel modo in cui non richiede un message pump per supportare la cancellazione sincrona , ma non penso che sia sempre ansible.

Mi sto perdendo qualcosa? Ci sono altri modi / modelli forse più portabili per affrontarlo?

Quindi non vogliamo fare un’attesa sincrona in quanto ciò bloccherebbe il thread dell’interfaccia utente e probabilmente anche il deadlock.

Il problema con la gestione in modo asincrono è semplicemente che il modulo verrà chiuso prima di essere “pronto”. Questo può essere risolto; semplicemente annullare la chiusura del modulo se l’attività asincrona non è stata ancora eseguita, quindi chiuderla di nuovo “per davvero” al termine dell’attività.

Il metodo può avere un aspetto simile a questo (gestione degli errori omessa):

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

Si noti che, per semplificare la gestione degli errori, a questo punto si potrebbe rendere async anche il metodo, invece di usare continuazioni esplicite.

Non sono d’accordo sul fatto che sia un odore di codice emettere una richiesta di cancellazione senza attendere che la cancellazione abbia effetto. La maggior parte delle volte, l’attesa non è necessaria.

In effetti, negli scenari dell’interfaccia utente, direi che è l’approccio comune. Se è necessario evitare effetti collaterali (ad esempio, eseguire il debug delle stampe o più realisticamente, IProgress.Report o un’istruzione return ), quindi inserire semplicemente un controllo esplicito per la cancellazione prima di eseguirli:

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

Questo è particolarmente utile in un contesto di interfaccia utente perché non ci sono condizioni di gara attorno alla cancellazione.

Ispirata alla risposta di @ Servy , ecco un’altra idea: mostrare una finestra di dialogo modale temporanea con un messaggio “Please wait …” e utilizzare il suo loop di messaggi modali per attendere in modo asincrono per l’attività in sospeso. La finestra di dialogo scompare automaticamente quando l’attività è stata completamente annullata.

Questo è ciò che ShowModalWaitMessage fa sotto, chiamato da MainForm_FormClosing . Penso che questo approccio sia un po ‘più user-friendly.

Finestra di attesa

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

Che ne dici di usare il vecchio modo:

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

Puoi apportare ulteriori modifiche per mantenere l’utente impegnato con una bella animazione