Отмена ожидающей задачи синхронно в streamе пользовательского интерфейса

Иногда, когда я просил отменить ожидающую задачу с CancellationTokenSource.Cancel , мне нужно убедиться, что задача правильно дошла до отмененного состояния , прежде чем я смогу продолжить. Чаще всего мне приходится сталкиваться с такой ситуацией, когда приложение заканчивается, и я хочу изящно отменить все ожидающие задачи. Тем не менее, это также может быть требованием спецификации рабочего процесса пользовательского интерфейса, когда новый фоновый процесс может начаться только в том случае, если текущий ожидающий полностью отменен или естественным образом завершился.

Я был бы признателен, если кто-то разделяет его / ее подход в решении этой ситуации. Я говорю о следующем шаблоне:

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

Как известно, он, как известно, способен вызывать тупик при использовании в streamе пользовательского интерфейса. Однако вместо этого не всегда можно использовать асинхронное ожидание (т. await task , например, вот один из случаев, когда это возможно). В то же время, это запах кода, который просто требует отмены и продолжения без фактического наблюдения за его состоянием.

В качестве простого примера, иллюстрирующего проблему, я могу убедиться, что следующая задача DoWorkAsync полностью отменена внутри обработчика событий FormClosing . Если я не _task внутри MainForm_FormClosing , я даже не вижу трассировку "Finished work item N" для текущего рабочего элемента, так как приложение завершается посередине ожидающей подзадачи (которая выполняется на stream бассейна). Если я все же жду, это приведет к тупику:

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

Это происходит из-за того, что цикл сообщений streamа пользовательского интерфейса должен продолжать передавать сообщения, поэтому асинхронное продолжение внутри DoWorkAsync (которое запланировано в streamе WindowsFormsSynchronizationContext ) имеет шанс выполнить и в конечном итоге достигло отмененного состояния. Однако насос заблокирован _task.Wait() , что приводит к тупиковой ситуации. Этот пример специфичен для WinForms, но проблема также актуальна в контексте WPF.

В этом случае я не вижу других решений, кроме как организовать вложенный цикл сообщений, ожидая _task . В отдаленном режиме он похож на Thread.Join , который продолжает накачивать сообщения, ожидая окончания streamа. Структура, похоже, не предлагает явного API задачи для этого, поэтому я в конечном итоге придумал следующую реализацию 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; } } } 

Я считаю, что описанный сценарий должен быть довольно распространенным для приложений пользовательского интерфейса, но я нашел очень мало материалов по этому вопросу. В идеальном случае процесс фоновой задачи должен быть разработан таким образом, чтобы не требовать, чтобы насос сообщений поддерживал синхронную отмену , но я не думаю, что это всегда возможно.

Я что-то упускаю? Существуют ли другие, возможно, более переносные способы / шаблоны для решения этой проблемы?

Поэтому мы не хотим делать синхронное ожидание, поскольку это будет блокировать stream пользовательского интерфейса, а также, возможно, блокировку.

Проблема с асинхронной обработкой заключается в том, что форма будет закрыта до того, как вы будете готовы. Это можно исправить; просто отмените закрытие формы, если асинхронная задача еще не выполнена, а затем закройте ее снова «по-настоящему», когда задача завершится.

Метод может выглядеть примерно так (обработка ошибок опущена):

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

Обратите внимание, что для облегчения обработки ошибок вы могли бы в этот момент сделать async метод, а не использовать явные продолжения.

Я не согласен с тем, что это запах кода для выдачи запроса на отмену, не дожидаясь, когда аннулирование вступит в силу. Большую часть времени ждать не нужно.

Фактически, в сценариях пользовательского интерфейса я бы сказал, что это общий подход. Если вам нужно избегать побочных эффектов (например, отладочных отпечатков или более реалистично, IProgress.Report или оператора return ), просто IProgress.Report явную проверку на отмену перед их выполнением:

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

Это особенно полезно в контексте пользовательского интерфейса, потому что нет условий гонки вокруг отмены.

Вдохновленный ответом @ Servy , вот еще одна идея: покажите временное модальное диалоговое окно с сообщением «Подождите …» и используйте его модальный цикл сообщений, чтобы ждать асинхронно для ожидающей задачи. Диалог автоматически исчезает, когда задача полностью отменена.

Вот что ShowModalWaitMessage ниже, MainForm_FormClosing из MainForm_FormClosing . Я думаю, что этот подход более удобен для пользователя.

Диалог ожидания

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

Как насчет использования более старого способа:

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

Вы можете сделать некоторые дополнительные изменения, чтобы пользователь был занят приятной анимацией

  • Выполнение задачи в фоновом режиме в приложении WPF
  • Запуск задач в foreach Loop использует значение последнего элемента
  • Должны ли мы переключиться на использование асинхронного ввода-вывода по умолчанию?
  • Использовать Task.Run () в синхронном методе, чтобы избежать тупиковой остановки в асинхронном методе?
  • ConfigureAwait подталкивает продолжение в stream пула
  • Разница между TPL и async / await (Обработка streamов)
  • Каков правильный способ отмены асинхронной операции, которая не принимает CancellationToken?
  • Давайте будем гением компьютера.