Разделяй-и-властвуй параллельно: fork-join

Урок про самую естественную модель параллелизма для рекурсивных алгоритмов.

Fork-join — модель, где задача рекурсивно разветвляется (fork) на подзадачи, выполняемые параллельно, а затем результаты собираются (join) при возврате из рекурсии.

Идея

Многие алгоритмы устроены как «разделяй и властвуй»: разбить задачу на части, решить части, объединить. Merge sort, быстрое преобразование Фурье, суммирование дерева, многие задачи на массивах. В fork-join подзадачи, на которые мы разбили, независимы — значит, их можно решать параллельно. Слово fork означает «разветвить»: породить параллельные подзадачи. Слово join — «дождаться их и объединить результаты».

parallel_sum(массив):
    если массив мал: вернуть обычную сумму
    иначе:
        L, R = разделить пополам
        fork:  left  = parallel_sum(L)   # параллельно
        fork:  right = parallel_sum(R)   # параллельно
        join                              # дождаться обоих
        вернуть left + right

Эмуляция рекурсивного деления

Покажем структуру fork-join на параллельном суммировании. В браузере реальных потоков нет — выполняем рекурсию последовательно, но печатаем, как задача разветвляется и собирается. На реальной машине ветви считались бы одновременно.

def parallel_sum(a, depth=0):
    indent = "  " * depth
    if len(a) <= 1:
        print(f"{indent}лист {a} -> {sum(a)}")
        return sum(a)
    mid = len(a) // 2
    print(f"{indent}fork {a} -> {a[:mid]} | {a[mid:]}")
    left = parallel_sum(a[:mid], depth + 1)   # ветвь 1 (параллельно)
    right = parallel_sum(a[mid:], depth + 1)  # ветвь 2 (параллельно)
    total = left + right                       # join: объединяем
    print(f"{indent}join -> {left} + {right} = {total}")
    return total

data = [3, 1, 7, 0]
print("сумма =", parallel_sum(data))

Вывод:

fork [3, 1, 7, 0] -> [3, 1] | [7, 0]
  fork [3, 1] -> [3] | [1]
    лист [3] -> 3
    лист [1] -> 1
  join -> 3 + 1 = 4
  fork [7, 0] -> [7] | [0]
    лист [7] -> 7
    лист [0] -> 0
  join -> 7 + 0 = 7
join -> 4 + 7 = 11
сумма = 11

Дерево рекурсии — это и есть структура параллелизма: ветви на одном уровне независимы и считаются одновременно, join ждёт обе и складывает. Глубина дерева log n задаёт минимальное число шагов, а число листьев — потенциальный параллелизм.

Порог гранулярности

Разветвлять до одиночных элементов расточительно — overhead на создание задачи превысит пользу. Поэтому в fork-join вводят порог (cutoff): когда подзадача стала достаточно мала (скажем, меньше 1000 элементов), её считают последовательно без дальнейшего ветвления. Это классический приём: параллелить крупные ветви, мелочь — последовательно.

Где это используется

Fork-join — основа целых систем: язык Cilk (cilk_spawn / cilk_sync), OpenMP tasks (#pragma omp task), Java ForkJoinPool и parallelStream, Threading Building Blocks (TBB). Все они дают программисту простой способ сказать «эти две ветви независимы, считай параллельно», а планировщик сам раскидывает задачи по ядрам.

Как работает под капотом

Когда задача делает fork, она кладёт одну ветвь в очередь, а вторую начинает считать сама. Свободные ядра подхватывают задачи из очередей — об этом механизме (work stealing) следующий урок. Join реализуется как ожидание готовности результата ветви: если ветвь ещё не посчитана, ядро может пока поработать над другой задачей.

Прелесть fork-join в том, что он отделяет выражение параллелизма от его исполнения. Программист лишь объявляет: «эти две ветви независимы». Сколько на самом деле ядер, как раскидать по ним задачи, что делать, если одна ветвь оказалась тяжелее, — всё это берёт на себя планировщик. Один и тот же fork-join код корректно и эффективно работает и на двух ядрах, и на шестидесяти, без единой правки. Эта переносимость — огромное преимущество перед ручным управлением потоками, где число потоков обычно зашито в код. Именно поэтому fork-join стал основой современных параллельных рантаймов: он даёт простую модель мышления и снимает с программиста бремя планирования.

Частые ошибки

  • Ветвить до одиночных элементов без порога — overhead задач съест выигрыш.
  • Делать fork для зависимых подзадач — если ветви делят состояние, будут гонки.
  • Забыть join перед использованием результата — прочитаете недосчитанное значение.

Итоги

  • Fork-join: рекурсивно разветвить на независимые подзадачи (fork), посчитать параллельно, объединить (join).
  • Дерево рекурсии = структура параллелизма; ветви одного уровня независимы.
  • Порог гранулярности (cutoff) переключает мелкие подзадачи на последовательный счёт.
  • Лежит в основе Cilk, OpenMP tasks, Java ForkJoinPool, TBB.
Проверьте себя
1. Что означают операции fork и join в одноимённой модели?
Afork — завершить, join — начать
Bfork — разветвить на параллельные подзадачи, join — дождаться их и объединить результаты
Cfork — отсортировать, join — слить
DЭто сетевые операции
2. Зачем в fork-join нужен порог гранулярности (cutoff)?
AЧтобы ограничить память
BЧтобы мелкие подзадачи считать последовательно, не тратя overhead на их ветвление
CЧтобы избежать гонок
DДля красоты кода
3. Какие системы основаны на модели fork-join?
AHTTP и TCP
BCilk, OpenMP tasks, Java ForkJoinPool, TBB
CSQL и NoSQL
DТолько GPU-драйверы