优雅地处理任务取消

50

当我需要取消大型/长时间运行的工作负载时,我经常使用类似于以下模板的任务执行操作:

public void DoWork(CancellationToken cancelToken)
{
    try
    {
        //do work
        cancelToken.ThrowIfCancellationRequested();
        //more work
    }
    catch (OperationCanceledException)
    {
        throw;
    }
    catch (Exception ex)
    {
        Log.Exception(ex);
        throw;
    }
}
OperationCanceledException 不应该被记录为错误,但如果任务要转换为被取消状态,则不得被忽略。除此之外的任何其他异常均无需在此方法的范围之外处理。
这种做法始终感觉有些笨拙,而 Visual Studio 默认情况下会在 OperationCanceledException 的 throw 处中断(尽管我现在已将“用户未处理的中断”关闭了,因为我使用了这种模式)。
更新: 现在是 2021 年,C#9 给了我一直想要的语法:
public void DoWork(CancellationToken cancelToken)
{
    try
    {
        //do work
        cancelToken.ThrowIfCancellationRequested();
        //more work
    }
    catch (Exception ex) when (ex is not OperationCanceledException)
    {
        Log.Exception(ex);
        throw;
    }
}
Ideally I think I'd like to be able to do something like this:
public void DoWork(CancellationToken cancelToken)
{
    try
    {
        //do work
        cancelToken.ThrowIfCancellationRequested();
        //more work
    }
    catch (Exception ex) exclude (OperationCanceledException)
    {
        Log.Exception(ex);
        throw;
    }
}
另一种方式是通过续传实现:
public void StartWork()
{
    Task.Factory.StartNew(() => DoWork(cancellationSource.Token), cancellationSource.Token)
        .ContinueWith(t => Log.Exception(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously);
}

public void DoWork(CancellationToken cancelToken)
{
    //do work
    cancelToken.ThrowIfCancellationRequested();
    //more work
}

但我并不太喜欢这种方式,因为异常技术上可能会有多个内部异常,而在记录异常时,您没有第一个示例中那么多的上下文信息(如果我不仅仅是记录它)。

我知道这有点像风格问题,但想知道是否有更好的建议?

我只能坚持使用第一种方案吗?


我使用的解决方案是,记录框架针对某些异常执行不同的操作。例如,忽略 OperationCancelledException,展开 AggregateException,仅记录 InvalidOperationException 的 innerException 等。 - adrianm
6个回答

21

那么,问题出在哪里呢?只需删除catch (OperationCanceledException)块,并设置适当的后续操作:

var cts = new CancellationTokenSource();
var task = Task.Factory.StartNew(() =>
    {
        var i = 0;
        try
        {
            while (true)
            {
                Thread.Sleep(1000);

                cts.Token.ThrowIfCancellationRequested();

                i++;

                if (i > 5)
                    throw new InvalidOperationException();
            }
        }
        catch
        {
            Console.WriteLine("i = {0}", i);
            throw;
        }
    }, cts.Token);

task.ContinueWith(t => 
        Console.WriteLine("{0} with {1}: {2}", 
            t.Status, 
            t.Exception.InnerExceptions[0].GetType(), 
            t.Exception.InnerExceptions[0].Message
        ), 
        TaskContinuationOptions.OnlyOnFaulted);

task.ContinueWith(t => 
        Console.WriteLine(t.Status), 
        TaskContinuationOptions.OnlyOnCanceled);

Console.ReadLine();

cts.Cancel();

Console.ReadLine();

TPL区分取消和故障。因此,取消(即在任务正文中抛出 OperationCancelledException 不是故障

主要观点: 不要处理任务正文中的异常而不重新抛出它们。


1
我对续传方法唯一的问题是我会失去上下文。例如,如果我正在处理一个项目列表,并且当抛出异常时需要记录我已经处理到集合中的哪个位置。我在原始问题中漏掉了一些东西,现在已经修复了,那就是重新抛出异常ex以允许任务转换为故障状态。 - Eamon
以下有一些更简单的答案。 - Casey Anderson

16

以下是优雅处理任务取消的方法:

处理“fire-and-forget”任务

var cts = new CancellationTokenSource( 5000 );  // auto-cancel in 5 sec.
Task.Run( () => {
    cts.Token.ThrowIfCancellationRequested();

    // do background work

    cts.Token.ThrowIfCancellationRequested();

    // more work

}, cts.Token ).ContinueWith( task => {
    if ( !task.IsCanceled && task.IsFaulted )   // suppress cancel exception
        Logger.Log( task.Exception );           // log others
} );

处理等待 Task 完成/取消

var cts = new CancellationTokenSource( 5000 ); // auto-cancel in 5 sec.
var taskToCancel = Task.Delay( 10000, cts.Token );  

// do work

try { await taskToCancel; }           // await cancellation
catch ( OperationCanceledException ) {}    // suppress cancel exception, re-throw others

11
你可以像这样做:
public void DoWork(CancellationToken cancelToken)
{
    try
    {
        //do work
        cancelToken.ThrowIfCancellationRequested();
        //more work
    }
    catch (OperationCanceledException) when (cancelToken.IsCancellationRequested)
    {
        throw;
    }
    catch (Exception ex)
    {
        Log.Exception(ex);
        throw;
    }
}

1
我更喜欢更严格的检查方式:catch (OperationCanceledException e) when (e.CancellationToken == cancelToken) - Ohad Schneider
2
@OhadSchneider通常情况下(不是在特定的琐碎示例中),无法保证cancelToken通过OperationCanceledException.CancellationToken进行传播。链接的令牌通常由异步方法在内部创建,隐藏了原始CancellationToken的标识。在大多数情况下,检查条件when (cancelToken.IsCancellationRequested)是我认为你能做到的最好的。 - Theodor Zoulias

10

C# 6.0有一个解决方案..过滤异常

int denom;

try
{
     denom = 0;
    int x = 5 / denom;
}

// Catch /0 on all days but Saturday

catch (DivideByZeroException xx) when (DateTime.Now.DayOfWeek != DayOfWeek.Saturday)
{
     Console.WriteLine(xx);
}

16
关键字实际上是'when'而不是'if'。 对于原始帖子(OP)的语法应为:catch( Exception ex ) when (!(ex is OperationCanceledException))。 - Spi
使用C# 9.0更好的语法:catch (Exception ex) when (ex is not OperationCanceledException) - alv

0
根据这篇MSDN博客文章,你应该捕获OperationCanceledException
async Task UserSubmitClickAsync(CancellationToken cancellationToken)
{
   try
   {
      await SendResultAsync(cancellationToken);
   }
   catch (OperationCanceledException) // includes TaskCanceledException
   {
      MessageBox.Show(“Your submission was canceled.”);
   }
}

如果您的可取消方法位于其他可取消操作之间,则在取消时可能需要执行清理。在这样做时,您可以使用上面的catch块,但一定要正确地重新抛出:

async Task SendResultAsync(CancellationToken cancellationToken)
{
   try
   {
      await httpClient.SendAsync(form, cancellationToken);
   }
   catch (OperationCanceledException)
   {
      // perform your cleanup
      form.Dispose();

      // rethrow exception so caller knows you’ve canceled.
      // DON’T “throw ex;” because that stomps on 
      // the Exception.StackTrace property.
      throw; 
   }
}

-2

我不完全确定你在这里想要实现什么,但我认为以下模式可能会有所帮助

public void DoWork(CancellationToken cancelToken)
{
    try
    {
        //do work
        cancelToken.ThrowIfCancellationRequested();
        //more work
    }
    catch (OperationCanceledException) {}
    catch (Exception ex)
    {
        Log.Exception(ex);
    }
}

你可能已经注意到我从这里删除了throw语句。这不会抛出异常,而只是忽略它。

如果你想做其他事情,请告诉我。

还有另一种方法,与你在代码中展示的非常接近。

    catch (Exception ex)
    {
        if (!ex.GetType().Equals(<Type of Exception you don't want to raise>)
        {
            Log.Exception(ex);

        }
    }

9
这段代码:catch (OperationCanceledException) {} 会将任务的状态设置为 RanToCompletion 而不是 Canceled。这在某些情况下是一个重要的区别。 - Dennis

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