Сравнение Erlang и Node.js

Дмитрий Демещук

Аннотация:

Многие программисты, пишущие на JavaScript, сокрушаются: «Ну почему на JS можно писать только клиентскую часть? Так хочется написать полноценный клиент-серверный продукт на одном языке».

С недавних пор такое желание стало осуществимым. На базе созданного датской командой Google движка V8, компилирующего JavaScript в машинный код, создана платформа Node.js (далее — Node) — технология, позволяющая писать полноценные асинхронные серверные приложения на JavaScript. Технология быстро получила популярность у веб-разработчиков и уже применяется в серьезных проектах с большими нагрузками для асинхронной обработки данных.

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


The V8 engine, created by the danish Google team, which compiles JavaScript to machine code, has given rise to the Node.js technology. It allows one to write full-fledged asynchronous server applications in JavaScript.

This article gives a short overview of the features and architecture of Node.js and compares it to the Erlang platform, which was mostly used in telecom projects before, but lately has found more and more use in the context of web development.

1  Введение

Erlang создавался компанией Ericsson для использования в телекоммуникационных системах. Сперва разработчики в течение трех лет экспериментировали с различными языками, выявляя их достоинства и недостатки для поставленной задачи. Для экономии времени разработчиков требовался язык высокого уровня, освобождавший их от рутинной работы вроде ручного выделения и освобождения памяти, и позволяющий программисту сфокусироваться на оперировании абстракциями предметной области. Из первоначального списка из примерно 30 языков победителями вышли Lisp, Parlog и Prolog; от последнего Erlang и унаследовал большую часть синтаксиса.

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

И вот, в 1987 году появился первый прототип Erlang, а через пять лет он уже стал использоваться в двух production-системах. С тех пор в Erlang было внесено множество изменений: добавлены инструменты для создания распределенных систем, оптимизированы многие механизмы, написано множество вспомогательных библиотек, и т. д.

Сейчас Erlang используется в production многими компаниями. Яндекс и Facebook используют ejabberd — многофункциональный сервер мгновенного обмена сообщениями, написанный на Erlang. Сервисы многих телеком-операторов (T-Mobile, Telia, Mobilearts, Cellpoint, Ericsson и др.) используют платформу Erlang, годами не останавливая выполняющийся на своих серверах код. Применяется Erlang и в системах мониторинга (Corelatus), платежных системах (Kreditor). Наконец, получивший популярность RabbitMQ, использующийся тысячами компаний для связи между различными компонентами своих систем, тоже написан на Erlang.



Основная причина появления Node.js — популярность языка JavaScript. По сути, JS был и остается единственным универсальным языком для создания фронт-энда в вебе. Его нишу неоднократно пытались занять другие технологии (Flash, Silverlight), но в конце-концов они проигрывали и отходили на второй план.

Развитие веб-технологий стало еще больше способствовать популярности JavaScript. Чтобы понять масштабность этого развития, можно взглянуть на тренды вакансий. Например, в начале 2005 года по данным indeed.com спрос на JS-программистов был вдвое меньше, чем спрос на разработчиков на C++. Сейчас, по тем же данным, JS уже обогнал C++, и темпы роста спроса на него и не думают снижаться.

Но дело не только в своего рода монополии JavaScript. Другая причина его популярности — удобство. Вобрав в себя немного от объектной и немного от функциональной парадигм, JS завоевал любовь множества программистов своей простотой и удобством.

Второй основной предпосылкой для создания Node.js явилось сильное возрастание производительности браузерных JS-движков. Постоянная гонка веб-браузеров заставила разработчиков оптимизировать JavaScript-движки (Rhino и SpiderMonkey от Mozilla, V8 от Google, Chakra — движок Internet Explorer 9.0 и др.), делая их максимально быстрыми, легкими и производительными. Многие программы, написанные на чистом C, будут работать медленнее, чем код, сгенерированный таким движком из JavaScript. Node, будучи основанным на гугловском движке V8, стал, пожалуй, одной из самых производительных серверных технологий.

Эти два фактора быстро сделали Node.js очень популярным в вебе. У него очень большое и активное сообщество1, постоянно появляются новые библиотеки и фреймворки различного назначения.

Несмотря на то, что технология совсем молодая, многие уже с успехом используют ее в работающих проектах. Среди самых известных — Yammer, сервис корпоративного микроблоггинга, Plurk — крупнейший азиатский микроблоггинговый сервис, Adcloud — рекламный сервис, базирующийся на облаках Amazon EC2, и многие другие.

2  Язык твой — друг твой

JavaScript, как очень известный и популярный язык, в особом представлении не нуждается. Главный его инструмент — first-class functions, то есть возможность использовать функции как полноценный тип данных (так же как числа, массивы или объекты). Функции можно объявлять анонимно, замыкать в них переменные, объявленные вне тела функции, передавать в качестве аргументов, сохранять в структурах данных (списках и словарях), а потом вызывать в любой нужный момент.

Для тех, кто пришел на него с объектных языков, подобных C++ или Java, JS покажется гораздо более привычным, чем Erlang. Имея почти полноценные объекты и подобие классов (сочетание примитивов и наследования через прототипы), дающие возможность наследования и полиморфизма во вполне привычном виде, и позволяя компенсировать отсутствие некоторых возможностей из ООП своей функциональной природой (например, инкапсуляцию можно сделать через замыкания), он позволяет разработчику писать в знакомом ему объектном стиле.

Кроме того, JS поддерживает хорошо зарекомендовавший себя формат данных JSON, который почти повсеместно используется для хранения и передачи данных, причем даже не только в Web. А в версии 1.8 стандарта появилась еще и нативная поддержка XML, позволяющая эффективно работать с XML — E4X.

Начиная с версии стандарта 1.7, JavaScript начал поддерживать destructuring assignment — присваивание, являющееся почти полноценным аналогом сопоставления с образцом, присущего высокоуровневым функциональным языкам:

var bar = 'foo', foo = 'bar'; [foo, bar] = [bar, foo];

С приходом пятой версии стандарта ECMAScript, фундамента языка JavaScript, в JS появился весьма полезный метод Object.freeze(), позволяющий делать объект неизменяемым (immutable) — впрочем, речь идет о «поверхностной» заморозке, а не о «глубокой». О пользе immutability будет рассказано далее. Интересно то, что в JS immutability может быть задействована по желанию программиста, позволяя использовать достоинства обоих методов там, где это нужно. Object.freeze() поддерживается и в Node.

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

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

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



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

Ещё одна особенность — в Erlang весьма неуклюжие механизмы работы с глобальными переменными2. Платформа, таким образом, словно бы дополнительно поощряет стремление избегать их использования.

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

В Erlang есть first-class functions, основа функциональной парадигмы. Здесь также есть замыкания и лямбды (анонимные функции). В отличие от Node.js, присутствует поддержка хвостовых вызовов. Они не только дают преимущество хвостовой рекурсии, не требующей стека вызовов, но и позволяют эффективно реализовывать интересные и полезные механизмы, например, конечные автоматы. Через них же в Erlang и ему подобных языках реализуются циклы. От своих функциональных предков Erlang получил и сопоставление с образцом. Оно позволяет писать лаконичный код и помогает удобно обрабатывать ошибки.

Отдельного упоминания заслуживает OTP (Open Telecom Platform), набор готовых Erlang-библиотек. OTP содержит, например, такие «модели поведения (behaviors)» (термин из OTP), как «обработчик событий» (gen_event) и «конечный автомат» (gen_fsm).

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

<< ProtocolVersion:4/integer-unit:8, HandshakeMessage:20/binary, SomeBitFlag:1/integer, Rest/binary >> = Packet.

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

3  Многопользовательские и многозадачные

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

Обе рассматриваемые технологии созданы как раз с расчетом на такие условия — Erlang и Node.js способны обрабатывать сотни тысяч одновременных подключений. Но решается эта задача в обеих платформах по-разному.



Работающая на базе Erlang система изнутри представляет собой множество мельчайших процессов, очень похожих на реализацию Green Threads в некоторых языках, но по поведению более близких к системным процессам, чем к потокам. Они существуют исключительно на уровне виртуальной машины, которая и занимается их планировкой, а снаружи, на уровне ОС, виден только основной процесс — сама виртуальная машина. Эти процессы легковесны — простейший процесс может занимать в памяти около нескольких сотен байт. На 64-битной архитектуре Erlang позволяет создать их более двухсот миллионов (до 268435456, если быть дотошным). На практике, однако, число одновременно запущенных процессов обычно куда более скромно: десятки или сотни тысяч. Что также очень важно, запуск и остановка таких процессов происходит очень быстро — всего несколько микросекунд.

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

Таким образом, область видимости процесса ограничивается областью видимости его функции, что обеспечивает инкапсуляцию данных. Так как в Erlang нету понятия глобальных переменных, то единственный способ хранить какие-либо данные внутри процесса, представленного рекурсивно вызывающей себя функцией, — это передавать эти данные в качестве аргумента этой функции. Получается своего рода состояние, которое «протягивается» через весь процесс и, возможно, как-то изменяется с течением времени:

... spawn(fun init/0), ... init() -> ZeroState = 1, loop(ZeroState). loop(State) -> NewState = do_something(State), io:format("Okay, this is my new internal state: ~p~n", [NewState]), loop(NewState).

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

С учетом вышенаписанного, процессы можно сравнить с объектами. У объекта тоже есть внутреннее состояние (в нашем случае — все данные в private-доступе) и есть методы (послали процессу сообщение — вызвали его метод).



В свою очередь, Node построен на событиях. Client-side программистам на JavaScript должна быть очень хорошо знакома эти схема: cоздаем событие, назначаем ему функцию-callback, и продолжаем выполнение программы. В момент возникновения события функция будет выполнена автоматически:

var events = require('events'); var server = new events.EventEmitter(); server.on('test', function() { console.log('All right. Message received.'); }); server.emit('test');

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

Вообще говоря, код, выполняющийся таким образом, выполняется строго последовательно. Любая асинхронно вызываемая функция в нужный момент вклинивается в императивно выполняемую последовательность команд. Распараллелить такую схему работы программы непросто, и Node до сих пор не поддерживает SMP, выполняясь на одном ядре процессора. Впрочем, этот недостаток компенсируется прекрасной производительностью и возможностью использования нескольких процессов Node одновременно.

Таким образом, базовой единицей асинхронности в Node является функция. Казалось бы — тот же процесс в Erlang. Но есть существенные отличия.

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

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



Как будет выглядеть, например, веб-сервер, написанный на обеих платформах?

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

  1. Разделение труда. Основной процесс занимается только тем, что принимает новые подключения. Каждый порождаемый процесс обрабатывает только его собственного клиента. С точки зрения ООП, мы здесь имеем один объект-диспетчер, принимающий вызовы от клиентов, и множество объектов-обработчиков, каждый из которых выполняется одним и тем же кодом, но работает с разными данными.
  2. Что если в одном из клиентских процессов произойдет непредвиденная ошибка, и он завершится? Так как процессы изолированы друг от друга, то память всех остальных запущенных процессов останется в неизмененном состоянии, и вся остальная система не пострадает. Более того, завершаясь, процесс может послать своему родительскому процессу сообщение с деталями о возникшей ошибке, если связать их функцией link() или вместо spawn() вызвать spawn_link().

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

Отдельный вопрос — что делать, если завершился главный процесс, который принимает новые подключения. Для этого у Erlang есть еще один мощный инструмент — supervisor, — который будет освещен в следующей главе.



Node поставляет удобный HTTP-сервер прямо в коробке. Создали объект http, повесили обработчик на событие нового подключения, вызвали метод listen(), указав нужный порт — и простейший веб-сервер готов:

var http = require('http'); http.createServer(function (req, res) { res.writeHead(200, {'Content-Type': 'text/plain'}); res.end('Hello World\n'); }).listen(8124, '127.0.0.1'); console.log('Server running at http://127.0.0.1:8124/');

Что же произойдет, если в функции-callback’е в процессе выполнения возникает ошибка? Если выброшенное исключение не будет отловлено через catch, HTTP-сервер прекратит работу, и программа просто завершится.

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

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

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

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

4  Сборка надежной конструкции из ненадежных компонентов

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



В Node объект http для этого имеет специальное событие close — на него можно повесить, например, перезапуск сервера, или сообщение об ошибке. Очевидно, список возможных системных ошибок (закрытие сокета, отсутствие файла на диске, недоступный IP-адрес, и т. д.) довольно ограничен, и по большей части уже предусмотрен в реализованных библиотеках. А значит, на системные события подобного рода всегда есть возможность отреагировать.

Однако, в процессе работы сервера может возникнуть ошибка, не являющаяся системной, но требующая переинициализации объекта. Для того, чтобы понять, как обрабатывать ошибки такого рода, следует рассмотреть принцип работы объекта net, на котором и базируется библиотека http.

Net подписывается на получение от операционной системы уведомлений о новых подключениях и при получении такого уведомления вызывает функцию accept, аналогичную по поведению accept’у, используемому в C. Вызов accept’а завернут в try-catch, причем дважды: первый, более глубокий catch в случае, когда открытых подключений слишком много, временно останавливает сервер, так что тот перестает принимать новые подключения и ждет, пока освободятся файловые дескрипторы. Затем этот catch в любом случае вызывает throw, пропуская ошибку на уровень выше. Второй catch закрывает сокет, если он еще открыт, отписывает объект от уведомлений от операционной системы, а затем сам посылает объекту сообщение close, которое программист может обработать.

Впрочем, создание слушающего сокета тоже завернуто в try-catch, и поэтому в случае, например, недостаточных прав или занятого порта, при инициализации объекта будет выброшено исключение.

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

Итого, объект имеет уже как минимум три различных типа ошибок, различающихся по области распространения: ошибки при создании объекта, ошибки при выполнении внутренних механизмов объекта и, наконец, ошибки, возникающие внутри callback’ов, связанных с определенными событиями этого объекта. Всего же в реализации объекта целых девять блоков try-catch, и каждый, само собой, обрабатывает свои специфические случаи. И еще один try-catch, скорее всего, придется вставить в функцию-обработчик событий, чтобы как-то отлавливать ошибки при обработке клиентских подключений.

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

Впрочем, на практике ситуация с отловом ошибок в Node оказывается не настолько страшной. Так, весь код нативных JS-модулей Node содержит всего лишь два десятка блоков try-catch — не так уж и много для реализации полноценного серверного API. Для большой системы, вероятно, потребуется вставить множество своих собственных, а для легкого веб-приложения эти два десятка предусматривают большинство возможных ошибок.



В Erlang задача переинициализации «корневых» механизмов решается немного более общо. Платформа OTP (Open Telecom Platform), неотъемлемая часть любой крупной Erlang-системы, поставляет для таких случаев специальный вид процессов — supervisor.

Задача supervisor’а — контролировать другие процессы. Если какой-то из подконтрольных ему процессов завершается, supervisor автоматически перезапускает его (а может, и все остальные процессы, если выставлена соответствующая опция). Можно задать максимальную частоту перезапусков (если она будет превышена — supervisor завершится с ошибкой) и максимальное число перезапусков для каждого подконтрольного процесса.

Если наш HTTP-сервер запустить с помощью supervisor’а, то в случае любой ошибки тот просто перезапустит корневой процесс, в котором заново создастся слушающий сокет. При этом, ввиду полной изоляции процессов в Erlang, уже запущенные клиентские процессы затронуты не будут и спокойно доработают до конца.

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

Таким образом, парадигма «Let it fail» работает и здесь: если процесс может упасть — пусть падает, мы его автоматически перезапустим.

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

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

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

Ещё сложнее становится, если некоторым ошибкам нужно дать пройти на уровень выше, к родительскому событийному объекту. Приходится в соответствующем блоке try-catch вычленять такие ошибки и снова делать throw, чтобы обработать эти специфические ошибки уровнем выше3. В результате, если ошибке нужно «пропрыгать» таким образом несколько уровней, приходится плодить ненужный код, в сущности делающий одно и то же — пропускающий нужный нам класс ошибок дальше, к вышестоящему try-catch. Ещё хуже, если блоку try-catch придется фильтровать несколько разных классов ошибок: например, одни пропускать дальше, другие обрабатывать, а третьи игнорировать. Каждый такой блок будет превращаться в нагромождение if’ов или case’ов.

5  Слон в посудной лавке

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



Один из плюсов в Node, который, несомненно, понравится и новичкам, и профессионалам — подробный и понятный backtrace. Любая ошибка сопровождается стеком вызовов, классом ошибки, описанием, и т. д. Особенно удобно это качество в сочетании с возможностью Node работать в режиме интерпретатора, превращая консоль Node в полноценный дебаггер. Кроме того, существуют мощные интерактивные дебаггеры для Node, которые даже можно подключать удаленно, например, ndb.

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

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

Наличие вышеупомянутого метода Object.freeze() может во многих случаях упростить жизнь, защищая переменные от лишних изменений, но необходимо помнить, «заморожен» ли объект, либо проверять его состояние каждый раз при изменении.

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



Backtrace в Erlang более скуп и лаконичен и часто больше сбивает новичков с толку, чем помогает им локализовать ошибку. Однако, привыкнув к нему, можно пользоваться им не менее быстро и эффективно, чем более дружественным backtrace’ом из C или того же Node.

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

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

Что касается ошибок программиста, то некоторые неудобные на первый взгляд качества Erlang неожиданным образом защищают систему от вторжений плохого кода. Так, те самые, поначалу столь непривычные, «непеременные переменные» мешают новичку повредить данные, выполнив какие-то неправильные действия в середине кода. И, если, внутри процесса данные защищены иммутабельностью, то межпроцессное взаимодействие даёт дополнительную защиту: при отправке между процессами данные копируются (передаются только по значению, а не по ссылке). Таким образом, данные труднее затереть или повредить; меньше боязни, что новый написанный модуль повлечет за собой проблемы в работе всей системы.

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

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

6  Немного о распределенности

Распределенные системы, в которых разные компоненты могут находиться на разных машинах, нуждаются в эффективных механизмах удаленного вызова процедур (Remote Procedure Call, сокращенно — RPC). Кроме того, для многоядерных процессоров для повышения производительности необходимо иметь возможность использовать все ядра, что обычно достигается порождением нескольких процессов, каждый из которых выполняется на собственном ядре, а при необходимости эти процессы общаются между собой (этот механизм называется IPC — Inter-Process Communication).

Как уже было упомянуто ранее, процесс работы Node сложно распараллелить. Но все же возможность работы на нескольких ядрах там есть. Node умеет работать с системными процессами и обмениваться с ними данными через stdin/stdout. Метод, конечно, небыстрый, но для многоядерных процессоров должен давать ощутимый выигрыш. Работа с процессами на уровне функционала чистого Node.js очень напоминает процессы в низкоуровневых языках вроде C — неудобно, но не смертельно.

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

С RPC, как и с IPC, тоже не все гладко. Единственное, что предлагает нативный Node для этих целей — старые добрые сокеты. Framework’и упрощают жизнь, но все равно напоминают скорее высокоуровневые client-server-библиотеки, чем полноценные RPC-интерфейсы.



Что касается Erlang, эти строки кода скажут больше, чем любые рассуждения:

{Node, ProcessID} ! Message. receive Value -> io:format("Process replied: ~p~n", [Value]); after 1000 -> io:format("Timeout 1s.~n") end. rpc:call(Node, ModuleName, FunctionName, Arguments).

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

7  Тесты производительности

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

Однако, некоторое представление о производительности платформ отсюда можно получить.


Сравнивать быстродействие арифметических операций на исследуемых платформах — достаточно трудоемкое занятие: различные операции занимают различное время, и трудно поставить Erlang и Node.js в равные условия. Кроме того, в случае с Erlang, возможен ряд оптимизаций, ускоряющих арифметику. В любом случае, одна и та же задача на Erlang и JavaScript, как правило, будет иметь разные оптимальные реализации.

Однако, некоторое представление об общей производительности можно получить, посмотрев на результаты сравнения Erlang и V8 в рамках тестов shootout. В некоторых тестах видно явное преимущество той или иной платформы, но в целом результаты недалеки друг от друга.

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

Также, для сравнения в тот же чарт добавлены некоторые популярные платформы: PHP, Perl, Ruby, Python, C++ и Java. Проигрывая Java и C++, Erlang и Node, тем не менее, идут впереди «традиционных» скриптовых платформ, со значительным перевесом.



Так как для Erlang веб становится самой распространенной областью применения, а для Node — фактически единственной, то интересно сравнить обе технологии в этом ключе.

В качестве объектов исследования были выбраны нативный http-сервер Node и самописный веб-сервер на Erlang5.

Чтобы поставить платформы в более-менее равные условия, Erlang запускался без поддержки SMP. Таким образом, веб-сервера работали на одном ядре. В качестве сервера для тестов был использован X-Large High-CPU инстанс облачного сервиса Amazon EC2. Запросы отсылались с такого же инстанса при помощи утилиты httperf.

Также, из настроек виртуальной машины, предоставляемых Erlang, были использованы следующие: "+K true" (kernel polling) и "-smp disable" (выполнение Erlang на одном процессоре). Исходный код веб-сервера компилировался с флагом "+native".

Node специальных настроек платформы не предоставляет, поэтому запускался стандартным образом.

У httperf регулировались две величины: число запросов в секунду (--rate) и общее число запросов за весь тест (--num-conns).


При небольшом, до 10000, общем числе запросов, оба веб-сервера показывают хорошую производительность, держа до 6000 запросов в секунду, выдавая до 6000 ответов в секунду. Однако у Node есть незначительный процент ошибок подключений, в то время как Erlang стабильно обрабатывает все запросы без исключения.

Однако после 7000 и более запросов в секунду картина несколько меняется. Скорость ответов от веб-сервера Node резко падает до 1000 и ниже, а число ошибок соединения возрастает. После 9000 запросов в секунду то же самое происходит и с Erlang.


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

Главная причина такого явления заключается в особенностях сборщика мусора V8.

Во-первых, собирая мусор по принципу «stop the world», V8 останавливает выполнение JS-кода до окончания освобождения памяти. А, так как весь код в V8 выполняется однопоточно, то каждый раз во время сборки мусора сервер становится недоступным и перестает совершать какие-либо действия.

Во-вторых, сборка мусора в V8 инициируется в следующих случаях: если невозможно выделение памяти, если превышен установленный порог используемой памяти и, наконец, если движку послано сообщение ожидания. Это сообщение ожидания и является на данный момент единственным способом вызова сборщика мусора вручную. Node использует его при нахождении в режиме ожидания, но внешнего интерфейса на уровне JS для сборки мусора не предоставляет.

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


В свою очередь, Erlang при тех же 30–50 тысячах запросов продолжает успешно обрабатывать подключения, почти не теряя производительности по сравнению с 10 тысячами запросов. Проблем с освобождением памяти, как у Node, у него нет. Во-первых, код выполняется многопоточно, и сборка мусора не блокирует все потоки разом6. А во-вторых, каждый процесс обладает своим сборщиком мусора, работающим независимо от других процессов. В результате по завершении каждого клиентского процесса память, занимаемая им, оперативно подчищается.

Само собой, такой механизм требует больше памяти, чем один глобальный сборщик мусора, поэтому веб-сервер на Erlang потребовал при прочих равных условиях несколько больше памяти, чем веб-сервер на Node. Тысяча одновременных соединений (без учета HTTP-пакетов) стоила Erlang примерно 2.5 мегабайта памяти, тогда как Node понадобилось всего 2 мегабайта. Конечно же, эта разница в полкилобайта на соединение нивелируется в случае работы с большими объемами пересылаемых данных — пять сотен байт в случае мегабайтного куска данных для отдачи клиенту погоды не делают.


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

Erlang же здесь выглядит более универсальным, но чуть более требовательным к ресурсам.

Наконец, следует привести кое-какие практические результаты. Вышеупомянутый азиатский сервис микроблоггинга Plurk при помощи Node держит более 100 тысяч одновременных подключений. А создатель Last.fm Ричард Джонс (Richard Jones) умудрился создать на Erlang приложение, выдерживающее до миллиона одновременных подключений к одному физическому серверу.

8  Аудитория

На сегодняшний день Erlang представляет собой уже вполне стабилизировавшийся продукт. Новые версии выходят по 3–5 раз в год, и в последнее время носят более косметический характер: оптимизировали какой-нибудь механизм, сделали рефакторинг кода в модулях, добавили пару полезных функций, и т. п. Некоторые системы так и продолжают работать на старых версиях, не испытывая больших затруднений.

На Erlang разрабатывается несколько баз данных с открытыми исходниками, несколько веб-серверов, есть крупные фреймворки, предназначенные, в основном, для веба. Реже можно встретить новые поведенческие модели (чаще всего — надстройки над моделями из OTP), средства для сборки, отладки и тестирования производительности.

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

Сообщество буквально ломится от множества фреймворков и библиотек. Улучшенные IPC и RPC, шаблонизаторы, надстройки над событийной моделью (например, очень удобная библиотека для управления асинхронными вычислениями, напоминающая монаду Cont из языка Haskell), интерфейсы для всех популярных баз данных.

Наконец, можно просто посмотреть на активность в mailing-листах и IRC-каналах обеих платформ: сообщество Node сейчас гораздо активнее и, похоже, уже многочисленнее.

9  Итоги

Node, несмотря на свою молодость, несомненно, является очень многообещающей и мощной технологией. Обеспечивая хорошую производительность, платформа подойдет даже для суровых веб-сервисов с миллионной аудиторией, особенно для comet-серверов.

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

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


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

Ввиду своего мощного инструментария для построения распределенных систем Erlang, скорее всего, будет также более удачным решением везде, где требуется RPC.


В общем, вопрос «что учить: Erlang или JavaScript?» имеет однозначный ответ: учите оба. За ними будущее.

Список литературы

[1]
Трескин М. Инструменты интроспекции в Erlang/OTP. Журнал <<Практика функционального программирования>>, (5), 2010.

1
Так, на Github насчитывается больше тысячи репозиториев, так или иначе использующих эту технологию.
2
По сути, в Erlang нет привычных глобальных переменных, а есть process dictionary и настройки приложения. Первое сами создатели Erlang называют злом, которое следует использовать только в очень редких случаях, по необходимости, а второе используется в основном при инициализации приложения, но не при его работе.
3
Подобные ситуации встречаются, например, в исходном коде tcp-модуля Node.js.
4
Подробнее эта система интроспекции рассмотрена в [1].
5
http://fprog.ru/2010/issue6/dmitry-demeshchuk-node.js-vs-erlang/erweb.erl
6
Впрочем, в приведенном примере это преимущество не используется, так как Erlang принудительно работает на одном ядре процессора.

Этот документ был получен из LATEX при помощи HEVEA