异步任务、取消和异常

Async Tasks, Cancellation, and Exceptions

我目前正在学习如何使用 Task 正确公开我们库 API 的异步部分,以便客户可以更轻松、更好地使用它们。我决定使用 the TaskCompletionSource approach 围绕它包装一个 Task,它不会在线程池上进行调度(在此处的实例中无论如何都不需要,因为它基本上只是一个计时器)。这很好用,但现在取消有点令人头疼。

该示例显示了基本用法,在令牌上注册委托,但比我的情况稍微复杂一点,更重要的是,我不太确定如何处理 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 if I await the task回来。我的猜测是这是正常行为(我希望是),并且当我想使用取消时,我应该在调用周围包装一个 try/catch

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

还有一点我不太明白:Documentation suggests任何异常,即使是与取消有关的异常,都由 TPL 包裹在 AggregateException 中。但是,在我的测试中,我总是直接得到 TaskCanceledException,没有任何包装。我在这里遗漏了什么,还是只是记录不完整?


TL;DR:

从评论来看,您似乎有一个接受 IAnimation 的动画库,执行它(显然是异步的),然后返回它完成的信号。

这不是实际的 任务 ,因为它不是必须 运行 在线程上进行的工作。这是一个异步操作,在 .NET 中使用 Task 对象公开。

此外,您实际上并不是在取消某些东西,您是在停止动画。这是一个完全正常的操作,所以它不应该抛出异常。如果你的方法 returned 一个值来解释动画是否完成会更好,例如:

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

更新

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

例如,您可以使用本地函数,而不是使用回调和 lambda。除了使代码更简洁之外,它们 不会 每次调用它们时都分配委托。此更改不需要客户端的 C# 7 支持:

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

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

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

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。

您所做的很好 - Task 表示将来会产生结果的一些操作,它没有必要与 运行 其他线程或类似内容上的任何内容相关。使用标准的取消方式来取消是完全正常的,而不是返回布尔值之类的东西。

回答您的问题:当您执行 tcs.TrySetCanceled() 时,它会将任务移至已取消状态(task.IsCancelled 为真)并且此时不会抛出任何异常。但是当你 await 这个任务时 - 它会注意到任务被取消了,这就是 TaskCancelledException 将被抛出的地方。这里没有任何内容被包装到聚合异常中,因为实际上没有任何内容可以包装 - TaskCancelledException 作为 await 逻辑的一部分抛出。现在,如果您改为执行 task.Wait() 之类的操作 - 那么它会像您期望的那样将 TaskCancelledException 换成 AggregateException

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

现在,如果您在常规任务中使用取消令牌 - 情况会有所不同。当您执行类似 token.ThrowIfCancellationRequested 的操作时,它实际上会抛出 OperationCancelledException(请注意,它不是 TaskCancelledException,但 TaskCancelledExceptionOperationCancelledException 的子类)。然后,如果用于抛出此异常的 CancellationToken 与启动时传递给任务的 CancellationToken 相同(例如您的 link) - 任务将进入已取消状态同样的方式。这与代码中具有相同行为的 tcs.TrySetCancelled 相同。如果令牌不匹配 - 任务将进入故障状态,就像抛出常规异常一样。

我总是建议人们阅读 Cancellation in Managed Threads 文档。它不是很完整;与大多数 MSDN 文档一样,它告诉您 可以 做什么,而不是 应该 做什么。但它肯定比关于取消的 dotnet 文档更清楚。

The example shows the basic usage

首先,请务必注意示例代码中的取消 仅取消任务 - 它 不会 取消基础操作.我强烈建议您不要这样做。

如果您想取消操作,则需要更新 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;
}

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

The documentation says either just returning and having the task status switch to RanToCompletion, or throwing an OperationCanceledException (which results in the task's result being Canceled) is fine.

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

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

  • 定期调用 ThrowIfCancellationRequested。此选项更适合 CPU 绑定代码。
  • 通过 Register 注册取消回调。此选项更适合 I/O-bound 代码。注意注册必须处理!

在您的具体情况下,您遇到了不寻常的情况。对于你的情况,我会采用第三种方法:

  • 在你的"per-frame work"中,勾选token.IsCancellationRequested;如果需要,则将 AsyncCompletedEventArgs.Cancelled 设置为 true.
  • 引发回调事件

这在逻辑上等同于第一种正确的方式(周期性调用ThrowIfCancellationRequested),捕获异常,并将其转化为事件通知。无一例外。

I always get a TaskCanceledException if I await the task returned. My guess would be that this is normal behaviour (I hope it is) and that I'm expected to wrap a try/catch around the call when I want to use cancellation.

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

But I guess if a user wants to distinguish between a task that finished normally and one that was canceled, the [cancellation exception] is pretty much a side-effect of ensuring that, right?

That's the accepted pattern,是的。

Documentation suggests that any exceptions, even those that pertain to cancellation, are wrapped in an AggregateException by the TPL.

仅当您的代码同步 阻塞任务时才会出现这种情况。它首先应该真正避免这样做。所以文档肯定又误导了。

However, in my tests I'd always get the TaskCanceledException directly, without any wrapper.

await 避免了 AggregateException 包装器。

更新 评论解释 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,那就更干净了。