3

我目前正在学习如何使用Tasks 正确地公开我们的库 API 的异步部分,以便它们可以更轻松、更好地为客户使用。我决定采用将 a 包裹起来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;
}

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

在当前表单中,当我调用TrySetCanceled时,如果我等待返回的任务TaskCompletionSource,我总是会得到一个。我的猜测是这是正常行为(我希望是这样),并且当我想使用取消时,我应该在呼叫周围加上/ 。TaskCanceledException trycatch

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

我不太明白的另一点:文档表明任何异常,即使是与取消有关的异常,AggregateException都由 TPL 包装在 an 中。但是,在我的测试中,我总是TaskCanceledException直接得到,没有任何包装。我在这里遗漏了什么,或者只是记录不佳?


TL;博士:

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

3 回答 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 library执行“可取消等待” ,或者您可以编写自己的等效扩展方法。

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

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

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

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

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

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

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

如果我等待返回的任务,我总是会收到 TaskCanceledException。我的猜测是这是正常行为(我希望是这样),并且当我想使用取消时,我应该在调用周围加上 try/catch。

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

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

这是公认的模式,是的。

文档表明,任何异常,即使是与取消有关的异常,都由 TPL 包装在 AggregateException 中。

仅当您的代码在任务上同步阻塞时,这才是正确的。它应该首先避免这样做。因此,文档肯定再次具有误导性。

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

await避免AggregateException包装。

更新评论解释CleanupFoo 一种取消方法。

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

但是,如果您必须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直接使用,它会更干净。

于 2017-04-28T13:55:58.193 回答
2

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

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

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

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

于 2017-04-28T09:35:28.713 回答
1

从评论中,看起来您有一个动画库,它接受 a 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 标志和最后一帧的结果类型。如果您不想使用无意义的字段(如果动画完成,为什么要指定帧?),您可以返回一个 Success 类型或一个实现了例如 IResult 的 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。

于 2017-04-28T09:26:17.187 回答