异步任务、取消和异常处理

4

我目前正在学习如何正确地使用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;
}

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

当前形式下,当我在TaskCompletionSource上调用TrySetCanceled时,如果我等待返回的任务,我总是会得到一个TaskCanceledException我的猜测是这是正常行为(希望如此),当我想使用取消功能时,我应该在调用周围包装一个try/catch

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

另外一个我没太明白的地方:文档建议说,任何异常,甚至是与取消相关的异常,都会由TPL包装在AggregateException中。然而,在我的测试中,我总是直接得到TaskCanceledException,没有任何包装器。我是漏掉了什么,还是文档写得不太好?


简短版:

  • 任务要转换为Canceled状态,必须有相应的异常,用户必须在异步调用周围包装一个try/catch才能检测到,对吗?
  • 抛出未包装的TaskCanceledException也是正常的行为,我没有做错什么吗?

1
不要用任务包装现有的基于事件的API(你的客户可以很好地完成这个工作),最好使用基于任务的API,并为仍然需要它们的任何人公开事件。RunFoo是做什么的?如何使其异步工作?如何取消 - Panagiotis Kanavos
在我的情况下,令牌没有传递给任何其他东西,因为目前没有任何东西内部使用任务。因此,没有人检查“IsCancellationRequested”。但即使如此,据我所了解,您有两个选项:一种是停止正在进行的操作,这将导致任务运行完成,但不会真正完成。另一个是自己抛出异常(例如通过令牌上的帮助程序方法),这将导致取消的任务...和TaskCanceledException。 - Joey
任务抽象了执行异步操作的单个任务/工作,例如响应事件运行的代码。当单个事件被触发时,单个任务会被执行。听起来你正在尝试将其用于非常不同的事情——注册/取消注册事件处理程序?你希望API看起来像什么?客户端是否期望等待任务完成?那意味着什么? - Panagiotis Kanavos
所讨论的案例是 Animator.Animate(IAnimation)。它以异步方式运行动画实例(同步方式无论如何都没有意义)。由于它是一个动画,因此没有任何长时间运行的内容,而是每帧要做的工作,在 WPF 中有一个专用事件来注册处理程序。从客户端的角度来看,我认为它符合任务的条件。旧的 API 在 Animate 方法中有一个回调参数,用于在动画完成后执行操作,在基于任务的变体中,这将通过等待然后执行清理操作(例如重新启用 UI 中的交互)来完成。 - Joey
让我们在聊天中继续这个讨论:http://chat.stackoverflow.com/rooms/142888/discussion-between-joey-and-panagiotis-kanavos。 - Joey
显示剩余2条评论
3个回答

5
我总是建议人们阅读托管线程中的取消文档。它并不完整;像大多数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注册取消回调。这个选项更适合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进行取消操作,那么你需要注册它。你需要处理该注册,并且最简单的方法可能是将其拆分为两个不同的方法。
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方法确实会取消操作,这就是为什么我在响应令牌请求时调用它。出于说明目的,应该将其重命名为更好的名称。 - Joey
好的,我已经阅读了MSDN文章和答案。我认为这里的一个重要部分取决于您认为我没有取消实际操作,但我确实取消了(我们将CleanupFoo命名为CancelAndCleanupFoo)。另外,据我现在的理解,如果在await时出现异常而不是返回例如Panagiotis建议的Task<bool>,似乎更符合惯例,并且更适合于框架的其余部分,因此即使它需要用户在使用取消时在端点处使用try/catch,该方法也应该是首选? - Joey
是的,在取消操作时抛出异常比使用 Task<bool> 更符合惯用法。但是,如果您使用 Register,则必须确保将其处理好,这很麻烦。我会更新我的答案来说明。 - Stephen Cleary
1
非常感谢。实际上,我最终只使用了 CancellationTokenAsyncCompletedEventArgs,这确实比注册和弄清如何处理(一开始我稍微误读了MSDN上的示例,但在理解后,我已经重写了代码)。现在进入更有趣的API部分,这可能更难转换/封装。我只是不想在几周后发布,然后让客户告诉我们我们的解决方案完全错误 ;-) - Joey

2
你所做的很好-任务代表着未来某个操作的结果,不一定涉及在另一个线程上运行或类似的事情。使用标准的取消手段(而不是返回布尔值之类的东西)正常,以实现取消功能。回答你的问题:当你执行tcs.TrySetCancelled()时,它会将任务移动到已取消状态(task.IsCancelled将变为true),此时不会抛出任何异常。但是当你await此任务时,它会注意到任务被取消,这就是抛出TaskCancelledException的时间点。没有将任何内容包装进AggregateException中,因为真的没有什么可包装的——TaskCancelledException作为await逻辑的一部分被抛出。现在,如果你像这样做:task.Wait(),那么它会如你所期望的那样将TaskCancelledException包装成AggregateException。
请注意,await无论如何都会取消AggregateExceptions的包装,因此你可能从不期望await task会抛出AggregateException。在多个异常的情况下,只会抛出第一个异常,其他异常将被吞噬。
现在,如果你在常规任务中使用取消令牌,情况就有些不同了。当你执行类似于token.ThrowIfCancellationRequested这样的操作时,它实际上会抛出OperationCancelledException(请注意,它不是TaskCancelledException,但TaskCancelledException无论如何也是OperationCancelledException的子类)。然后,如果用于抛出此异常的CancellationToken与启动任务时传递的CancellationToken相同(就像你链接中的示例一样),任务将以相同的方式移至已取消状态。这与你代码中的tcs.TrySetCancelled相同,具有相同的行为。如果标记不匹配,则任务将进入故障状态,就像常规异常被抛出一样。

好的,这很有帮助。这是一次阅读文档、.NET源代码(比我使用的框架更新)和试错的旅程,所以有些东西还有点模糊。 - Joey

1

从评论看来,您有一个接受 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;
}

你可以返回更复杂的结果,例如包含已完成/停止标志和最终帧的结果类型,如果动画停止了。如果不想使用无意义的字段(如果动画已完成,为什么要指定帧?),则可以返回实现了IResult的Success类型或Stopped类型。在C# 7之前,需要检查返回类型或使用重载来访问不同类型。但是,使用模式匹配,您可以通过switch获取实际结果,例如:
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/catch中,这看起来有点丑陋。无论如何,在库中有更多的Task机会,不仅仅是动画(还有图形布局需要任务化),但我首先选择了最简单的情况进行测试。 - Joey
@Joey,你需要记住Task可能代表需要执行的任务,也可能代表正在运行的异步操作,一种样板指示器。C# 7在方面也可以提供很大帮助,特别是当你需要处理不同形状类型时。事实上,形状是模式匹配和解构的典型示例。 - Panagiotis Kanavos

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接