【问题标题】:Use threads when traversing a tree遍历树时使用线程
【发布时间】:2012-04-19 09:42:52
【问题描述】:

我想加快遍历树的过程。下面是一个节点的例子:

    class Node
    {
        public List<Node> Children { get; set; }
        public int SompeProperty { get; set; }
        public String SomeOtherProperty { get; set; }
    }

我遍历 try 的方式是这样的:

    static void TraverseTree(Node ParentNode)
    {
        if (ParentNode.Children == null)
            return;

        foreach (var child in ParentNode.Children)
        {
            TraverseTree(child);               
        }
    }

ParentNode.Children 方法大约需要 1 毫秒,因为 Node 代表文件或目录。我只是用这个节点的例子来更好地说明我的观点。

因此,如果您考虑一下,如果第一个节点有 4 个子节点,并且每个子节点都有 10000000 个后代,那么如果我们在一个单独的线程中利用并行的优势遍历这 4 个子节点中的每一个,我们可以提高遍历的速度编程。如果情况是这样,那么我会采取这种方法。但是如果我事先不知道树的结构,我怎么能做到呢?

我一直在想:

1) 开始遍历树将前 10 个具有子节点的节点放在堆栈上,然后在单独的线程上开始遍历每个节点。

2) 执行以下操作:

    static void TraverseTree(Node ParentNode)
    {
        if (ParentNode.Children == null)
            return;

        foreach (var child in ParentNode.Children)
        {
            ThreadPool.QueueUserWorkItem(new WaitCallback((x) =>
            {                    
                TraverseTree(child);   
            }), null);                            
        }
    }

这通常会给我带来奇怪的结果,但速度要快得多。


结果

使用 task 将算法的速度提高了大约 40% 以下是结果:

使用以下算法扫描我的整个 C:\ 驱动器大约需要 5.81 秒:

        //directoryPath  = "C:\"
    var now = DateTime.Now;

        Task<List<ScanItem>> t1 = new Task<List<ScanItem>>(() =>
        {
            return GetAllFilesInDirectory(directoryPath);
        });

        t1.Start();

        t1.Wait();

        var done = DateTime.Now-now;  // done = 5.81 average

使用以下算法扫描我的整个 C:\ 驱动器大约需要 3.01 秒:

        //directoryPath  = "C:\"  
        var now = DateTime.Now;


        // get all directories in my c: drive it should only contain directories
        var directories = Directory.GetDirectories(directoryPath);

        // directories = 17 directories:  inetpub, MSOCache, PrefLogs, ProgramFiles, ProgramFiles (x86) etc...

        Task<List<ScanItem>>[] myTasks = new Task<List<ScanItem>>[directories.Length];

        // create a task fore each directory in the c:\ drive
        for (int k = 0; k < myTasks.Length; k++)
        {
            var currentDir = directories[k];
            myTasks[k] = new Task<List<ScanItem>>(() =>
            {
                return GetAllFilesInDirectory(currentDir);
            });                
        }

        // start all the tasks
        for (int k = 0; k < myTasks.Length; k++)
            myTasks[k].Start();


        Task.WaitAll(myTasks); // wait for all tasks to finish

        var done = now - DateTime.Now;  // average about 3.01 seconds

如果我在哪里遍历列表,第一个算法返回 318,222 个文件和目录(这是正确的数字)。第二个算法返回 318,195 非常接近我不明白为什么...

我正在一台有 8 个内核的计算机上对此进行测试。也许如果我在有 2 个内核的计算机上使用一个任务运行它可能比创建所有这 17 个任务更快。

如果您想知道我使用什么算法来快速获取文件,请查看https://stackoverflow.com/a/724184/637142

【问题讨论】:

  • 值得注意的是,与并行化遍历相关的开销。如果您在每个节点上做了很多工作,您会看到一些相当大的收益;但是如果你在每个节点的 CPU 上做的很少,那么创建新任务和调度它们的开销实际上可能弊大于利。
  • 是不是忘记把顶层目录下的文件添加到318195了?
  • 是的,这就是我得到不同结果的原因......我错过了在 c:\ dir 中添加目录和文件。

标签: c# multithreading performance parallel-processing traversal


【解决方案1】:

使用任务并行库,而不是滚动您自己的并行代码。它非常适合解决这类问题。

TPL 的工作方式不是您将线程分配给问题,您只需将问题分解为“任务”,然后让 TPL 负责找出如何在可用工作人员池中并行化工作。只需为树的每个子分支创建一个任务;这些任务可以反过来为他们的子分支产生他们自己的任务。 TPL 将从池中分配线程,直到处理器饱和。

因此,让 TPL 知道您的任务是在 CPU 还是 I/O 上进行门控很重要:

  • 如果任务受 CPU 限制,则 TPL 将为每个 CPU 分配一个池线程,并让其他任务等待直到有可用内核;最大化吞吐量并使所有处理器饱和。这正是你想要的:如果你买了一台有四个处理器的机器,其中两个是空闲的,那么你就为两个不使用的内核付费。

  • 如果 单个 任务是 I/O 绑定的,那么您可以在创建任务时使用 LongRunning 选项向 TPL 指示此任务不应占用整个内核;其他任务应该在该核心上轮流完成。

  • 如果看起来确实如此,您有 许多 I/O 绑定任务,那么您应该考虑改用 TaskCompletionSource,因为这样可以更有效地使用“延续”回调。还可以考虑使用 C# 5 的新 async/await 特性来安排延续;它提供了一种更愉快的异步代码编写方式。

当然,不要忘记,如果问题实际上是机器的 I/O 能力饱和,那么再多的 处理器 并行度也不会产生影响。如果您正在为游泳池注水,向同一个水龙头添加更多软管不会增加通过该水龙头的流量。

【讨论】:

  • 确保向 TPL 提供有关任务是否将受处理器限制的提示 - 如何?
  • @ebb:我已经澄清了这一段。
【解决方案2】:

如果你想并行遍历一棵树,你必须:

  • 对树进行分区,以保证单独的线程在树的不同部分工作(例如,从根开始,您可以将后代节点分配给新线程,直到达到最大并行度。
  • 确保您的树结构通常可以被多个线程安全地遍历(即遍历不会在树实现中导致状态更改副作用)。
  • 确保在遍历期间没有线程更新树。

如果您得到“奇怪的结果”,则上述其中一项可能不正确。请记住,在多线程示例中,遍历节点的顺序 是不确定的。您在声明结果“奇怪”时是否考虑到了这一点?

即便如此:

  • 在目录示例中,您很可能会遇到 IO 争用限制了多线程方法的有效性
  • 遍历内存中的节点往往会将事物踢出缓存,从而降低使用多线程的投资回报 (false sharing)。

【讨论】:

  • 这就是为什么我第一次遍历我的 c:\ 大约需要 40 秒,而第二次遍历大约需要 8 秒?这就是你所说的缓存?如果我在哪里分别遍历 c:\ 驱动器中的每个文件夹会减慢进程?
  • @Tono:第二次遍历文件系统时,是的,目录结构的信息会被操作系统缓存。对于内存中的节点集合(即,一个不必进入磁盘的节点),存在类似的问题,即某些内存行将位于 CPU 缓存中。串行访问它们可能比随机访问它们快得多。因此,如果您的工作负载不是 CPU 密集型的,多线程实际上会减慢速度。
  • 我将结果放在我的编辑中...我有兴趣在只有 1 个核心的计算机上测试它
【解决方案3】:

请记住,只有当您的应用程序在单核上占用 100% 的 CPU 时间时,多线程才有用;如果 CPU 使用率低(因为它在硬盘驱动器或网络之后等待),您将看不到并行运行代码的任何好处。

【讨论】:

    【解决方案4】:

    最近我不得不创建一个算法,该算法能够发现一个巨大的树结构(实际上是文件系统,但它可以是任何东西)并在每个项目上执行 异步操作。我想出了一个能够做到这一点的小型库(使用 .Net TPL 和并发队列构建):

    • 并行发现一棵大树
    • 父项总是在子项之前处理
    • 资源使用取决于给定的最大并行度,而不是树大小
    • 异步工作

    Parallel async TreeWalker

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 2015-10-11
      • 1970-01-01
      • 1970-01-01
      • 2018-01-21
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多