【问题标题】:How does Parallel.ForEach handles cancellation or ThrowIfCancellationRequested() and exceptionsParallel.ForEach 如何处理取消或 ThrowIfCancellationRequested() 和异常
【发布时间】:2019-06-27 08:14:28
【问题描述】:

我创建了一个 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 按钮时,我只弹出一个“Caught”消息框,而不是两次。

假设主线程 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按钮,token已经设置为取消,所以当辅助工作线程到达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,虽然token设置为cancel,但是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 两次,一次在第一条语句之前,一次在最后一条语句之后?

【问题讨论】:

  • 评论不用于扩展讨论;这个对话是moved to chat
  • 好的,明确一点:数组包含两个应该模拟工作负载的字符串,对吧? primary 线程是您创建的使用Task.Factory.StartNew(() => ProcessFiles()); 执行Parallel.ForEach 循环的线程。 Parallel.ForEach 然后执行(在主线程上)并可能创建两个工作线程(一个用于每个负载或字符串)。
  • 有两种情况。 第一种情况:您在操作委托中调用ThrowIfCancellationRequested():当您使用try-catch 包围您的操作代码时,工作线程不会出现异常。如果没有这样的 try-catch,Parallel.ForEach 会捕获异常。这将在主线程上。重新抛出此异常。但是由于您在工作线程上调用了ThrowIfCancellationRequested(),所以起源是工作线程。
  • 第二种情况:您没有在您的操作中显式调用CancellationToke.ThrowIfCancellationRequested(),或者在CancellationToke.ThrowIfCancellationRequested() 方法之后发生取消,然后下次Parallel.ForEach 检查CancellationToke.IsCancelRequested 时,它会抛出异常。由于 `Parallel.ForEach 在主线程上执行,异常的来源将是主线程。
  • 请再次检查我的答案。我对其进行了更新以解决您关于主线程和工作线程的实际线程数和定义的问题。向下滚动以获取有关如何以及在哪些线程上引发异常的摘要。

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


【解决方案1】:

Parallel.ForEach 如何处理取消##

您的观察是正确的。但一切正常。由于设置了ParallelOptions.CancellationToken 属性,一旦CancellationToken.IsCancellationRequested 评估为true,Parallel.ForEach 就会抛出OperationCanceledException

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

最后一个内部Parallel.ForEach 步骤是在创建ParallelLoopResultParallel.ForEach 的返回值)之前加入线程。在此操作之前,再次评估 CancellationToken.IsCancellationRequested。由于您在执行Thread.Sleep(5000) 时取消了Parallel.ForEach 的执行,因此您必须等待最多5 秒,直到框架重新检查此属性并可以抛出OperationCanceledException。你可以测试一下。 Thread.Sleep(x) 需要 x/1000 秒才能显示 MessageBox

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

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


编辑以回答后续问题:

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

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

由于专用的调用堆栈以及异常到调用堆栈和捕获处理程序到异常(以及调用堆栈)的排他映射,因此在线程范围(调用堆栈)中抛出的任何异常都无法在外部捕获线程的范围(使用Thread 时)。在这种情况下,范围意味着它由调用堆栈(及其调用帧)描述的地址空间。除非没有直接在线程本身中捕获,否则它将使应用程序崩溃。相反,Task(当使用Task.Waitawait 等待时)吞下所有异常并将它们包装在AggregateException 中。

DoParallel() 抛出的异常不会被捕获:

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

但在以下两个示例中,两个catch 处理程序都被调用来处理异常:

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
}

最后两个示例使用 Task Parallel Library - TPL,它使用 SynchronizationContext 来允许线程共享上下文,因此例如在线程之间传播异常。由于Parallel.ForEach 使用Task.Wait() (TPL),它能够捕获工作线程的异常(如果你还没有在你的操作中捕获它),执行一些清理(取消其他工作线程)线程和内部资源的处置),然后最终将OperationCanceledException 传播到外部范围。

所以因为抛出了异常,

  • 操作系统中断应用程序并检查异常表以查找由try 指令映射到此线程的潜在处理程序。
  • 它找到一个并重构上下文以执行catch 处理程序(在您的情况下,下一个 catch 处理程序是 Parallel.ForEach 的内部处理程序)。应用程序仍处于暂停状态 - 其他 线程仍处于停放状态。
  • Parallel.ForEach 处理程序执行清理并结束其他 线程 before 应用程序继续,因此 before 任何工作线程 可以自己抛出额外的异常。
  • 应用程序继续执行throwParallel.ForEach catch 处理程序。
  • 应用程序再次停止寻找外部范围(Parallel.ForEach 的消费者范围) catch 处理程序。
  • 如果没有使用try 注册,应用程序将终止并出现错误。

这就是为什么Parallel.ForEach 总是抛出一个异常。


编辑回答后续问题 Q3:

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

适用于以下场景:

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 使用TaskTask.Wait() (_TPL_) 来处理线程,因此使用SynchronizationContext。在TPL(或SynchronizationContext)的场景中,线程上下文是共享的,不再是孤立的(与Thread 线程相反)。这允许Parallel.ForEach 捕获子线程抛出的异常。

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

在检查 Q3 代码示例的异常调用堆栈时,您会看到起源是工作线程,而不是“主”Parallel.ForEach 线程。您刚刚在主线程中捕获了异常,因为它包含最接近源的 catch 处理程序 - 工作线程。因此,主线程可以在不取消的情况下完成。


Parallel.ForEach 和线程

我认为你的理解是错误的:

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

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

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


概括和总结

假设设置了ParallelOptions.CancellationToken属性,有两种可能的情况:

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

第二种情况:没有在您的操作委托中显式调用CancellationToken.ThrowIfCancellationRequested(),或者取消发生CancellationToken.ThrowIfCancellationRequested()方法之后被调用,那么下次Parallel.ForEach 内部检查CancellationToken.IsCancelRequested 时,Parallel.ForEach 将抛出异常。 Parallel.ForEach 总是在分配任何资源之前评估 CancellationToken.IsCancelRequested。由于Parallel.ForEach 在主线程上执行,所以这个异常的来源当然是主线程。除了取消请求之外,任何异常都可以随时停止Parallel.ForEach 的执行。

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

【讨论】:

  • 我不知道 CancellationToken.IsCancellationRequested 会再次被评估,现在这很有意义。对于Q2,我刚刚意识到每个线程都有自己的堆栈,所以它不会知道它被一个try catch块包围,这就是为什么只有一个异常(由主线程抛出),我的理解是否正确?跨度>
  • @secondimage 我扩展了您后续问题的答案。
  • 我认为在 C 中没有这样的事情,即在堆上创建所有本地人的副本?无论如何,我提出了另一个关于线程堆栈的问题,你能看看stackoverflow.com/questions/56793050/…
  • @secondimage C 本身不支持多线程(标准库)。您需要像 POSIX.(pthread) 这样的扩展库。它的工作原理基本相同。必须复制局部变量(非共享内存),否则线程会覆盖彼此的内存值(共享内存就是这种情况)。
  • @secondimage 我更新了答案(部分包含异常示例),以更清楚地说明为什么异常不能超出线程范围(线程范围 = 调用堆栈)。
猜你喜欢
  • 2011-07-01
  • 2017-03-02
  • 1970-01-01
  • 2020-11-09
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多