016 Устройство Node.js

В начале 2000-х годов у нас были стандартный многопоточные серверы. Один поток процессора сервера выполнял один запрос от пользователя. Основная проблема заключалась в том, что 1 поток занимал 1 мб памяти и само переключение потоков требовало определённых ресурсов компьютера.

Далее проблема переходила в то, что наш поток блокировался в процессе выполнения своих операций и простаивал пока происходило логирование данных, получение данных из базы и происходил сам рендер шаблона

Так же вследствие того, что поток занимал 1 мб памяти, то при выделении большого количества потоков, у нас может появиться стопор в виде недостатка памяти (а на момент начала 21-ого века 10 гб памяти было большой цифрой)

Поэтому вдальнейшем была придумана система, при которой наш поток не блокируется

Поэтому схема, которая работала раньше, преобразуется уже в другую:

  1. Мы имеем запросы
  2. Все они поступают в основной поток, который выполняет задачи запросов
  3. Если задача достаточно тяжёлая, то она отправляется на дополнительный поток сервера (например, криптография, чтение файлов и так далее)

Так же в ноде существуют такие понятия как: стек и куча.

  1. Куча хранит в себе переменные
  2. Стек - это хранилище стека вызова функций

Конкретно тут мы видим ситуацию со схемы выше. Стек - это основной поток, который выполняет все операции. Конкретно функция setTimeout не будет блокировать все остальные операции - она вынесется в дополнительный поток, где и обработается.

node - это не однопоточный фреймворк. Это фреймворк, который имеет один основной поток (стек вызовов) и множество дополнительных потоков, в котором выполняются более затратные операции.

Составляющие NodeJS:

  1. Движок V8 (виртуальная машина JS), который разрабатывается в google и находится в хроме;
  2. Библиотека LibUV. Она реализует концепцию Event Loop, Thread Pool и асинхронный ввод/вывод;
  3. Стандартная библиотека. Она содержит в себе функциональность работы с файловой системой, криптографией, ивентами и всеми остальными функциями ноды (аналог - WebAPI);
  4. Далее наш код будет биндиться в C/C++ код, который уже будет выполнять эффективные операции над нашими операциями;
  5. Так же мы можем подключать C/C++ аддоны к нашему приложению;
  6. 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("Конец");

Тут уже нужно описать несколько моментов:

  1. Большая операция. Мы имеем крайне тяжеловесную операцию, которая выполняется самой первой, так как по времени она зарезолвлена сразу. Из-за неё фаза стопорится и прошлый таймер, так как он уже проверен был, выполнится намного позже - только на следующем заходе в новую фазу.
  2. Промис. Он выполнился на этапе 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(), который выполняется вместе с промисом после каждой фазы. Сама тиковая операция очень важна, когда мы совершаем те же htttp-реквесты или для регистрации обработчиков некоторых событий.

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()
  • Если нам нужно измерить время выполнения функции, то уже её нужно декорировать