016 Устройство Node.js
В начале 2000-х годов у нас были стандартный многопоточные серверы. Один поток процессора сервера выполнял один запрос от пользователя. Основная проблема заключалась в том, что 1 поток занимал 1 мб памяти и само переключение потоков требовало определённых ресурсов компьютера.
Далее проблема переходила в то, что наш поток блокировался в процессе выполнения своих операций и простаивал пока происходило логирование данных, получение данных из базы и происходил сам рендер шаблона
Так же вследствие того, что поток занимал 1 мб памяти, то при выделении большого количества потоков, у нас может появиться стопор в виде недостатка памяти (а на момент начала 21-ого века 10 гб памяти было большой цифрой)
Поэтому вдальнейшем была придумана система, при которой наш поток не блокируется
Поэтому схема, которая работала раньше, преобразуется уже в другую:
- Мы имеем запросы
- Все они поступают в основной поток, который выполняет задачи запросов
- Если задача достаточно тяжёлая, то она отправляется на дополнительный поток сервера (например, криптография, чтение файлов и так далее)
Так же в ноде существуют такие понятия как: стек и куча.
- Куча хранит в себе переменные
- Стек - это хранилище стека вызова функций
Конкретно тут мы видим ситуацию со схемы выше. Стек - это основной поток, который выполняет все операции. Конкретно функция setTimeout
не будет блокировать все остальные операции - она вынесется в дополнительный поток, где и обработается.
node - это не однопоточный фреймворк. Это фреймворк, который имеет один основной поток (стек вызовов) и множество дополнительных потоков, в котором выполняются более затратные операции.
Составляющие NodeJS:
- Движок V8 (виртуальная машина JS), который разрабатывается в google и находится в хроме;
- Библиотека LibUV. Она реализует концепцию Event Loop, Thread Pool и асинхронный ввод/вывод;
- Стандартная библиотека. Она содержит в себе функциональность работы с файловой системой, криптографией, ивентами и всеми остальными функциями ноды (аналог - WebAPI);
- Далее наш код будет биндиться в C/C++ код, который уже будет выполнять эффективные операции над нашими операциями;
- Так же мы можем подключать C/C++ аддоны к нашему приложению;
- NodeJS API
К нам пришёл на сервер запрос от пользователя, первым делом он попадает в движок V8 откуда попадает в биндер ноды, уже который переводит запрос в очередь событий. В рамках очереди событий, у нас есть Event Loop, который крутится и обслуживает всю нашу систему. После же Event Loop отправляет все задачи в Callstack (тот самый основной поток - Stack), где задача и выполняется на движке V8, либо, если встречается тяжёлая задача, она идёт на выполнение в отдельный поток. После выполнения задачи уже из этого стека результат задачи идёт обратно в ивент луп, откуда и передаётся обратно на входные системы для выдачи ответа. Если выполнилась тяжёлая задача, то её коллбэки будут передаваться обратно в очередь событий, откуда пройдёт стандартный путь стандартной задачи.
017 Event Loop
Event Loop имеет свои фазы, которые описаны в его документации. Самой важной и основной является четвёртая, потому как нода очень быстро работает с вводом/выводом.
Так как указаны не все выполняемые элементы языка, то между этими фазами выполняются остальные функции (те же промисы)
В полной картине выполнения ивент лупа выполняется в самом начале инициализация всех импортов и функций. Далее по порядку выполяются все элементы ивент лупа. Потом ивент луп проверяет, на закончена ли программа (например, если в моменте совершения лупа таймер был 3 секунды, то луп повторяется до тех пор, пока время таймера не кончится и не выполнится операция).
018 Таймеры
Первое, что нужно отметить это то, что таймер не гарантирует нам выполнение функции ровно в то время, которое мы указали. Он гарантирует нам, что функция сработает не раньше указанного времени. И чем более высоконагруженные задачи будут в стеке, тем на большее время будет отдаляться выполнение функции от таймера
Чтобы вызывать таймаут с аргументами, нужно просто перечислить их через запятую
Так же мы можем отменять выполнение таймера через clearTimeout()
И так же мы можем отменить работу интервала. Интервал setInterval()
уже повторяет функцию каждое определённое время.
Функция setImmediate()
мгновенно выполняет функцию сразу в самом конце программы
Так же мы можем убрать ссылку из стека на таймер через функцию unref()
Но так же мы можем обратно вернуть ссылку на таймер, чтобы он выполнился через ref()
019 Пример работы event loop
Фазы Event Loop
- инициализация
Фазы
- таймеры
- pending callbacks
- idle, prepare
- poll
- check
- close callback
- проверка на окончание
Мы проинициализировали все функции в нашем коде, выполнили все синхронные функции, затем уже приступили к выполнению асинхронных функций. Конкретно тут видно, что функция таймаута задержалась на выполнение последнего console.log
и поэтому сильно отдалилась в выполнении от начального значения
Функция setImmediate
относится к завершающим, поэтому она выполняется в конце порядка этой фазы ивент лупа
Дальше мы встречаемся с тем, что setImmediate
выполнился до нашего таймаута. Почему?
Мы входим в первую фазу ивент лупа, смотрим, что таймер незарезолвен, так как его таймер не вышел, дальше уже идёт пункт check
, где и вызвается setImmediate
, и он там и вызывается.
Уже во второй фазе ивент лупа наш таймаут зарезолвен, что даёт нам возможность его выполнить, и вот уже сейчас его результат выведен.
Уже тут ивент луп выполняет три круга. Выполняет иммедиэйт. На втором получает ответ от асинхронного прочтения файла через fs
и выполняет его колбэк-функцию. На третьем резолвится таймер и выполняется его коллбэк
Тут уже нужно описать несколько моментов:
- Большая операция. Мы имеем крайне тяжеловесную операцию, которая выполняется самой первой, так как по времени она зарезолвлена сразу. Из-за неё фаза стопорится и прошлый таймер, так как он уже проверен был, выполнится намного позже - только на следующем заходе в новую фазу.
- Промис. Он выполнился на этапе
microtask, nextTick
, который выполняется сразу после каждого этапа нашей фазы ивент лупа!
И примерно так выглядят фазы, если мы включим в схему выполнение промисов
Ну и так же если мы воткнём в большую операцию промис, то он начнёт выполняться сразу после неё:
Ну и так же process.nextTick()
, который выполняется вместе с промисом после каждой фазы.
Сама тиковая операция очень важна, когда мы совершаем те же htttp-реквесты или для регистрации обработчиков некоторых событий.
020 Stack вызова
Стек вызовов (Call Stack) в JS устроен по принципу LIFO (Last In, First Out — последним вошёл, первым вышел). Цикл событий постоянно проверяет стек вызовов на предмет того, имеется ли в нём функция, которую нужно выполнить. Если при выполнении кода в нём встречается вызов некоей функции, сведения о ней добавляются в стек вызовов и производится выполнение этой функции.
Конкретно тут в примере мы видим по шагам, как выполняется логирование результата функции и куда попадают и где сохраняются наши результаты внутри системы стека.
Уже в этом примере можно увидеть, что таймер после инициализации в стеке сразу из него выходит, чтобы не стопорить стек, и чтобы тот продолжал свою работу в стандартном режиме
Чтобы просмотреть стек вызова, мы можем зайти в отладчик внутри VSCode и в нём просмотреть все полученные значения стека вызова
Особенности стека
- Стек может быть переполнен (err: StackExided). Произойти ошибка может, если была запущена рекурсивная функция без явного конца
021 Worker threads
Это упрощённая схема работы воркера. Из стека тяжёлые задачи попадают в воркер, а уже из него возвращаются колбэки тех тяжёлых задач. Однако не все задачи попадают в отдельные воркеры.
На самом деле рабочий поток предоставляется нашим задачам, если на то будут оправданы ресурсы. В основном процессе все наши функции очень тесно соприкасаются с C++, который и обрабатывает запросы на выполнение от JS, либо отправляет задачу в один из рабочих процессов. По умолчанию рабочих процессов всего 4 (как установлено в libuv)
Каждый поток, грубо говоря - это одно ядро процессора. 4 треда - это условный четырёхядерный процессор, который может без потери производительности выполнять операции. Если мы хотим выделить 8 воркер тредов на 4-хядерном процессоре, то эти потоки будут выполняться последовательно.
Конкретно под воркер тред выделяются задачи по работе с файловой системой, днс.лукап, редкие пайпы и тяжёлые для ЦПУ задачи (то же шифрование)
Сейчас мы можем чётко увидеть, что у нас выделено всего 4 воркер треда по умолчанию на выполнение тяжёлых задач
Так же мы можем выделить большее количество ядер для выполнения операции. Зачастую такая настройка не нужна, так как мы запускаем сервер в контейнере на виртуальных процессорах.
Ну и так же мы можем отправлять запросы на сервер с такой же проверкой времени
022 Измерение производительности
Стандартный способ измерения производительности производится через performance.mark()
+ performance.measure()
, которые нам позволяют отмерить промежуток выполнения определённых операций
Ну и есть второй способ: используя хуки производительности
Когда какие измерения нужно проводить?
- Если нужно измерить кусок кода, то нужно использовать
performance.mark()
- Если нам нужно измерить время выполнения функции, то уже её нужно декорировать