一尘不染

在UI线程上同步取消待处理的任务

c#

有时,一旦我通过要求取消了待处理的任务CancellationTokenSource.Cancel,就需要确保 该任务已正确达到已取消状态
,然后才能继续。当应用程序终止时,我通常会遇到这种情况,而我想优雅地取消所有待处理的任务。但是,这也可能是UI工作流规范的要求,当新的后台进程仅在当前的未决进程已完全取消或自然结束时才能启动。

如果有人分享他/她的方式来处理这种情况,我将不胜感激。 我正在谈论以下模式:

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

照原样,众所周知,当在UI线程上使用时,能够轻松导致死锁。然而,它并不总是可以使用一个异步等待,而不是(即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);
        }
    }
}

发生这种情况是因为UI线程的消息循环必须继续泵送消息,所以内部的异步继续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;
        }
    }
}

我相信所描述的场景对于UI应用程序应该是很普遍的,但是我发现关于此主题的材料很少。 理想情况下,应该以不需要消息泵支持同步取消的方式设计后台任务过程
,但是我认为这并非总是可能的。

我想念什么吗?还有其他也许更便携的方式来处理它吗?


阅读 251

收藏
2020-05-19

共1个答案

一尘不染

因此,我们不想进行同步等待,因为那样会阻塞UI线程,甚至可能导致死锁。

异步处理它的问题很简单,就是在您“准备就绪”之前将关闭表单。那可以解决;简单地取消关闭形式,如果异步任务尚未完成,然后关闭它再次“真正”的时候,任务
完成。

该方法看起来像这样(省略了错误处理):

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

请注意,为使错误处理更容易,您此时也可以使方法async也可以,而不是使用显式延续。

2020-05-19