繁体   English   中英

异步任务,取消和例外

[英]Async Tasks, Cancellation, and Exceptions

我目前正在学习如何使用Task正确地公开我们的库API的异步部分,以便它们可以更容易和更好地用于客户。 我决定采用TaskCompletionSource方法在它周围包装一个没有在线程池上调度的Task (在这里不需要的实例中,因为它基本上只是一个计时器)。 这很好,但现在取消有点令人头疼。

该示例显示了基本用法,在令牌上注册委托,但比我的情况稍微复杂一点,更重要的是,我不确定如何处理TaskCanceledException 文档说要么只返回并将任务状态切换到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;
}

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

在目前的形式,当我打电话TrySetCanceledTaskCompletionSource ,我总是得到一个TaskCanceledException 如果我等待任务返回。 我的猜测是这是正常的行为(我希望它是这样)并且我希望在我想要使用取消时围绕通话包装一个try / catch

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

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


TL; DR:

  • 对于要转换到Canceled状态的任务,总是需要相应的异常,并且用户必须围绕异步调用包装try / catch以便能够检测到,对吧?
  • 被解包的TaskCanceledException也是预期和正常的,我在这里做错了什么?

我总是建议人们阅读托管主题文档中的取消 它不完整; 像大多数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;
}

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

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

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

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

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

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

  • 在“每帧工作”中,检查token.IsCancellationRequested ; 如果请求,则在AsyncCompletedEventArgs.Cancelled设置为true引发回调事件。

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

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

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

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

这是公认的模式 ,是的。

文档表明,任何异常,甚至是与取消相关的异常,都会被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 ,那么它会更清晰。

从评论中,看起来你有一个接受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)
{
}

UPDATE

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

例如,您可以使用本地函数,而不是使用回调和lambdas。 除了使代码更清晰之外,它们每次调用时都不会分配代理。 此更改不需要客户端的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。

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

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

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

现在,如果您使用取消令牌与常规任务 - 事情有点不同。 当你执行像token.ThrowIfCancellationRequested之类的OperationCancelledException ,它实际上会抛出OperationCancelledException (注意它不是TaskCancelledException但是TaskCancelledException仍然是OperationCancelledException子类)。 然后,如果CancellationToken用来抛出此异常是一样CancellationToken传递给任务时,它开始(如例如,通过你的链接上) -任务将转移到已取消状态一样。 这与代码中具有相同行为的tcs.TrySetCancelled相同。 如果令牌不匹配 - 任务将转到Faulted状态,就像抛出常规异常一样。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM