【问题标题】:Traversing a Binary Tree with multiple threads多线程遍历二叉树
【发布时间】:2009-12-03 19:55:19
【问题描述】:

所以我正在参加 Java 速度竞赛。我有(处理器数量)线程在工作,它们都需要添加到二叉树中。最初我只是使用了一个同步的 add 方法,但我想这样做,以便线程可以通过树相互跟随(每个线程只有它正在访问的对象上的锁)。不幸的是,即使对于一个非常大的文件(48,000 行),我的新二叉树也比旧二叉树慢。我认为这是因为我每次在树中移动时都会获取和释放锁。这是最好的方法还是有更好的方法?

每个节点都有一个名为lock的ReentrantLock,getLock()和releaseLock()只需调用lock.lock()和lock.unlock();

我的代码:

public void add(String sortedWord, String word) {

    synchronized(this){
        if (head == null) {
            head = new TreeNode(sortedWord, word);
            return;
        }
        head.getLock();
    }

    TreeNode current = head, previous = null;
    while (current != null) {

        // If this is an anagram of another word in the list..
        if (current.getSortedWord().equals(sortedWord)) {
            current.add(word);
            current.releaseLock();
            return;
        }
        // New word is less than current word
        else if (current.compareTo(sortedWord) > 0) {
            previous = current;
            current = current.getLeft();
            if(current != null){
                current.getLock();
                previous.releaseLock();
            }
        }
        // New word greater than current word
        else {
            previous = current;
            current = current.getRight();
            if(current != null){
                current.getLock();
                previous.releaseLock();
            }
        }
    }

    if (previous.compareTo(sortedWord) > 0) {
        previous.setLeft(sortedWord, word);
    }
    else {
        previous.setRight(sortedWord, word);
    }
    previous.releaseLock();
}

编辑:澄清一下,我的代码结构如下:主线程从文件中读取输入并将单词添加到队列中,每个工作线程从队列中提取单词并执行一些工作(包括对它们进行排序和添加他们到二叉树)。

【问题讨论】:

  • 一个小建议:您可能想要减去一个而不是 NumberOfProcessors 线程 - 因为操作系统至少会使用一个,为每个处理器定义一个线程几乎可以保证一些上下文交换开销。
  • 我认为这会很好,因为假设线程会相互等待很多时间并不是不合理的。
  • 如果他们互相等待,那就不好了,因为他们没有工作。
  • 这就是重点。他们在添加到此列表时互相等待。即使使用花哨的锁定,他们仍然将大部分时间花在彼此等待上。
  • 问题和大多数答案中有一些非常错误的术语:B 树不是二叉树,它是一个复杂得多的数据结构:en.wikipedia.org/wiki/B-tree

标签: java multithreading optimization binary-tree


【解决方案1】:

另一件事。在性能关键代码中绝对没有二叉树的位置。缓存行为将扼杀所有性能。它应该有一个更大的扇出(一个缓存行)[编辑] 使用二叉树,您访问了太多的非连续内存。看看朱迪树上的材料。

并且您可能希望在开始树之前从至少一个字符的基数开始。

并首先对 int 键而不是字符串进行比较。

也许看看尝试

并摆脱所有线程和同步。只是尝试使问题内存访问绑定

[编辑] 我会这样做有点不同。我会为字符串的每个第一个字符使用一个线程,并给他们自己的 BTree(或者可能是 Trie)。我会在每个线程中放置一个非阻塞工作队列,并根据字符串的第一个字符填充它们。通过对添加队列进行预排序并对 BTree 进行合并排序,您可以获得更高的性能。在 BTree 中,我将使用表示前 4 个字符的 int 键,仅引用叶页中的字符串。

在速度竞赛中,您希望受到内存访问限制,因此没有使用线程。如果没有,您仍然对每个字符串进行了过多的处理。

【讨论】:

  • 我以为我们在讨论 B 树,它与二叉搜索树(或一般二叉树)有很大不同。
  • 我查了一下,我看到的唯一区别是二叉树是只有两个子节点的 B-Tree..
  • 而且它们不能在现代硬件上运行(过去十年建成)。看看细节,它们在速度竞赛中很重要
【解决方案2】:

我实际上会开始研究compare()equals() 的使用,看看那里是否可以改进。您可以使用针对您的用例优化的不同compare() 方法将您的 String 对象包装在另一个类中。例如,考虑使用hashCode() 而不是equals()。哈希码被缓存,因此未来的调用会更快。 考虑实习字符串。我不知道 vm 是否会接受这么多字符串,但值得一试。

(这将是对答案的评论,但太罗嗦了)。

读取节点时,您需要在到达每个节点时为每个节点获取一个读锁。如果您对整棵树进行读锁定,那么您将一无所获。 到达要修改的节点后,释放该节点的读锁并尝试获取写锁。代码类似于:

当前树节点; // 为每个节点添加一个 ReentrantReadWriteLock。

//进入当前节点:
current.getLock().readLock().lock();
if (isTheRightPlace(current) {
current.getLock().readLock().unlock();
current.getLock().writeLock().lock(); // 注意:getLock 返回一个 ConcurrentRWLock
// 做事然后释放锁
current.getLock().writeLock().unlock();
} 其他 {
current.getLock().readLock().unlock();
}

【讨论】:

  • 我尝试了类似的代码,但它显着减慢了速度。说在我需要添加时读取锁定整个树然后写入锁定整个树的答案实际上工作得很好(它总是给出正确的答案,即使有 4 个线程和 479,000 个单词的列表)。修复 compareTo() 方法听起来是个好主意..
【解决方案3】:

您可以尝试使用可升级的读/写锁(可能称为可升级的共享锁等,我不知道 Java 提供了什么):对整个树使用单个 RWLock。在遍历 B-Tree 之前,您获取读取(共享)锁并在完成后释放它(在 add 方法中获取和释放,仅此而已)。

在您必须修改 B-Tree 的地方,您获取写入(独占)锁(或“升级”从读取到写入锁),插入节点并降级为读取(共享)锁。

使用这种技术,检查和插入头节点的同步也可以被删除!

它应该看起来像这样:

public void add(String sortedWord, String word) {

    lock.read();

    if (head == null) {
        lock.upgrade();
        head = new TreeNode(sortedWord, word);
        lock.downgrade();
        lock.unlock();
        return;
    }

    TreeNode current = head, previous = null;
    while (current != null) {

            if (current.getSortedWord().equals(sortedWord)) {
                    lock.upgrade();
                    current.add(word);
                    lock.downgrade();
                    lock.unlock();
                    return;
            }

            .. more tree traversal, do not touch the lock here ..
            ...

    }

    if (previous.compareTo(sortedWord) > 0) {
        lock.upgrade();
        previous.setLeft(sortedWord, word);
        lock.downgrade();
    }
    else {
        lock.upgrade();
        previous.setRight(sortedWord, word);
        lock.downgrade();
    }

    lock.unlock();
}

不幸的是,经过一番谷歌搜索后,我找不到适合 Java 的“可升级”rwlock。 “类 ReentrantReadWriteLock” 不可升级,但是,您可以解锁读取,然后锁定写入,并且(非常重要):重新检查导致这些行的条件再次(例如 @987654323 @)。这很重要,因为另一个线程可能在读取解锁和写入锁定之间进行了更改。

for details check this question and its answers

最终B树的遍历将并行运行。只有找到目标节点时,线程才会获取排他锁(其他线程只会在插入时阻塞)。

【讨论】:

  • 在不重新检查我的条件的情况下执行synchronized(OtherLock){ this.readLock().unlock(); this.writeLock().lock(); } 是否安全?
  • 感谢这加快了速度。
  • 我将它切换为拥有一个名为 writeLock 的对象并执行:synchronized(writeLock){ do_things(); this.readLock().unlock(); return; },它似乎可以工作:)
  • 这是不正确的。即使认为 java 接口(类 ReadWriteLock 或类似)似乎有两个独立的锁,但事实并非如此。读写锁不是独立的东西!!!在您的情况下,同步部分中的读锁可能仍处于活动状态。但概念是,当获取写锁时,没有读者可以处于活动状态。获取写锁时,它会一直等待,直到所有读锁都被释放。
  • 看起来唯一安全的方法是(只要你需要写):rwl.getReadLock().unlock(); /* short-unlocked-time-here: other threads may modify tree now */ rwl.getWriteLock().lock(); if( condition is still true ) { .. do write .. has_written=true; } rwl.writeLock.unlock(); if( has_written ) return; else ...
【解决方案4】:

锁定和解锁是开销,你做的越多,你的程序就会越慢。

另一方面,分解任务和并行运行部分将使您的程序更快地完成。

“收支平衡”点在很大程度上取决于程序中特定锁的争用量以及运行程序的系统架构。如果争用很少(就像在这个程序中出现的那样)并且有很多处理器,这可能是一个好方法。但是,随着线程数量的减少,开销将占主导地位,并发程序会变慢。您必须在目标平台上分析您的程序才能确定这一点。

另一个需要考虑的选项是使用不可变结构的非锁定方法。例如,您可以将旧(链接)列表附加到新节点,而不是修改列表,然后对AtomicReference 执行compareAndSet 操作,确保您赢得了设置words 集合的数据竞赛在当前树节点中。如果没有,请再试一次。您也可以将AtomicReferences 用于树节点中的左右子节点。同样,这是否更快,必须在您的目标平台上进行测试。

【讨论】:

  • 我喜欢 AtomicReference 的想法,但我认为我已经做得够多了。
【解决方案5】:

考虑到每行一个数据集,48k 行并不算多,您只能猜测您的操作系统和虚拟机将如何破坏您的文件 IO 以使其尽可能快。

在这里尝试使用生产者/消费者范式可能会出现问题,因为您必须仔细平衡锁的开销与实际的 IO 量。如果您只是尝试改进执行文件 IO 的方式,您可能会获得更好的性能(考虑类似mmap())。

【讨论】:

  • 这很好。我在 /usr/share/dict/words(470,000 字)上尝试了它,但花哨的锁定仍然比使用同步方法慢两倍。谢谢你的主意。文件 IO 并不是真正的问题,因为读取整个文件只需要大约 1 秒(我有另一个用于混洗文件的类,它非常快)。
【解决方案6】:

我会说这样做是不是要走的路,甚至没有考虑同步性能问题。

这个实现比原来的完全同步版本慢的事实可能是一个问题,但更大的问题是这个实现中的锁定一点也不健壮。

例如,假设您将null 传递给 sortedWord;这将导致NullPointerException 被抛出,这意味着您最终会持有当前线程中的锁,从而使您的数据结构处于不一致的状态。另一方面,如果你只是synchronize这个方法,你就不用担心这些事情了。考虑到同步版本也更快,这是一个简单的选择。

【讨论】:

  • 这是一场速度竞赛,而不是生产代码。这是选择“正确”方法的一个重要因素。
  • 问题是,我可以根据我的其他代码保证它永远不会被传递为 null。在调用 b-tree 的 add() 函数的函数中,它实际上在调用 this 之前检查了 if(sortedWord == null)。这并不意味着特别安全,但我的程序总是有效的。
【解决方案7】:

您似乎实现了二叉搜索树,而不是 B-Tree。

无论如何,您是否考虑过使用 ConcurrentSkipListMap?这是一个有序的数据结构(Java 6 中引入),应该有很好的并发性。

【讨论】:

    【解决方案8】:

    我有一个愚蠢的问题:由于您正在读取和修改文件,因此您将完全受到读/写磁头移动速度和磁盘旋转速度的限制。那么使用线程和处理器有什么好处呢?光盘不能同时做两件事。

    或者这一切都在 RAM 中?

    添加:好的,我不清楚并行性在这里可以为您提供多少帮助(有些可能),但无论如何,我建议您尽可能从每个线程中挤出每个周期。 This is what I'm talking about. 例如,我想知道像那些对“get”和“compare”方法的调用这样看似无辜的睡眠代码是否比您预期的要花费更多的时间。如果是这样,您也许可以每个都做一次,而不是 2 或 3 次——诸如此类。

    【讨论】:

    • 主线程正在读取输入并将其添加到队列中,然后有(处理器数量)线程从队列中提取数据。 B-Tree 只是工作线程必须做的一件事,所以我们不需要等待 IO。
    • 好的,感谢您的澄清。现在,我要做的是通过获取堆栈快照来查看每个线程通常在等待什么。这样您就可以看到在 I/O 上花费了多少时间,在同步上花费了多少时间,如果幸运的话,您还可以看到其他一些您实际上不需要做的事情。
    • 摆脱所有线程听起来是正确的。它应该是内存访问绑定
    • 我实际上只是在和某人谈论线程,显然关闭它们并没有太大的区别,但它仍然是一个显着的改进,尤其是对于非常大的数据集。
    猜你喜欢
    • 2012-01-01
    • 2022-11-11
    • 1970-01-01
    • 2021-11-20
    • 1970-01-01
    • 2021-03-08
    • 2020-01-15
    • 1970-01-01
    相关资源
    最近更新 更多