[Перевод] В чем секрет скорости NodeJS?
Предлагаем вам перевод статьи Евгения Обрезкова, в которой он кратко и по делу рассказывает о причинах высокой скорости NodeJS: потоки, event loop, оптимизирующий компилятор и, конечно же, сравнение с PHP. Куда уж без него.
В очередной статьей о NodeJS хочу поговорить об ещё одном преимуществе программной платформы: о скорости выполнения кода.
Что мы имеем в виду под скоростью выполнения
Вычислить последовательность Фибоначи или отправить запрос к базе данных?
Когда мы говорим о веб-сервисах, скорость выполнения включает все действия, которые необходимы для того, чтобы выполнить запрос и отослать его обратно клиенту. NodeJS отличает высокая скорость — начиная с открытия соединения и заканчивая отправкой ответа.
Как только вы поймёте, что происходит в сервере NodeJS во время выполнения запроса, вам станет ясно, почему это происходит так быстро.
Но сначала давайте обратимся к тому, как обрабатываются запросы на других языках. PHP — лучший пример, потому что он очень популярен и не предлагает никаких оптимизаций по умолчанию.
От чего страдает PHP
Вот список того, что уменьшает скорость выполнения кода в PHP:
- PHP имеет синхронную модель выполнения. Это означает, что когда вы обрабатываете запрос или пишете в базу данных, другие операции блокируются. Поэтому приходится ждать окончания операции, прежде чем начать делать что-то другое.
- Каждый запрос к веб-сервису создает отдельный процесс PHP интерпретатора, который выполняет ваш код. Тысячи подключений означают тысячи выполняющихся процессов, которые потребляют память. Вы можете наблюдать, как память используется всё больше и больше вместе с новыми подключениями.
- PHP не имеет JIT компилятора. Это важно, если у вас есть код, который используется очень часто, и вы хотите быть уверенными в том, что он близок к машинному коду, насколько это возможно.
Это самые критичные минусы PHP. Но, по моему мнению, их намного больше.
Теперь мы посмотрим, как NodeJS справляется с подобными задачами.
Магия NodeJS
NodeJS однопоточна и асинхронна. Любая операция ввода-вывода не блокирует работу. Это значит, что ты можешь читать файлы, отправлять электронные письма, запрашивать базу данных и совершать другие действия… одновременно.
Каждый запрос не создает отдельный процесс NodeJS. Напротив, в NodeJS постоянно работает и ждет подключений всего один процесс. JavaScript код выполняется в главном потоке этого процесса, а все операции ввода-вывода выполняются в других потоках практически без задержки.
Виртуальная машина в NodeJS (V8), которая выполняет JavaScript, имеет JIT компиляцию. Когда виртуальная машина получает исходный код, она может скомпилировать его прямо во время работы. Это значит, что операции, которые вызываются часто, могут быть скомпилированы в машинный код. И это значительно улучшит скорость выполнения.
По сути, здесь были изложены преимущества асинхронной модели. Позвольте мне объяснить, как это работает в NodeJS.
Понимайте вашу асинхронность
Вашему вниманию предлагаю пример концепции асинхронной обработки (спасибо Кириллу Яковенко).
Представьте, что у вас есть 1000 шаров на вершине горы. И ваша задача — толкнуть все шары, чтобы они оказались у её основания. Вы не можете толкнуть одновременно тысячу шаров, только каждый по отдельности. Но это не значит, что вы должны ждать, когда шар достигнет основания, чтобы толкнуть следующий.
Синхронное выполнение означает для вас потерю времени. Вы ждете, когда шар окажется у основания.
Асинхронное выполнение похоже на то, что у вас появляется 1000 дополнительных рук. И вы можете запустить все шары одновременно. После чего ждете только сообщения о том, что все они внизу, и собираете результаты.
Как асинхронное выполнение помогает веб-сервису работать?
Представим, что каждый шар — это запрос в базу данных. У вас большой проект, где много запросов, аггрегаций, и так далее. Когда вы обрабатываете все данные синхронным способом, это блокирует выполнение кода. Асинхронным способом вы выполняете все запросы одновременно, а затем только собираете данные.
В реальной жизни, когда у вас много соединений, это значительно ускоряет работу.
Как асинхронный способ реализован в NodeJS?
Event Loop
Event loop — это конструкция, которая ответственна за обработку событий в какой-то программе. Event loop почти всегда работает асинхронно относительно источника сообщений. Когда вы вызываете операцию ввода-вывода, NodeJS сохраняет коллбек, связанный с этой операцией, и продолжает обработку других событий. Коллбэк будет вызван, когда все необходимые данные будут получены.
Наиболее развернутое определение event loop:
Event loop, message dispatcher, message loop, message pump или run loop — это программная конструкция, которая ожидает и обрабатывает события или сообщения в программе. Конструкция работает путем создания запросов к внутренней или внешней службе доставки сообщений (которая обычно блокирует запрос до тех пор, пока сообщение не получено), после чего она вызывает обработчик соответствующего события («обрабатывает событие»). Event loop может быть использована в связке с reactor, если источник событий имеет такой же интерфейс как и файлы, к которому можно сделать запрос вида select или poll (poll в смысле Unix system call). Event loop почти всегда работает асинхронно по отношению к источнику сообщений.
Давайте посмотрим на простую иллюстрацию, которая объясняет, как event loop работает в NodeJS.
Event Loop в NodeJS
Когда веб-сервис получает запрос, тот отправляется в event loop. Event loop регистрирует операцию в пуле потоков с нужным коллбеком. Коллбек будет вызван, когда обработка запроса завершится. Ваш коллбек может также делать другие «тяжелые» операции, такие как запросы в базу данных. Но делает это таким же способом — регистрирует операцию в пуле потоков с нужным коллбеком.
Как насчет выполнения кода и его скорости? Мы собираемся поговорить о виртуальной машине и о том, как она выполняет JavaScript код. То есть о V8.
Как V8 оптимизирует ваш код?
В Wingolog описано, как работает виртуальная машина V8. Я упростил изложенный там материал и предлагаю выжимку.
Ниже будут обозначены базовые принципы работы виртуальной машины V8 и способы того, как она оптимизирует код JavaScript. Это будет техническая информация, поэтому можете пропустить эту часть, если не знаете, как работают компиляторы. А если вы хотите знать больше о V8, то советую обратиться к специализированному источнику.
V8 имеет три типа компилятора, но обсудим только два: Full и Crankshaft (третий компилятор называется Turbofun).
Full-компилятор работает быстро и производит «типовой код». У функции Javascript он берет AST (Abstract Syntax Tree) и переводит его в типовой нативный код. На этом этапе применяется только одна оптимизация — инлайн кэширование.
Когда код скомпилирован и запущен, V8 стартует поток профайлера, чтобы узнать, какие функции используются часто, а какие — нет. Виртуальная машина также собирает отчеты об использовании типов, так что она может записать типы информации, которая через неё проходит.
После того, как V8 определила, какие функции используются часто, и получила отчет об использовании типов, она старается запустить модифицированный AST через оптимизирующий компилятор — Crankshaft.
В отличие от Full-компилятора, Crunshaft работает не так быстро, но пытается производить оптимизированный код. Cranshaft состоит из двух компонентов: Hydrogen и Lithium.
Hydrogen-компилятор создает CFG (Control Flow Graph) из AST (на основании отчета об использовании типов). Этот граф представлен в форме SSA (Static Single Assignment). На основании простой структуры HIR (High-Level Intermediate Representation) и формы SSA, компилятор может применять много оптимизаций, таких как constant folding, method inlining, и так далее…
Lithium-компиллятор переводит оптимизированный HIR в LIR (Low-Level Intermediate Representation). LIR концептуально похож на машинный код, но в большинстве случае не зависит от платформы. В противоположность HIR, форма LIR — ближе к three-address коду.
Только после этого, оптимизированный код может заменить старый неоптимизированный и продолжить выполнять ваше приложение намного быстрее.
Полезные ссылки