Изучение внутреннего устройства Node.js

Опубликовано: 2022-03-10
Краткое резюме ↬ Node.js — интересный инструмент для веб-разработчиков. Благодаря высокому уровню параллелизма он стал ведущим кандидатом для людей, выбирающих инструменты для использования в веб-разработке. В этой статье мы узнаем, из чего состоит Node.js, дадим ему осмысленное определение, поймем, как внутренние компоненты Node.js взаимодействуют друг с другом, и изучим репозиторий проекта для Node.js на GitHub.

С момента представления Node.js Райаном Далем на европейской конференции JSConf 8 ноября 2009 года он получил широкое распространение в технологической отрасли. Такие компании, как Netflix, Uber и LinkedIn, доверяют утверждению о том, что Node.js может выдерживать большой объем трафика и параллелизм.

Вооружившись базовыми знаниями, начинающие и средние разработчики Node.js борются со многими проблемами: «Это просто среда выполнения!» «У него есть циклы событий!» «Node.js — однопоточный, как JavaScript!»

Хотя некоторые из этих утверждений верны, мы углубимся в среду выполнения Node.js, чтобы понять, как она запускает JavaScript, посмотрим, является ли она на самом деле однопоточной, и, наконец, лучше поймем взаимосвязь между ее основными зависимостями, V8 и libuv. .

Предпосылки

  • Базовые знания JavaScript
  • Знакомство с семантикой Node.js ( require , fs )

Что такое Node.js?

Может показаться заманчивым предположить, что многие люди думают о Node.js, наиболее распространенное определение которого состоит в том, что это среда выполнения для языка JavaScript . Чтобы рассмотреть это, мы должны понять, что привело к такому выводу.

Node.js часто называют комбинацией C++ и JavaScript. Часть C++ состоит из привязок, выполняющих низкоуровневый код, которые позволяют получить доступ к оборудованию, подключенному к компьютеру. Часть JavaScript использует JavaScript в качестве исходного кода и запускает его в популярном интерпретаторе языка, называемом движком V8.

При таком понимании мы могли бы описать Node.js как уникальный инструмент, сочетающий JavaScript и C++ для запуска программ вне среды браузера.

Но можем ли мы на самом деле назвать это средой выполнения? Чтобы определить это, давайте определим, что такое среда выполнения.

В одном из своих ответов на StackOverflow DJNA определяет среду выполнения как «все, что вам нужно для выполнения программы, но без инструментов для ее изменения». В соответствии с этим определением мы можем с уверенностью сказать, что все, что происходит, пока мы запускаем наш код (на любом языке), выполняется в среде выполнения.

Другие языки имеют свою собственную среду выполнения. Для Java это среда выполнения Java (JRE). Для .NET это общеязыковая среда выполнения (CLR). Для Erlang это BEAM.

Тем не менее, некоторые из этих сред выполнения имеют другие языки, которые зависят от них. Например, в Java есть Kotlin, язык программирования, который компилируется в код, понятный JRE. В Эрланге есть Эликсир. И мы знаем, что существует множество вариантов разработки .NET, которые все выполняются в среде CLR, известной как .NET Framework.

Теперь мы понимаем, что среда выполнения — это среда, предназначенная для успешного выполнения программы, и мы знаем, что V8 и множество библиотек C++ позволяют выполнять приложение Node.js. Сам Node.js является реальной средой выполнения, которая связывает все вместе, чтобы сделать эти библиотеки единым целым, и понимает только один язык — JavaScript — независимо от того, на чем построен Node.js.

Еще после прыжка! Продолжить чтение ниже ↓

Внутренняя структура Node.js

Когда мы пытаемся запустить программу Node.js (например, index.js ) из нашей командной строки с помощью командного node index.js , мы вызываем среду выполнения Node.js. Эта среда выполнения, как уже упоминалось, состоит из двух независимых зависимостей, V8 и libuv.

Основные зависимости Node.js
Основные зависимости Node.js (большая предварительная версия)

V8 — это проект, созданный и поддерживаемый Google. Он берет исходный код JavaScript и запускает его вне среды браузера. Когда мы запускаем программу через команду node , исходный код передается средой выполнения Node.js в V8 для выполнения.

Библиотека libuv содержит код C++, обеспечивающий низкоуровневый доступ к операционной системе. Такие функции, как работа в сети, запись в файловую систему и параллелизм, по умолчанию не поставляются в версии 8, которая является частью Node.js, на которой выполняется наш код JavaScript. Благодаря набору библиотек libuv предоставляет эти и другие утилиты в среде Node.js.

Node.js — это связующее звено между двумя библиотеками, что делает их уникальным решением. Во время выполнения скрипта Node.js понимает, какому проекту передать управление и когда.

Интересные API для серверных программ

Если мы немного изучим историю JavaScript, мы узнаем, что он предназначен для добавления некоторой функциональности и взаимодействия со страницей в браузере. А в браузере мы бы взаимодействовали с элементами объектной модели документа (DOM), из которых состоит страница. Для этого существует набор API, которые в совокупности называются DOM API.

DOM существует только в браузере; это то, что анализируется для отображения страницы, и в основном это написано на языке разметки, известном как HTML. Кроме того, браузер существует в окне, следовательно, объект window действует как корень для всех объектов на странице в контексте JavaScript. Эта среда называется средой браузера и представляет собой среду выполнения для JavaScript.

API-интерфейсы Node.js вызывают libuv для некоторых функций
API-интерфейсы Node.js взаимодействуют с libuv (большой предварительный просмотр)

В среде Node.js у нас нет ни страницы, ни браузера — это сводит на нет наше знание глобального объекта окна. Что у нас есть, так это набор API, которые взаимодействуют с операционной системой для предоставления дополнительных функций программе JavaScript. Эти API для Node.js ( fs , path , buffer , events , HTTP и т. д.), как они у нас есть, существуют только для Node.js, и они предоставляются Node.js (сама среда выполнения), так что мы может запускать программы, написанные для Node.js.

Эксперимент: как fs.writeFile создает новый файл

Если V8 был создан для запуска JavaScript вне браузера и если среда Node.js не имеет того же контекста или среды, что и браузер, то как бы мы сделали что-то вроде доступа к файловой системе или создания HTTP-сервера?

В качестве примера возьмем простое приложение Node.js, которое записывает файл в файловую систему в текущем каталоге:

 const fs = require("fs") fs.writeFile("./test.txt", "text");

Как показано, мы пытаемся записать новый файл в файловую систему. Эта функция недоступна в языке JavaScript; он доступен только в среде Node.js. Как это выполняется?

Чтобы понять это, давайте ознакомимся с кодовой базой Node.js.

Перейдя к репозиторию GitHub для Node.js, мы видим две основные папки: src и lib . В папке lib есть код JavaScript, предоставляющий хороший набор модулей, которые по умолчанию включаются в каждую установку Node.js. Папка src содержит библиотеки C++ для libuv.

Если мы заглянем в папку lib и просмотрим файл fs.js , мы увидим, что он полон впечатляющего кода JavaScript. В строке 1880 мы заметим оператор exports . Этот оператор экспортирует все, к чему мы можем получить доступ, импортировав модуль fs , и мы видим, что он экспортирует функцию с именем writeFile .

Поиск function writeFile( (где функция определена) приводит нас к строке 1303, где мы видим, что функция определена с четырьмя параметрами:

 function writeFile(path, data, options, callback) { callback = maybeCallback(callback || options); options = getOptions(options, { encoding: 'utf8', mode: 0o666, flag: 'w' }); const flag = options.flag || 'w'; if (!isArrayBufferView(data)) { validateStringAfterArrayBufferView(data, 'data'); data = Buffer.from(data, options.encoding || 'utf8'); } if (isFd(path)) { const isUserFd = true; writeAll(path, isUserFd, data, 0, data.byteLength, callback); return; } fs.open(path, flag, options.mode, (openErr, fd) => { if (openErr) { callback(openErr); } else { const isUserFd = false; writeAll(fd, isUserFd, data, 0, data.byteLength, callback); } }); }

В строках 1315 и 1324 мы видим, что единственная функция writeAll вызывается после некоторых проверок. Мы находим эту функцию в строке 1278 в том же файле fs.js

 function writeAll(fd, isUserFd, buffer, offset, length, callback) { // write(fd, buffer, offset, length, position, callback) fs.write(fd, buffer, offset, length, null, (writeErr, written) => { if (writeErr) { if (isUserFd) { callback(writeErr); } else { fs.close(fd, function close() { callback(writeErr); }); } } else if (written === length) { if (isUserFd) { callback(null); } else { fs.close(fd, callback); } } else { offset += written; length -= written; writeAll(fd, isUserFd, buffer, offset, length, callback); } }); }

Также интересно отметить, что этот модуль пытается вызвать сам себя. Мы видим это в строке 1280, где вызывается fs.write . В поисках функции write мы обнаружим немного информации.

Функция write начинается со строки 571 и занимает около 42 строк. Мы видим повторяющийся шаблон в этой функции: то, как она вызывает функцию в модуле binding , как показано в строках 594 и 612. Функция в модуле binding вызывается не только в этой функции, но практически в любой экспортируемой функции. в файле fs.js В нем должно быть что-то особенное.

Переменная binding объявлена ​​в строке 58, в самом верху файла, и щелчок по вызову этой функции раскрывает некоторую информацию с помощью GitHub.

Объявление переменной привязки
Объявление переменной привязки (большой предварительный просмотр)

Эта функция internalBinding находится в модуле loaders. Основная функция модуля загрузчиков — загрузить все библиотеки libuv и подключить их через проект V8 с Node.js. Как это происходит, довольно волшебно, но чтобы узнать больше, мы можем внимательно изучить функцию writeBuffer , которая вызывается модулем fs .

Мы должны посмотреть, где это соединяется с libuv и где появляется V8. В верхней части модуля загрузчиков в хорошей документации говорится следующее:

 // This file is compiled and run by node.cc before bootstrap/node.js // was called, therefore the loaders are bootstraped before we start to // actually bootstrap Node.js. It creates the following objects: // // C++ binding loaders: // - process.binding(): the legacy C++ binding loader, accessible from user land // because it is an object attached to the global process object. // These C++ bindings are created using NODE_BUILTIN_MODULE_CONTEXT_AWARE() // and have their nm_flags set to NM_F_BUILTIN. We do not make any guarantees // about the stability of these bindings, but still have to take care of // compatibility issues caused by them from time to time. // - process._linkedBinding(): intended to be used by embedders to add // additional C++ bindings in their applications. These C++ bindings // can be created using NODE_MODULE_CONTEXT_AWARE_CPP() with the flag // NM_F_LINKED. // - internalBinding(): the private internal C++ binding loader, inaccessible // from user land unless through `require('internal/test/binding')`. // These C++ bindings are created using NODE_MODULE_CONTEXT_AWARE_INTERNAL() // and have their nm_flags set to NM_F_INTERNAL. // // Internal JavaScript module loader: // - NativeModule: a minimal module system used to load the JavaScript core // modules found in lib/**/*.js and deps/**/*.js. All core modules are // compiled into the node binary via node_javascript.cc generated by js2c.py, // so they can be loaded faster without the cost of I/O. This class makes the // lib/internal/*, deps/internal/* modules and internalBinding() available by // default to core modules, and lets the core modules require itself via // require('internal/bootstrap/loaders') even when this file is not written in // CommonJS style.

Здесь мы узнаем, что для каждого модуля, вызываемого из объекта binding в разделе JavaScript проекта Node.js, есть его эквивалент в разделе C++ в папке src .

Из нашего fs мы видим, что модуль, который это делает, находится в node_file.cc . Каждая функция, доступная через модуль, определена в файле; например, у нас есть writeBuffer в строке 2258. Фактическое определение этого метода в файле C++ находится в строке 1785. Кроме того, вызов той части libuv, которая выполняет фактическую запись в файл, можно найти в строках 1809 и 1815, где функция uv_fs_write вызывается асинхронно.

Что мы получаем от этого понимания?

Как и многие другие среды выполнения интерпретируемых языков, среда выполнения Node.js может быть взломана. С большим пониманием мы могли бы делать то, что невозможно со стандартным дистрибутивом, просто просматривая исходный код. Мы могли бы добавить библиотеки, чтобы внести изменения в способ вызова некоторых функций. Но, прежде всего, это понимание является основой для дальнейших исследований.

Является ли Node.js однопоточным?

На основе libuv и V8 Node.js имеет доступ к некоторым дополнительным функциям, которых нет у типичного движка JavaScript, работающего в браузере.

Любой JavaScript, который запускается в браузере, будет выполняться в одном потоке. Поток при выполнении программы подобен черному ящику, расположенному поверх процессора, в котором выполняется программа. В контексте Node.js некоторый код может выполняться в таком количестве потоков, сколько могут нести наши машины.

Чтобы проверить это конкретное утверждение, давайте рассмотрим простой фрагмент кода.

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test.txt", "test", (err) => { If (error) { console.log(err) } console.log("1 Done: ", Date.now() — startTime) });

В приведенном выше фрагменте мы пытаемся создать новый файл на диске в текущем каталоге. Чтобы увидеть, сколько времени это может занять, мы добавили небольшой тест для отслеживания времени запуска скрипта, который дает нам продолжительность в миллисекундах скрипта, создающего файл.

Если мы запустим код выше, мы получим такой результат:

Результат времени, необходимого для создания одного файла в Node.js
Время, затраченное на создание одного файла в Node.js (большой предварительный просмотр)
 $ node ./test.js -> 1 Done: 0.003s

Это очень впечатляет: всего 0,003 секунды.

Но давайте сделаем что-нибудь действительно интересное. Сначала давайте продублируем код, создающий новый файл, и обновим число в операторе журнала, чтобы отразить их позиции:

 const fs = require("fs"); // A little benchmarking const startTime = Date.now() fs.writeFile("./test1.txt", "test", function (err) { if (err) { console.log(err) } console.log("1 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test2.txt", "test", function (err) { if (err) { console.log(err) } console.log("2 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test3.txt", "test", function (err) { if (err) { console.log(err) } console.log("3 Done: %ss", (Date.now() — startTime) / 1000) }); fs.writeFile("./test4.txt", "test", function (err) { if (err) { console.log(err) } console.log("4 Done: %ss", (Date.now() — startTime) / 1000) });

Если мы попытаемся запустить этот код, мы получим нечто, что поразит нас. Вот мой результат:

Результат времени, необходимого для создания нескольких файлов
Создание множества файлов одновременно (Большой предварительный просмотр)

Во-первых, мы заметим, что результаты не согласованы. Во-вторых, мы видим, что время увеличилось. Что творится?

Низкоуровневые задачи делегируются

Как мы теперь знаем, Node.js является однопоточным. Часть Node.js написана на JavaScript, а часть — на C++. Node.js использует те же концепции цикла событий и стека вызовов, с которыми мы знакомы из среды браузера, а это означает, что JavaScript-части Node.js являются однопоточными. Но низкоуровневая задача, требующая общения с операционной системой, не является однопоточной.

Низкоуровневые задачи делегируются ОС через libuv
Низкоуровневое делегирование задач Node.js (большая предварительная версия)

Когда Node.js распознает вызов как предназначенный для libuv, он делегирует эту задачу libuv. В своей работе libuv требует потоков для некоторых своих библиотек, отсюда и использование пула потоков при выполнении программ Node.js, когда они необходимы.

По умолчанию пул потоков Node.js, предоставляемый libuv, содержит четыре потока. Мы могли бы увеличить или уменьшить этот пул потоков, вызвав process.env.UV_THREADPOOL_SIZE в верхней части нашего скрипта.

 // script.js process.env.UV_THREADPOOL_SIZE = 6; // … // …

Что происходит с нашей программой для создания файлов

Похоже, что как только мы вызываем код для создания нашего файла, Node.js обращается к части своего кода libuv, которая выделяет поток для этой задачи. Этот раздел в libuv получает некоторую статистическую информацию о диске перед работой с файлом.

Эта статистическая проверка может занять некоторое время; следовательно, поток освобождается для некоторых других задач, пока статистическая проверка не будет завершена. Когда проверка завершена, секция libuv занимает любой доступный поток или ждет, пока поток не станет доступным для него.

У нас есть только четыре вызова и четыре потока, поэтому потоков достаточно для обхода. Вопрос только в том, насколько быстро каждый поток будет обрабатывать свою задачу. Мы заметим, что первый код, попавший в пул потоков, первым вернет свой результат и блокирует все остальные потоки во время выполнения своего кода.

Заключение

Теперь мы понимаем, что такое Node.js. Мы знаем, что это время выполнения. Мы определили, что такое среда выполнения. И мы глубоко изучили, что составляет среду выполнения, предоставляемую Node.js.

Мы прошли долгий путь. И из нашего небольшого тура по репозиторию Node.js на GitHub мы можем изучить любой интересующий нас API, следуя тому же процессу, который мы использовали здесь. Node.js имеет открытый исходный код, поэтому мы, конечно же, можем погрузиться в исходный код, не так ли?

Несмотря на то, что мы затронули несколько нижних уровней того, что происходит в среде выполнения Node.js, мы не должны предполагать, что знаем все. Приведенные ниже ресурсы указывают на некоторую информацию, на которой мы можем построить наши знания:

  • Введение в Node.js
    Будучи официальным веб-сайтом, Node.dev объясняет, что такое Node.js, а также его менеджеры пакетов и перечисляет веб-фреймворки, созданные на его основе.
  • «JavaScript и Node.js», книга для начинающих по Node.
    Эта книга Мануэля Кисслинга прекрасно объясняет Node.js после предупреждения о том, что JavaScript в браузере отличается от JavaScript в Node.js, хотя оба они написаны на одном языке.
  • Начало Node.js
    Эта книга для начинающих выходит за рамки объяснения среды выполнения. В нем рассказывается о пакетах и ​​потоках, а также о создании веб-сервера с помощью среды Express.
  • LibUV
    Это официальная документация по вспомогательному коду C++ среды выполнения Node.js.
  • V8
    Это официальная документация движка JavaScript, позволяющая писать Node.js с помощью JavaScript.