Вызов CancellationTokenSource.Cancel никогда не возвращается

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

вот как это происходит:

  1. есть фрагмент кода, назовем его "рабочий код", который ждет некоторого асинхронного кода. Чтобы сделать вещи простыми, скажем, этот код ожидает на Задача.Задержка:

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

непосредственно перед тем, как" рабочий код " вызывает Task.Delay он выполняется в потоке T1. Продолжение (то есть линия, следующая за "await" или блоком внутри catch), будет выполнено позже на T1 или, возможно, на каком-то другом потоке в зависимости от ряда факторов.

  1. есть еще один фрагмент кода, назовем его "клиентский код", который решает отменить Task.Delay. Этот код вызывает cancellationToken.Cancel. Вызов Cancel сделано на потоке T2.

я ожидал бы, что поток T2 продолжится, вернувшись к вызывающемуCancel. Я также ожидаю увидеть содержание catch (OperationCanceledException) выполняется очень скоро в потоке T1 или в каком-либо потоке, отличном от T2.

что происходит дальше, удивляет. Я вижу это в потоке T2, после , выполнение немедленно продолжается с блоком внутри catch (OperationCanceledException). И это происходит в то время как Cancel все еще находится в callstack. Это как если бы вызов Cancel захвачен кодом, который он отменяется. Вот скриншот Visual Studio, показывающий этот стек вызовов:

Call stack

больше контекста

вот еще один контекст о том, что делает фактический код: Существует" рабочий код", который накапливает запросы. Запросы подаются неким "клиентским кодом". Каждые несколько секунд "рабочий код" обрабатывает эти запросы. Запросы обработки исключаются из очереди. Однако время от времени" клиентский код " решает, что он достиг точки, где он хочет, чтобы запросы обрабатывались немедленно. Чтобы сообщить об этом" Рабочему коду", он вызывает метод Jolt это" рабочий код " обеспечивает. Метод Jolt который вызывается "клиентским кодом" реализует эту функцию, отменяя Task.Delay это выполняется основным циклом кода рабочего. Код рабочего имеет свой Task.Delay отменено и приступает к обработайте запросы, которые уже были поставлены в очередь.

фактический код был разделен до самой простой формы, и код доступно на GitHub.

окружающая среда

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

проблема не может быть воспроизведена в универсальных приложениях для Windows, где код работает так, как я ожидал, и вызов Cancel сразу возвращается.

2 ответов


CancellationTokenSource.Cancel не просто установить IsCancellationRequested флаг.

на CancallationToken класс Register метод, что позволяет регистрировать обратные вызовы,которые будут вызываться при отмене. И эти обратные вызовы вызываются CancellationTokenSource.Cancel.

давайте посмотрим на исходный код:

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 метод исходный код как это немного слишком долго, чтобы включить здесь. Но самое интересное:

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 конечно. Давайте посмотрим на его исходный код:

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

Хммм... что это?!--42-->InternalRegisterWithoutEC метод?

internal CancellationTokenRegistration InternalRegisterWithoutEC(Action<object> callback, Object state)
{
    return Register(
        callback,
        state,
        false, // useSyncContext=false
        false  // useExecutionContext=false
     );
}

Аргх. useSyncContext=false - это объясняет поведение вы видите, как TargetSyncContext свойство используется в ExecuteCallbackHandlers будет false. Поскольку контекст синхронизации не используется, отмена выполняется на CancellationTokenSource.Cancelконтекст вызова.


это ожидаемое поведение CancellationToken/Source.

несколько похоже на how 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)
}