C#中的并行树遍历

时间:2011-08-17 21:26:29

标签: c# parallel-processing task-parallel-library tree-traversal

我需要快速遍历一棵树,我想并行完成。我宁愿使用并行扩展而不是手动旋转一堆线程。

我当前的代码看起来像这样:

   public void Traverse(Node root)
    {
        var nodeQueue = new Queue<Node>();
        nodeQueue.Enqueue(root);
        while (nodeQueue.Count!=0)
        {
            var node = nodeQueue.Dequeue();
            if (node.Property = someValue) DoSomething(node);
            foreach (var node in node.Children)
            {
                nodeQueue.Enqueue(node);
            }
        }
    }

我真的希望Parallel.ForEach有一个Parallel.While模拟。我在Implementing Parallel While with Parallel.ForEach看到了Stephen Toub的文章。如果正确读取它仍然无法工作,因为我正在改变我试图迭代的队列。

我是否需要使用任务工厂和递归(这有风险吗?)?还是有一些我忽略的简单解决方案?

编辑: @svick

树有超过250,000个节点。 现在最大深度是14个节点,包括根。

根目录下有大约500个节点,之后的平衡具有相当随机的分布。 我很快就会得到更好的分布数据。

@Enigmativity:

是的,许多用户同时修改了树,但我通常会为树或子树提供共​​享读锁,或允许脏读。

对node.Children的调用可以被认为是原子的。

DoSomething实际上是几个代理之一,对于一些昂贵的操作,我可能会收集节点的快照列表并在遍历之外处理它们。

我意识到我应该看一般情况(遍历的子树而不是整个树。)为此,我在树的每个节点上运行遍历并查看总时间。

我为每个遍历算法使用了Parallel.ForEach(nodes,Traverse),其中节点包含所有~250k节点。这模拟(某种程度上)许多用户同时请求许多不同的节点。

00256ms广度优先顺序

00323ms广度优先连续工作(我将静态计数器增加为“工作”)

01495ms Kirks第一个回答

01143ms Svicks第二个回答

00000ms Recursive Single Threaded在60年代后没有完成

<00> 00000ms电影的答案在60年代后没有完成

@Enigma,我认为我可能会以某种方式弄乱你的算法,因为它看起来应该更快。

结果让我惊讶地说至少。 为了让自己相信编译器并没有神奇地优化遍历,我不得不在广度第一顺序中添加一些工作。

对于头部的单次遍历,并行化第一级仅具有最佳性能。但是,随着我向第二级添加更多节点(2000而不是500),这个数字几乎没有改善。

5 个答案:

答案 0 :(得分:7)

最直接的方法是为每个子节点创建一个Task,然后等待所有这些节点:

public void Traverse(Node root)
{
    if (node.Property == someValue)
        DoSomething(node);

    var tasks = new List<Task>();

    foreach (var node in node.Children)
    {
        // tmp is necessary because of the way closures close over loop variables
        var tmp = node;
        tasks.Add(Task.Factory.StartNew(() => Traverse(tmp)));
    }

    Task.WaitAll(tasks.ToArray());
}

Task相当轻量级,所以创造很多它们的效果相当不错。但它们确实有一些开销,所以做一些更复杂的事情就像拥有一些共享队列的任务一样可能会更快。如果这是你要去的方式,不要忘记空队列并不意味着所有工作都已完成。如果你这样做的话,来自System.Collections.Concurrent命名空间的类将会派上用场。

编辑:由于树的形状(根有大约500个孩子),并行处理第一级应该可以提供良好的性能:

public void Traverse(Node root, bool parallel = true)
{
    if (node.Property == someValue)
        DoSomething(node);

    if (parallel)
    {
        Parallel.ForEach(node.Children, node =>
        {
            Traverse(node, false);
        });
    }
    else
    {
        foreach (var node in node.Children)
        {
            Traverse(node, false);
        }
    }
}

答案 1 :(得分:3)

我可能会遗漏一些东西,但我认为根本不需要whilewhile只是确保您遍历每个节点。

而是只为树中的每个节点递归调用函数。

public void Traverse(Node root)
{         
    if (root.Property = someValue) DoSomething(node);    
    Parallel.ForEach<Node>(root.Children, node => Traverse(node));
} 

编辑:当然替代方案,如果您更喜欢水平处理而不是垂直处理而且昂贵的操作是DoSomething,则首先执行Traverse

public IEnumerable<Node> Traverse(Node root)
{
    // return all the nodes on this level first, before recurring
    foreach (var node in root.Children)
    {
        if (node.Property == someValue)
            yield return node;
    }

    // next check children of each node
    foreach (var node in root.Children)
    {
        var children = Traverse(node);
        foreach (var child in children)
        {
            yield return child;
        }
    }
}

Parallel.ForEach<Node>(Traverse(n), n => DoSomething(n));

答案 2 :(得分:3)

由于树的遍历非常快,对Children的调用是原子的,并且需要并行执行DoSomething委托的代价是昂贵的,这是我的看法解决方案。

我开始认为我需要一个以节点作为参数的函数,创建一个执行DoSomething的任务,递归调用自己为所有子节点创建任务,最后返回一个任务等待所有内部任务完成。

这是:

Func<Node, Task> createTask = null;
createTask = n =>
{
    var nt = Task.Factory.StartNew(() =>
    {
        if (n.Property == someValue)
            DoSomething(n);
    });
    var nts = (new [] { nt, })
        .Concat(n.Children.Select(cn => createTask(cn)))
        .ToArray();

    return Task.Factory.ContinueWhenAll(nts, ts => { });
};

调用它并等待遍历完成所需的全部是:

createTask(root).Wait();

我通过创建一个节点树来测试这个节点,其中有500个孩子离开根,有14个级别,每个节点有1或2个后续子节点。这给了我总共319,501个节点。

我创建了一个执行某些工作的DoSomething方法 - for (var i = 0; i < 100000 ; i++) { }; - 然后运行上面的代码并将其与处理同一个串联树进行比较。

并行版本耗时5,151 ms。顺序版本为13,746毫秒。

我还进行了一项测试,我将节点数减少到3,196,并将DoSomething的处理时间增加了100倍。如果TPL的任务很快完成,那么TPL会非常巧妙地恢复运行顺序,从而延长处理时间,使代码以更多的并行性运行。

现在并行版本需要3,203毫秒。顺序版本花了11,581ms。而且,如果我只调用createTask(root)函数而不等待它完成它只需要126ms。这意味着树的遍历非常快,因此在遍历期间锁定树并在处理过程中将其解锁是有意义的。

我希望这会有所帮助。

答案 3 :(得分:1)

假设你有 p 处理器,你可以用 p 分区对 root.Children 进行 Parallel.For 。这些中的每一个都会对子树进行传统的单线程遍历,比较,而不是 DoSomething ,将 DoSomething 的委托排入并发队列。如果分布基本上是随机和平衡的,并且由于遍历仅进行遍历/排队,那么该部分将花费 1 / p 时间。此外,遍历可能会在所有 DoSomethings 执行之前耗尽自己,因此您可以让 p 消费者( DoSomething 的执行者)为您提供最大并行执行,假设所有这些操作都是独立的。

通过对具有随机分布的子树的根子项的这种天真分区,遍历本身将是快速的。随着您的消费者按处理器大致分配,您还可以获得最大的并行 DoSomething 操作。

答案 4 :(得分:0)

也许使用List或Array而不是队列会有所帮助。还可以使用另一个List / Array来填充要访问的下一个节点。在完成整个宽度之前,您不会处理列表。像这样:

List<Node> todoList = new List<Node>();
todoList.Add(node);
while (todoList.Count > 0)
{
    // we'll be adding next nodes to process to this list so it needs to be thread-safe
    // or just sync access to a non-threadsafe list
    // if you know approx how many nodes you expect, you can pre-size the list
    ThreadSafeList<Node> nextList = new ThreadSafeList<Node>();  

    //todoList is readonly/static so can cache Count in simple variable
    int maxIndex  =  todoList.Count-1;
    // process todoList in parallel
    Parallel.For(0, maxIndex, i =>
    {
        // if list reads are thread-safe then no need to sync, otherwise sync
        Node x = todoList[i];

        //process x;
        // e.g. do somehting, get childrenNodesToWorkOnNext, etc.

        // add any child nodes that need to be processed next
        // e.g. nextList.add(childrenNodesToWorkOnNext);
    });

   // done with parallel processing by here so use the next todo list
   todoList = nextList;
)