Подтвердить что ты не робот

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

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

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

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

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

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

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

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

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

    /// <summary>
    /// WaitHandle and Task extensions
    /// by Noseratio - https://stackoverflow.com/users/1768303/noseratio
    /// </summary>
    public static class WaitExt
    {
        /// <summary>
        /// Wait for a handle and pump messages with DoEvents
        /// </summary>
        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<bool> 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 <= 0;
                };
            }

            // pump messages
            while (true)
            {
                // throw if cancellation requested from outside
                token.ThrowIfCancellationRequested();

                // do an instant check
                if (handle.WaitOne(0)) 
                    return true;

                // pump the pending message
                System.Windows.Forms.Application.DoEvents();

                // check if timed out
                if (hasTimedOut())
                    return false;

                // the queue status high word is non-zero if a Windows message is still in the queue
                if ((Win32.GetQueueStatus(EVENT_MASK) >> 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 (e.g. 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;
        }

        /// <summary>
        /// Win32 interop declarations
        /// </summary>
        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;
        }
    }
}

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

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

4b9b3361

Ответ 1

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

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

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

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

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

Ответ 2

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

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

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

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

Ответ 3

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

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

Wait Dialog

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

Ответ 4

Как использовать более старый способ:

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

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