2015-07-18 34 views
5

Tôi có một tình huống mà một cuộc gọi đến CancellationTokenSource.Cancel không bao giờ trở lại. Thay vào đó, sau khi Cancel được gọi (và trước khi nó trả về), việc thực hiện tiếp tục với mã hủy của mã đang bị hủy. Nếu mã được hủy bỏ không sau đó gọi bất kỳ mã awaitable sau đó người gọi ban đầu được gọi là Cancel không bao giờ được kiểm soát trở lại. Điều này rất lạ. Tôi mong đợi Cancel để chỉ cần ghi lại yêu cầu hủy và trả lại ngay lập tức độc lập với chính việc hủy. Thực tế là các thread mà Cancel đang được gọi là kết thúc lên thực thi mã thuộc về các hoạt động đang được hủy bỏ và nó như vậy trước khi trở về người gọi Cancel trông giống như một lỗi trong khuôn khổ.Một cuộc gọi đến CancellationTokenSource.Cancel không bao giờ trả lại

Sau đây là cách này đi:

  1. Có một đoạn mã, chúng ta hãy gọi nó là “mã nhân viên” được chờ đợi vào một số mã async. Để làm cho mọi việc đơn giản giả sử mã này đang chờ trên Task.Delay:

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

Ngay trước khi “mã nhân viên” gọi Task.Delay nó được thực hiện trên thread T1. Việc tiếp tục (đó là dòng sau "chờ đợi" hoặc khối bên trong bắt) sẽ được thực hiện sau đó trên T1 hoặc có thể trên một số chủ đề khác tùy thuộc vào một loạt các yếu tố.

  1. Có một đoạn mã khác, hãy gọi nó là “mã khách hàng” quyết định hủy Task.Delay. Mã này gọi cancellationToken.Cancel. Cuộc gọi đến Cancel được thực hiện trên luồng T2.

Tôi mong đợi chuỗi T2 sẽ tiếp tục bằng cách quay lại người gọi Cancel. Tôi cũng mong đợi để xem nội dung của catch (OperationCanceledException) thực hiện rất sớm trên thread T1 hoặc trên một số chủ đề khác hơn T2.

Điều gì xảy ra tiếp theo là đáng ngạc nhiên. Tôi thấy rằng trên thread T2, sau khi Cancel được gọi, việc thực hiện tiếp tục ngay lập tức với khối bên trong catch (OperationCanceledException). Và điều đó xảy ra trong khi Cancel vẫn còn trên callstack. Nó giống như cuộc gọi đến Cancel bị tấn công bởi mã mà nó đang bị hủy. Dưới đây là một ảnh chụp màn hình của Visual Studio cho thấy cuộc gọi này stack:

Call stack

Nhiều bối cảnh

Dưới đây là một số bối cảnh hơn về những gì các mã thực tế thực hiện: Có một “mã nhân” mà tích lũy yêu cầu. Yêu cầu đang được gửi bởi một số "mã khách hàng". Mỗi vài giây "mã công nhân" xử lý các yêu cầu này. Các yêu cầu được xử lý được loại bỏ khỏi hàng đợi. Tuy nhiên, thỉnh thoảng, “mã khách hàng” quyết định rằng nó đến một điểm mà nó muốn yêu cầu được xử lý ngay lập tức. Để truyền đạt điều này đến “mã công nhân”, nó gọi phương thức Jolt rằng “mã công nhân” cung cấp. Phương thức Jolt đang được gọi bằng "mã máy khách" thực hiện tính năng này bằng cách hủy bỏ một Task.Delay được thực hiện bởi vòng lặp chính của công nhân. Mã của nhân viên bị hủy Task.Delay và tiếp tục xử lý các yêu cầu đã được xếp hàng đợi.

Mã thực tế bị tước xuống biểu mẫu đơn giản nhất và mã là available on GitHub.

Môi trường

Vấn đề này có thể được sao chép trong giao diện điều khiển ứng dụng, đại lý nền cho Universal Apps dành cho Windows và các đại lý nền cho Universal Apps cho Windows Phone 8.1.

Sự cố không thể được sao chép trong ứng dụng Universal cho Windows nơi mã hoạt động như tôi mong đợi và cuộc gọi đến Cancel sẽ trả về ngay lập tức.

+0

* Vấn đề không thể được sao chép trong ứng dụng Universal * - bởi vì trong trường hợp này, có một bối cảnh đồng bộ hóa trên chuỗi nơi bạn gọi 'await Task.Delay (...)', do đó việc tiếp tục được kích hoạt bởi 'CancellationTokenSource.Cancel' được đăng không đồng bộ vào ngữ cảnh đó. Do đó, không có bế tắc. – Noseratio

Trả lời

6

CancellationTokenSource.Cancel không đơn giản là đặt cờ IsCancellationRequested.

Lớp CancallationTokenRegister method, cho phép bạn đăng ký cuộc gọi lại sẽ được gọi khi hủy. Và những callbacks này được gọi là CancellationTokenSource.Cancel.

Chúng ta hãy nhìn vào source code:

public void Cancel() 
{ 
    Cancel(false); 
} 

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

Đây là NotifyCancellation phương pháp:

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

Ok, bây giờ nắm bắt được rằng ExecuteCallbackHandlers có thể thực hiện callbacks hoặc bối cảnh mục tiêu, hoặc trong bối cảnh hiện tại. Tôi sẽ cho bạn xem một số ExecuteCallbackHandlers method source code vì nó hơi dài để đưa vào đây. Nhưng phần thú vị là:

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

Tôi đoán bây giờ bạn đang bắt đầu hiểu nơi tôi sẽ xem tiếp theo ... Task.Delay tất nhiên. Hãy xem số source code:

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

Hmmm ... đó là gì InternalRegisterWithoutEC method?

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

Argh. useSyncContext=false - điều này giải thích hành vi bạn đang xem là thuộc tính TargetSyncContext được sử dụng trong ExecuteCallbackHandlers sẽ là sai. Do bối cảnh đồng bộ hóa không được sử dụng, việc hủy bỏ được thực thi theo ngữ cảnh cuộc gọi của CancellationTokenSource.Cancel.

4

Đây là hành vi mong đợi của CancellationToken/Source.

Hơi tương tự như cách TaskCompletionSource hoạt động, CancellationToken đăng ký được thực hiện đồng bộ bằng cách sử dụng chuỗi cuộc gọi. Bạn có thể thấy rằng trong CancellationTokenSource.ExecuteCallbackHandlers được gọi khi bạn hủy.

Sẽ hiệu quả hơn khi sử dụng cùng một chuỗi đó hơn là lập lịch tất cả các lần tiếp tục này trên ThreadPool. Thông thường hành vi này không phải là một vấn đề, nhưng nó có thể là nếu bạn gọi CancellationTokenSource.Cancel bên trong một khóa như là chủ đề là "bị tấn công" trong khi khóa vẫn được thực hiện. Bạn có thể giải quyết các vấn đề như vậy bằng cách sử dụng Task.Run. Bạn thậm chí có thể biến nó thành một phương thức mở rộng:

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

Ôi trời, không phải vấn đề reentrancy khác trong TPL. Những lựa chọn tồi tệ. Tôi mừng là có ai đó đã bước vào mỏ này trước khi tôi làm. – usr

+0

Cảm ơn bạn i3arnon. Câu trả lời của bạn giải thích những gì đang xảy ra ở đây. BTW, tôi không nghĩ rằng tôi có thể chỉ cần xóa khóa. Khóa ở đó để đảm bảo rằng GetCurrentCancellationToken không nhận được mã thông báo hủy lỗi lỗi thời tại một thời điểm khi mã thông báo gần đây đã có hiệu lực. Tuy nhiên, tôi có thể áp dụng đề xuất của bạn về việc sử dụng Task.Run. Và tôi không phải đợi cho đến khi hoàn thành việc hủy bỏ. – Ladi

+0

Ý của bạn là * đã yêu cầu * thay vì * hoàn thành * ở đây: '// đảm bảo chỉ tiếp tục khi hủy hoàn tất'? - Nếu không, mọi cuộc gọi hủy có thể được đăng ký thông qua 'Token.Register' có thể được gọi sau khi' Token.WaitHandle' được báo hiệu. Một vấn đề khác có thể xảy ra khi sử dụng 'Task.Run' như thế này là bất kỳ ngoại lệ nào được đưa ra bởi những lời gọi đó sẽ bị mất. Tôi muốn sử dụng 'QueueUserWorkItem'. Điều này có thể không phải là trường hợp với logic của Ladi, nhưng nói chung tôi nghĩ nó sẽ thích hợp hơn để làm điều đó, nơi 'Token' được quan sát, với cái gì đó như [this] (https://goo.gl/tzL2Fo). – Noseratio

Các vấn đề liên quan