如何在WinForms应用程序中取消PLINQ查询

4

我正在开发一个应用程序,处理大量文本数据并统计单词出现的频率(参见:源代码词云)。

以下是我的代码的简化核心部分:

  1. 枚举所有扩展名为*.txt的文件。
  2. 枚举每个文本文件中的单词。
  3. 按单词分组并计算出现次数。
  4. 按出现次数排序。
  5. 输出前20个。

LINQ的表现很好。转向PLINQ后,性能显著提高。但是...在长时间运行的查询期间取消操作失效了。

似乎OrderBy查询将数据同步回主线程,而Windows消息没有被处理。

在下面的示例中,我根据MSDN How to: Cancel a PLINQ Query中的说明实现了取消操作,但它不起作用 :(

还有其他想法吗?

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Windows.Forms;

namespace PlinqCancelability
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
            m_CancellationTokenSource = new CancellationTokenSource();
        }

        private readonly CancellationTokenSource m_CancellationTokenSource;

        private void buttonStart_Click(object sender, EventArgs e)
        {
            var result = Directory
                .EnumerateFiles(@"c:\temp", "*.txt", SearchOption.AllDirectories)
                .AsParallel()
                .WithCancellation(m_CancellationTokenSource.Token)
                .SelectMany(File.ReadLines)
                .SelectMany(ReadWords)
                .GroupBy(word => word, (word, words) => new Tuple<int, string>(words.Count(), word))
                .OrderByDescending(occurrencesWordPair => occurrencesWordPair.Item1)
                .Take(20);

            try
            {
                foreach (Tuple<int, string> tuple in result)
                {
                    Console.WriteLine(tuple);
                }
            }
            catch (OperationCanceledException ex)
            {
                Console.WriteLine(ex.Message);
            }
        }

        private void buttonCancel_Click(object sender, EventArgs e)
        {
            m_CancellationTokenSource.Cancel();
        }

        private static IEnumerable<string> ReadWords(string line)
        {
            StringBuilder word = new StringBuilder();
            foreach (char ch in line)
            {
                if (char.IsLetter(ch))
                {
                    word.Append(ch);
                }
                else
                {
                    if (word.Length != 0) continue;
                    yield return word.ToString();
                    word.Clear();
                }
            }
        }
    }
}
3个回答

3
如Jon所说,您需要在后台线程上启动PLINQ操作。这样,用户界面不会在等待操作完成时挂起(因此可以调用取消按钮的事件处理程序和取消令牌的Cancel方法)。当令牌被取消时,PLINQ查询会自动取消,所以您不需要担心这个问题。
这是一种实现方式:
private void buttonStart_Click(object sender, EventArgs e)
{
  // Starts a task that runs the operation (on background thread)
  // Note: I added 'ToList' so that the result is actually evaluated
  // and all results are stored in an in-memory data structure.
  var task = Task.Factory.StartNew(() =>
    Directory
        .EnumerateFiles(@"c:\temp", "*.txt", SearchOption.AllDirectories)
        .AsParallel()
        .WithCancellation(m_CancellationTokenSource.Token)
        .SelectMany(File.ReadLines)
        .SelectMany(ReadWords)
        .GroupBy(word => word, (word, words) => 
            new Tuple<int, string>(words.Count(), word))
        .OrderByDescending(occurrencesWordPair => occurrencesWordPair.Item1)
        .Take(20).ToList(), m_CancellationTokenSource.Token);

  // Specify what happens when the task completes
  // Use 'this.Invoke' to specify that the operation happens on GUI thread
  // (where you can safely access GUI elements of your WinForms app)
  task.ContinueWith(res => {
    this.Invoke(new Action(() => {
      try
      {
        foreach (Tuple<int, string> tuple in res.Result)
        {
          Console.WriteLine(tuple);
        }
      }
      catch (OperationCanceledException ex)
      {
          Console.WriteLine(ex.Message);
      }
    }));
  });
}

@Jon 谢谢你的回答。这样做是可行的,但是代码看起来有些杂乱。使用 PLINQ 的原因之一是与线程相关的细节被抽象化了。在接受你的解决方案之前,我希望有更加优雅的解决方案出现。 - George Mamaladze
谢谢,我已经根据一个修正过的代码进行了适应。最好将相同的取消令牌传递给任务。在这种情况下,您不需要在任务内部捕获OperationCanceled异常。在结束时检查任务的.IsCanceled和.Exception属性就足够了。 - George Mamaladze

1

您目前正在UI线程中迭代查询结果。即使查询是并行执行的,您仍然在UI线程中迭代结果。这意味着UI线程太忙于执行计算(或等待查询从其它线程获取结果)而无法响应“取消”按钮的点击。

您需要将迭代查询结果的工作推到后台线程上。


好的,那么如何在另一个线程中调用取消呢?它也会太忙而无法接受取消调用,不是吗?“太忙”不是正确的答案。我已经在“ReadWords(string line)”方法中添加了“Thread.Sleep(10);”,但这并没有帮助。主线程只是被阻塞等待AsParallel线程加入回来。 - George Mamaladze
1
@gmamaladze:你可以从UI线程执行取消操作 - 你所需要做的就是确保它足够空闲以响应点击! - Jon Skeet
@gmamaladze:让UI线程“休眠”仍然会使其停止响应事件。您根本不应该在UI线程中执行长时间运行的任务。同样,迭代查询结果的线程不必“接受”取消调用 - 这与响应单击事件根本不同。 - Jon Skeet

-1

我认为我找到了一些优雅的解决方案,更适合于LINQ / PLINQ概念。

我正在声明一个扩展方法。

public static class ProcessWindowsMessagesExtension
{
    public static ParallelQuery<TSource> DoEvents<TSource>(this ParallelQuery<TSource> source)
    {
        return source.Select(
            item =>
            {
                Application.DoEvents();
                Thread.Yield();
                return item;
            });
    }
}

然后将其添加到我的查询中,无论我想要响应的地方。

var result = Directory
            .EnumerateFiles(@"c:\temp", "*.txt", SearchOption.AllDirectories)
            .AsParallel()
            .WithCancellation(m_CancellationTokenSource.Token)
            .SelectMany(File.ReadLines)
            .DoEvents()
            .SelectMany(ReadWords)
            .GroupBy(word => word, (word, words) => new Tuple<int, string>(words.Count(), word))
            .OrderByDescending(occurrencesWordPair => occurrencesWordPair.Item1)
            .Take(20);

它运行得很好!

想了解更多信息并获得可供操作的源代码,请查看我的帖子:{{link1:“如果你能取消我”或WinForms中PLINQ的可取消性和响应性}}


Application.DoEvents() 是提高应用程序响应能力的相当糟糕的方法。即使它在许多情况下都可以工作,但如果您的代码更复杂(只需搜索 SO),它可能会给您带来很多麻烦。 - Andriy K

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