Как работает event loop в Node.js
Первый основной тезис Node.js заключается в том, что операции ввода/вывода обходятся дорого:
Вообще, это самое большое расточительство в современном программировании — ждать, пока завершится операция ввода/вывода(I/O). Ниже я приведу список различных подходов, так или иначе влияющих на производительность (на основе статьи Сэма Рашинга (Sam Rushing):
-
синхронные операции: за раз вы обрабатываете только один запрос, обработка запросов происходит в порядке очереди. Плюсы: простота. Минусы: Любой запрос может заблокировать обработку всех остальных.
-
Создание нового процесса: каждый раз вы запускаете новый процесс для обработки нового запроса. Плюсы: простота. Минусы:
fork()
— это молоток unix-программистов. Он прост в применении, а каждая проблема всегда похожа на гвоздь. Обычно такой подход приводит к плохой масштабируемости. Всё таки, сотни открытых подключений — это сотни запущенных процессов. -
Потоки: Вы обрабатываете каждый запрос в отдельном треде. Плюсы: Простота. В отличии от
fork()
, для запуска треда нужно меньше ресурсов. Минусы: Ваш компьютер может не поддерживать работу с тредами. Кроме того, программирование тредов очень быстро становится головной болью: приходится постоянно беспокоиться о проблемах доступа к общим ресурсам.
Второй основной тезис заключается в том, что обработка каждого нового подключения в отдельном треде приводит к большим расходам памяти. К примеру, Apache, работая с тредами, потребляет гораздо больше памяти, чем Nginx.
Apache использует многопоточность. В зависимости от настроек, для обработки нового запроса запускается новый тред или поднимется еще один процесс. Вы можете наблюдать за ростом количества потребляемой памяти, увеличивающимся параллельно с ростом количества одновременных подключений. Nginx и Node.js, напротив, не используют многопоточность, потому как потоки и процессы обходятся бóльшими расходами памяти. И Node.js и Nginx построены на основе событий, и используют только один поток. Однопоточность избавляет их от расходов на обработку тысяч потоков или процессов.
Node.js использует один поток для выполнения всего вашего кода
Это действительно так. Node.js работает в одном потоке. Вы ничего не можете выполнить параллельно. Пример ниже заблокирует весь сервер на одну секунду:
while(new Date().getTime() < now + 1000) {
// do nothing
}
Пока этот код выполняется, Node.js не будет реагировать на любые запросы от клиентов, потому что Node.js использует один поток для выполнения всего вашего кода. То же самое произойдет, если вы выполните полный тяжелых вычислений код. Например, изменяющий размер изображения, точно так же заблокирует обработку других запросов.
…тем не менее, все, кроме вашего кода, запускается параллельно
Нет ни одного способа выполнить код параллельно в рамках обработки запроса. Однако, для того, чтобы избежать блокировки сервера, все операции ввода-вывода асинхронны, и используют события для взаимодействия с сервером:
c.query(
'SELECT SLEEP(20);',
function (err, results, fields) {
if (err) {
throw err;
}
res.writeHead(200, {'Content-Type': 'text/html'});
res.end('<html><head><title>Hello</title></head><body><h1>Return from
async DB query</h1></body></html
>');
c.end();
}
);
Если выполнить этот код обрабатывая один из поступивших запросов, то сервер приступит к обработке следующих запросов не дожидаясь ответа базы данных.
Почему такой подход хорош? Когда стоит использовать асинхронность вместо синхронности?
Синхронные операции хороши своей простотой. Особенно, по сравнению с тредами, где проблема с параллельным доступом к ресурсам так и хочет свести ваш код к состоянию «WTF?!».
В Node.js вам не нужно волноваться о том, что происходит на бэкэнде: достаточно просто использовать коллбеки при работе с I/O. Это даст вам уверенность в том, что ваш код не заблокирует работу сервера. В отличии от использования дополнительных тредов или процессов, это хорошее решение с точки зрения производительности.
Асинхронный I/O — это замечательно, потому что операции ввода/вывода гораздо затратнее, чем простое исполнение кода. К тому же, программа должна заниматься более полезными делами, чем постоянно ждать I/O.
Событийный цикл — это «сущность которая перехватывает и обрабатывает внешние события и конвертирует их в функции обратного вызова». То есть, вызовы I/O — это определенные точки, в которых Node.js может переключаться от одного запроса к другому. При обращении к I/O, ваш код сохраняет коллбек, и возвращает контроль обратно, в среду выполнения Node.js. Сохраненный коллбек будет вызван позднее, когда все необходимые данные будут получены.
Конечно, на стороне бэкенда, используются процессы и потоки для доступа к базам данных и для выполнения различных процессов. Однако, все это не присутствует явно в вашем коде, так что вам не нужно будет об этом беспокоиться. Единственное, о чем вам стоит помнить, так это то, что операции I/O, например, с базой данных или с другими процессами будут асинхронными с точки зрения каждого запроса, и результаты работы этих процессов будут возвращаться через событийный цикл обратно в код. В сравнении с моделью работы Apache, здесь гораздо меньше тредов, как и потраченных на них ресурсов, потому что здесь нет необходимости создавать новый тред для каждого запроса. Просто, когда вам действительно необходимо что-то получить или выполнить что-то в параллельном потоке, то управлять этим будет Node.js
Node.js предполагает, быстрый ответ на входящие запросы, так что, такой подход
стоит применять не только к I/O: сложные расчеты, загружающие процессор должны быть
вынесены в отдельные процессы, с которыми вы сможете взаимодействовать
с помощью событий. Либо тяжелые операции можно вынести из основного потока,
используя такие абстракции, как WebWorkers. Это подразумевает, что вы
не сможете распараллелить работу вашего кода, без использования отдельного
фонового потока, с которым вы сможете взаимодействовать с помощью событий.
В основном, все объекты, которые могут бросать события (к примеру, инстансы
EventEmitter) поддерживают асинхронное взаимодействие на основе коллбеков.
Вы можете использовать это в работе с блокирующим кодом. Взаимодействие можно
организовать с помощью файлов, сокетов, или дочерних процессов, каждый
из которых будет представлен в Node.js инстансом EventEmitter. Поддержку
многоядерности можно реализовать также. Советую посмотреть node-http-proxy
.
Внутренняя реализация
Внутри, для работы событийного цикла, nodejs использует libev. В приложение к нему Node.js использует libeio, который использует очереди потоков для реализации асинхронного ввода/вывода. Узнать об этом больше вы можете в документации к libev.
Так как же мы реализуем асинхронность в Node.js?
Тим Касвел (Tim Caswell) определил следующие паттерны в своей потрясающей презентации:
-
Функции первого класса. Мы подходим к таким функциям как к данным, разбрасывая их вокруг и выполняя их по необходимости.
-
Функциональные композиции. Так же известны как анонимные функции или замыкания, которые выполняются после того, как что-то произойдет в I/O.
-
Счетчики коллбеков. Повесив функции обратного вызова к определенным событиям, вы не можете гарантировать порядок их выполнения. Так что, если вам необходимо дождаться выполнения нескольких запросов, то самый простой способ решения такой задачи — считать каждую завершенную операцию, и, таким образом, проверять, все ли необходимые операции были завершены. Это пригодиться, если вам обязательно нужно дождаться результатов. Например, считая количество выполненных запросов к базе данных в коллбеке, мы можем определить, когда все наши запросы будут выполнены, и только тогда пойти дальше. Запросы к базам данных запустятся параллельно, потому что I/O библиотека поддерживает это (например, благодаря пуллу подключений).
-
Событийные циклы. Как упоминалось раньше, вы можете обернуть блокирующий код в событийную абстракцию, запустив его в дочернем процессе, и забрав данные из этого процесса по окончанию его работы.
Во всем этом нет ничего сложного!