如何取消 await 中的任务?

188
我正在使用这些Windows 8 WinRT任务进行开发,尝试使用下面的方法取消一个任务,它在某种程度上是有效的。CancelNotification方法确实被调用了,这让你以为任务已经被取消了,但实际上任务在后台继续运行,直到完成后,任务的状态始终是已完成而不是已取消。有没有办法在任务被取消时完全停止它?
private async void TryTask()
{
    CancellationTokenSource source = new CancellationTokenSource();
    source.Token.Register(CancelNotification);
    source.CancelAfter(TimeSpan.FromSeconds(1));
    var task = Task<int>.Factory.StartNew(() => slowFunc(1, 2), source.Token);

    await task;            

    if (task.IsCompleted)
    {
        MessageDialog md = new MessageDialog(task.Result.ToString());
        await md.ShowAsync();
    }
    else
    {
        MessageDialog md = new MessageDialog("Uncompleted");
        await md.ShowAsync();
    }
}

private int slowFunc(int a, int b)
{
    string someString = string.Empty;
    for (int i = 0; i < 200000; i++)
    {
        someString += "a";
    }

    return a + b;
}

private void CancelNotification()
{
}

刚刚发现了这篇文章,帮助我理解了取消异步操作的各种方式。 - Uwe Keim
相关链接:异步等待Task<T>完成并设置超时。这与您的代码实际执行的操作(取消等待)有关,而不是您希望它执行的操作(取消操作本身)。 - undefined
4个回答

275

请阅读有关“Cancellation”(自.NET 4.0引入以来基本保持不变)和“Task-Based Asynchronous Pattern”的内容,后者提供了如何在async方法中使用CancellationToken的指南。(Cancellation 取消操作)(基于任务的异步模式)

简而言之,您需要将CancellationToken传递给每个支持取消操作的方法,该方法必须定期检查它。

private async Task TryTask()
{
  CancellationTokenSource source = new CancellationTokenSource();
  source.CancelAfter(TimeSpan.FromSeconds(1));
  Task<int> task = Task.Run(() => slowFunc(1, 2, source.Token), source.Token);

  // (A canceled task will raise an exception when awaited).
  await task;
}

private int slowFunc(int a, int b, CancellationToken cancellationToken)
{
  string someString = string.Empty;
  for (int i = 0; i < 200000; i++)
  {
    someString += "a";
    if (i % 1000 == 0)
      cancellationToken.ThrowIfCancellationRequested();
  }

  return a + b;
}

4
兄弟,如果我没有访问缓慢方法的权限,有没有办法实现它?例如,假设slowFunc在一个黑盒子中,你只能调用该方法,但不能修改其中任何内容? - Carlo
12
大多数长时间运行的同步方法都有一些取消它们的方式,有时是通过关闭底层资源或调用另一个方法来实现。CancellationToken 具备与自定义取消系统进行交互所需的所有钩子,但是没有什么可以取消不可取消的方法。 - Stephen Cleary
4
好的,我建议您在异步方法中永远不要使用WaitResult,而应该始终使用await来正确处理异常。 - Stephen Cleary
16
好的,我会尽力为您翻译。以下是需要翻译的内容:只是好奇,为什么没有一个例子使用“CancellationToken.IsCancellationRequested”,而是建议抛出异常呢? - user1618054
2
如果一个方法需要一个 CancellationToken,那么期望的行为是在操作被取消时抛出一个 OperationCanceledException。这是标准行为。任何其他行为(比如返回一个特殊值)都会很不寻常,并需要明确的文档说明。 - Stephen Cleary
显示剩余17条评论

48

或者,为了避免修改slowFunc(比如说你没有源代码的访问权限):

var source = new CancellationTokenSource(); //original code
source.Token.Register(CancelNotification); //original code
source.CancelAfter(TimeSpan.FromSeconds(1)); //original code
var completionSource = new TaskCompletionSource<object>(); //New code
source.Token.Register(() => completionSource.TrySetCanceled()); //New code
var task = Task<int>.Factory.StartNew(() => slowFunc(1, 2), source.Token); //original code

//original code: await task;  
await Task.WhenAny(task, completionSource.Task); //New code

您还可以使用https://github.com/StephenCleary/AsyncEx中的优秀扩展方法,使代码看起来非常简单:

await Task.WhenAny(task, source.Token.AsTask());

3
整个异步等待实现看起来非常棘手。我不认为这样的结构会使源代码更易读。 - Maxim
1
谢谢,有一点需要注意--注册的令牌应稍后处理,另一件事是--在UI应用程序中使用ConfigureAwait,否则可能会出错。 - astrowalker
@astrowalker:确实,token的注册最好是注销(dispose)。这可以通过在传递给Register()的委托中调用返回的对象上的dispose来完成。 但是,由于在这种情况下“source”token仅为本地变量,因此所有内容都将被清除... - sonatique
1
其实只需要将它嵌套在using中即可。 - astrowalker
3
我非常确定这个例子实际上并没有以任何方式中断未修改的慢速函数。它只是在计时器中并行启动一个新任务,并等待任何一个完成。如果超时,它确实会停止等待最终的await-task-whenany,但它也允许慢速运行的函数继续运行,这可能浪费资源,并且与问题所要求的远离。问题是关于完全停止任务。我认为这是与主题的重要分歧,请在答案中添加一两个单词来说明。 - quetzalcoatl
显示剩余3条评论

25

尚未涉及的一种情况是如何在异步方法中处理取消。例如,考虑一个简单的情况,您需要上传一些数据到服务,让它进行计算,然后返回一些结果。

public async Task<Results> ProcessDataAsync(MyData data)
{
    var client = await GetClientAsync();
    await client.UploadDataAsync(data);
    await client.CalculateAsync();
    return await client.GetResultsAsync();
}

如果你想支持取消操作,最简单的方法是传入一个令牌,在每个异步方法调用之间检查它是否已被取消(或使用 ContinueWith)。但如果这些调用非常耗时,则等待时间可能很长。我创建了一个小助手方法,以在取消时立即失败。

public static class TaskExtensions
{
    public static async Task<T> WaitOrCancel<T>(this Task<T> task, CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        await Task.WhenAny(task, token.WhenCanceled());
        token.ThrowIfCancellationRequested();

        return await task;
    }

    public static Task WhenCanceled(this CancellationToken cancellationToken)
    {
        var tcs = new TaskCompletionSource<bool>();
        cancellationToken.Register(s => ((TaskCompletionSource<bool>)s).SetResult(true), tcs);
        return tcs.Task;
    }
}

所以,要使用它,只需要在任何异步调用中添加.WaitOrCancel(token)

public async Task<Results> ProcessDataAsync(MyData data, CancellationToken token)
{
    Client client;
    try
    {
        client = await GetClientAsync().WaitOrCancel(token);
        await client.UploadDataAsync(data).WaitOrCancel(token);
        await client.CalculateAsync().WaitOrCancel(token);
        return await client.GetResultsAsync().WaitOrCancel(token);
    }
    catch (OperationCanceledException)
    {
        if (client != null)
            await client.CancelAsync();
        throw;
    }
}
请注意,这不会停止您正在等待的任务,任务将继续运行。 您需要使用其他机制来停止它,例如示例中的 CancelAsync 调用,或者更好的方法是将相同的 CancellationToken 传递给 Task,以便它最终可以处理取消。试图中止线程 不推荐使用

3
请注意,尽管这会取消任务的等待,但它不会取消实际的任务(例如UploadDataAsync可能会在后台继续执行,但一旦完成,它将不再调用CalculateAsync,因为那部分已经停止等待)。对于您来说,这可能有问题,特别是如果您想重试操作。如果可能的话,沿着整个过程传递CancellationToken是首选选项。 - Miral
2
@Miral 这是真的,但是有许多异步方法不需要取消标记。例如,WCF服务在生成带有异步方法的客户端时将不包括取消标记。正如示例所示,并且正如Stephen Cleary所指出的那样,假定长时间运行的同步任务有一些可以取消它们的方式。 - kjbartel
2
这就是为什么我说“尽可能”的原因。大多数情况下,我只是想提醒人们注意这个警告,以便后来查找此答案的人不会产生错误的印象。 - Miral
1
@Miral 谢谢。我已经更新以反映这个警告。 - kjbartel
遗憾的是,这不能与像“NetworkStream.WriteAsync”这样的方法一起使用。 - Zeokat

6

我想要补充已经被接受的答案。我遇到了问题,但是我在处理完成事件方面采取了不同的方法。我没有运行await,而是将完成处理程序添加到任务中。

Comments.AsAsyncAction().Completed += new AsyncActionCompletedHandler(CommentLoadComplete);

事件处理程序应该长成这个样子

private void CommentLoadComplete(IAsyncAction sender, AsyncStatus status )
{
    if (status == AsyncStatus.Canceled)
    {
        return;
    }
    CommentsItemsControl.ItemsSource = Comments.Result;
    CommentScrollViewer.ScrollToVerticalOffset(0);
    CommentScrollViewer.Visibility = Visibility.Visible;
    CommentProgressRing.Visibility = Visibility.Collapsed;
}

使用这个路由,所有的处理都已经为您完成,当任务被取消时,它只触发事件处理程序,您可以在那里查看是否已被取消。

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