Erlang — классный функциональный язык (или как мы сели в лужу)

2546de322038490fbd0dedd99b38fd23.png

Однажды я устроился в проект на Erlang. Вообще мой профиль тогда был в основном Java и немного BigData. Но по результатам собеседования договорились что я попробую написать небольшое тестовое задание — и сам пойму нравится ли мне язык — и ребята оценят, гожусь ли я им. Ну и за выходные справился — язык непривычный, но не очень сложный — и интересный, с необычными «фишками». Команде же, куда меня взяли, оказался удобен мой опыт в бигдате и амазоне.

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

Статья для широкого круга читаетелей, не знакомых с языком — знатоки же Эрланга в частности и ФП вообще возможно найдут неточности в моём повествовании — дело было лет 6 назад — так что можете смело поправлять и даже ругать при необходимости :)

Бэкграунд

Проект, куда я пришёл, разрабатывал полезные сервисы для тех, кто держит свои приложения в Амазоне. Например у вас интернациональный онлайн-магазинчик кормов для улиток — и вам нужно записывать логи транзакций для аудита -, но тут вы обнаруживаете:

  • для европейских и для американских покупателей транзакции требуется сохранять в разные регионы амазона (в европейский и американский, это GDPR)

  • старые логи хорошо бы перебрасывать в «холодное» (более дешевое) хранилище

  • а ещё по ним может потребоваться (когда случится аудит) искать, скажем, за 7 лет

И вот чтобы вам не компостировать себе мозг всеми этими сложностями — мы делали удобные магические сервисы.

Основоположники команды были выходцами из телекома и прямо говорили «да, мы пишем на Эрланге потому что он клёвый и нам нравится». Иными словами это во многом была тоже БигДата, но в довольно непривычном виде.

Про сам Erlang

Эрланг это один из самых «чисто-функциональных» языков — быть может он уступает только Haskell, но в том из-за суперстрогой типизации мозг можно свихнуть -, а здесь наоборот типизация довольно слабая. Сам язык интерпретируемый, хотя и компилируется в некий байт-код выполняемый виртуальной машиной (BEAM). Примерно как в Java.

Почему «чисто-функциональный»? А вот:

В языке нет переменных! Точнее говоря, раз присвоив значение какому-либо идентификатору, вы уже не можете его поменять. Сперва кажется что так жить нельзя -, но с учетом локальных идентификаторов все не так страшно.

Также нет циклов! Вместо них используется рекурсия (преимущественно, хвостовая).

Все сложные объекты данных тоже иммутабельны! На базовом уровне там есть списки и кортежи (tuples). Например, в списке ничего поменять нельзя, но можно использовать этот список пририсовав к нему ещё один элемент — получится более длинный список для которого исходный является частью. Таким образом в исходном ничего не поменялось.

Иммутабельность переменных и структур данных даёт, как известно, возможность спокойно параллелить работу без всякой синхронизации и конкурентности. И Эрланг сильно на это заточен — в частности в нём есть две крутых фичи из коробки:

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

Кроме того — всё это было придумано очень давно — Эрланг позволяет строить кластера из эрланговых инстансов и запускать приложения почти бесшовно взаимодействующие между этими инстансами. Т.е. внешняя очередь сообщений не требуется — его собственные мессаджи умеют ходить между инстансами. Впрочем, эта фишка в проекте не использовалась — в основном мы запускали эрланг на виртуальных машинах амазона или в его «лямбдах».

Пара Примеров

Чтобы продемонстрировать особенности упомянутые выше, посмотрим пару очень простых примеров. Они вообще-то для бенчмарка были сделаны — поэтому в репе на гитхабе можно посмотреть тот же код на других языках.

Двойной вложенный цикл с простой арифметикой — эта программа строит последовательность из «гипотезы Коллатца» (школьная задачка) для каждого числа от единицы до N — и считает сумму длинн этих последовательностей.

-module(collatz).
-export([start/0]).

collatz(N, R) ->
  if
    N == 1 -> R;
    N rem 2 > 0 -> collatz(3 * N + 1, R + 1);
    true -> collatz(N div 2, R + 1)
  end.

total(0, R) -> R;
total(N, R) -> total(N - 1, collatz(N, 0) + R).

start() ->
  N = list_to_integer(os:getenv("MAXN")),
  Res = total(N, 0),
  io:format("sum=~b, avg=~g\n", [Res, Res / N]).

В первой строчке мы придумываем название для модуля, во второй описываем единственную доступную наружу функцию start c 0 аргументов.

Функция start в 14-й строчке делает следующее — получает значение N из переменных среды, вызывает вычисление (функция total) и печатает результат.

Функция total это внешний цикл, бегущий от 1 до N (точнее наоборот) — она имеет два аргумента — до скольки считать — и «аккумулятор» для результата — типичная особенность функций с «хвостовой рекурсией». Мы каждый раз вызывая эту функцию рекурсивно будем передавать в этот параметр сосчитанное на данном шаге плюс полученное с предыдущих шагов -, а на самом последнем по вложенности вызове просто вернём этот параметр как результат.

И вот она описана двумя строчками — тут используется «мэтчинг» по параметрам — если её вызвали для N=0, то нужно просто вернуть результат из второго аргумента. В противном случае нужно вызвать функцию collatz для текущего значения N чтобы узнать собственно длину последовательности — её мы прибавим к текущему значению результата — и совершим рекурсивный вызов с уменьшенным значением N-1.

Вот оказывается, как мы работаем с «неизменяемыми» переменными — внутри функции переменная (в частности, аргумент пришедший в функцию) — не изменяется -, но в следующем рекурсивном вызове (следующей итерации) мы конечно можем к этому значению что-то прибавить.

Наконец функция collatz — она очень похожа, но «мэтчинг» здесь осуществляется за счет конструкции if-end — она больше похожа на switch из других языков — проверяются перечисленные условия — и какое «сыграет» — соответствующий результат и будет значением if-end (и всей функции).

Если вы решите поиграться с Эрлангом, возможно вы некоторое время потратите на то чтобы понять как программа запускается. Там нужно при запуске указать вызываемую экспортированную функцию — в общем, лучше подсмотрите в файле run.sh репозитория.

Второй пример — на использование массива — Простые Числа — здесь мы генерируем (простым способом trial division) массив простых чисел. Причем массив используется и для поиска делителей следующих чисел. Здесь нас ожидает небольшая засада связанная с «функциональностью» языка. Как уже говорилось, для последовательностей в нём есть списки (list), но к списку можно добавлять элементы только с одной стороны — и итерировать потом по списку тоже можно только с этой стороны. А нам-то надо добавлять новые найденные числа в конец, а делители перебирать каждый раз от начала. То есть нужен не односвязный список, а очередь или массив. Что же, массивы в эрланге есть, хотя и с нюансом — давайте посмотрим.

-module(primes).
-export([start/0]).

divfound(P, I, A) ->
  D = array:get(I, P),
  if
    D * D > A -> false;
    A rem D == 0 -> true;
    true -> divfound(P, I+1, A)
  end.

primes(N, P, A) ->
  S = array:size(P),
  if
    N == S -> P;
    true -> case divfound(P, 0, A) of
      true -> primes(N, P, A+2);
      false -> primes(N, array:set(array:size(P), A, P), A+2)
    end
  end.

start() ->
  N = list_to_integer(os:getenv("MAXN")),
  io:format("primes[~b] = ~b~n", [N, array:get(N-1, primes(N, array:from_list([2, 3, 5, 7]), 9))]).

Здесь в подробности углубляться уже не придётся — структура программы похожа на предыдущую — две внутренних рекурсивных функции primes и divfound — обеспечивают нам вычисление в два вложенных цикла. Основное что мы заметим — «аккумулятор» теперь это не скаляр (число), а массив, к которому добавляются числа. Причем каждый раз когда мы что-то добавляем или меняем в массиве — мы получаем новый массив (это достигается за счет мудрёной внутренней структуры на кортежах) — который передаём в следующую итерацию цикла — то есть в нижележащий рекурсивный вызов.

Разобрать логику я осмелюсь оставить вам на самостоятельное изучение (кому интересно) -, но главное заметим, с массивом мы работаем через функции специального модуля array — как я уже сказал, массивы на самом деле это деревья из кортежей — и добавляя число мы получаем новый кортеж с одной из ветвей которого «свисает» старое дерево, как-то так.

Из этого следует один неприятный момент — такие массивы, конечно, не работают так же быстро как обычные. В терминах алгоритмической «сложности» их быстродействие приблизительно сравнимо с логарифмом от количества элементов — то есть O(log(N)).

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

И как же мы сели в лужу?

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

Я упоминал на примере магазина работающего на амазоновских сервисах, что кастомеру может понадобиться поиск по логам для аудита. Это было одной из заявленных «фишек» нашего сервиса. Как это должно было работать?

Накапливающиеся от кастомера логи складываются в бинарные пакеты довольно сложной конструкции и все это хранится в S3 хранилище амазона. Чтобы работать с пакетами в хранилище эффективно мы обычно использовали «лямбды» — это в амазоне есть такой сервис — ты можешь программно, буквально одним щелчком запустить на короткое время (до 5 мин) большое количество (до 1000 кажется) небольших виртуалок, чтобы запроцессить требуемый объём данных.

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

Я в это время был занят другим небольшим сервисом — он копировал (тоже с помощью лямбд) пакеты в «холодное» хранилище для удешевления хранения старых данных. И вот я с этим справился — и меня зовут присоединиться к группе ребят которая занимается поиском.

Потому что там небольшая трабла которую надо поресерчить - работает медленно.

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

  1. Erlang работает медленно сам по себе — немного быстрее CPython (по тем временам). Это не удивительно — язык интерпретируемый. Сейчас ситуация заметно лучше — в 24-й версии добавили JIT-компилятор -, но тогда он был доступен только как сайд-проект.

  2. Дополнительно тормозят операции с иммутабельными структурами данных. Пакеты в основном были представлены как binary — грубо говоря иммутабельная строка байт, которую можно индексировать или слайсить. Любое изменение требует, естественно, скопировать весь binary (то есть, возможно, весь пакет на несколько килобайт).

Ну, что значит «медленно»? С точки зрения пользователя это выглядело так, что просто запускается в 100 раз больше лямбд (потому что пакеты разбираются в 100 раз медленнее чем хочется) — и в результате один поисковый запрос стоит не 5 центов допустим, а 5 долларов. Боль.

Заключение

В общем, хотя мы потратили ещё 2–3 месяца на попытку так и сяк ускорить работу, переписать что-то на Си — исправить ситуацию решительно не удалось. Либа для разбора наших кастомных пакетов была написана (на Эрланге же) отцами основателями, доки на формат не было и переделать бы её саму -, но всем страшно.

Это полезный опыт в том смысле что нужно осторожнее выбирать инструменты, даже если очень их любишь. Любители молотков ещё в состоянии забивать шурупы -, но любители отвёрток завернуть гвозди уже не смогут, не так ли.

В то же время, речь не идёт о том что Эрланг плохой!

Действительно, в большом количестве проектов быстродействие самого языка не имеет такого критического значения — зачастую основные потери времени — на операциях ввода/вывода, передачи по сети. Конкретно здесь — на bulk-processing данных — типичная для BigData ситуация — действительно это был не лучший вариант. Кто заглядывал в недра Hadoop, наверняка замечал, например, что он хоть и написан на Java -, но очень «не по-java-нски» — в частности иммутабельных данных там избегают (иногда это оказывается сюрпризом).

В общем надеюсь по крайней мере энтузиасты ФП у кого до сих пор руки до Erlang не доходили — заинтересуются его попробовать. Можно не возиться с установкой, а использовать докер-образ.

Из основных минусов я бы добавил только что язык немного архаичен (по-моему до сих пор исходные файлы невозможно разложить по вложенным директориям например) — и у него сравнительно небольшое коммьюнити, что может осложнять поиск какой-то специфической инфы. Тем не менее документация подробна и её много. Улучшения появляются стабильно — и ненулевое число популярных проектов на нём написаны (наверное наиболее на слуху Rabbit MQ).

© Habrahabr.ru