在 UI 线程上同步取消挂起的任务

Posted

技术标签:

【中文标题】在 UI 线程上同步取消挂起的任务【英文标题】:Cancelling a pending task synchronously on the UI thread 【发布时间】:2014-01-19 12:41:37 【问题描述】:

有时,一旦我使用CancellationTokenSource.Cancel 请求取消挂起的任务,我需要确保任务已正确达到取消状态,然后才能继续。大多数情况下,当应用程序终止并且我想优雅地取消所有待处理的任务时,我会遇到这种情况。但是,这也可能是 UI 工作流规范的要求,即只有在当前待处理的进程完全取消或自然结束时才能启动新的后台进程。

如果有人分享他/她处理这种情况的方法,我将不胜感激。我说的是以下模式:

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

众所周知,当在 UI 线程上使用时,它很容易导致死锁。但是,并不总是可以使用异步等待来代替(即await task;例如,here 是可能的情况之一)。同时,简单地请求取消并继续而不实际观察其状态是一种代码味道。

作为一个说明问题的简单示例,我可能想确保在FormClosing 事件处理程序中完全取消了以下DoWorkAsync 任务。如果我不等待MainForm_FormClosing 中的_task,我什至可能看不到当前工作项的"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://***.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://***.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 应用程序来说应该很常见,但我发现关于这个主题的材料很少。 理想情况下,后台任务进程应该设计成不需要消息泵来支持同步取消,但我认为这并不总是可能的。

我错过了什么吗?还有其他可能更便携的方式/模式来处理它吗?

【问题讨论】:

作为一个快速说明,您不应该将 cts 传递到您的等待呼叫中吗?即使用 await Task.Delay(_cts) 或类似的东西而不是 Thread.Sleep @LukeMcGregor,我故意将Sleep 放在那里,就像Task.Run 启动的一些原子CPU 绑定操作的模拟,它不支持即时取消。事实上,如果我在这个示例代码中使用await Task.Delay(1000, ct) 而不是await Task.Run(...),它不会导致死锁。但是,以下仍然可以:await Task.Run(async () =&gt; /* do some pool thread work first, then delay */ await Task.Delay(1000, ct); ); 使用 Wait 阻塞 UI 方法是一种反模式。为什么不在令牌上注册回调,并在异步任务完成并处理您的结果时让它返回给您。 msdn.microsoft.com/en-us/library/dd321663%28v=vs.110%29.aspx @Random, ct.Register(callback, useSynchronizationContext: false) 在取消后台 IO 任务时确实很有帮助。我确实使用它,例如通过从callback 调用HttpClient.CancelPendingRequests 来取消挂起的HTTP 请求。但是,我看不出它如何应用于像上面的DoWorkAsync 这样的情况,在这种情况下,CPU 绑定的后台工作块是从 UI 线程开始的。如果您可以详细说明,请随时发布答案。 好的,我将睡眠增加到了更高的数字,我明白你的意思... 【参考方案1】:

所以我们不希望进行同步等待,因为这会阻塞 UI 线程,并且还可能导致死锁。

异步处理的问题在于表单会在您“准备好”之前关闭。这可以修复;如果异步任务尚未完成,只需取消表单关闭,然后在任务确实完成时再次“真正地”关闭它。

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

void MainForm_FormClosing(object sender, FormClosingEventArgs e)

    if (!_task.IsCompleted)
    
        e.Cancel = true;
        _cts.Cancel();
        _task.ContinueWith(t => Close(), 
            TaskScheduler.FromCurrentSynchronizationContext());
    

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

【讨论】:

@Servy,你会如何处理定时等待?假设如果任务行为不端,我们不想永远等待。我认为你不能用这种方法做到这一点。你需要另一个任务/线程来处理它。 @gwiazdorrr 这是一个微不足道的例子。只需将第二个取消令牌添加到我的代码中设置为在您想要的任何超时时间后取消的延续。 整个条件可以简化为if (!_task.IsCompleted)。那是因为IsCompleted 意味着Task 以任何方式完成,而不仅仅是成功;这与检查Status是否为RanToCompletion相同,这很令人困惑。 @svick 我不记得了,所以我很保守。感谢您指出。 @Servy,处理异常的正确方法是什么?【参考方案2】:

我不同意在不等待取消生效的情况下发出取消请求是一种代码异味。大多数时候,不需要等待。

事实上,在 UI 场景中,我会说这是常用的方法。如果您需要避免副作用(例如,调试打印,或更实际地,IProgress&lt;T&gt;.Reportreturn 语句),那么只需在执行它们之前插入显式的取消检查:

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

这在 UI 上下文中特别有用,因为取消时没有竞争条件。

【讨论】:

斯蒂芬,这是一个非常有趣的观点......我承认这一点,但我有点难以接受,恕我直言。我非常不愿意让我的应用程序在上面的Sleep 中间退出,特别是如果在Sleep 之后有一些不透明的资源等待正确关闭(例如遗留 COM 对象)。无论如何,您是否会将这种未观察到的取消的想法也扩展到异步等待(我们讨论过here)? 是的,如果我没听错的话。有一些异步 API 是不可取消的——烦人,但这就是生活。因此,我使用了事后显式检查来取消(抛出异常)或忽略结果(只是返回)。我没有太多 COM 互操作经验,但 .NET 非常擅长清理几乎所有内容;如果您的应用程序仍在运行,则仍然有 STA 线程泵送,如果没有,那么操作系统无论如何都会清理所有内容。 我假设一旦取消,任务最终会完成,因此任务的数量不会无限增长。此外,几乎任何类型的服务器都可以接受多个连接,所以我没有看到任何重叠的问题。对我来说,这种“等到资源被清理干净”的逻辑只有在资源无法共享并且新任务可能需要相同的资源时才需要。 我想我可以同意这一点:如果不涉及共享或昂贵的资源,则可以请求取消并让任务结束(或最近的ct.ThrowIfCancellationRequested())而不观察这个。 @MattSmith:如果您谈论的是实际的后台线程,那么是的,在取消请求和任务完成之间总会存在竞争条件(通常是良性的)。 OTOH,如果它是 UI/ASP.NET 上下文中的 async 方法,则不存在竞争条件。我有几篇相关的博客文章:async disposal(“选项 1:处理意味着取消”部分)和callback contexts(我们使用 CT 作为“回调上下文”,有点)。【参考方案3】:

受@Servy's answer 的启发,这里有另一个想法:显示带有“请稍候...”消息的临时模态对话框,并利用其模态消息循环异步等待挂起的任务。任务完全取消后,对话框会自动消失。

这就是ShowModalWaitMessage 在下面所做的,从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;
        
    

【讨论】:

1) 你准备好应对用户关闭对话框的情况了吗?您要么想要禁用该选项,要么确保它在他们这样做时正常工作。 2) 而不是计算等待多长时间的滴答计数await Task.WhenAny(Task.Delay(2000), task) 实质上为任务添加了“超时”。另一种选择是在将ContinueWith(t =&gt; , new TaskCompletionSource(2000).Token); 分配给_task 之前将ContinueWith(t =&gt; , new TaskCompletionSource(2000).Token); 添加到任何任务上,而不是在此处添加超时功能。 @Servy,对,这些都是好点。这只是一个可以通过多种方式改进的概念代码。此外,如果用户过早关闭“等待...”对话框,我可能想接受Stephen's answer 并让应用程序关闭。在这种情况下,至少会尝试优雅地关闭。 @Servy, 2) 而不是计算等待多长时间的滴答计数只是 await Task.WhenAny(Task.Delay(2000), task) - 我会宁可使用await Task.WhenAll(Task.Delay(2000), task),目标是让对话框至少停留 2 秒,这样它就不会闪烁并立即消失。 那么你会使用WhenAll而不是WhenAny 这是我在处理关键资源“关闭”时更喜欢使用的方法(例如在允许其控制应用程序终止之前等待一个物理机器受控停止。) 【参考方案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");
    

您可以做一些进一步的修改,让用户忙于制作漂亮的动画

【讨论】:

我们不需要BeginInvokeTask.Run 也可以,但请在 cmets here 中查看我的想法。

以上是关于在 UI 线程上同步取消挂起的任务的主要内容,如果未能解决你的问题,请参考以下文章

ThreadPoolExecutor : 拉出挂起的任务

ThreadPoolExecutor:拉出挂起的任务

ThreadPool类(线程池)

js Event Loop 事件循环

Thread类

多线程挂起UI?