Привет, Хабр! Представляю вашему вниманию перевод статьи "Everything you need to know about Node.js" автора Jorge Ramon.
В наши дни платформа Node.js является одной из самых популярных платформ для построения эффективных и масштабируемых REST API's. Она так же подходит для построения гибридных мобильных приложений, десктопных программ и даже для IoT.
Я работаю с платформой Node.js более 6 лет и я на самом деле люблю её. Этот пост главным образом пытается быть путеводителем по тому, как Node.js работает на самом деле.
Давайте же начнём!!
О чем пойдёт речь:
Мир до Node.js
Многопоточный сервер
Веб-приложения, написанные следуя клиент/серверной архитектуре, работают по следующей схеме — клиент запрашивает нужный ресурс у сервера и сервер отправляет ресурс в ответ. В этой схеме сервер, ответив на запрос, прерывает соединение.
Такая модель эффективна поскольку каждый запрос к серверу потребляет ресурсы (память, процессорное время и т.д.). Для того чтобы обрабатывать каждый последующий запрос от клиента, сервер должен завершить обработку предыдущего.
Значит ли это, что сервер может обрабатывать только один запрос за раз? Не совсем! Когда сервер получает новый запрос он создаёт отдельный поток для его обработки.
Поток, если простыми словами, это время и ресурсы, что CPU выделаят на выполнение небольшого блока инструкций. С учётом сказанного, сервер может обрабатывать несколько запросов одновременно, но только по одному на поток. Такая модель так же называться thread-per-request model.
Для обработки N запросов серверу нужно N потоков. Если сервер получает N+1 запросов, тогда он должен ждать пока один из потоков не станет доступным.
На рисунке выше, сервер может обрабатывать до 4 запросов (потоков) единовременно и когда он получает следующие 3 запроса, эти запросы должны ждать пока любой из этих 4 потоков не станет доступным.
Один из способов избавиться от ограничений — добавить больше ресурсов (памяти, ядер процессора и т. д.) на сервер, но это не самое лучшее решение….
И, конечно, не забываем о технологических ограничениях.
Блокирующий ввод/вывод
Ограниченное число потоков на сервере не единственная проблема. Возможно, Вам стало интересно почему один поток не может обрабатывать несколько запросов одновременно? всё из-за блокирующих операций ввода/вывода.
Допустим, Вы разрабатываете онлайн магазин и Вам нужна страница где пользователь может просматривать список всех товаров.
Пользователь стучится на http://yourstore.com/products и сервер рендерит HTML файл со всеми продуктами с базы данных в ответ. Совсем не сложно, да?
Но, что же происходит за кулисами?
- Когда пользователь стучится на
/products
особый метод или функция должна выполниться, что бы обработать запрос. Маленький кусочек кода (Ваш или фреймворка) анализирует URL-адрес запроса и ищет подходящий метод или функцию. Поток работает. - Теперь нужный метод или функция выполняться, так как и в первом пункте — поток работает.
- Так как Вы хороший разработчик, Вы сохраняете все системные логи в файл, ну и конечно же, что бы быть уверенными, что роутер выполняет нужный метод/функцию — Вы так же логируете строку "Method X executing!!». Но всё это блокирующие операции ввода/вывода. Поток ждёт.
- Все логи сохранены и следующие строки функции выполняются. Поток работает снова.
- Время обращаться к базе данных и получать все продукты – простой запрос, вроде
SELECT * FROM products
, выполняет свою работу, но угадайте что? Да-да, это блокирующая операция ввода/вывода. Поток ждёт. - Вы получили массив или список всех продуктов, но убедитесь, что Вы всё это залогировали. Поток ждёт.
- Теперь у Вас есть все продукты и пришло время рендерить шаблон для будущей страницы, но перед этим Вам нужно их прочитать. Поток ждёт.
- Движок рендеринга делает свою работу и шлёт ответ клиенту. Поток работает снова.
- Поток свободен, словно птица в небесах.
На сколько медленны операции ввода/вывода? Ну это зависит от конкретной. Давайте обратимся к таблице:
Операция | Количество CPU тактов |
---|---|
CPU Registers | 3 такта |
L1 Cache | 8 тактов |
L2 Cache | 12 тактов |
RAM | 150 тактов |
Disk | 30,000,000 тактов |
Network | 250,000,000 тактов |
Операции сети и чтения с диска слишком медленные. Представьте сколько запросов или обращений к внешним API ваша система могла бы обработать за это время.
Подбивая итоги: операции ввода/вывода заставляют поток ждать и тратить ресурсы впустую.
Проблема C10K
Проблема
C10k (англ. C10k; 10k connections — проблема 10 тысяч соединений)
В ранние 2000-е, серверные и клиентские машины были медленными. Проблема возникала при параллельной обработке 10 000 клиентских соединений к одной машине.
Но почему традиционная модель thread-per-request (поток на запрос) не могла решить эту проблему? Что ж, давайте используем немного математики.
Нативная реализация потоков выделаят больше 1 Мб памяти на поток, выходя из этого – для 10 тысяч потоков требуется 10 Гб оперативной памяти и это только для стека потоков. Да, и не забывайте, мы в начале 2000-х!!
В наши дни серверные и клиентские компьютеры работают быстрее и эффективней и почти любой язык программирования или фреймворк справляются с этой проблемой. Но фактически проблема не исчерпана. Для 10 миллионов клиентских соединений к одной машине проблема возвращается вновь (но теперь она C10M Problem).
JavaScript спасение?
Осторожно, спойлеры!!!
Node.js на самом деле решает проблему C10K… но как?!
Серверный JavaScript не был чем-то новым и необычным в начале 2000-х, на тот момент уже существовали реализации поверх JVM (java virtual machine) – RingoJS и AppEngineJS, что работали на модели thread-per-request.
Но если они не смогли решить проблему, тогда как Node.js смог?! Всё из-за того, что JavaScript однопоточный.
Node.js и цикл событий
Node.js
Node.js это серверная платформа, что работает на движке Google Chrome – V8, который умеет компилировать JavaScript код в машинный код.
Node.js использует событийно-ориентированную модель и неблокирующую ввод / вывод архитектуру, что делает его легковесным и эффективным. Это не фреймворк, и не библиотека, это среда выполнения JavaScript.
Давайте напишем маленький пример:
// Importing native http module
const http = require('http');
// Creating a server instance where every call
// the message 'Hello World' is responded to the client
const server = http.createServer(function(request, response) {
response.write('Hello World');
response.end();
});
// Listening port 8080
server.listen(8080);
Non-blocking I/O
Node.js использует неблокирующие ввод/вывод операции, что же это значит:
- Главный поток не будет блокироваться операциями ввода/вывода.
- Сервер будет продолжать обслуживать запросы.
- Нам придётся работать с асинхронным кодом.
Давайте напишем пример, в котором на запрос к /home
сервер в ответ шлёт HTML страницу, а для всех других запросов — 'Hello World'. Что бы отослать HTML страницу сначала ее нужно прочитать из файла.
home.html
<html>
<body>
<h1>This is home page</h1>
</body>
</html>
index.js
const http = require('http');
const fs = require('fs');
const server = http.createServer(function(request, response) {
if (request.url === '/home') {
fs.readFile(`${ __dirname }/home.html`, function (err, content) {
if (!err) {
response.setHeader('Content-Type', 'text/html');
response.write(content);
} else {
response.statusCode = 500;
response.write('An error has ocurred');
}
response.end();
});
} else {
response.write('Hello World');
response.end();
}
});
server.listen(8080);
Если запрашиваемый url-адрес /home
, тогда используется нативный модуль fs
для чтения файла home.html
.
Функции что попадают в http.createServer
и fs.readFile
как аргументы — колбэки. Эти функции будут выполнены в какой-то из моментов в будущем (Первая, как только сервер получит запрос, а вторая — когда файл будет прочитан с диска и помещён в буфер).
Пока файл считывается с диска, Node.js может обрабатывать другие запросы и даже считывать файл снова и всё это в одном потоке… но как?!
Цикл событий
Цикл событий — это магия, которая происходит внутри Node.js. Это буквально бесконечный цикл и на самом деле один поток.
Libuv — C библиотека которая реализует этот паттерн и является частью ядра Node.js. Вы можете узнать больше о libuv здесь.
Цикл событий имеет 6 фаз, каждое исполнение всех 6 фаз называют tick-ом.
- timers: в этой фазе выполняются коллбэки, запланированные методами
setTimeout()
иsetInterval()
; - pending callbacks: выполняются почти все коллбэки, за исключением событий
close
, таймеров иsetImmediate()
; - idle, prepare: используется только для внутренних целей;
- poll: ответственен за получение новых событий ввода/вывода. Node.js может блокироваться на этом этапе;
- check: коллбэки, вызванные методом
setImmediate()
, выполняються на этом этапе; - close callbacks: например,
socket.on('close', ...)
;
Хорошо, есть только один поток, и этот поток и есть цикл событий, но тогда кто выполняет все операции ввода/вывода?
Обратите внимание!!!
Когда циклу событий нужно выполнить операцию ввода/вывода он использует поток ОС с тредпула (thread pool), а когда задача выполнена, коллбэк ставится в очередь во время фазы pending callbacks.
Разве это не круто?
Проблема CPU-ёмких задач
Node.js кажется идеальным! Вы можете создавать всё, что захотите.
Давайте напишем API для вычислений простых чисел.
Простое число – это целое (натуральное) число больше единицы и делимое только на 1 и на само себя.
Дано число N, API должен вычислять и возвращать первые N простых чисел в список (или массив).
primes.js
function isPrime(n) {
for(let i = 2, s = Math.sqrt(n); i <= s; i++) {
if(n % i === 0) return false; return n > 1;
}
}
function nthPrime(n) {
let counter = n;
let iterator = 2;
let result = [];
while(counter > 0) {
isPrime(iterator) && result.push(iterator) && counter--;
iterator++;
}
return result;
}
module.exports = { isPrime, nthPrime };
index.js
const http = require('http');
const url = require('url');
const primes = require('./primes');
const server = http.createServer(function (request, response) {
const { pathname, query } = url.parse(request.url, true);
if (pathname === '/primes') {
const result = primes.nthPrime(query.n || 0);
response.setHeader('Content-Type', 'application/json');
response.write(JSON.stringify(result));
response.end();
} else {
response.statusCode = 404;
response.write('Not Found');
response.end();
}
});
server.listen(8080);
prime.js
это реализация нужных вычислений: функция isPrime
проверяет является ли число простым, а nthPrime возвращает N таких чисел.
Файл же index.js
отвечает за создание сервера и использует модуль prime.js
для обработки каждого запроса на /primes
. Число N прокидывается через строку запроса в URL-адресе.
Что бы получить первых 20 простых чисел нам нужно сделать запрос на http://localhost:8080/primes?n=20
.
Предположим, к нам стучатся 3 клиента и пытаются получить доступ к нашему не блокирующемуся вводом/выводом API:
- Первый запрашивает 5 простых чисел каждую секунду.
- Второй запрашивает 1000 простых чисел каждую секунду
- Третий запрашивает 10 000 000 000 простых чисел, но...
Когда третий клиент шлёт запрос – главный поток блокируется и это главный признак проблемы CPU-ёмких задач. Когда главный поток занят исполнением «тяжёлой» задачи он становится недоступен для других задач.
Но как насчёт libuv? Если Вы помните, эта библиотека помогает Node.js исполнять операции ввода/вывода с помощью потоков ОС избегая блокировки главного потока и Вы абсолютно правы, это решение нашей проблемы, но для того, что бы это стало возможным, наш модуль должен быть написан на языке C++, что бы libuv могла с ним работать.
К счастью, начиная с v10.5 в Node.js добавлен нативный модуль Worker Threads.
Воркеры и их потоки
Как говорит нам документация:
Воркеры полезны для выполнения CPU-ёмких JavaScript операций; не используйте их для операций ввода/вывода, уже встроенные в Node.js механизмы более эффективно справляться с такими задачи, чем Worker thread.
Исправление кода
Пришло время переписать наш код:
primes-workerthreads.js
const { workerData, parentPort } = require('worker_threads');
function isPrime(n) {
for(let i = 2, s = Math.sqrt(n); i <= s; i++)
if(n % i === 0) return false;
return n > 1;
}
function nthPrime(n) {
let counter = n;
let iterator = 2;
let result = [];
while(counter > 0) {
isPrime(iterator) && result.push(iterator) && counter--;
iterator++;
}
return result;
}
parentPort.postMessage(nthPrime(workerData.n));
index-workerthreads.js
const http = require('http');
const url = require('url');
const { Worker } = require('worker_threads');
const server = http.createServer(function (request, response) {
const { pathname, query } = url.parse(request.url, true);
if (pathname === '/primes') {
const worker = new Worker('./primes-workerthreads.js', { workerData: { n: query.n || 0 } });
worker.on('error', function () {
response.statusCode = 500;
response.write('Oops there was an error...');
response.end();
});
let result;
worker.on('message', function (message) {
result = message;
});
worker.on('exit', function () {
response.setHeader('Content-Type', 'application/json');
response.write(JSON.stringify(result));
response.end();
});
} else {
response.statusCode = 404;
response.write('Not Found');
response.end();
}
});
server.listen(8080);
В файле index-workerthreads.js
при каждом запросе на /primes
создаётся экземпляр класса Worker
(с нативного модуля worker_threads
) для выгрузки и исполнения файла primes-workerthreads.js
в поток воркера. Когда список простых чисел просчитан и готов, инициируется событие message
– результат попадает в главный поток из-за того, что у воркера не осталось работы он также инициирует событие exit
, позволяя основному потоку отправлять данные клиенту.
primes-workerthreads.js
изменён немного. Он импортирует workerData
(это копия параметров, переданных с основного потока) и parentPort
через который результат роботы воркера передаётся назад в главный поток.
Теперь давайте испробуем наш пример снова и посмотрим, что случиться:
Основной поток больше не блокируется !!!!!
Теперь всё работает как нужно, но плодить воркеры без всяких на то причин все же не лучшая практика, создавать потоки не дешёвое удовольствие. Обязательно создайте пул потоков перед этим.
Заключение
Node.js мощная технология, которую стоит изучить при возможности.
Моя личная рекомендация – всегда будьте любопытными! Если Вы знаете, как что-то работает изнутри, Вы сможете работать с этим более эффективно.
Это всё на сегодня, ребята. Я надеюсь этот пост был полезен для Вас и вы узнали что-то новое о Node.js.
Спасибо за прочтение и до встречи в следующих постах.
Комментарии (18)
kinguru
19.07.2019 22:17вообще то сейчас уже все многопоточное и доступ к файлам тоже, базе
CoolWolf
19.07.2019 22:25а вы статью точно внимательно читали? JS однопоточный, но неблокирующий ввод-вывод реализован путём запуска отдельных потоков (threads) через средства ОС, соответственно вся работа с сетью и файлами и запускается в отдельных потоках.
mamont80
20.07.2019 12:31Вот это кстати непонятный момент. Потоков в ноде для IO вроде как нет, но это только с точки зрения прикладного программиста. На самом деле под капотом какие-то потоки в ОС на каждую IO-операцию формируются. Не получаем ли мы те же самые N потоков на N запросов только скрытно?
CoolWolf
20.07.2019 13:40Для I/O используется пул потоков, размер которого ограничен по-умолчанию 4-мя потоками, соответственно одновременно может выполняться только 4-ре I/O операции, остальные встают в очередь, так что это потенциальное узкое место. При необходимости размер пула можно увеличить. И не для всех операций этот пул используется, если внешний API поддерживает неблокирующий ввод-вывод, то пул не нужен.
klvov
20.07.2019 12:19Спасибо, здесь действительно содержатся те факты про nodejs и worker threads, которые надо держать в уме, если хочется сконструировать на nodejs что-то более или менее нагруженное. Правда, для тех, кто интересуется платформой, эти факты, скорее всего, не будут новостью.
Тем не менее, совсем недавно мне рассказывали, что неблокирующим образом nodejs умеет обращаться только к MongoDB, а когда используются «старые» реляционные БД (Oracle, Postgres), то обращение к ним из nodejs все равно блокирует поток, потому что для них нету асинхронных неблокирующих драйверов (какие есть для монги). Может быть, кто-нибудь из экспертов откомментирует, так это или не так?CoolWolf
20.07.2019 13:42Не работал с монгой, так что не дам комментариев по её поводу, но вот утверждение о том, что Оракл или постгрес блокируют поток — однозначно ложный. Если бы это было так, то применение этих СУБД с нодой было бы невозможным :)
Если внешняя система не поддерживает неблокирующий I/O, нода использует пул потоков, о котором в статье как раз говорится.
kalininmr
20.07.2019 14:06-1а, извините, чем хуже гринлеты, go, asyncio в питоне и прочее?
Virviil
21.07.2019 08:50От asyncio не отличается ничем. А акторы просто лучше: https://stressgrid.com/blog/benchmarking_go_vs_node_vs_elixir/
61brg
20.07.2019 17:33Почему-то вспомнилась реализация многозадачности в win 3.1 (или 95, но в меньшей мере) и та же беда с CPU ёмкими задачами, а появление модуля Worker Threads говорит том, что чуда не произошло.
Собственно, а существуют ли исследования о том, на сколько подобная экономия на «переключении задач» эффективна? Вот почему-то мне кажется, что значительное количество одновременно обслуживаемых клиентов у Node будет, если эти клиенты в основной массе простаивают (затраты на переключение задачи в многопоточной реализации сопоставимы с затратами на выполнение полезного действия), но в случае, когда все клиенты работают (затраты на переключения задачи малы по сравнению с затратами на выполнение полезного действия) эффект будет минимальный (если вообще будет).IgorPie
21.07.2019 20:38Имхо, Node тут как спутниковый навигатор для авто: позволит создать оптимальный маршрут. Но если дороги загружены на 7+ баллов, то чуда конечно же не случится.
xPomaHx
23.07.2019 02:38Никак не могу понять, по сути потоки в ОС это же тоже самое что эвентлуп, тока он реализован в 1 потоке и более высокоуровневый, почему тогда много«поточность» в нем дешевле чем системный.
irbis_al
23.07.2019 12:16Потому что LOOP это один поток… Когда процессор переключается на другой поток, он должен сделать переключение контекста… эТо дорогостоящая операция ,-он типа сохраняет все переменные в стек… делает ещё кучу всего… потом переключается на другой процесс(поток), восстанавливает его контекст. Делает дофига ненужного.(с точки зрения КПД)
Вот Википедия пишет про контекст процесса
А вот если один процесс хоть и в петле переключать контекст не нужно.xPomaHx
23.07.2019 16:35Получается что это недоработка осей, что они не дают инструмента в виде дешёвых потоков с общей памятью например, и приходится это реализовывать на уровне приложений.
CoolWolf
Спасибо за хороший перевод хорошей статьи!
kahi4
Поддерживаю, разве только бесполезных гифок можно было по-меньше вставить, 0 например.
belkamax05
Перейдите к оригинальной версии, и уважайте чужой труд по их переносу.
DemjanUA Автор
Спасибо за прочтение!