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

Часть багов связана с инфраструктурой и очередями задач. Пересказывать их довольно скучно, и вряд ли вам будут интересно. Но один бажок на тему Кложи и параллельных вычислений мне запомнился, и сейчас его расскажу.

Как вы знаете, Кложа хороша своими коллекциями – это прямо алмаз, одно из немногих утешений в айти. Вместе с коллекциями прилагаются штук триста функций: всякие map, reduce и так далее, включая экзотику.

Начиная с какой-то версии в Кложу завезли параллельные map и reduce. Пакет называется clоjure.core.reducers. Сигнатуры параллельных функций почти идентичны оригиналам. Идея в том, что ты такой хоп – заменил reduce на r/reduce, и вычисления раскидались по ядрам. Нашлись коллеги, которые, не читая документации, так и сделали – а я пожал их плоды.

Есть огромных файл, где каждая строка – джейсончик. Нужно построить мапу {id -> entity}, чтобы быстро дергать из нее сущность по айдишке. Скажем, в Питоне это делается так:

{entity["id"]: entity
  for entity in file.read_lines()
}

В Кложе это тоже решается тремя строчками. Но коллега использовал не простой reduce, а который параллельный. Логика такова: коллекция большая, пусть колбасится параллельно. И вот я вызываю функцию, которая строит эту мапу, и замечаю – в ней нет половины ключей. Пропали. Что такое?

Дебажил я передебажил и выяснил вот что.

В документаци clоjure.core.reducers написано, что коллекции не всегда обрабатываются параллельно. Критериев несколько, например коллекция мала и нет смысла ее делить. Но главный критерий таков: коллекция не должна быть ленивой. А те коллекции, что коллеги читают из файла, ленивы. Это нормально, потому что файл огромный и читать в память все разом нельзя. Но получается, что вся параллельность идет псу под хвост – параллельные r/map и r/reduce сводятся к последовательным аналогам.

Другими словами, параллельной обработки никогда не было. Вызовы есть, но эффекта нет. Никто этого не замерял и не проверял.

Почему же у меня возникла ошибка? Дело в том, что я передал вектор – не ленивую коллекцию. Она попадала под критерии параллельности, и произошло вот что. Пакет clоjure.core.reducers использует подход ForkJoin. На этапе Fork коллекция бьется на части, и каждая часть обрабатывается в своем потоке. Получаются, скажем, два словаря {id -> entity} для каждой части. Далее наступает фаза Join – их нужно объединить. Функция r/reduce принимает дополнительную функцию для сборки финального результата, но ее не передали. А если ее нет, вызывается редуцирующая функция. Она приняла два словаря и неправильно их обработала, в результате чего пропала половина данных. Нужно было передать туда функцию merge, но никто не знал, для чего это в принципе.

Когда я это исправил, нашел еще один косяк у себя самого. Когда записи индексируют по id, с объединением проблем нет, потому что ключи уникальны. Но представим, что выполняется группировка по какому-то рейтингу. В этом случае результат такой:

{"a" [1 2 3], "b" [5 4 1]}

Если дать эту задачу на параллельное вычисление, они могут вернуть что-то такое:

{"a" [1 2], "b" [5]}
{"a" [2 3], "c" [11]}

То есть и в первом потоке был такой ключ, и во втором. Если тупо объединить словари, получится вот что:

{"a" [2 3], "b" [5], "c" [11]}

, то есть из “a” пропадет 1. Ошибки не будет, все пройдет молча, и догнать причину будет сложно. Правильная функция объединения будет такой:

(partial merge-with into)

С ней получим результат {"a" [1 2 3], "b" [5], "c" [11]}.

Ради интереса прошелся по коду коллег: почти везде используются ленивые коллекции. Это значит, что параллельные вычисления банально не работают. Я хотел поправить, но плюнул: давайте-ка сами.

В сухом остатке:

  • человек подключил либу для параллельных вычислений

  • вычисления всегда протекали последовательно

  • при попытке вычислить что-то параллельно получали ошибочный результат. Исключения нет, все тихо, ищи сам.

В Гарри Поттере была фраза: “не доверяй тому, что мыслит, если не знаешь, где у него мозги”. Я бы перефразировал: не доверяй быстрым библиотекам, если не понимаешь, за счет чего достигается скорость. Реалии таковы, что в погоне за скоростью срезают углы. Это ни хорошо ни плохо, это факт, который нужно знать.

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