Отмена ожидающей задачи синхронно в streamе пользовательского интерфейса
Иногда, когда я просил отменить ожидающую задачу с CancellationTokenSource.Cancel
, мне нужно убедиться, что задача правильно дошла до отмененного состояния , прежде чем я смогу продолжить. Чаще всего мне приходится сталкиваться с такой ситуацией, когда приложение заканчивается, и я хочу изящно отменить все ожидающие задачи. Тем не менее, это также может быть требованием спецификации рабочего процесса пользовательского интерфейса, когда новый фоновый процесс может начаться только в том случае, если текущий ожидающий полностью отменен или естественным образом завершился.
Я был бы признателен, если кто-то разделяет его / ее подход в решении этой ситуации. Я говорю о следующем шаблоне:
_cancellationTokenSource.Cancel(); _task.Wait();
Как известно, он, как известно, способен вызывать тупик при использовании в streamе пользовательского интерфейса. Однако вместо этого не всегда можно использовать асинхронное ожидание (т. await task
, например, вот один из случаев, когда это возможно). В то же время, это запах кода, который просто требует отмены и продолжения без фактического наблюдения за его состоянием.
- Почему это асинхронное действие зависает?
- Вызов синхронного асинхронного метода
- Секвенирование и реорганизация задач
- Лучший способ конвертировать метод async на основе обратного вызова в ожидаемую задачу
- Шаблон для самоотдачи и перезапуска задачи
В качестве простого примера, иллюстрирующего проблему, я могу убедиться, что следующая задача 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; } } }
Я считаю, что описанный сценарий должен быть довольно распространенным для приложений пользовательского интерфейса, но я нашел очень мало материалов по этому вопросу. В идеальном случае процесс фоновой задачи должен быть разработан таким образом, чтобы не требовать, чтобы насос сообщений поддерживал синхронную отмену , но я не думаю, что это всегда возможно.
Я что-то упускаю? Существуют ли другие, возможно, более переносные способы / шаблоны для решения этой проблемы?
- Как ограничить количество одновременных операций асинхронного ввода-вывода?
- Запуск нескольких задач async и ожидание их завершения
- Выполнение задач параллельно
- В чем разница между возвратом пустоты и возвратом задачи?
- WaitAll vs WhenAll
- Когда использовать Task.Delay, когда использовать Thread.Sleep?
- Запуск двух асинхронных задач параллельно и сбор результатов в .NET 4.5
- Очередь процесса с многопоточным или задачами
Поэтому мы не хотим делать синхронное ожидание, поскольку это будет блокировать stream пользовательского интерфейса, а также, возможно, блокировку.
Проблема с асинхронной обработкой заключается в том, что форма будет закрыта до того, как вы будете готовы. Это можно исправить; просто отмените закрытие формы, если асинхронная задача еще не выполнена, а затем закройте ее снова «по-настоящему», когда задача завершится.
Метод может выглядеть примерно так (обработка ошибок опущена):
void MainForm_FormClosing(object sender, FormClosingEventArgs e) { if (!_task.IsCompleted) { e.Cancel = true; _cts.Cancel(); _task.ContinueWith(t => Close(), TaskScheduler.FromCurrentSynchronizationContext()); } }
Обратите внимание, что для облегчения обработки ошибок вы могли бы в этот момент сделать async
метод, а не использовать явные продолжения.
Я не согласен с тем, что это запах кода для выдачи запроса на отмену, не дожидаясь, когда аннулирование вступит в силу. Большую часть времени ждать не нужно.
Фактически, в сценариях пользовательского интерфейса я бы сказал, что это общий подход. Если вам нужно избегать побочных эффектов (например, отладочных отпечатков или более реалистично, IProgress
или оператора return
), просто IProgress
явную проверку на отмену перед их выполнением:
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"); }
Вы можете сделать некоторые дополнительные изменения, чтобы пользователь был занят приятной анимацией