2015-07-18 2 views
5

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

Вот как это идет:

  1. Существует кусок кода, давайте назовем это «рабочий код», который ожидает некоторую асинхронная коды. Для того, чтобы сделать простые вещи, скажем, этот код ожидает на Task.Delay:

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

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

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

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

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

Call stack

Более контекст

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

Фактический код был удален до его простейшей формы и код available on GitHub.

Environment

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

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

+0

* Проблема не может быть воспроизведена в универсальных приложениях * - потому что в этом случае есть контекст синхронизации в потоке, где вы вызываете 'await Task.Delay (...)', поэтому продолжение, вызванное 'CancellationTokenSource.Cancel' асинхронно отправляется в этот контекст. Следовательно, нет тупика. – Noseratio

ответ

6

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

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

4

Ожидаемое поведение 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) 
} 
+1

О боже, а не проблема повторного входа в TPL. Плохой выбор. Я рад, что кто-то еще наступил на эту шахту, прежде чем я это сделал. – usr

+0

Спасибо i3arnon. Ваш ответ объясняет, что здесь происходит. Кстати, я не думаю, что могу просто снять замок. Блокировка была там, чтобы удостовериться, что GetCurrentCancellationToken не получает устаревший токен отмены в тот момент, когда последний из них уже действует. Однако я могу применить ваше предложение об использовании Task.Run. И мне не нужно ждать завершения аннулирования. – Ladi

+0

Вы имели в виду * запрошенный *, а не * завершенный * здесь: '// не забудьте продолжить, только когда аннулирование завершено?? - В противном случае любые обратные вызовы отмены, возможно, зарегистрированные через «Token.Register», могут вызываться после того, как была сигнализирована «Token.WaitHandle». Другая потенциальная проблема с использованием 'Task.Run' - это то, что любые исключения, вызванные этими обратными вызовами, будут потеряны. Я бы предпочел использовать 'QueueUserWorkItem'. Это может быть не так с логикой @ Ladi, но в целом я думаю, что было бы более целесообразно делать это там, где наблюдается «токен», с чем-то вроде [this] (https://goo.gl/tzL2Fo). – Noseratio

Смежные вопросы