Erlang. Прагматичный рассказ про прагматичный язык.

22 марта 2012

15 октября 2011 выступал на 5-й встрече сообщества scala.by. Было клева, аудитория оказалась весьма заинтересованная, засыпали вопросами. Хотя, казалось бы, Erlang для сообщества Scala программистов немного оффтопик. Но нет. Некоторые даже приехали из других городов, чтобы послушать. Я был весьма польщен этим :)

Встреча была довольно долгая, затянулась часов на 5. Сперва я рассказывал про историю Erlang и давал общий обзор языка (многопоточность, устойчивость к ошибкам, распределенность, горячее обновление). Затем была довольно длинная live coding сессия, где я делал сервер сокращения ссылок :) Сперва сделал его без OTP, потом переделал в нормальный gen_server (чтобы наглядно показать, почему с gen_server лучше, чем без него). Потом была беседа про OTP, и вопросы по другим темам.

Здесь отчет о встрече на сайте сообщества. Фотки доступны здесь и здесь.

Видеозапись встречи:

Первая часть моего выступления также доступна в формате plain txt :) И вот она ниже:

Пару слов о том, как создавался Erlang

Есть такая шведская компания Ericsson -- производитель телекоммуникационного оборудования. А для оборудования нужен еще и софт, управляющий им. И этот софт Ericsson тоже делает, причем очень давно :)

У Ericsson была своя закрытая проприетарная технология PLEX, представляющая собой язык программирования и операционную систему. Она и использовалась для создания программного обеспечения для их оборудования.

В середине 80х годов в Ericsson задумались о том, что софт делается недостаточно эффективно, и нужно что-то придумать. У них было подразделение Computer Science Laboratory, которому была поставлена задача перепробовать все значимые языки программирования, существовавшие на тот момент, и выбрать наиболее подходящий для их нужд. Этим занималась команда в составе: Joe Armstrong, Robert Virding и Mike Williams под руководством Bjarne Dacker.

В течение 2х лет команда пробовала разные языки: ML, Ada, Modula, CLU, Smalltalk, Prolog и другие. Эти языки были хороши каждый по-своему. Но требования Ericsson к языку были довольно суровые:

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

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

С 1994 года Erlang начал применяться во внутренних проектах компании. В 1995 году Erlang был официально выпущен в релиз и стал широко использоваться внутри компании. В 1996 году вышел OTP фреймворк. В 1998 году Erlang и OTP были выпущены в open source.

Долгое время Erlang был мало известен за пределами компании Ericsson. Но где-то с 2006 года он стал известен, и с тех пор популярность языка и сфера его применения растет. И вот почему:

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

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

Обзор языка

Итак, в Erlang изначально, на уровне дизайна языка, заложены:

Многопоточность

Многопоточность в Erlang не зависит от операционной системы и ее потоков. Сердцем Erlang является Erlang Run-Time System (ERTS) -- виртуальная машина, которая имеет свои: sheduler для управления потоками, I/O систему и сборщик мусора.

Потоки в Erlang очень дешовые, создаются быстро, требуют мало ресурсов. Их можно создавать сотни тысяч. Создание нового потока в Erlang -- это такая же легкая и быстрая операция, как создание объекта в Java :) Тогда как потоки операционной системы, создаются медленнее и при создании резервируют несколько мегабайт памяти. А это значит, что их можно создать ограниченное количество (на 32-разрядной машине не больше нескольких сотен). Поток в Erlang тоже имеет свою область памяти (head и stack), но она сначала минимальна (несколько килобайт) и увеличивается по мере необходимости.

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

Отправка сообщения является асинхронной операцией. Поток отправитель продолжает заниматься своими делами, не дожидаясь ответа. Но при необходимости можно реализовать синхронный вызов -- подождать, пока придет какой-нибудь ответ.

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


run() ->
Pid = spawn(fun ping/0),
Pid ! self(),
receive
pong -> ok
end.

ping() ->
receive
From -> From ! pong
end.

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

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

Но есть и недостатки. Копирование данных может быть дорогой операцией, если данных много. Поэтому erlang проекты стараются проектировать так, чтобы передавать между процессами небольшие сообщения. Однако необходимость работать с большими объемами данных есть, и тогда приходится использовать ETS/DETS таблицы или базы данных.

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

Устойчивость к ошибкам

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

Erlang следует концепции Let it crash, которая означает, что если в процессе возникла ошибка, то пусть этот процесс умрет. Но смерть его не должна остаться незамеченной. Умирая, процесс посылает сообщение, которое получает другой, связанный с ним процесс. Этот другой процесс и должен обработать данную ситуации. Он может перезапустить умерший процесс, либо умереть сам и передать обработку дальше, либо обработать эту ситуацию как-либо иначе.

Такой подход облегчает жизнь программисту, ибо ему не приходится слишком задумываться об обработке ошибок и засорять код конструкциями try...catch. Программист оптимистично пишет только "happy case". Код от этого только выигрывает в плане компактности и читабельности.

Архитектурно система строится из процессов двух видов. Одни процессы -- workers, выполняют реальную работу, другие процессы -- supervisors, контролируют цикл жизни рабочих процессов и обрабатывают их смерть. Supervisors могут быть организованы в дерево, где все узлы -- supervisors, а листья -- workers.

Представим себе ситуацию: worker-процесс получает невалидные входящие данные, умирает, и умирая посылает сообщение: "враги отравили меня ядом, считайте меня коммунистом". Supervisor получает это сообщение, и запускает новые worker-процесс, который занимает место умершего и продолжает работу. Или же supervisor может решить, что проблема слишком опасна, и умереть сам, вместе со всеми своими подопечными процессами. И умирая он пошлет сообщение: "Параграф 78, группа должна уничтожить сама себя. Считайте меня Гошей Куценко." Это сообщение получит вышестоящий supervisor, и он пошлет новую группу спецназа взамен погибшей.

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

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

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

Распределенность

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

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

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

Если же нужно строить систему из ограниченных в правах узлов, то придется отказаться от message passing, и налаживать общение через TCP сокеты, как и в других языках.

Коль уж мы упомянули Erlang-узел (node), то нужно уточнить, что это такое. Узел -- это экземпляр виртуальной машины Erlang, со своим пространством памяти и своими процессами. В принципе можно запустить два и больше узла на одной машине. Что не очень нужно в продакшн, но весьма удобно в разработке.

Горячее обновление

Итак, у нас есть крутая система, которая работает без остановки уже N лет. Периодически выходят обновления в коде: бакфиксы, новые фичи и т.д., и нужно, чтобы система подхватывала эти обновления без перезапуска.

Эта возможность тоже заложена на уровне языка. Каждый раз, когда мы вызываем someModule:someFunction(...), виртуальная машина проверяет, были ли изменения в этом модуле, и вызывает самую последнюю его версию. Можно даже запустить такой вызов в цикле с некоторым интервалом, и во время работы цикла обновить модуль. В следующей итерации цикла будет вызван новый метод.

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

comments powered by Disqus