首页 文章

异步任务,取消和例外

提问于
浏览
3

我目前正在学习如何使用 Task 正确地公开我们的库API的异步部分,以便它们可以更容易和更好地用于客户 . 我决定用the TaskCompletionSource approach包围一个 Task 围绕它't get scheduled on the thread pool (in the instance here unneeded anyway since it's基本上只是一个计时器) . 这很好,但现在取消有点令人头疼 .

该示例显示了基本用法,在令牌上注册委托,但比我的情况稍微复杂一点,更重要的是,我不确定如何处理 TaskCanceledException . The documentation表示只是返回并将任务状态切换为 RanToCompletion ,或者抛出 OperationCanceledException (这导致任务的结果为 Canceled )没问题 . 但是,这些示例似乎只涉及或至少提及通过传递给 TaskFactory.StartNew 的委托启动的任务 .

我的代码目前(大致)如下:

public Task Run(IFoo foo, CancellationToken token = default(CancellationToken)) {
  var tcs = new TaskCompletionSource<object>();

  // Regular finish handler
  EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(null);
  // Cancellation
  token.Register(() => {
    tcs.TrySetCanceled();
    CancelAndCleanupFoo(foo);
  });

  RunFoo(foo, callback);
  return tcs.Task;
}

(在执行过程中没有结果也没有可能的异常;我选择从这里开始的一个原因,而不是在库中更复杂的地方 . )

在当前形式中,当我在 TaskCompletionSource 上调用 TrySetCanceled 时,如果我等待返回的任务,我总是得到 TaskCanceledException . 我的猜测是,这是正常的行为(我希望是这样),并且我希望在我想要使用取消时围绕呼叫包裹 try / catch .

如果我不使用 TrySetCanceled ,那么我最终将在完成回调中运行,任务看起来像是正常完成 . 但我想如果用户想要区分正常完成的任务和被取消的任务, TaskCanceledException 几乎是确保这一点的副作用,对吧?

另一点我不太明白:Documentation suggests任何异常,甚至是那些与取消相关的异常都被TPL包装在 AggregateException 中 . 但是,在我的测试中,我总是直接得到 TaskCanceledException ,没有任何包装 . 我在这里遗漏了一些东西,还是记录得不好?


TL;DR:

  • 对于要转换到 Canceled 状态的任务,总是需要相应的异常,并且用户必须围绕异步调用包装 try / catch 才能检测到,对吧?

  • 被抛出的 TaskCanceledException 也是预期和正常的,我在这里做错了什么?

3 回答

  • 1

    我总是建议人们阅读Cancellation in Managed Threads docs . 它不完整;像大多数MSDN文档一样,它告诉你你可以做什么,而不是你应该做什么 . 但它肯定比取消的dotnet文档更清晰 .

    该示例显示了基本用法

    首先,重要的是要注意示例代码中的取消仅取消任务 - 它确实 not 取消基础操作 . 我强烈建议你不要这样做 .

    如果要取消该操作,则需要更新 RunFoo 以获取 CancellationToken (有关如何使用它,请参阅下文):

    public Task Run(IFoo foo, CancellationToken token = default(CancellationToken)) {
      var tcs = new TaskCompletionSource<object>();
    
      // Regular finish handler
      EventHandler<AsyncCompletedEventArgs> callback = (sender, args) =>
      {
        if (args.Cancelled)
        {
          tcs.TrySetCanceled(token);
          CleanupFoo(foo);
        }
        else
          tcs.TrySetResult(null);
      };
    
      RunFoo(foo, token, callback);
      return tcs.Task;
    }
    

    如果您无法取消 foo ,则根本不要取消API支持:

    public Task Run(IFoo foo) {
      var tcs = new TaskCompletionSource<object>();
    
      // Regular finish handler
      EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(null);
    
      RunFoo(foo, callback);
      return tcs.Task;
    }
    

    然后,调用者可以对任务执行可取消等待,这对于此场景来说是一种更合适的代码技术(因为它是取消的等待,而不是任务所代表的操作) . 执行"cancelable wait"可以通过my AsyncEx.Tasks library完成,或者您可以编写自己的等效扩展方法 .

    文档说要么只是返回并将任务状态切换到RanToCompletion,要么抛出OperationCanceledException(导致任务的结果被取消)就可以了 .

    是的,这些文档具有误导性 . 首先,请不要只是回来;您的方法将成功完成任务 - 表示操作已成功完成 - 实际上操作未成功完成 . 这可能适用于某些代码,但一般来说肯定不是一个好主意 .

    通常,响应 CancellationToken 的正确方法是:

    • 定期调用 ThrowIfCancellationRequested . 对于CPU绑定代码,此选项更好 .

    • 通过 Register 注册取消回调 . 对于I / O绑定代码,此选项更好 . 请注意,必须处理注册!

    在您的特定情况下,您有一个不寻常的情况 . 在你的情况下,我会采取第三种方法:

    • 在"per-frame work"中,选中 token.IsCancellationRequested ;如果请求,则在 AsyncCompletedEventArgs.Cancelled 设置为 true 的情况下引发回调事件 .

    这在逻辑上等同于第一种正确的方式(定期调用 ThrowIfCancellationRequested ),捕获异常并将其转换为事件通知 . 没有例外 .

    如果我等待返回的任务,我总是得到一个TaskCanceledException . 我的猜测是这是正常的行为(我希望是这样)并且我希望在我想要使用取消时围绕调用包装try / catch .

    可以取消的任务的正确消耗代码是将 await 包装在try / catch中并捕获 OperationCanceledException . 由于各种原因(许多历史),一些API将导致 OperationCanceledException 和有些会导致 TaskCanceledException . 由于 TaskCanceledException 派生自 OperationCanceledException ,因此使用代码可以捕获更常见的异常 .

    但我想如果用户想要区分正常完成的任务和被取消的任务,[取消例外]几乎是确保这一点的副作用,对吧?

    That's the accepted pattern,是的 .

    文档表明,任何异常,甚至是与取消相关的异常,都会被TPL包含在AggregateException中 .

    只有在代码同步阻塞任务时才会出现这种情况 . 它应该首先避免做什么 . 因此,文档肯定会再次误导 .

    但是,在我的测试中,我总是直接得到TaskCanceledException,没有任何包装器 .

    await 避免使用 AggregateException 包装器 .

    Update 用于评论解释 CleanupFoo 是一种取消方法 .

    我首先建议尝试在 RunFoo 启动的代码中直接使用 CancellationToken ;这种方法几乎肯定会更容易 .

    但是,如果您必须使用 CleanupFoo 进行取消,那么您需要 Register 它 . 您需要处理该注册,最简单的方法可能是将其拆分为两种不同的方法:

    private Task DoRun(IFoo foo) {
      var tcs = new TaskCompletionSource<object>();
    
      // Regular finish handler
      EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(null);
    
      RunFoo(foo, callback);
      return tcs.Task;
    }
    
    public async Task Run(IFoo foo, CancellationToken token = default(CancellationToken)) {
      var tcs = new TaskCompletionSource<object>();
      using (token.Register(() =>
          {
            tcs.TrySetCanceled(token);
            CleanupFoo();
          });
      {
        var task = DoRun(foo);
        try
        {
          await task;
          tcs.TrySetResult(null);
        }
        catch (Exception ex)
        {
          tcs.TrySetException(ex);
        }
      }
      await tcs.Task;
    }
    

    正确地协调和传播结果 - 同时防止资源泄漏 - 非常尴尬 . 如果你的代码可以直接使用 CancellationToken ,它会更清晰 .

  • 3

    你正在做的事情很好 - 任务代表一些未来结果的操作,没有必要在另一个线程或类似的东西上运行任何东西 . 使用标准的取消方法,取消,而不是返回类似布尔值的东西,这是完全正常的 .

    要回答你的问题:当你执行 tcs.TrySetCanceled() 时,它会将任务移动到已取消状态( task.IsCancelled 将为true),此时不会抛出任何异常 . 但是当你 await 这个任务时 - 它会注意到任务被取消,这就是抛出 TaskCancelledException 的点 . 这里没有任何内容包含在聚合异常中,因为实际上没有任何内容可以包装 - TaskCancelledException 作为 await 逻辑的一部分被抛出 . 现在,如果您将执行类似 task.Wait() 的操作 - 那么它会将 TaskCancelledException 包装成 AggregateException ,如您所料 .

    请注意, await 无论如何都要解包AggregateExceptions,因此您可能永远不会期望 await task 抛出AggregateException - 如果出现多个异常,则只会抛出第一个 - 其余的将被吞噬 .

    现在,如果您使用取消令牌与常规任务 - 事情有点不同 . 当你执行像 token.ThrowIfCancellationRequested 之类的操作时,它会实际抛出 OperationCancelledException (注意它不是 TaskCancelledException 但是 TaskCancelledException 无论如何都是 OperationCancelledException 的子类) . 然后,如果用于抛出此异常的 CancellationToken 与启动时传递给任务的 CancellationToken 相同(例如链接中的示例) - 任务将以相同的方式移动到“已取消”状态 . 这与具有相同行为的代码中的 tcs.TrySetCancelled 相同 . 如果令牌不匹配 - 任务将转到Faulted状态,就像抛出常规异常一样 .

  • 1

    从评论中,看起来你有一个接受 IAnimation 的动画库,执行它(显然是异步的),然后发信号通知它已完成 .

    这不是一个实际的任务,因为它不是一个异步操作,在.NET中使用Task对象公开 .

    此外,你实际上没有取消某些东西,你正在停止动画 . 那个's a perfectly normal operation so it shouldn' t抛出异常 . 如果你的方法返回一个解释动画是否完成的值会更好,例如:

    public Task<bool> Run(IAnimation animation, CancellationToken token = default(CancellationToken)) {
      var tcs = new TaskCompletionSource<bool>();
    
      // Regular finish handler
      EventHandler<EventArgs> callback = (sender, args) => tcs.TrySetResult(true);
      // Cancellation 
      token.Register(() => {
                             CleanupFoo(animation);
                             tcs.TrySetResult(false);
                           });
      RunFoo(animation, callback);
      return tcs.Task;
    }
    

    运行动画的调用很简单:

    var myAnimation = new SomeAnimation();
    var completed = await runner.Run(myAnimation,token);
    if (completed)
    {
    }
    

    UPDATE

    这可以通过一些C#7技巧进一步改进 .

    例如,您可以使用本地函数,而不是使用回调和lambda . 除了使代码更清晰之外,它们在每次调用时都不会分配代理 . 这个变化并没有改变:

    Task<bool> Run(IAnimation animation, CancellationToken token = default(CancellationToken)) {
      var tcs = new TaskCompletionSource<bool>();
    
      // Regular finish handler
      void OnFinish (object sender, EventArgs args) => tcs.TrySetResult(true);
      void OnStop(){
        CleanupFoo(animation);
        tcs.TrySetResult(false);
      }
    
      // Null-safe cancellation 
      token.Register(OnStop);
      RunFoo(animation, OnFinish);
      return tcs.Task;
    }
    

    您还可以返回更复杂的结果,例如包含Finished / Stopped标志的结果类型以及动画停止时的最终帧 . 如果您不想使用无意义的字段(为什么在动画完成时指定一个帧?),您可以返回实现例如IResult的Success类型或Stopped类型 .

    在C#7之前,您需要检查返回类型或使用重载来访问不同类型 . 通过模式匹配,您可以通过开关获得实际结果,例如:

    interface IResult{}
    public class Success:IResult{}
    
    public class Stopped { 
        public int Frame{get;}
        Stopped(int frame) { Frame=frame; }
    }
    
    ....
    
    var result=await Run(...);
    switch (result)
    {
        case Success _ : 
            Console.WriteLine("Finished");
            break;
        case Stopped s :
            Console.WriteLine($"Stopped at {s.Frame}");
            break;
    }
    

    模式匹配实际上比类型检查更快 . 这要求客户端支持C#7 .

相关问题