Parallel.ForEach如何处理取消或ThrowIfCancellationRequested()和异常

时间:2019-06-27 08:14:28

标签: c# .net wpf task-parallel-library

我创建了一个WPF应用程序以查看TPL的工作方式,并对输出结果感到困惑。下面是我的代码:

// Two buttons, 'Process' button and 'Cancel' button
public partial class MainWindow : Window 
{
   private CancellationTokenSource cancelToken = new CancellationTokenSource();
   public MainWindow()
   {
      InitializeComponent();
   }
   //...

   private void cmdProcess_Click(object sender, EventArgs e)  // Sequence A
   {
      Task.Factory.StartNew(() => ProcessFiles()); 
   }

    private void cmdCancel_Click(object sender, EventArgs e)   //Sequence B
   {
      cancelToken.Cancel();  
   }

   private void ProcessFiles() 
   {
      ParallelOptions parOpts = new ParallelOptions();
      parOpts.CancellationToken = cancelToken.Token;
      parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;

      string[] files = { "first", "second" };
      try
      {
         Parallel.ForEach(files, parOpts, currentFile =>
         {
            parOpts.CancellationToken.ThrowIfCancellationRequested();  //Sequence C
            Thread.Sleep(5000);
         });
      }
      catch (OperationCanceledException ex)
      { 
         MessageBox.Show("Caught");
      }
   }

}

当我按下click按钮,然后快速按下cancel按钮时,我得到一个“已捕获”消息框,它仅弹出一次,而不是两次。

假设主线程ID为1,工作线程为2和3 所以我有两个问题:

Q1-当我按下cancel按钮时,辅助线程2和3已经执行了'parOpts.CancellationToken.ThrowIfCancellationRequested();' (当然,我的鼠标单击速度不能与线程的执行速度一样快)。当他们执行ThrowIfCancellationRequested时,cancelToken尚未被取消,这意味着线程2和线程3的取消按钮尚未被单击。那么那些工作线程为什么仍会引发异常?

Q2-为什么我只有一个弹出消息框,不是两个,一个用于线程2,一个用于线程3?

Q3-我将Parallel.ForEach修改为:

try
{
   Parallel.ForEach(files, parOpts, currentFile =>
   {
      Thread.Sleep(5000);
      parOpts.CancellationToken.ThrowIfCancellationRequested(); 

   });
}
catch (OperationCanceledException ex)
{ 
   MessageBox.Show("Caught");
}

现在我可以在工作线程到达ThrowIfCancellationRequested()之前按“取消”按钮,但是主线程仍然只抛出一个异常。我按了Cancal按钮,令牌已设置为取消,所以当辅助工作线程到达parOpts.CancellationToken.ThrowIfCancellationRequested();时,它也不应该抛出异常吗?并且该异常无法通过主线程中的try catch处理(每个线程都有其自己的堆栈),因此我应该获得一个未处理的异常来暂停应用程序,但事实并非如此,我只是得到了一个由主线程抛出的异常,并且此异常是由主线程还是辅助线程引发的?

Q4-我将代码修改为:

private void ProcessFilesz()
{
    ParallelOptions parOpts = new ParallelOptions();
    parOpts.CancellationToken = cancelToken.Token;
    parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;

    cancelToken.Cancel(); // cancel here
    string[] files = { "first", "second" };
    try
    {
        Parallel.ForEach(files, parOpts, currentFile =>
        {
            MessageBox.Show("Underline Thread is " + Thread.CurrentThread.ManagedThreadId.ToString());
            parOpts.CancellationToken.ThrowIfCancellationRequested();
        });

    }
    catch (OperationCanceledException ex)
    {
        MessageBox.Show("catch");
    }
}

再次,虽然令牌被设置为取消,但仍然没有消息框弹出,但是MessageBox.Show(...)语句位于parOpts.CancellationToken.ThrowIfCancellationRequested();语句的上方,因此MessageBox.Show()应该首先执行,但为什么根本不执行?还是CLR吊起parOpts.CancellationToken.ThrowIfCancellationRequested();到顶部隐式成为第一个声明?

Q5-我将代码修改为:

try
{
   Parallel.ForEach(files, parOpts, currentFile =>
   {
      Thread.Sleep(5000); // I pressed the cancel button on the main thread when the worker thread is sleeping
   });
}
catch (OperationCanceledException ex)
{
   MessageBox.Show("Caught");
}

所以我有足够的时间按“取消”按钮,有一条“捕获”消息,但是为什么仍然有异常?现在我知道Parallel.ForEach在所有资源昂贵的操作之前检查CancellationToken.IsCancellationRequested`,这是否意味着Parallel.ForEach将在执行所有内部语句之后检查IsCancellationRequested?我的意思是Parallel.ForEach将检查IsCancellationRequested两次,一次在第一条语句之前,一次在最后一条语句之后?

1 个答案:

答案 0 :(得分:3)

Parallel.ForEach如何处理取消

您的观察是正确的。但是一切都正常。由于设置了ParallelOptions.CancellationToken属性,一旦Parallel.ForEach计算为true,OperationCanceledException就会引发CancellationToken.IsCancellationRequested

所有支持取消的框架类的行为如下(例如Task.Run)。在执行任何昂贵的资源分配之前(在内存或时间上都很昂贵),为了提高效率,框架在执行过程中多次检查了取消令牌。 Parallel.ForEach例如由于所有线程管理,必须执行许多昂贵的资源分配。在每个分配步骤之前(例如,初始化,产生工作线程或派生,应用分区程序,调用操作等),将再次评估CancellationToken.IsCancelRequested

最后一个内部Parallel.ForEach步骤是在创建ParallelLoopResult(返回值Parallel.ForEach)之前加入线程。在执行此操作之前,将再次评估CancellationToken.IsCancellationRequested。由于您在执行Parallel.ForEach时取消了Thread.Sleep(5000)的执行,因此您必须等待最长5秒钟的时间,直到框架重新检查此属性并可以抛出OperationCanceledException。您可以对此进行测试。直到显示Thread.Sleep(x)为止,需要花费{/ {1}}的x / 1000秒。

另一个取消MessageBox的机会被委托给消费者。消费者的动作很可能长时间运行,因此需要在Parallel.ForEach结束之前 取消。如您所知,可以通过(反复)调用Parallel.ForEach来强制过早取消,这一次会使CancellationToken.ThrowIfCancellationRequested()抛出CancellationToken(而不是OperationCanceledException)。

要回答最后一个问题,为什么只看到一个 Parallel.ForEach:在您已经注意到的特殊情况下,您太慢了,无法在代码到达{之前单击“取消”按钮{1}},但是可以在线程从睡眠中唤醒之前单击它。因此,MessageBox引发异常(在加入线程和创建CancellationToken.ThrowIfCancellationRequested()之前)。因此会抛出一个异常。但是即使您有足够快的速度在到达Parallel.ForEach之前取消循环,由于循环会中止所有线程,并立即引发未捕获的异常,因此仍然只会显示一个ParallelLoopResult。要允许每个线程引发异常,您必须捕获每个异常并进行累积,然后再将它们包装在CancellationToken.ThrowIfCancellationRequested()中。有关更多详细信息,请参见:Microsoft Docs: How to Handle Exceptions in Parallel Loops


编辑以回答后续问题:

  

对于第二季度,我只是意识到每个线程都有自己的堆栈,所以不会   知道它周围有一个try catch块,这就是为什么   我的理解是只有一个异常(由主线程抛出)   对吗?

说每个线程都有专用的调用堆栈时,您是对的。但是,当您编写应该同时执行的代码时,则会在堆上为每个线程创建所有本地变量的副本。对于MessageBox块也是如此。 AggregateException指示编译器定义一个处理程序(指令指针),然后由try-catch指令将其注册到异常处理程序表中。该表由操作系统管理。异常表将每个处理程序映射到一个异常。每个异常都映射到调用堆栈。因此,异常和捕获处理程序仅限于显式调用堆栈。由于处理程序可以访问线程本地内存,因此它也必须是副本。这意味着每个线程都“知道”其Catch处理程序。

由于专用的调用堆栈以及异常到调用堆栈的唯一映射以及将捕获处理程序映射到异常(因此也映射到了调用堆栈),因此在线程范围(调用堆栈)中引发的任何异常都无法在外部捕获线程的范围(使用try时)。在这种情况下,作用域是指调用堆栈(及其调用框架)描述的地址空间。除非未直接陷入线程本身,否则它将使应用程序崩溃。相反,catch(等待使用ThreadTask时)将吞下所有异常并将它们包装在Task.Wait中。

不会捕获await引发的异常:

AggregateException

但是在以下两个示例中,两个DoParallel()处理程序均被调用以处理异常:

try 
{
  Thread thread = new Thread(() => DoParallel());
  thread.Start();
}
catch (Exception ex)
{
  // Unreachable code
}

catch

最后两个示例使用的是任务并行库-TPL ,该库使用try { await Task.Run(() => DoParallel()); } catch (AggregateException ex) { // Reachable code } 来允许线程共享上下文,因此例如在线程之间传播异常。由于try { var task = new Task(() => DoParallel()); task.Start(); task.Wait(); } catch (AggregateException ex) { // Reachable code } 使用SynchronizationContext TPL ),因此它可以捕获工作线程的异常(如果尚未在操作中捕获它),以执行一些清除操作(取消其他工作线程和内部资源的处置),然后最终将Parallel.ForEach传播到外部作用域。

因此,因为引发了异常,

  • 操作系统中断应用程序,并检查异常表中是否存在通过Task.Wait()指令映射到此线程的潜在处理程序。
  • 它找到一个并重建上下文以执行OperationCanceledException 处理程序(在您的情况下,下一个try处理程序是catch的内部处理程序)。申请仍在暂停-其他 线程仍处于停放状态。
  • catch处理程序执行清理并结束其他操作 应用程序继续运行的之前线程,因此任何工作线程的之前 可以自己抛出其他异常。
  • 应用程序通过执行Parallel.ForEach Parallel.ForEach的re throw来继续 处理程序。
  • 应用程序再次停止以寻找外部范围(Parallel.ForEach的消费者范围) catch处理程序。
  • 如果未使用Parallel.ForEach注册任何应用程序,则该应用程序将因错误而终止。

    这就是catch总是抛出一个异常的原因。


编辑以回答后续问题Q3:

  

现在我可以在工作线程到达ThrowIfCancellationRequested()之前按“取消”按钮,但是主线程仍然只抛出一个异常。我按了Cancal按钮,令牌已设置为取消,因此,当辅助工作线程到达parOpts.CancellationToken.ThrowIfCancellationRequested();时,它也不应该抛出异常吗?并且该异常无法通过主线程中的try catch处理(每个线程都有其自己的堆栈),因此我应该获得一个未处理的异常来暂停应用程序,但事实并非如此,我只是得到了一个由主线程抛出的异常,并且此异常是由主线程还是工作线程引发的?

在以下情况下:

try

由于在这种情况下,您可以在Parallel.ForEach完成之前取消它,所以在执行try { Parallel.ForEach(files, parOpts, currentFile => { Thread.Sleep(5000); parOpts.CancellationToken.ThrowIfCancellationRequested(); }); } catch (OperationCanceledException ex) { MessageBox.Show("Caught"); } 的那一刻,在工作线程(执行您的动作委托)上会产生异常。在Parallel.ForEach方法的内部看起来就像:

CancellationToken.ThrowIfCancellationRequested()

如前所述,CancellationToken.ThrowIfCancellationRequested()使用public void ThrowIfCancellationRequested() { if (IsCancellationRequested) ThrowOperationCanceledException(); } // Throws an OCE; separated out to enable better inlining of ThrowIfCancellationRequested private void ThrowOperationCanceledException() { throw new OperationCanceledException(Environment.GetResourceString("OperationCanceled"), this); } Parallel.ForEach处理线程,因此使用Task。在 TPL (或Task.Wait() (_TPL_))的情况下,线程上下文是共享的,不再隔离(与SynchronizationContext线程相反)。这允许SynchronizationContext捕获子线程引发的异常。

这意味着Thread内没有未处理的异常,因为正如您可以在异常流的逐步说明中看到的那样,Parallel.ForEach在内部捕获了所有异常(可能是由于( TPL )来清理和处置分配的资源,然后最后重新抛出Parallel.ForEach

在检查Q3代码示例的异常的调用堆栈时,您将看到源是工作线程,而不是'primary'Parallel.ForEach线程。您刚刚在主线程中捕获了该异常,因为该异常包含最接近原始线程的OperationCanceledException处理程序-工作线程。因此,主线程可以完成而无需取消。


Parallel.ForEach和线程

我认为您的想法是错误的:

  

...主线程也在执行Parallel.ForEach中的语句,不是吗?我在帖子中有一个错字,只有两个活动线程,而不是三个。 string []只有两个元素,因此主线程需要“第一个”来处理,而一个工作线程需要“两个”来处理...

这不是事实。明确说明:初始示例中的数组包含两个应该用来模拟工作负荷的字符串,对吗?主线程是您创建的用于使用Parallel.ForEach执行catch循环的线程。为了使UI线程在长时间运行的Parallel.ForEach中保持响应状态,这是一种常见的做法。 Task.Factory.StartNew(() => ProcessFiles());因此在主线程上执行,并且可能创建两个工作线程-每个负载(或字符串)一个。 可能,因为Parallel.ForEach实际上使用由线程备份的任务。最大的 thread 计数受处理器计数和Parallel.ForEach的限制。由于框架执行了性能优化,因此 tasks 的实际数量必须与迭代项的数量或Parallel.ForEach的值不匹配。

  

TaskScheduler方法在其执行的整个生命周期内可能会使用比线程更多的任务,因为现有任务已完成并被新任务替代。这使基础MaxDegreeOfParallelism对象有机会添加,更改或删除为循环服务的线程。   可以决定在Parallel.ForEach允许的更少线程上执行动作委托。 (来源:Microsoft Docs: Parallel.ForEach


概括并总结

假设设置了TaskScheduler属性,则有两种可能的情况:

第一种情况:您确实在请求取消之后,在操作委托中调用了MaxDegreeOfParallelism,但是在之前 {{1 }}内部评估ParallelOptions.CancellationToken。现在,如果您用CancellationToken.ThrowIfCancellationRequested()包围了操作代码,那么工作线程就不会再有异常了。如果没有这样的Parallel.ForEach,则CancellationToken.IsCancellationRequested将在内部捕获此异常(进行一些清理)。这将在主线程上。 try-catch处置分配的资源后,将重新引发此异常。因为您在工作程序上调用了try-catch,所以源仍然是该工作程序线程。除了取消请求之外,任何异常都可以随时停止执行Parallel.ForEach

第二种情况:不要在行动委托中显式调用Parallel.ForEach或取消发生在{em 1}}方法被调用,然后下一次CancellationToken.ThrowIfCancellationRequested()内部检查Parallel.ForEach时,CancellationToken.ThrowIfCancellationRequested()将引发异常。 CancellationToken.ThrowIfCancellationRequested()始终在分配任何资源之前求值Parallel.ForEach。由于CancellationToken.IsCancelRequested是在主线程上执行的,因此此异常的来源当然是主线程。除了取消请求之外,任何异常都可以随时停止执行Parallel.ForEach

未设置Parallel.ForEach属性时,将不会对CancellationToken.IsCancelRequested进行内部Parallel.ForEach评估。如果有Parallel.ForEach请求,则ParallelOptions.CancellationToken无法做出反应,并将继续其资源密集型工作,除非,调用Parallel.ForEach不会引发异常。除了取消请求之外,任何异常都可以随时停止执行CancellationToken.IsCancelRequested