取消任务会抛出异常

84

根据我对Tasks的了解,下面的代码应该可以在不抛出异常的情况下取消当前正在执行的任务。我原本认为任务取消的整个意义就是礼貌地“请求”任务停止,而不是中止线程。

以下程序的输出为:

转储异常

[OperationCanceledException]

取消并返回最后一个计算出的素数。

我正在尝试在取消时避免任何异常。我该如何实现?

void Main()
{
    var cancellationToken = new CancellationTokenSource();

    var task = new Task<int>(() => {
        return CalculatePrime(cancellationToken.Token, 10000);
    }, cancellationToken.Token);

    try
    {
        task.Start();
        Thread.Sleep(100);
        cancellationToken.Cancel();
        task.Wait(cancellationToken.Token);         
    }
    catch (Exception e)
    {
        Console.WriteLine("Dumping exception");
        e.Dump();
    }
}

int CalculatePrime(CancellationToken cancelToken, object digits)
{  
    int factor; 
    int lastPrime = 0;

    int c = (int)digits;

    for (int num = 2; num < c; num++)
    { 
        bool isprime = true;
        factor = 0; 

        if (cancelToken.IsCancellationRequested)
        {
            Console.WriteLine ("Cancelling and returning last calculated prime.");
            //cancelToken.ThrowIfCancellationRequested();
            return lastPrime;
        }

        // see if num is evenly divisible 
        for (int i = 2; i <= num/2; i++)
        { 
            if ((num % i) == 0)
            {             
                // num is evenly divisible -- not prime 
                isprime = false; 
                factor = i; 
            }
        } 

        if (isprime)
        {
            lastPrime = num;
        }
    }

    return lastPrime;
}

1
如果您试图避免异常,那么就不应该使用ThrowIfCancellationRequested引发异常。从CalculatePrime中优雅地返回即可。 - K-ballo
我已经删除了cancelToken.ThrowIfCancellationRequested();,但仍然得到相同的结果。 - Razor
不要将取消标记传递给Task.wait,因为您已请求取消任务。请改为调用Task.wait()。 - K-ballo
嗨。我仍然不明白为什么需要这个类,如果我们需要手动检查“ThrowIfCancellationRequested”或“IsCancellationRequested”。我认为当我调用“Cancel()”时,任务应该自动取消。 - Rashmin Javiya
5个回答

127

我想在取消时避免任何异常。

你不应该这么做。

在TPL中,抛出OperationCanceledException是表达“您调用的方法被取消”的惯用方式。不要反对它-只需预期它。

这是一件好事,因为这意味着当您有多个操作使用相同的取消标记时,您不需要在每个级别上用检查来查看您刚调用的方法是否实际正常完成或者是否由于取消而返回。您可以在各处使用CancellationToken.IsCancellationRequested,但从长远来看,这会使您的代码变得不太优美。

请注意,您的示例中有两个代码片段会引发异常-一个在任务本身内部:

cancelToken.ThrowIfCancellationRequested()

还有一种方式是等待任务完成:

task.Wait(cancellationToken.Token);

老实说,我不认为你真的想将取消令牌传递到task.Wait调用中...因为那样会允许其他代码取消你的等待。考虑到你知道你刚刚取消了该令牌,这是毫无意义的 - 它一定会抛出异常,无论任务是否已经注意到取消。

  • 使用不同的取消令牌(以便其他代码可以独立地取消你的等待)
  • 使用超时
  • 只需等待所需时间

14
请注意,TPL 中实现了属性(IsCancellationRequested)和方法(ThrowIfCancellationRequested)。因此在非常特殊的情况下,如果您确实不想承担抛出异常的成本,您可以避免使用它,但我建议在没有证据表明它是系统瓶颈的情况下不要这样做。 - Jon Skeet
13
我目前遇到了麻烦,主要是因为取消似乎是一种正常操作,但是使用异常来进行正常的流程控制感觉不太对。我错过了什么吗? - Tim Barrass
10
@TimBarrass说:“这让我感到有点不对,但我可以接受。如果你把异常不看作一个错误,而是一种表达‘我没有完成你要求我的操作 - 原因在这里’的方式,那么可能更有意义。不使用异常来控制流程的原则可能有点夸大其词 - 在某种程度上它们始终被用于流程控制。更多的是在正常的成功路径中不使用它们。” - Jon Skeet
2
@JonSkeet 在这里对另一个回复发表了不错的评论 -- 指出取消是一种罕见/偶发事件,因此基于性能(例如)的批评可能基本上无关紧要。但更与您的回复密切相关的是 -- 我想到取消在某种意义上确实是“异常”的... /微微眯起眼睛 - Tim Barrass
3
这是非常依赖语境的,但通常你会尝试编写代码,使其在任务被取消的任何时间点都不会受到影响。 - Jon Skeet
显示剩余2条评论

81

在这一行代码中,你明确地抛出了一个异常:

cancelToken.ThrowIfCancellationRequested();
如果你想要优雅地退出任务,那么你只需要去掉那一行即可。
通常人们使用这个作为一种控制机制来确保当前的处理中止,而不会运行任何额外的代码。此外,调用ThrowIfCancellationRequested()时无需检查取消,因为它在功能上等同于:
if (token.IsCancellationRequested) 
    throw new OperationCanceledException(token);

当使用ThrowIfCancellationRequested()时,你的任务可能会像这样:

int CalculatePrime(CancellationToken cancelToken, object digits) {
    try{
        while(true){
            cancelToken.ThrowIfCancellationRequested();

            //Long operation here...
        }
    }
    finally{
        //Do some cleanup
    }
}

此外,如果令牌被取消,Task.Wait(CancellationToken)将引发异常。 要使用此方法,您需要在Try...Catch块中包装Wait调用。

MSDN:如何取消任务


2
谢谢,Josh。在每次循环中调用ThrowIfCancellationRequested()而不是重复检查取消标志是有道理的。 - Razor
60
作为警告:将“ThrowIfCancellationRequested”替换为对“IsCancellationRequested”的一堆检查可以平稳退出,就像这个答案所说的那样。但这不仅仅是一个实现细节;它会影响可观察行为:任务不再以取消状态而是以“RanToCompletion”状态结束。而且不仅会影响显式状态检查,还可能更微妙地影响使用ContinueWith等任务链接,这取决于使用的TaskContinuationOptions。我认为避免使用“ThrowIfCancellationRequested”是危险的建议。 - Eamon Nerbonne
如果标记被取消,Task.Wait(CancellationToken) 将抛出异常。这就是我的问题所在。谢谢。 - granadaCoder
难道另一种选择不是只等待令牌的句柄吗?即 token.WaitHandle.WaitOne(),而不是让你的等待坐在 try/catch 块中? - Snoop

11

以上一些答案读起来好像ThrowIfCancellationRequested()是一个选项。但在这种情况下,它不是选项,因为你将无法得到最后的质数。当取消意味着丢弃任何中间结果时,“调用的方法已被取消”的惯用方式是定义的。如果您对取消的定义是“停止计算并返回最后一个中间结果”,则您已经离开了那条路。

特别是从运行时间方面讨论收益也是非常误导人的: 实现的算法运行时间很糟糕。即使高度优化取消也没有任何好处。

最简单的优化是展开这个循环并跳过一些不必要的周期:

for(i=2; i <= num/2; i++) { 
  if((num % i) == 0) { 
    // num is evenly divisible -- not prime 
    isprime = false; 
    factor = i; 
  }
} 

您可以:

  • 对于每个偶数,可以节省(num/2)-1个周期,总体上略少于50%(展开),
  • 对于每个素数,可以节省(num/2)-num的平方根个周期(根据最小质因子的数学选择边界),
  • 对于每个非素数,至少可以节省那么多周期,预计节省更多,例如num = 999以1个周期结束而不是499个周期(如果找到答案,则中断)并且
  • 可以再节省50%的周期,这当然是25%的总体节省(根据质数的数学选择步骤,展开处理特殊情况2)。

这相当于在内部循环中储蓄了75%的保证最低值(粗略估计为90%),只需将其替换为:

if ((num % 2) == 0) {
  isprime = false; 
  factor = 2;
} else {
  for(i=3; i <= (int)Math.sqrt(num); i+=2) { 
    if((num % i) == 0) { 
      // num is evenly divisible -- not prime 
      isprime = false; 
      factor = i;
      break;
    }
  }
} 

有许多更快的算法(我不会讨论,因为我已经足够离题),但这种优化相当容易,仍然证明了我的观点: 当你的算法远非最佳时,不要担心微观优化运行时间。


+1,但我不确定你的答案是否充分说明了使用的算法与正确算法相比有多慢。他应该从最大数开始,使用Baillie-PSW素性测试逐渐递减。 - Sam Harwell
我认为眼前的任务不是解决“在规定的计算时间内计算尽可能大的质数”的问题,而是通过示例学习编码,比如“计算直到取消请求为止的所有质数,并返回计算得到的最大质数”。我不想开始关于随机质数测试的讨论,因为要求可能需要对检测到的质数进行100%的保证。对于大质数,这将导致确定性的AKS素性测试,而AKS素性测试又使用或被埃拉托斯特尼筛法击败,对于小于2^23的所有数字,如果我没记错的话。 - No answer
3
抱歉,我偏离了原来的话题。然而,在回答“280Z28 Jan 15 at 1:37”时遗漏的一点是,内部循环的75%保证节约使得任何“取消运行时讨论”都变得过时。考虑到取消只在每个调用中执行一次,因此不应该有太多应用程序需要担心其运行时。 - No answer

11
另外需要注意使用ThrowIfCancellationRequested而不是IsCancellationRequested的好处:我发现当需要使用ContinueWith并且设置了TaskContinuationOptions.OnlyOnCanceled的继续选项时,IsCancellationRequested不会导致条件的ContinueWith触发。然而,ThrowIfCancellationRequested将会设置任务的取消状态,从而导致ContinueWith触发。

注意:这仅在任务已经运行而不是任务开始时才成立。这就是为什么我在启动和取消之间添加了一个Thread.Sleep()的原因。

CancellationTokenSource cts = new CancellationTokenSource();

Task task1 = new Task(() => {
    while(true){
        if(cts.Token.IsCancellationRequested)
            break;
    }
}, cts.Token);
task1.ContinueWith((ant) => {
    // Perform task1 post-cancellation logic.
    // This will NOT fire when calling cst.Cancel().
}

Task task2 = new Task(() => {
    while(true){
        cts.Token.ThrowIfCancellationRequested();
    }
}, cts.Token);
task2.ContinueWith((ant) => {
    // Perform task2 post-cancellation logic.
    // This will fire when calling cst.Cancel().
}

task1.Start();
task2.Start();
Thread.Sleep(3000);
cts.Cancel();

2
这是因为当任务以给定的异常类型退出时,任务状态才会被设置为Canceled,否则,如果你只是“return”退出它,它的状态将被设置为RanToCompletion。因此,OnlyOnCanceled代码块不会被调用。 - almulo

1
你有两个东西在监听令牌,一个是计算质数的方法,另一个是名为task的Task实例。计算质数的方法应该正常返回,但是当task正在运行时被取消了,所以它会抛出异常。在构造task时不需要给它提供令牌。

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