Киллер-фичи Erlang

26 октября 2014

Вчера выступил на 2-м митапе FuncBy, опять рассказал про Erlang. Очередной вводный доклад, каких было уже несколько штук. Не хотел совсем уж повторяться, добавил кое-что свежее.

Потоки в Erlang, немного бенчмарков и цифр

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

А давайте выясним, что значит эта "легковесность" в конкретных цифрах? И сколько конкретно потоков можно создать?

Начнем со второго вопроса.

В документации по erl, в разделе Emulator Flags описан флаг +P, устанавливающий лимит на число потоков. И там сказано, что можно задавать значения в диапазоне 1024 - 134,217,727 (2^10 - 2^27), и дефолтное значение 262,144 (2^18)

134 миллиона потоков – этого вполне хватит :) А вот дефолтных 260 тыс может и не хватить. Для бенчмарков не хватит, так что будем запускать erl с этим флагом, задавая лимит побольше.

Бенчмарк я сделал вот такой:

-module(test).
-export([test/1, start/1, do_some_work/1]).

test(Num) ->
    {Time, ok} = timer:tc(?MODULE, start, [Num]),
    NumProcesses = erlang:system_info(process_count),
    {Time, NumProcesses}.

start(Num) ->
    [spawn_link(?MODULE, do_some_work, [N]) || N <- lists:seq(0, Num)],
    ok.

do_some_work(_N) ->
    timer:sleep(20000),
    done.

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

Для чистоты эксперимента, чтобы разные запуски теста не мешали друг другу, мы будем каждый раз заново запускать ноду, причем вот такой командой:

erl +P 1048576

То есть, увеличиваем лимит потоков до 1 миллиона (точнее до 2^20, ибо значение нужно округлять к степени двойки).

Тестирую на ноутбуке Thinkpad T520, 4 ядра Intel Core i5, 2.50GHz, 4Gb оперативной памяти, из которых свободны 2Gb.

И вот результаты:

Число потоков Время запуска
200K 0.7 секунды
400K 2 секунды
600K 2.8 секунды
800K 11.7 секунд
1M 20 секунд

Видим, что при количестве потоков 200К-600К новый поток стартует за 3-5 микросекунд. При 800К-1М это дело замедляется, но это из-за того, что тут уже не хватает оперативной памяти.

Давайте посмотрим, что с расходом памяти. Для этого используем другой тест:

-module(test2).
-export([start/1, do_some_work/1]).

start(Num) ->
    [spawn_link(?MODULE, do_some_work, [N]) || N <- lists:seq(0, Num)],
    ok.

do_some_work(_N) ->
    Info = process_info(self(), [total_heap_size, heap_size, stack_size]),
    io:format("~p ~p~n", [self(), Info]),
    done.

Все просто, запускаем поток и с помощью process_info смотрим его статистику. Видим такое:

[{total_heap_size,233},
 {heap_size,233},
 {stack_size,1},
 {memory,2696}]

На старте поток получает стек размером в 1 машинное слово, и кучу размером в 233 машинных слова. (Машинное слово зависит от архитектуры и платформы, в моем 64-разрядном линуксе это 8 байт). Всего новый поток занимает 2696 байт, включая стек, кучу и память под свои метаданные.

Ну а чтобы запустить миллион потоков нужно 2.5 Гб. У меня свободных было только 2 Гб, из-за этого и были тормоза.

Теперь мы знаем, что такое "легковестный поток". Это запуск за 3-5 микросекунд и расход памяти 2.5 Кб на поток.

Всеми этими потоками управляют планировщики виртуальной машины. Их несколько, по одному на каждое ядро процессора. Желающие подробностей могут посмотреть вебинар Lukas Larsson - Understanding the Erlang Scheduler.

Я только отмечу, что планировщики умеюют балансировать нагрузку, перераспределяя потоки между собой. У них нет задачи постоянно держать равномерную нагрузку на все ядра процессоров, но есть задача избежать больших перекосов, когда одно ядро загружено на 100%, а другое вообще простаивает.

Надежность, уровни изоляции ошибок

IT индустрия пока не научилась создавать код без ошибок. Но научилась худо-бедно с ними жить.

Никто не хочет, чтобы рухнула вся нода из-за ошибки в обработке какого-то одного запроса. Большинство языков для этого предлагает механизм исключений. И разработчик может получить относительно безопасный код, если правильно расставит try…catch и правильно обработает ошибки.

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

Вторым уровнем является дерево супервизоров. В Erlang есть специальные потоки, которые сами не выполняют полезной работы, а наблюдают за другими. Такие специальные потоки называются supervisor (наблюдатели). Ну а потоки, которые выполняют реальную работу, называются worker (рабочие).

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

Супервизоры наблюдают не только за рабочими процессами, но и друг за другом. Для этого все потоки организованы в дерево, где узлами являются супервизоры, а листьями – рабочие потоки.

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

Третий уровень изоляции ошибок – объединение нод в кластер. Если нода все-таки падает, или вообще сервер выходит из строя из-за проблем с железом, то ее функцию может взять на себя резервная нода.

Программировать распределенные системы сложно на любом языке. Но Erlang делает это немного проще, т.к. изначально разрабатывался для этого.

Отладка работающией ноды в продакшене

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

Но есть кое-что в Erlang, что повторить в других языках очень сложно.

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

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

Этот механизм позволяет получать в реальном времени информацию:

Теоретически можно узнать почти все о работе ноды. Практически этой информации очень много, и нужно уметь выбрать именно ту, которая важна :)

Данные трассировки можно наблюдать в консоли, или перенаправить в файл, или в свой обработчик. Ее можно получать на той же ноде, или перенаправить на другую ноду, и обрабатывать и анализировать там.

И пару слов о памяти и сборке мусора

Сборка мусора в функциональных языках несколько проще, чем в императивных, благодаря неизменяемым переменным. Из-за этого точно известно, что переменные, созданные раньше, не могут указывать на объекты, созданные позже.

Сборщик мусора в Erlang делит объекты на два поколения: молодые и старые. И исходит из предположения, что большинство молодых объектов являются короткоживущими, и для них память нужно чистить чаще. А большинство старых объектов являются долгоживущими, и для них память можно чистить реже.

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

Нет эффекта stop world, как в JVM, когда сборщик мусора нужно остановить всю ноду для своей работы. В Erlang все сборщики работают независимо друг от друга, в разные моменты времени, и останавливают только свой поток.

Если поток короткоживущий (что довольно обычно для Erlang), то после его завершения вся память потока целиком освобождается, а сборщик мусора даже не успевает поработать.

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

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

comments powered by Disqus