
Проблема, которая заставила меня сделать то выступление, заключается в том, что, по-моему, система обучения Node выстроена неправильно. Большинство учебных материалов сосредоточено на пакетах Node, но не на самой платформе. Часто этих пакеты служат обёртками для модулей Node (вроде
http
или stream
). Как результат, тот, кто не знает Node и сталкивается с проблемой, источником которой может оказаться не некий пакет, а платформа, оказывается в крайне невыгодном положении.Я выбрал несколько вопросов и ответов с той конференции и включил их в эту статью. Сами вопросы представлены в заголовках разделов статьи. Попытайтесь, прочтя вопрос, не читать дальше, а сначала мысленно на него ответить. Если вы найдёте ошибку в моих ответах — пожалуйста дайте мне знать.
Вопрос №1. Что такое стек вызовов и является ли он частью движка V8?
Стек вызовов (Call Stack) определённо является частью V8. Это — структура данных, которую V8 использует для отслеживания вызовов функций. Каждый раз, когда мы вызываем функцию, V8 помещает ссылку на эту функцию в стек вызовов, а когда из этой функции вызываются другие функции, продолжает делать то же самое со ссылками на них. Кроме того, в стек попадают и функции, которые вызывают сами себя рекурсивно.

Стек вызовов. Скриншот из моего курса на Pluralsight, посвящённого продвинутому изучению Node.js
Когда, при вложенных вызовах функций, функция завершает выполнение, V8 извлекает ссылку на функцию из верхней части стека вызовов и подставляет возвращённое ей значение туда, куда требует логика программы.
Почему это важно понимать при работе с Node? Дело в том, что на один процесс Node приходится только один стек вызовов. Если стек будет полон, процесс окажется нагружен какой-то работой. Об этом стоит помнить.
Вопрос №2. Что такое цикл событий? Является ли он частью движка V8?
Как вы думаете, где на следующем рисунке изображён цикл событий (event loop)?

Окружение V8. Скриншот из моего курса на Pluralsight, посвящённого продвинутому изучению Node.js
Цикл событий реализован в библиотеке
libuv
. Он не является частью V8.Цикл событий — это сущность, которая обрабатывает внешние события и преобразует их в вызовы коллбэков. Это — довольно сложно устроенный цикл, который берёт события из очереди событий и помещает их коллбэки в стек вызовов.
Если вы впервые слышите о цикле событий, вышеприведённые рассуждения могут оказаться не особенно вразумительными. Цикл событий является частью гораздо более общей картины:

Цикл событий. Скриншот из моего курса на Pluralsight, посвящённого продвинутому изучению Node.js
Для того, чтобы понять сущность цикла событий, полезно знать о том, в какой среде он работает. Нужно понимать роль V8, знать об API Node, и о том, как работает очередь событий, код, связанный с которыми, выполняется в V8.
API Node — это функции, вроде
setTimeout
или fs.readFile
. Они не являются частью JavaScript. Это — просто функции, доступ к которым даёт нам Node.Цикл событий находится в центре всего этого (конечно, на самом деле, всё это устроено сложнее) и играет роль организатора. Когда стек вызовов V8 пуст, цикл событий может принять решение о том, что следует выполнять дальше.
Вопрос №3. Что будет делать Node, когда стек вызовов и очереди цикла событий окажутся пустыми?
Ответ прост: Node просто завершит работу.
Когда вы запускаете приложение, Node автоматически запускает цикл событий, а когда цикл событий простаивает, когда ему нечего делать, процесс завершает работу.
Для того, чтобы процесс Node не завершался, нужно поместить что-нибудь в очередь событий. Например, когда вы запускаете таймер или HTTP-сервер, вы сообщаете циклу событий о том, что ему нужно продолжать работу и следить за этими событиями.
Вопрос №4. Помимо движка V8 и библиотеки libuv, какие ещё внешние зависимости есть у Node?
Вот некоторые самостоятельные библиотеки, которые может использовать процесс Node:
http-parser
c-ares
OpenSSL
zlib
Все они, по отношению к Node, являются внешними. У них имеется собственный исходный код, их распространение регулируется отдельными лицензиями. Node просто их использует.
Об этом стоит помнить для того, чтобы знать, где именно выполняется код вашей программы. Если вы, например, занимаетесь сжатием данных, вы можете столкнуться с проблемой, которая произошла в недрах стека
zlib
. Возможно, причина — в ошибке библиотеки, поэтому не стоит валить всю вину на Node.Вопрос №5. Можно ли запустить процесс Node без V8?
Это хитрый вопрос. Для запуска процесса Node нужен JS-движок, но V8 — это не единственный доступный движок. В качестве альтернативы можно воспользоваться Chakra.
Взгляните на этот Github-репозиторий для того, чтобы узнать подробности о проекте
node-chakra
.Вопрос №6. В чём разница между module.exports и exports?
Для экспорта API модулей всегда можно пользоваться командой
module.exports
. Можно, за исключением одной ситуации, использовать и exports
:module.exports.g = ... // Ok
exports.g = ... // Ok
module.exports = ... // Ok
exports = ... // Совсем не Ok
Почему?
Команда
exports —
это просто ссылка, псевдоним для конструкции module.exports
. Когда вы пытаетесь записать что-нибудь непосредственно в exports
, вы меняете ссылку, которая там хранится, как результат, при последующих обращениях к exports
вы уже не работаете с тем, на что эта переменная ссылается в официальном API (а это — module.exports
). Записав что-нибудь в exports
, вы превращаете это ключевое слово в локальную переменную, находящуюся в области видимости модуля.Вопрос №7. Почему в модулях переменные верхнего уровня не являются глобальными?
Предположим, у вас имеется модуль
module1
, в котором определена переменная верхнего уровня g
:// module1.js
var g = 42;
Далее, есть ещё один модуль,
module2
, к которому подключают module1
и пытаются обратиться к переменной g
, получая в ответ сообщение об ошибке g is not defined
.Почему? Ведь, если сделать то же самое в браузере, то, после подключения скриптов, к их глобальным переменным обращаться можно.
Каждый файл Node оборачивается в собственное немедленно вызываемое функциональное выражение (IIFE, Immediately Invoked Function Expression). Все переменные, объявленные в файле Node, оказываются внутри этого IIFE и снаружи не видны.
Вот вопрос, связанный с рассматриваемым вопросом: что будет выведено после запуска следующего файла Node, в котором имеется лишь одна строчка кода:
// script.js
console.log(arguments);
Очевидно, в консоль попадут какие-то аргументы!

Вывод аргументов
Почему? Дело в том, что этот файл Node выполняет как функцию. Node оборачивает код в функцию и у этой функции имеется пять аргументов, которые и можно видеть на рисунке.
Вопрос №8. Объекты exports, require и module глобально доступны в каждом файле, но каждый файл имеет их собственные экземпляры. Как такое возможно?
Когда вам нужен объект
require
, вы просто вызывает его напрямую, так, как если бы он был глобальной переменной. Однако, если исследовать require
в двух разных файлах, окажется, что перед нами — два разных объекта. Почему это так?Всё дело — в уже знакомых нам IIFE:

Исследование особенностей работы Node
Как видите, IIFE передаёт коду следующие пять аргументов:
exports
, require
, module
, __filename
, и __dirname
.Эти пять переменных кажутся глобальными при использовании их в Node, но они, на самом деле, являются обычными аргументами функции.
Вопрос №9. Что такое циклические зависимости модулей в Node?
Если у вас имеется модуль
module1
, который зависит от module2
, а module2
, в свою очередь, зависит от module1
, что произойдёт? Будет выведено сообщение об ошибке?// module1
require('./module2');
// module2
require('./module1');
Никакого сообщения об ошибке не будет. Node позволяет подобное.
Итак, в
module1
подключается module2
, но так как в module2
подключается module1
, а module1
пока не полностью готов, module1
просто получит неполную версию module2
. Теперь вы об этом знаете.Вопрос №10. Когда допустимо использовать синхронные методы для работы с файловой системой (вроде readFileSync)?
Каждый асинхронный метод объекта
fs
в Node имеет синхронную версию. Зачем пользоваться синхронными методами вместо асинхронных?Иногда в синхронных методах нет ничего плохого. Например, они могут пригодиться на этапе инициализации, при загрузке сервера. Часто ими так и пользуются, когда всё, что делается после инициализации, зависит от загруженных на этапе инициализации данных. Вместо того, чтобы заниматься конструированием кода, основанного на коллбэках, в подобных ситуациях, когда выполняется единоразовая загрузка каких-либо данных, вполне приемлемы синхронные методы.
Однако, если вы пользуетесь синхронными методами внутри обработчиков неких событий, вроде коллбэка HTTP-сервера, отвечающего за обработку запросов, то это, без вариантов, совершенно неправильно. Делать так настоятельно не рекомендуется.
Итоги
Надеюсь, вы смогли ответить на все эти вопросы, или, по крайней мере, на некоторые из них.
Уважаемые читатели! Если бы вы оказались на конференции по JS, на месте автора этой статьи, какие вопросы по Node.js вы задали бы аудитории?
Комментарии (51)
Leg3nd
03.11.2017 16:32Однако, если вы пользуетесь синхронными методами внутри обработчиков неких событий, вроде коллбэка HTTP-сервера, отвечающего за обработку запросов, то это, без вариантов, совершенно неправильно. Делать так настоятельно не рекомендуется.
И почему? Если мне в обработчике события в середние последовательности действий нужно получить какие-то данные из файла, а на следующих шагах делать что-то с этими данными, мне что колбеки городить? Бред.faiwer
03.11.2017 16:54+1Вы заблокируете весь thread своим синхронным методом. А это значит, что весь JS вашего сервера на время чтения файла перестанет работать. Он будет ждать окончания чтения файла. А если у вас HTTP-сервер который должен обрабатывать множество соединений одновременно, то все эти соединения повиснут. Такое, разумеется, в большинстве случаев неприемлемо.
Gentlee
03.11.2017 16:54Бред это в однопоточном js блокировать цикл событий во время операций I/O. Более того, async/await вам в помощь, если не любите колбеки.
Leg3nd
03.11.2017 17:43+1Стоп, чтение файла происходит же в колбеке обработчике события, а сам этот колбек должен аснхронно же выполняться.
RidgeA
03.11.2017 18:05Чтение файла происходит в отдельном потоке, который вызывает callback после завершения операции.
Видимо есть недоразумение.
Есть, например, функция fs.readFile и fs.readFileSync.
Вторая работает без callback и возвращает результат операции.
Именно вторая и блокирует основной поток выполнения.
Так же есть другие fs.*Sync функцииLeg3nd
03.11.2017 18:28-1Я имелл ввиду колбек http запроса должен быть ассинхронным. Ведь если ты передаешь колбек в setTimeout он ассинхронный, если передаешь колбек в readFileSync, колбек тоже ассинхронно выполняется. Почему же когда ты передаешь колбек в http.createServer, то он выполняется синхронно, а ты вполне логично ожидаешь тут ассинхронности. Ну то есть ты ожидаешь, что весь код внутри колбека будет ассинхронно выполняться по отношению к внешнему коду.
RidgeA
03.11.2017 18:39нельзя передать callback в readFileSync
Среда выполнения — однопоточная (на самом деле нет, но программисту доступен только 1 поток). Если внутри callback асинхронной функции выполнить длительное синхронное действие (а чтение файла именно таким и может быть), то это заблокирует обработку всех остальных функций и они будут ждать своей очереди.
попробуйте выполнить что-то вроде
setTimeout(() => {while (true) {}}, 0)
это приведет к тому, что функция-callback, которая передана в setTimeout, когда до нее дойдет очередь, заблокирует основной поток навсегда (ну, до принудительного прерывания).Leg3nd
03.11.2017 18:57Перепутал, имелл ввиду readFile. Но вы правы.
Честно говоря думал что следующие два куска кода эквивалентны:
setInterval(() => { let file = fs.readFileSync( '...', // путь к очень жирному файлу {encoding: 'utf8'}); }, 0);
и вот этот:
setInterval(async () => { let file = await new Promise((resolve, reject) => { fs.readFile( '...', // путь к очень жирному файлу {encoding: 'utf8'}, (err, data) => { resolve(data); }); }); }, 0);
Но оказалось нет (Leg3nd
03.11.2017 19:20Оказывается даже вот такой код:
setInterval(async () => { let file = await new Promise((resolve, reject) => { let data = fs.readFileSync( '...', // путь к очень жирному файлу {encoding: 'utf8'}); }); resolve(data); }, 0);
Работает как первый вариант, а не как второй выше, блокируя основной поток. Офигеть…mayorovp
03.11.2017 19:57А что фигеть-то? Вам же сразу сказали: синхронные версии методов блокируют весь процесс целиком.
PaulMaly
03.11.2017 22:18Тот момент, когда побежал переписывать кучу кода за последние пару лет и закрывать весь технологический долг сразу)))
mayorovp
03.11.2017 18:50То, что он выполняется асинхронно, еще не означает что он выполняется в каком-то другом потоке кроме основного.
Leg3nd
03.11.2017 19:04Уже разобрался эксперементируюя. Но вот открываю я доку ноды для readFile и readFileSync и там ни слова об этом.
RidgeA
03.11.2017 20:58In busy processes, the programmer is strongly encouraged to use the asynchronous versions of these calls. The synchronous versions will block the entire process until they complete--halting all connections.
Leg3nd
04.11.2017 14:54Большое спасибо всем за разъяснения. Я теперь гораздо лучше понимаю этот момнет в ноде.
faiwer
03.11.2017 16:57Не совсем понятна соль 4-го вопроса. Теперь я знаю, что node использует некую
c-ares
. И что мне с этим делать? Автор пишет, что:
система обучения Node выстроена неправильно
Ок. Теперь с осознанием того, что node использует c-ares всё поменялось? :)
P.S. ответы на большую часть вопросов знал.
Varim
03.11.2017 17:34на один процесс Node приходится только один стек вызовов.
Точно на один процесс, а не на один поток?mayorovp
03.11.2017 18:46Да, точно. Решения с несколькими потоками существуют — но обычно в процессе ноды всего 1 видимый программисту поток.
Varim
03.11.2017 19:10Ага. Но я немного о другом. Поток (он же поток выполнения?) всегда имеет call stack, а возможно еще какой-то state например для работы корутин. То есть стек вызовов привязан к потоку, а не к процессу. В общем то поток это «код + стек вызовов». Ну да ладно.
mayorovp
03.11.2017 19:12В разделении потоков и процессов с точки зрения языка Javascript на платформе Node.js нет никакого смысла: эти слова обозначают одно и то же. Потому что у каждого процесса ровно 1 видимый поток.
mwizard
03.11.2017 20:03Нет, каждый активный инстанс WebWorker является отдельным нативным потоком со своим event loop.
mwizard
03.11.2017 20:04-1Что значит "только один стек вызовов на процесс"? Во-первых, многопоточность — у каждого потока свой стек вызовов и свой event loop. Во-вторых, генераторы и async-функции, которые сохраняют свой call stack между вызовами.
jakobz
04.11.2017 00:59У меня когнитивный диссонанс. Node, и «понять как работает стек» — это как-то вообще какие-то диаметрально противоположные вселенные. Нет?
Akuma
04.11.2017 01:25Ну вообще полезно знать как работает то, чем пользуетесь :)
Как минимум о цикле событий, об изолированности модулей и о том, что не нужно в однопоточном приложении блокировать поток.VolCh
04.11.2017 11:10Блокировка потока точно не специфика ноды, как и работа стека.
Akuma
04.11.2017 11:16Там выше человек утверждает, что в синхронном чтении файлов нет ничего плохого.
Вы точно увсерены, что «блокировка потока — не специфика ноды»? :)
Без понимания происходящего человек огребает кучу проблем. Я все же думаю, что подобные знания полезны как минимум.VolCh
06.11.2017 00:22Точно уверен. О том, что для однопоточных серверных приложений, обслуживающих несколько клиентов одновременно, желательно избегать блокировки потока, я знал ещё лет 20 назад.
Akuma
06.11.2017 00:26Не знаю что там было 20 лет назад. Я тогда думал как купить мороженное.
А вот в наше время при работе с Node.js желательно знать такие вещи как «блокировка потока». Хотя бы про синхронные чтения файлов или запросы к БД.
Ну, может это я так придираюсь и хочу слишком многого от сегодняшних пограмистов.VolCh
06.11.2017 22:22В наше время желательно знать такие вещи как "блокировка потока" при программировании на любом языке. JS ничем особым не выделяется.
Akuma
04.11.2017 01:24Практически не использую Ноду, но смог правильно ответить на 7 вопросов из 10.
У вас точно был полный зал разработчиков Node?Tsimur_S
04.11.2017 13:04посмотрите выше на спорящих с автором и удивление пропадёт.
Akuma
04.11.2017 13:36+1Ну ладно, там человек просто считал, что асинхронность === многопоточность. (Ну как «просто» — конечно странно работать с Node и не знать этого, ну да ладно).
Но как например можно не догадаться про ответ на вопрос 7:
Почему в модулях переменные верхнего уровня не являются глобальными?
Сейчас и браузерный JS так же работает. Webpack, по моему, уже в каждой подворотне и уж хотя бы раз, но человек видел итоговый JS, который он создает.
Vadem
05.11.2017 15:04У меня, например, похожая ситуация.
Я Ноду не использую вообще. Немного изучал её раньше.
Ответил на 9 из 10 вопросов.
Так что кажется, что автор всё-таки приувеличивает количество не ответивших.
Или просто многие не стали поднимать руки зная ответ на вопрос.
deilux
06.11.2017 03:16Всё верно же! Множества людей пишущих на ноде и не пишущих на ней — банально не пересекаются :))
Livid
06.11.2017 16:13+2Не будем переходить на личности, но есть у меня подозрение, что если уровень доклада был сравним со статьёй, люди, знающие ноду, просто тихонько слились поискать доклад поинтереснее. Потому что статья ну вообще ни о чём. От того, что я узнал, что библитоека, реализующая в ноде event loop, называется libuv, мне ни тепло ни холодно. Вряд ли те, кому это как раз не помешало бы, поймут, узнав название, что такое event loop вообще, и куда его прикладывать чтоб проняло.
Про стек вызовов вообще какая-то невнятная муть написана, и непонимающим понимания она точно не прибавит, да и я что-то в собственных знаниях засомневался, уж больно мутно.
flatscode
Думаю, что здесь не хватает дополнения о том, что чего это?
Для изоляции пространства имен с целью избежания конфликтов имен в разных модулях.
gro
Или, резюмируя — потому что, иначе, это не было бы модулем.
Holix
Да просто модули это одноразовые функции конструирующие объект :)
Разве что
return
не нужен.Или вот еще ну очень упрощённое(без сохранения объектов и распутывания петель) представление функции
require
: