Обход дерева в несколько потоков

Всем привет, хочу поделиться с общественностью некоторым объемом информации, который как мне показалось трудно найти в интернете.

Что такое дерево, смотрим Википедию.


Рис.1 Пример дерева.

Итак, зачем вообще может понадобится обходить дерево в несколько потоков? В моем случае, это была необходимость поиска файлов на диске. Понятно, что физически диск работает в один поток, но тем не менее, многопоточный поиск файлов может дать ускорение, в случае, когда один-единственный поток ищет файл в многоуровневой иерархии, а искомый файл лежит в соседней папке с одним уровнем. Также доказательством актуальности применения многопоточного поиска служит его реализации во многих успешных коммерческих продуктах. Не исключаю, что возможны и другие варианты применения алгоритма, пишите в комментариях.

Для начала предлагаю рассмотреть анимацию:



Что здесь происходит? Вся суть алгоритма сводится к тому, что:

  1. Первый поток обходит дерево начиная с корня на всю глубину по «крайне левому» пути, т.е. всегда при перемещении вглубь поворачивает налево или другими словами всегда выбирает последний дочерний узел.
  2. Параллельно первый поток собирает все пропущенные дочерние узлы и отправляет их в очередь (либо в стек) где их забирает другой поток, очередь или стек должны реализовывать многопоточный подход, и алгоритм повторяется, подставляя на место корня только-что взятый узел.

    По сути дела, в целом алгоритм выглядит так (один цвет – один поток):

По сути дела, в целом алгоритм выглядит так (один цвет – один поток):


Программная реализация на Java


Привожу пример кода, который может кому-то пригодится, я его долго искал, но так и не нашел:

import java.io.File;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingDeque;

public class MultiThreadTreeWalker extends Thread {

    private static BlockingQueue<File> nodesToReview = new LinkedBlockingDeque<>();
    private File f;

    public MultiThreadTreeWalker(File f) {
        this.f = f;
    }

    public MultiThreadTreeWalker() {
    }

    public static void main(String[] args) {
        Executor ex = Executors.newFixedThreadPool(2);
        MultiThreadTreeWalker mw =  new MultiThreadTreeWalker(new File("C:\\"));
        mw.run();
        for (int i = 0;i<2;i++) {
            ex.execute(new MultiThreadTreeWalker());
        }
    }

    @Override
    public void run() {
        if (f != null) { //только для первого потока
            reviewFileSystem(f);
        } else {
            try {
                while (true) {


                    f = nodesToReview.take();
                    reviewFileSystem(f);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

    void reviewFileSystem(File f) {
        if (f == null) {
            return;
        }
        if (f.isFile()) {

            //завершение обхода (+дополнительная логика)
            System.out.println("Файл " + f.getName() + " найден потоком " + Thread.currentThread());
            return;
        }
        File[] files = f.listFiles();

        if (files.length != 0) {

            for (int i = 0; i < files.length - 1; i++) {

                nodesToReview.add(files[i]); //добавление файлов всех кроме последнего

            }
            //последний дочерний узел используется для перехода дальше

            File last = files[files.length - 1];

            reviewFileSystem(last);

        }

    }

}


Заключение


Как видно многопоточность в Java можно реализовать достаточно просто посредством BlockingQueue, структуры данных реализующей доступ нескольких потоков к хранимым данных.

В общем-то это и все, если вкратце, пишите комментарии о том какие еще есть способы или методы обхода дерева, хотелось бы услышать мнение гуру в этом вопросе. Пишите вопросы, пожелания.

P.S. также советую всем прочитать комментарии (там много интересного)

Ссылка на GitHub. Там же рядом лежит поисковик на JavaFX, если кто-то захочет потестить, то я только буду рад.
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 12

    +6
    Первый поток обходит дерево начиная с корня на всю глубину по «крайне левому» пути, т.е. всегда при перемещении вглубь поворачивает налево или другими словами всегда выбирает последний дочерний узел.
    Параллельно первый поток собирает все пропущенные дочерние узлы и отправляет их в очередь (либо в стек) где их забирает другой поток, очередь или стек должны реализовывать многопоточный подход, и алгоритм повторяется, подставляя на место корня только-что взятый узел.

    Зачем такая сложная логика, если можно просто при переходе по dfs в детей брать потоки из какого-то ограниченного пула.
      +3

      Вы используете Executor неправильно! В него нужно отправлять не потоки, а отдельные задачи.


      Если уж вы создали несколько потоков MultiThreadTreeWalker — вам их нужно просто взять и запустить, их не надо отправлять для запуска в Executor, это глупо. Кстати, поток запускается вызовом start, а не run.


      Если же вы решили использовать Executor, а не потоки — то вам не нужна очередь, очередь уже есть внутри Executor! Все, что вам требуется — создать по задаче на каждый узел дерева, и отправить их в Executor...


      Как-то так оно должно в итоге выглядеть:


      private Executor ex;
      
      void reviewFileSystem(File f) {
          if (f.isFile()) {
              // ...
              return;
          }
      
          for (File f2 : f.listFiles()) {
              ex.execute(() -> reviewFileSystem(f2));
          }
      }

      Как видно, код на порядок проще вашего.


      Отдельная проблема — как дождаться окончания подобного обхода, но вы её решать даже не пытались.

        0
        А не проще использовать BFS? Стандартный алгоритм, от DFS отличается только одним — очередь вместо стека. В первую очередь обходит вершины, близкие к корню.
          0
          вообще, это сильно зависит от такого, какое дерево выстраивают данные. Если это файловая система, то скорее всего максимальная глубина сильно меньше максимальной ширины одного уровня, следовательно затраченная память (и время на ее алокацию и очистку) меньше при DFS не смотря на использование стека.
            +1

            В многопоточном варианте "порядок обхода вершин" — оксюморон, так что BFS и DFS ничем и не отличаются.

              0
              Ну потоки же нужны не для того, чтобы были, а с какой-то целью. Автор описывает ее как
              многопоточный поиск файлов может дать ускорение, в случае, когда один-единственный поток ищет файл в многоуровневой иерархии, а искомый файл лежит в соседней папке с одним уровнем
              Вот и возникает закономерный вопрос — а зачем изобретать велосипед, если можно взять BFS (однопоточный).
            0
            1) Очень похоже на паттерн producer-consumer, где в качестве буфера выступает стек. Правда, конкретно в этом случае, один поток является одновременно и производителем и потребителем.
            2) Не знаю, как в Java, а на .Net можно было бы сделать все это в один поток, но асинхронно, не нагружая CPU, но нагрузив при этом SSD по полной. Подозреваю, что в Java так тоже можно.
            3) Кроме того, если это просто поиск файла и важна скорость, то на винде и, возможно, в линухе, для этого есть служба индексирования файлов.
              0

              Не могу сказать, что прямо гуру, но если уж автор интересуется мнением, то, надеюсь, не обидится на пару простых замечаний и предложений в образовательных целях)


              По поводу алгоритма

              Если отбросить все детали реализации (а там есть над чем работать) и оставить только сам алгоритм, то при распараллеливании стоит задуматься в первую очередь над КПД потоков:


              • Во-первых, в исходном алгоритме рабочий за раз обрабатывает самостоятельно лишь одну ветвь под-дерева, все остальные отправляя в блокирующую структуру. Каждый вызов add и take блокирует все остальные аналогичные вызовы, тем самым образуется "бутылочное горлышко"
              • Во-вторых, первый поток почему-то отличается от остальных и прекращает свою жизнь на первом же встретившимся листе, что не имеет практического смысла

              Общий алгоритм на основе блокирующей очереди можно скорректировать, например, так. Пусть мы хотим обойти дерево с помощью N потоков, изначально работает лишь один, а другие не были созданы заранее. Он:


              1. Создает счетчик T = N-1;
              2. Если у рассматриваемого узла есть M потомков (он папка):
                1. Если M < N, то шаг пропускается. Иначе, делит потомков на N частей, для N-1 из них:
                  • Если T > 0, то присваивает T = T-1 и создает новый поток (который будет работать по тому же алгоритму с шага 2), передает ему для обработки рассматриваемую часть;
                  • Если T == 0, то добавляет всю часть одной задачей в очередь;
                2. Если K >= N, то берет оставшующуюся N-ую часть из разделения на шаге 3;
                3. Иначе берет задачу из очереди;
              3. Иначе (если файл) — обрабатывает его и возвращается на предыдущий уровень рекурсии;
              4. Рекурсивно повторяет алгоритм для каждого файла из новой задачи с шага 2.

              Такой алгоритм уменьшает нагрузку на очередь и не тратит процессорное время до того момента, как у программы появились данные для обработки. Сами операции с очередью теперь работают с множеством данных, тем самым уменьшая необходимое количество обращений к ней. При этом часть работы производится в неблокирующей форме, не оказываясь в очереди вообще (и таких будет значительное число, сильно сказывающееся на производительности)


              Стоит лишь заметить, что операции с T должны быть синхронизированы между потоками. В отличие от синхронизации на очереди, это ограничение легко обойти, храня локальную копию значения, и выполняя синхронизацию на шаге 2.1 только когда она > 0


              По поводу реализации

              Помимо сказанного другими выше, в коде не отслеживается конец задачи. Решение тривиально: нужно лишь проверять перед взятием новой задачи, сколько потоков в настоящий момент уже ждут у нее. Если значение равно N-1, значит, работы больше не светит, программу можно сворачивать


              В такой простой задаче не нужно использовать пулы потоков, если не предполагается его передача функции "извне". Мы либо контролируем полностью процесс распараллеливания, либо полагаемся на внешние интерфейсы для многопоточности, смешивать два подхода не имеет смысла


              Не стоит просто игнорировать исключения в потоках — если добавил try, то сделай обработку в catch, иначе просто пробрось его наверх. И проверяй неявные исключения — listFiles() иногда возвращает null, в следующей строчке может получиться NullPointerException


              И напоследок — очень советую использовать встроенные в джаву функции работы с потоками, не ограничивая себя одним интерфейсом блокирующей очереди

                0
                Для больших деревьев, которые имеет смысл обходить в несколько потоков гораздо удобнее будет использовать ForkJoinPool
                  0
                  Не исключаю, что возможны и другие варианты применения алгоритма, пишите в комментариях.

                  Очень актуально для алгоритмов альфа-бета отсечения и подобных, которые используются в антагонистических играх, таких как шахматы, реверси и других. Сложность заключается в том, что на каждом узле нужно решать, создавать ли новый поток или оставаться в старом? Причем делать это скорее всего динамически.


                  Правда в играх с очень большим фактором ветвления (например, го), такие методы будут неэффективны. Тогда используется алгоритм перебор дерева методом Монте-Карло. Его, к счастью, распараллелить уже относительно легко, т.к. используется элемент случайности.

                    0

                    А там в итоге есть какойто прирост к скорости, если сравнивать с однопоточным обходом? Если да, то можно было бы взглянуть на бенчмарки?


                    А то как-то это странно все выглядит

                      +1
                      Всем спасибо за комментарии! В данный момент работаю над второй частью поста в которой будут бенчмарки по вышеупомянутым алгоритмам. Когда выйдет точно не могу сказать =)

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое