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 Таймеры
Первое, что нужно отметить это то, что таймер не гарантирует нам выполнение функции ровно в то время, которое мы указали. Он гарантирует нам, что функция сработает не раньше указанного времени. И чем более высоконагруженные задачи будут в стеке, тем на большее время будет отдаляться выполнение функции от таймера
const start = performance.now();
setTimeout(() => {
console.log(performance.now() - start);
console.log("Прошла секунда");
}, 1000);
Чтобы вызывать таймаут с аргументами, нужно просто перечислить их через запятую
function myFunc(arg) {
console.log(arg);
}
setTimeout(myFunc, 1000, "Этот мир");
Так же мы можем отменять выполнение таймера через clearTimeout()
const timerId = setTimeout(() => {
console.log("BOOOOOM!");
}, 5000);
setTimeout(() => {
clearTimeout(timerId);
console.log("Таймер очищен");
}, 1000);
И так же мы можем отменить работу интервала. Интервал setInterval()
уже повторяет функцию каждое определённое время.
const intervalId = setInterval(() => {
console.log(performance.now());
}, 1000);
setTimeout(() => {
clearTimeout(intervalId);
console.log("Интервал очищен");
}, 5000);
Функция setImmediate()
мгновенно выполняет функцию сразу в самом конце программы
console.log("Начало");
setImmediate(() => {
console.log("Immediate");
});
console.log("Конец");
Так же мы можем убрать ссылку из стека на таймер через функцию unref()
const timerId = setTimeout(() => {
console.log("BOOOOOM!");
}, 5000);
timerId.unref(); // таймер не отработает
Но так же мы можем обратно вернуть ссылку на таймер, чтобы он выполнился через ref()
const timerId = setTimeout(() => {
console.log("BOOOOOM!");
}, 5000);
timerId.unref(); // таймер отцеплен
setImmediate(() => {
timerId.ref(); // таймер обратно прицепили и выполнили
})
019 Пример работы event loop
Фазы Event Loop
- инициализация
Фазы
- таймеры
- pending callbacks
- idle, prepare
- poll
- check
- close callback
- проверка на окончание
Мы проинициализировали все функции в нашем коде, выполнили все синхронные функции, затем уже приступили к выполнению асинхронных функций. Конкретно тут видно, что функция таймаута задержалась на выполнение последнего console.log
и поэтому сильно отдалилась в выполнении от начального значения
console.log("Начало");
// таймер
setTimeout(() => {
console.log(performance.now(), " timer 0ms");
}, 0);
console.log("Конец");
Функция setImmediate
относится к завершающим, поэтому она выполняется в конце порядка этой фазы ивент лупа
console.log("Начало");
// таймер
setTimeout(() => {
console.log(performance.now(), " timer 0ms");
}, 0);
// check
setImmediate(() => {
console.log("Immediate");
});
console.log("Конец");
Дальше мы встречаемся с тем, что setImmediate
выполнился до нашего таймаута. Почему?
Мы входим в первую фазу ивент лупа, смотрим, что таймер незарезолвен, так как его таймер не вышел, дальше уже идёт пункт check
, где и вызвается setImmediate
, и он там и вызывается.
Уже во второй фазе ивент лупа наш таймаут зарезолвен, что даёт нам возможность его выполнить, и вот уже сейчас его результат выведен.
console.log("Начало");
// таймер
setTimeout(() => {
console.log(performance.now(), " timer 0ms");
}, 100);
// check
setImmediate(() => {
console.log("Immediate");
});
console.log("Конец");
Уже тут ивент луп выполняет три круга. Выполняет иммедиэйт. На втором получает ответ от асинхронного прочтения файла через fs
и выполняет его колбэк-функцию. На третьем резолвится таймер и выполняется его коллбэк
const fs = require("fs");
console.log("Начало");
// таймер
setTimeout(() => {
console.log(performance.now(), " timer 100ms");
}, 100);
// check
setImmediate(() => {
console.log("Immediate");
});
// poll
fs.readFile(__dirname, () => {
console.log("File was read(?)!");
});
console.log("Конец");
Тут уже нужно описать несколько моментов:
- Большая операция. Мы имеем крайне тяжеловесную операцию, которая выполняется самой первой, так как по времени она зарезолвлена сразу. Из-за неё фаза стопорится и прошлый таймер, так как он уже проверен был, выполнится намного позже - только на следующем заходе в новую фазу.
- Промис. Он выполнился на этапе
microtask, nextTick
, который выполняется сразу после каждого этапа нашей фазы ивент лупа!
const fs = require("fs");
console.log("Начало");
// таймер
setTimeout(() => {
console.log(performance.now(), " timer 100ms");
}, 100);
// check
setImmediate(() => {
console.log("Immediate");
});
// poll
fs.readFile(__dirname, () => {
console.log("File was read(?)!");
});
// большая операция
setTimeout(() => {
for (let i = 0; i < 1000000000; i++) {}
console.log("Big operation done")
});
// microtask, nextTick
Promise.resolve().then(() => {
console.log("Promise");
});
console.log("Конец");
И примерно так выглядят фазы, если мы включим в схему выполнение промисов
- инициализация
## Фазы
// microtask, nextTick
- таймеры
// microtask, nextTick
- pending callbacks
// microtask, nextTick
- idle, prepare
// microtask, nextTick
- poll
// microtask, nextTick
- check
// microtask, nextTick
- close callback
- проверка на окончание
Ну и так же если мы воткнём в большую операцию промис, то он начнёт выполняться сразу после неё:
// большая операция
setTimeout(() => {
for (let i = 0; i < 1000000000; i++) {}
console.log("Big operation done");
// microtask, nextTick
Promise.resolve().then(() => {
console.log("Promise timeout");
});
});
Ну и так же process.nextTick()
, который выполняется вместе с промисом после каждой фазы.
Сама тиковая операция очень важна, когда мы совершаем те же http-реквесты или для регистрации обработчиков некоторых событий.
const fs = require("fs");
console.log("Начало");
// таймер
setTimeout(() => {
console.log(performance.now(), " timer 100ms");
}, 100);
// check
setImmediate(() => {
console.log("Immediate");
});
// poll
fs.readFile(__dirname, () => {
console.log("File was read(?)!");
});
// большая операция
setTimeout(() => {
for (let i = 0; i < 1000000000; i++) {}
console.log("Big operation done");
// microtask, nextTick
Promise.resolve().then(() => {
console.log("Promise timeout");
});
});
// microtask, nextTick
Promise.resolve().then(() => {
console.log("Promise");
});
// nextTick
process.nextTick(() => console.log("Tick"));
console.log("Конец");
020 Stack вызова
Стек вызовов (Call Stack) в JS устроен по принципу LIFO (Last In, First Out — последним вошёл, первым вышел). Цикл событий постоянно проверяет стек вызовов на предмет того, имеется ли в нём функция, которую нужно выполнить. Если при выполнении кода в нём встречается вызов некоей функции, сведения о ней добавляются в стек вызовов и производится выполнение этой функции.
Конкретно тут в примере мы видим по шагам, как выполняется логирование результата функции и куда попадают и где сохраняются наши результаты внутри системы стека.
Уже в этом примере можно увидеть, что таймер после инициализации в стеке сразу из него выходит, чтобы не стопорить стек, и чтобы тот продолжал свою работу в стандартном режиме
Чтобы просмотреть стек вызова, мы можем зайти в отладчик внутри VSCode и в нём просмотреть все полученные значения стека вызова
Особенности стека
- Стек может быть переполнен (err: StackExided). Произойти ошибка может, если была запущена рекурсивная функция без явного конца
021 Worker threads
Это упрощённая схема работы воркера. Из стека тяжёлые задачи попадают в воркер, а уже из него возвращаются колбэки тех тяжёлых задач. Однако не все задачи попадают в отдельные воркеры.
На самом деле рабочий поток предоставляется нашим задачам, если на то будут оправданы ресурсы. В основном процессе все наши функции очень тесно соприкасаются с C++, который и обрабатывает запросы на выполнение от JS, либо отправляет задачу в один из рабочих процессов. По умолчанию рабочих процессов всего 4 (как установлено в libuv)
Каждый поток, грубо говоря - это одно ядро процессора. 4 треда - это условный четырёхядерный процессор, который может без потери производительности выполнять операции. Если мы хотим выделить 8 воркер тредов на 4-хядерном процессоре, то эти потоки будут выполняться последовательно.
Конкретно под воркер тред выделяются задачи по работе с файловой системой, днс.лукап, редкие пайпы и тяжёлые для ЦПУ задачи (то же шифрование)
Сейчас мы можем чётко увидеть, что у нас выделено всего 4 воркер треда по умолчанию на выполнение тяжёлых задач
// подключение библиотеки шифрования
const crypto = require("crypto");
// фиксируем начало выполнения
const start = performance.now();
// выполняем шифрование
for (let i = 0; i < 50; i++) {
crypto.pbkdf2("test", "salt", 100000, 64, "sha512", () => {
console.log(performance.now() - start);
});
}
Так же мы можем выделить большее количество ядер для выполнения операции. Зачастую такая настройка не нужна, так как мы запускаем сервер в контейнере на виртуальных процессорах.
// подключение библиотеки шифрования
const crypto = require("crypto");
// фиксируем начало выполнения
const start = performance.now();
// увеличиваем количество воркер тредов до 8
process.env.UV_THREADPOOL_SIZE = 8;
// выполняем шифрование
for (let i = 0; i < 50; i++) {
crypto.pbkdf2("test", "salt", 100000, 64, "sha512", () => {
console.log(performance.now() - start);
});
}
Ну и так же мы можем отправлять запросы на сервер с такой же проверкой времени
// подключение библиотеки запросов
const https = require("https");
// фиксируем начало выполнения
const start = performance.now();
// выполняем отправку запросов
for (let i = 0; i < 50; i++) {
// отравляем запрос яндексу на получение данных
https.get("https://www.yandex.ru", (res) => { // получаем результат
// если мы получаем данные, то что-то делаем
res.on("data", () => {});
// срабатывает, когда получаем последний байт данных
res.on("end", () => {
console.log(performance.now() - start);
});
});
}
022 Измерение производительности
Стандартный способ измерения производительности производится через performance.mark()
+ performance.measure()
, которые нам позволяют отмерить промежуток выполнения определённых операций
function slow() {
// делаем отметку по времени - начало
performance.mark("start");
const arr = [];
for (let i = 0; i < 100000000; i++) {
arr.push(i * i);
}
// делаем отметку по времени - конец
performance.mark("end");
// и тут будет происходить подсчёт времени нашей производительности
performance.measure("slow", "start", "end");
// получает все наши измерения
console.log(performance.getEntries());
// получает только одно наше измерение по имени
console.log(performance.getEntriesByName("slow"));
}
slow();
Ну и есть второй способ: используя хуки производительности
// импортируем хук производительности
// хук - это элемент, который позволяет на что-то подписаться
const perf_hooks = require("perf_hooks");
// оборачиваем функцию теста в хук проверки затраченного времени, чтобы определить затраты
test = perf_hooks.performance.timerify(test);
// и тут инстанциируем сам обзёрвер
const performanceObserver = new perf_hooks.PerformanceObserver(
(items, observer) => {
// получаем все элементы
console.log(items.getEntries());
// получаем последний элемент нашей нужной нам функции
const entry = items.getEntriesByName("slow").pop();
// имя вхождения и время выполнения
console.log(`${entry.name}: ${entry.duration}`);
// отключаем сам обзёрвер
observer.disconnect();
}
);
// и тут указываем откуда получаем время выполнения
performanceObserver.observe({ entryTypes: ["measure", "function"] });
function test() {
const arr = [];
for (let i = 0; i < 100000000; i++) {
arr.push(i * i);
}
}
function slow() {
// делаем отметку по времени - начало
performance.mark("start");
const arr = [];
for (let i = 0; i < 100000000; i++) {
arr.push(i * i);
}
// делаем отметку по времени - конец
performance.mark("end");
// и тут будет происходить подсчёт времени нашей производительности
performance.measure("slow", "start", "end");
// получает только одно наше измерение по имени
console.log(performance.getEntriesByName("slow"));
}
slow();
test();
Когда какие измерения нужно проводить?
- Если нужно измерить кусок кода, то нужно использовать
performance.mark()
- Если нам нужно измерить время выполнения функции, то уже её нужно декорировать