可以检测.NET库代码中的不受控制的取消吗?

Sen*_*ith 6 c# asynchronous task task-parallel-library taskcompletionsource

我发现我无法区分受控/合作与"不受控制"的任务/代表取消,而无需检查特定任务或代表背后的来源.

具体来说,我总是假设当OperationCanceledException从"低级操作"中捕获抛出时,如果引用的令牌无法与当前操作的令牌匹配,那么它应该被解释为失败/错误.这是它放弃(退出)的"低级操作"的声明,但不是因为你要求它这样做.

不幸的是,TaskCompletionSource无法关联一个CancellationToken作为取消的原因.因此,没有内置调度程序支持的任何任务无法传达其取消的原因,并且可能错误地将合作取消误报为错误.

更新:由于.NET 4.6 TaskCompletionSource 可以关联起来CancellationToken ,如果新的过载SetCanceledTrySetCanceled使用.

例如以下内容

public Task ShouldHaveBeenAsynchronous(Action userDelegate, CancellationToken ct)
{
    var tcs = new TaskCompletionSource<object>();

    try
    {
      userDelegate();
      tcs.SetResult(null);   // Indicate completion
    }
    catch (OperationCanceledException ex)
    {
      if (ex.CancellationToken == ct)
        tcs.SetCanceled(); // Need to pass ct here, but can't
      else
        tcs.SetException(ex);
    }
    catch (Exception ex)
    {
      tcs.SetException(ex);
    }

    return tcs.Task;
}

private void OtherSide()
{
    var cts = new CancellationTokenSource();
    var ct = cts.Token;
    cts.Cancel();
    Task wrappedOperation = ShouldHaveBeenAsynchronous(
        () => { ct.ThrowIfCancellationRequested(); }, ct);

    try
    {
        wrappedOperation.Wait();
    }
    catch (AggregateException aex)
    {
        foreach (var ex in aex.InnerExceptions
                              .OfType<OperationCanceledException>())
        {
            if (ex.CancellationToken == ct)
                Console.WriteLine("OK: Normal Cancellation");
            else
                Console.WriteLine("ERROR: Unexpected cancellation");
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

即使通过分发给所有组件的取消令牌请求取消,也会导致"错误:意外取消".

核心问题是TaskCompletionSource不知道CancellationToken,但是如果在"任务"中包含异步操作的"转到"机制无法跟踪这一点,那么我认为没有人可以指望它跨接口跟踪(图书馆的边界.

实际上TaskCompletionSource可以处理这个问题,但必要的TrySetCanceled重载是内部的,因此只有mscorlib组件才能使用它.

那么,是否有任何人都有一种模式,表明已在任务和代理边界"处理"取消?

Sen*_*ith 1

郑重声明:是的,API 已损坏,因为 TaskCompletionSource 应该接受 CancellationToken。.NET 运行时修复了此问题以供自己使用,但在 .NET 4.6 之前并未公开该修复(TrySetCanceled 的重载)。

作为任务使用者,有两个基本选择。

  1. 始终检查任务状态
  2. 只需检查您自己的 CancellationToken 并在请求取消时忽略任务错误。

所以像这样:

object result;
try
{
    result = task.Result;
}
// catch (OperationCanceledException oce) // don't rely on oce.CancellationToken
catch (Exception ex)
{
    if (task.IsCancelled)
        return; // or otherwise handle cancellation

    // alternatively
    if (cancelSource.IsCancellationRequested)
        return; // or otherwise handle cancellation

    LogOrHandleError(ex);
}
Run Code Online (Sandbox Code Playgroud)

第一个依赖库编写者使用TaskCompletionSource.TrySetCanceled,而不是使用提供匹配标记的OperationCanceledException 执行TrySetException。

第二个不依赖库编写者“正确”地做任何事情,除了做任何必要的事情来处理他们的代码异常。这可能无法记录错误以进行故障排除,但无论如何都无法(合理地)从外部代码内部清除操作状态。

对于任务生产者来说,可以

  1. 尝试通过使用反射将令牌与任务取消关联起来来遵守 OperationCanceledException.CancellationToken 契约。
  2. 使用 Continuation 将令牌与返回的任务关联起来。

后者很简单,但是像 Consumer 选项 2 一样,可能会忽略任务错误(甚至在执行序列停止之前很久就标记任务已完成)。

两者的完整实现(包括缓存委托以避免反射)...

更新:对于 .NET 4.6 及更高版本,只需调用TaskCompletionSource.TrySetCanceled接受CancellationToken. 当链接到 .NET 4.6 时,使用下面的扩展方法的代码将自动切换到该重载(如果使用扩展方法语法进行调用)。

static class TaskCompletionSourceExtensions
{
    /// <summary>
    /// APPROXIMATION of properly associating a CancellationToken with a TCS
    /// so that access to Task.Result following cancellation of the TCS Task 
    /// throws an OperationCanceledException with the proper CancellationToken.
    /// </summary>
    /// <remarks>
    /// If the TCS Task 'RanToCompletion' or Faulted before/despite a 
    /// cancellation request, this may still report TaskStatus.Canceled.
    /// </remarks>
    /// <param name="this">The 'TCS' to 'fix'</param>
    /// <param name="token">The associated CancellationToken</param>
    /// <param name="LazyCancellation">
    /// true to let the 'owner/runner' of the TCS complete the Task
    /// (and stop executing), false to mark the returned Task as Canceled
    /// while that code may still be executing.
    /// </param>
    public static Task<TResult> TaskWithCancellation<TResult>(
        this TaskCompletionSource<TResult> @this,
        CancellationToken token,
        bool lazyCancellation)
    {
        if (lazyCancellation)
        {
            return @this.Task.ContinueWith(
                (task) => task,
                token,
                TaskContinuationOptions.LazyCancellation |
                    TaskContinuationOptions.ExecuteSynchronously,
                TaskScheduler.Default).Unwrap();
        }

        return @this.Task.ContinueWith((task) => task, token).Unwrap();
        // Yep that was a one liner!
        // However, LazyCancellation (or not) should be explicitly chosen!
    }


    /// <summary>
    /// Attempts to transition the underlying Task into the Canceled state
    /// and set the CancellationToken member of the associated 
    /// OperationCanceledException.
    /// </summary>
    public static bool TrySetCanceled<TResult>(
        this TaskCompletionSource<TResult> @this,
        CancellationToken token)
    {
        return TrySetCanceledCaller<TResult>.MakeCall(@this, token);
    }

    private static class TrySetCanceledCaller<TResult>
    {
        public delegate bool MethodCallerType(TaskCompletionSource<TResult> inst, CancellationToken token);

        public static readonly MethodCallerType MakeCall;

        static TrySetCanceledCaller()
        {
            var type = typeof(TaskCompletionSource<TResult>);

            var method = type.GetMethod(
                "TrySetCanceled",
                System.Reflection.BindingFlags.Instance |
                System.Reflection.BindingFlags.NonPublic,
                null,
                new Type[] { typeof(CancellationToken) },
                null);

            MakeCall = (MethodCallerType)
                Delegate.CreateDelegate(typeof(MethodCallerType), method);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

和测试程序...

class Program
{
    static void Main(string[] args)
    {
        //var cts = new CancellationTokenSource(6000); // To let the operation complete
        var cts = new CancellationTokenSource(1000);
        var ct = cts.Token;
        Task<string> task = ShouldHaveBeenAsynchronous(cts.Token);

        try
        {
            Console.WriteLine(task.Result);
        }
        catch (AggregateException aex)
        {
            foreach (var ex in aex.Flatten().InnerExceptions)
            {
                var oce = ex as OperationCanceledException;
                if (oce != null)
                {
                    if (oce.CancellationToken == ct)
                        Console.WriteLine("OK: Normal Cancellation");
                    else
                        Console.WriteLine("ERROR: Unexpected cancellation");
                }
                else
                {
                    Console.WriteLine("ERROR: " + ex.Message);
                }
            }
        }

        Console.Write("Press Enter to Exit:");
        Console.ReadLine();
    }

    static Task<string> ShouldHaveBeenAsynchronous(CancellationToken ct)
    {
        var tcs = new TaskCompletionSource<string>();

        try
        {
            //throw new NotImplementedException();

            ct.WaitHandle.WaitOne(5000);
            ct.ThrowIfCancellationRequested();
            tcs.TrySetResult("this is the result");
        }
        catch (OperationCanceledException ex)
        {
            if (ex.CancellationToken == ct)
                tcs.TrySetCanceled(ct);
            else
                tcs.TrySetException(ex);
        }
        catch (Exception ex)
        {
            tcs.TrySetException(ex);
        }

        return tcs.Task;
        //return tcs.TaskWithCancellation(ct, false);
    }
}
Run Code Online (Sandbox Code Playgroud)