【问题标题】:Async Tasks, Cancellation, and Exceptions异步任务、取消和异常
【发布时间】:2017-09-26 07:25:23
【问题描述】:

我目前正在学习如何使用 Tasks 正确地公开我们库 API 的异步部分,以便他们可以更轻松、更好地为客户使用。我决定使用the TaskCompletionSource approach 将Task 包裹在它周围,该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 如果我等待返回的任务。我的猜测是这是正常行为(我希望是这样),并且当我想使用取消功能时,我应该在调用周围加上 try/catch。

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

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


TL;DR:

  • 对于转换到Canceled 状态的任务,总是需要相应的异常,用户必须在异步调用周围包裹try/catch 才能检测到它,对吗?
  • TaskCanceledException 被打开也是意料之中的正常现象,我在这里没有做错什么吗?

【问题讨论】:

  • 与其将现有的基于事件的 API 与任务包装起来(您的客户可以很好地做到这一点),不如使用基于 task 的 API 并公开事件任何仍然想要它们的人。 RunFoo 是做什么的?你如何使它异步工作?你如何取消它?
  • PS:取消时不需要例外。接受令牌的方法可以检查IsCancellationRequested 标志并自行取消——不管这意味着什么。例如,不能取消 Http 请求,只能取消等待响应。
  • 它通过为CompositionTarget.OnRendering 注册一个事件处理程序来运行动画,因此它实际上不需要在线程池上运行(如前所述,就这个问题而言,它实际上只是一个计时器)。因此它已经异步工作,我通过取消注册OnRendering 的事件处理程序来取消它(加上更多的簿记)。所有这些都已经到位。这里的重点是公开一个基于任务的 API而如果可能的话不必重写所有的内部结构。特别是如果事情已经异步工作就好了,只是不公开任何等待
  • 好吧,在我的情况下,令牌不会传递给其他任何东西,因为内部还没有使用任务。所以没有人可以检查IsCancellationRequested。但即便如此,据我了解,你有两个选择:一个是停止做你正在做的事情,这会导致任务运行完成,只是......缺少 actual 完成。另一种是自己抛出异常(例如,通过令牌上的辅助方法),这会导致任务被取消......和 ​​TaskCanceledException。
  • 任务抽象出异步执行的单个任务/作业,例如响应事件运行的代码。当一个 single 事件被触发时,一个 single 任务被执行。听起来您正试图将它用于非常不同的事情 - 注册/取消注册事件处理程序?您希望 API 看起来如何?客户会等待任务完成吗?这意味着什么?

标签: c# task-parallel-library task taskcompletionsource


【解决方案1】:

从 cmets 看来,您有一个接受 IAnimation 的动画库,执行它(显然是异步的),然后发回信号表明它已完成。

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

此外,您实际上并没有取消什么,而是停止了动画。这是一个完全正常的操作,所以它不应该抛出异常。如果您的方法返回一个解释动画是否完成的值会更好,例如:

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

您还可以返回更复杂的结果,例如,如果动画停止,则结果类型包含 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。

【讨论】:

  • 呵呵,这倒是我没想到的。我选择了Task(没有类型参数),因为无论如何都没有任何有用的返回,但是能够区分取消和正常完成听起来也很有用,但是必须将await 包装成try/@ 987654329@ 只是因为这个看起来有点难看。无论如何,库中@9​​87654330@s 的机会更多,不仅仅是动画(还有图形布局留待任务化),但我先选择了最简单的情况进行测试。
  • @Joey 你必须记住,Task 可能代表一个需要执行的 task,或者它可能代表一个正在运行的异步操作,一种样板指示器。 library's 方面的 C# 7 也可以提供很多帮助,尤其是当您需要处理不同的形状类型时。事实上,形状是模式匹配和解构的典型例子
【解决方案2】:

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

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

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

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

【讨论】:

  • 好的;这有帮助。这是一个阅读文档、.NET 源代码(比我使用的框架更新)和反复试验的旅程,所以有些事情有点模糊。
【解决方案3】:

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

示例显示基本用法

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

如果您想取消操作,则需要更新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 执行“可取消等待”,或者您可以编写自己的等效扩展方法。

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

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

通常,回复CancellationToken 的正确方法是:

  • 定期致电ThrowIfCancellationRequested。此选项更适合 CPU 密集型代码。
  • 通过Register 注册取消回调。此选项更适合 I/O 绑定代码。请注意,必须处理注册!

在您的特定情况下,您遇到了不寻常的情况。在你的情况下,我会采取第三种方法:

  • 在您的“每帧工作”中,检查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 包装器。

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

【讨论】:

  • 我可能会在周二详细阅读并考虑一下,但在那之前:CleanupFoo 方法确实取消了操作,这就是为什么我调用它来响应令牌的请求。出于说明目的,应该更好地重命名它。
  • 好的,阅读 MSDN 文章和答案。我相信这里很大一部分取决于您的想法,即我不会取消实际操作,我会这样做(让我们命名CleanupFooCancelAndCleanupFoo)。同样据我所知,在await 上出现异常而不是返回,例如Panagiotis 建议的Task&lt;bool&gt; 似乎更惯用,并且更适合框架的其余部分,因此即使在用户端需要try/catch 如果他们想使用取消,这种方法也应该是可取的?
  • 是的,在取消时抛出异常比Task&lt;bool&gt; 更惯用。但是,如果你使用Register,那么你一定要处理掉它,这很尴尬。我会更新我的答案来说明。
  • 非常感谢。我实际上只使用了CancellationToken 和AsyncCompletedEventArgs,这最终确实比注册和弄清楚如何处理更简单(对于我当前的,相当琐碎的用例)(我最初误读了 MSDN 上的示例) ,但在我理解之后,我反正已经重写了代码)。现在进入 API 中更有趣的部分,它可能更难转换/包装。我只是不想在几周后发布,然后让客户告诉我们我们的解决方案完全错误;-)
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-09-27
  • 1970-01-01
相关资源
最近更新 更多