关于 c#:对 CancellationTokenSource.Cancel 的调用永远不会返回

A call to CancellationTokenSource.Cancel never returns

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

这是怎么回事:

  • 有一段代码,我们称之为工人代码吗?那是在等待一些异步代码。为了简单起见,让我们说这段代码正在等待 Task.Delay:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    try
    {
        await Task.Delay(5000, cancellationToken);
        // a€|
    }
    catch (OperationCanceledException)
    {
        // a€|.
    }
  • 就在一个€?the worker codea€?之前调用它在线程 T1 上执行的 Task.Delay
    延续(即 a€?awaita€? 之后的行或 catch 内的块)将稍后在 T1 或其他线程上执行,具体取决于一系列因素。

  • 还有一段代码,让我们称之为客户端代码?决定取消 Task.Delay。此代码调用 cancellationToken.Cancel。对 Cancel 的调用是在线程 T2 上进行的。
  • 我希望线程 T2 通过返回到 Cancel 的调用者来继续。我还希望看到 catch (OperationCanceledException) 的内容很快在线程 T1 或 T2 以外的某个线程上执行。

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

    Call

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

    实际代码被精简为最简单的形式,代码可在 GitHub 上获得。

    环境

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

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


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

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

    调用

    我们来看看源码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public void Cancel()
    {
        Cancel(false);
    }

    public void Cancel(bool throwOnFirstException)
    {
        ThrowIfDisposed();
        NotifyCancellation(throwOnFirstException);            
    }

    这里是 NotifyCancellation 方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    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 方法的源代码,因为它有点太长了,不能在这里包含。但有趣的是:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    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。我们来看看它的源码:

    1
    2
    3
    4
    5
    // Register our cancellation token, if necessary.
    if (cancellationToken.CanBeCanceled)
    {
        promise.Registration = cancellationToken.InternalRegisterWithoutEC(state => ((DelayPromise)state).Complete(), promise);
    }

    嗯……那个InternalRegisterWithoutEC方法是什么?

    1
    2
    3
    4
    5
    6
    7
    8
    9
    internal CancellationTokenRegistration InternalRegisterWithoutEC(Action<object> callback, Object state)
    {
        return Register(
            callback,
            state,
            false, // useSyncContext=false
            false  // useExecutionContext=false
         );
    }

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


    这是 CancellationToken/Source 的预期行为。

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

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

    1
    2
    3
    4
    5
    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)
    }


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

    CancelAfter 的源代码在这里。

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

    文档在这里。