对 CancellationTokenSource.Cancel 的调用永远不会返回

Posted

技术标签:

【中文标题】对 CancellationTokenSource.Cancel 的调用永远不会返回【英文标题】:A call to CancellationTokenSource.Cancel never returns 【发布时间】:2015-10-08 07:56:44 【问题描述】:

我遇到的情况是,对 CancellationTokenSource.Cancel 的调用永远不会返回。相反,在调用Cancel 之后(在它返回之前),执行将继续执行正在取消的代码的取消代码。如果被取消的代码随后没有调用任何可等待的代码,那么最初调用Cancel 的调用者永远不会重新获得控制权。这很奇怪。我希望Cancel 简单地记录取消请求并立即独立于取消本身返回。事实上,调用Cancel 的线程最终会执行属于被取消操作的代码,并且在返回到Cancel 的调用者之前执行此操作,这看起来像是框架中的一个错误。

这是怎么回事:

    有一段代码,我们称之为“工作代码”,它正在等待一些异步代码。为简单起见,假设这段代码正在等待 Task.Delay:

    try
    
        await Task.Delay(5000, cancellationToken);
        // … 
    
    catch (OperationCanceledException)
    
        // ….
    
    

就在“工作代码”调用Task.Delay 之前,它正在线程T1 上执行。 延续(即“等待”之后的行或 catch 内的块)稍后将在 T1 或其他线程上执行,具体取决于一系列因素。

    还有一段代码,我们称之为“客户端代码”,它决定取消Task.Delay。此代码调用cancellationToken.Cancel。对 Cancel 的调用是在线程 T2 上进行的。

我希望线程 T2 通过返回到 Cancel 的调用者来继续。我还希望看到catch (OperationCanceledException) 的内容很快会在线程 T1 或 T2 以外的某个线程上执行。

接下来发生的事情令人惊讶。我看到在线程 T2 上,在调用 Cancel 之后,执行立即继续使用 catch (OperationCanceledException) 中的块。当Cancel 仍在调用堆栈上时,就会发生这种情况。就好像对Cancel 的调用被取消它的代码劫持了。这是显示此调用堆栈的 Visual Studio 屏幕截图:

更多上下文

以下是有关实际代码作用的更多上下文: 有一个累积请求的“工人代码”。一些“客户端代码”正在提交请求。每隔几秒钟,“工作代码”就会处理这些请求。已处理的请求将从队列中删除。 然而,偶尔,“客户端代码”会决定它达到了它希望立即处理请求的地步。为了将其传达给“工作人员代码”,它调用了“工作人员代码”提供的方法Jolt。由“客户端代码”调用的方法Jolt通过取消由工作人员的代码主循环执行的Task.Delay来实现此功能。 Worker 的代码已取消其 Task.Delay 并继续处理已排队的请求。

实际代码被精简为最简单的形式,代码为available on GitHub。

环境

该问题可以在控制台应用程序、适用于 Windows 的通用应用程序的后台代理和适用于 Windows Phone 8.1 的通用应用程序的后台代理中重现。

该问题无法在适用于 Windows 的通用应用程序中重现,其中代码按我预期的方式工作并且对 Cancel 的调用立即返回。

【问题讨论】:

无法在通用应用程序中重现该问题 - 因为在这种情况下,您调用await Task.Delay(...) 的线程上有一个同步上下文,因此由CancellationTokenSource.Cancel 触发的延续被异步发布到该上下文。因此,没有死锁。 【参考方案1】:

CancellationTokenSource.Cancel 不只是设置IsCancellationRequested 标志。

CancallationToken 类有一个Register method,它允许您注册将在取消时调用的回调。而这些回调由CancellationTokenSource.Cancel调用。

我们来看看source code:

public void Cancel()

    Cancel(false);


public void Cancel(bool throwOnFirstException)

    ThrowIfDisposed();
    NotifyCancellation(throwOnFirstException);            

这是NotifyCancellation 方法:

private void NotifyCancellation(bool throwOnFirstException)

    // fast-path test to check if Notify has been called previously
    if (IsCancellationRequested)
        return;

    // If we're the first to signal cancellation, do the main extra work.
    if (Interlocked.CompareExchange(ref m_state, NOTIFYING, NOT_CANCELED) == NOT_CANCELED)
    
        // Dispose of the timer, if any
        Timer timer = m_timer;
        if(timer != null) timer.Dispose();

        //record the threadID being used for running the callbacks.
        ThreadIDExecutingCallbacks = Thread.CurrentThread.ManagedThreadId;

        //If the kernel event is null at this point, it will be set during lazy construction.
        if (m_kernelEvent != null)
            m_kernelEvent.Set(); // update the MRE value.

        // - late enlisters to the Canceled event will have their callbacks called immediately in the Register() methods.
        // - Callbacks are not called inside a lock.
        // - After transition, no more delegates will be added to the 
        // - list of handlers, and hence it can be consumed and cleared at leisure by ExecuteCallbackHandlers.
        ExecuteCallbackHandlers(throwOnFirstException);
        Contract.Assert(IsCancellationCompleted, "Expected cancellation to have finished");
    

好的,现在要注意的是ExecuteCallbackHandlers 可以在目标上下文或当前上下文中执行回调。我会让你看一下ExecuteCallbackHandlers method source code,因为它有点太长了,不能在这里包含。但有趣的是:

if (m_executingCallback.TargetSyncContext != null)


    m_executingCallback.TargetSyncContext.Send(CancellationCallbackCoreWork_OnSyncContext, args);
    // CancellationCallbackCoreWork_OnSyncContext may have altered ThreadIDExecutingCallbacks, so reset it. 
    ThreadIDExecutingCallbacks = Thread.CurrentThread.ManagedThreadId;

else

    CancellationCallbackCoreWork(args);

我想现在你开始明白我接下来要看的地方了……当然是Task.Delay。我们来看看它的source code:

// Register our cancellation token, if necessary.
if (cancellationToken.CanBeCanceled)

    promise.Registration = cancellationToken.InternalRegisterWithoutEC(state => ((DelayPromise)state).Complete(), promise);

嗯……那是什么InternalRegisterWithoutEC method?

internal CancellationTokenRegistration InternalRegisterWithoutEC(Action<object> callback, Object state)

    return Register(
        callback,
        state,
        false, // useSyncContext=false
        false  // useExecutionContext=false
     );

啊。 useSyncContext=false - 这解释了您所看到的行为,因为 ExecuteCallbackHandlers 中使用的 TargetSyncContext 属性将是错误的。由于没有使用同步上下文,所以取消是在CancellationTokenSource.Cancel的调用上下文中执行的。

【讨论】:

很好的解释,但是 SyncContext 问题的解决方法是什么?【参考方案2】:

这是CancellationToken/Source 的预期行为。

有点类似于TaskCompletionSource 的工作方式,CancellationToken 注册是使用调用线程同步执行的。您可以在 CancellationTokenSource.ExecuteCallbackHandlers 中看到,当您取消时会调用它。

使用同一个线程比在ThreadPool 上安排所有这些延续要高效得多。通常这种行为不是问题,但如果您在锁内调用CancellationTokenSource.Cancel 可能会出现问题,因为线程被“劫持”而锁仍然被占用。您可以使用Task.Run 解决此类问题。您甚至可以将其作为扩展方法:

public static void CancelWithBackgroundContinuations(this CancellationTokenSource)

    Task.Run(() => CancellationTokenSource.Cancel());
    cancellationTokenSource.Token.WaitHandle.WaitOne(); // make sure to only continue when the cancellation completed (without waiting for all the callbacks)

【讨论】:

天哪,不是 TPL 中的另一个重入问题。糟糕的选择。我很高兴有人在我之前踩到了这个地雷。 谢谢 i3arnon。您的回答解释了这里发生了什么。顺便说一句,我认为我不能简单地移除锁。锁定的目的是确保 GetCurrentCancellationToken 在更新的取消令牌已经生效的时间点不会获得过时的取消令牌。但是,我可以应用您关于使用 Task.Run 的建议。而且我不必等到取消完成。 您的意思是 requested 而不是 completed 这里:// make sure to only continue when the cancellation completed? - 否则,任何可能通过Token.Register 注册的取消回调都可能在Token.WaitHandle 发出信号后被调用。像这样使用Task.Run 的另一个潜在问题是这些回调抛出的任何异常都将丢失。我宁愿使用QueueUserWorkItem。 @Ladi 的逻辑可能不是这种情况,但通常我认为在观察到 Token 的情况下这样做会更合适,比如 this。 @Ladi 这个等待只等待实际的取消,而不是所有的回调。这应该非常快。您不一定需要等待,但这样更安全。 @JérômeMEVEL 这意味着您只能在持有CancelltionTokenSource 的情况下取消令牌。如果您只持有CancellationToken,则无法取消操作,只有在请求取消时才会收到通知。这里的请求对象是源。【参考方案3】:

由于这里已经列出的原因,我相信您希望以零毫秒的延迟实际使用CancellationTokenSource.CancelAfter 方法。这将允许取消在不同的上下文中传播。

The source code for CancelAfter is here.

它在内部使用 TimerQueueTimer 来发出取消请求。这没有记录,但应该可以解决 op 的问题。

Documentation here.

【讨论】:

在哪里记录了使用具有零延迟的CancelAfter 会在不同的上下文中调用回调? 看源码,它利用一个TimerQueueTimer在指定时间后执行。 CancelAfter 不会阻塞。

以上是关于对 CancellationTokenSource.Cancel 的调用永远不会返回的主要内容,如果未能解决你的问题,请参考以下文章

C# 中通过CancellationTokenSource实现对超时任务的取消

Task CancellationTokenSource和Task.WhenAll的应用

使用CancellationTokenSource类实现线程超时时终止线程

如何确定 CancellationTokenSource 范围?

没有 CancellationTokenSource 的新 CancellationToken() 和 IsCancellationRequested

C# 中 多线程同步退出方案 CancellationTokenSource