В этом году, на конференции Forward.js, посвящённой JavaScript, я выступал с докладом «You don’t know Node». Во время выступления я задал аудитории несколько вопросов о Node, и большинство присутствующих не смогли ответить на многие из них. А ведь мой доклад слушали технические специалисты. Никаких подсчётов я не производил, но выглядело всё именно так, да и несколько слушателей, которые подошли ко мне после выступления, это подтвердили.


Проблема, которая заставила меня сделать то выступление, заключается в том, что, по-моему, система обучения 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)


  1. flatscode
    03.11.2017 16:09

    Вопрос №7. Почему в модулях переменные верхнего уровня не являются глобальными?

    Думаю, что здесь не хватает дополнения о том, что чего это?

    Для изоляции пространства имен с целью избежания конфликтов имен в разных модулях.


    1. gro
      03.11.2017 18:47
      +1

      Или, резюмируя — потому что, иначе, это не было бы модулем.


    1. Holix
      07.11.2017 11:36

      Да просто модули это одноразовые функции конструирующие объект :)


      (function (exports, require, module, __filename, __dirname) {
      // module code
      })(the_module.exports, require, the_module, module_file_name, module_folder);

      Разве что return не нужен.


      Или вот еще ну очень упрощённое(без сохранения объектов и распутывания петель) представление функции require:


      function require(file_name) {
          var module_text = fs.readFileSync(file_name, 'utf8'),
              module = new Module(...);
          (new Function ("exports", "require", "module", "__filename", "__dirname", module_text)) (module.exports, require, module, file_name, Path.dirname(file_name));
          return module.exports;
      }


  1. RPG18
    03.11.2017 16:20

    http-parser сделан в рамках Node


  1. Leg3nd
    03.11.2017 16:32

    Однако, если вы пользуетесь синхронными методами внутри обработчиков неких событий, вроде коллбэка HTTP-сервера, отвечающего за обработку запросов, то это, без вариантов, совершенно неправильно. Делать так настоятельно не рекомендуется.

    И почему? Если мне в обработчике события в середние последовательности действий нужно получить какие-то данные из файла, а на следующих шагах делать что-то с этими данными, мне что колбеки городить? Бред.


    1. faiwer
      03.11.2017 16:54
      +1

      Вы заблокируете весь thread своим синхронным методом. А это значит, что весь JS вашего сервера на время чтения файла перестанет работать. Он будет ждать окончания чтения файла. А если у вас HTTP-сервер который должен обрабатывать множество соединений одновременно, то все эти соединения повиснут. Такое, разумеется, в большинстве случаев неприемлемо.


    1. Gentlee
      03.11.2017 16:54

      Бред это в однопоточном js блокировать цикл событий во время операций I/O. Более того, async/await вам в помощь, если не любите колбеки.


      1. Leg3nd
        03.11.2017 17:43
        +1

        Стоп, чтение файла происходит же в колбеке обработчике события, а сам этот колбек должен аснхронно же выполняться.


        1. RidgeA
          03.11.2017 18:05

          Чтение файла происходит в отдельном потоке, который вызывает callback после завершения операции.

          Видимо есть недоразумение.
          Есть, например, функция fs.readFile и fs.readFileSync.
          Вторая работает без callback и возвращает результат операции.
          Именно вторая и блокирует основной поток выполнения.

          Так же есть другие fs.*Sync функции


          1. Leg3nd
            03.11.2017 18:28
            -1

            Я имелл ввиду колбек http запроса должен быть ассинхронным. Ведь если ты передаешь колбек в setTimeout он ассинхронный, если передаешь колбек в readFileSync, колбек тоже ассинхронно выполняется. Почему же когда ты передаешь колбек в http.createServer, то он выполняется синхронно, а ты вполне логично ожидаешь тут ассинхронности. Ну то есть ты ожидаешь, что весь код внутри колбека будет ассинхронно выполняться по отношению к внешнему коду.


            1. RidgeA
              03.11.2017 18:39

              нельзя передать callback в readFileSync

              Среда выполнения — однопоточная (на самом деле нет, но программисту доступен только 1 поток). Если внутри callback асинхронной функции выполнить длительное синхронное действие (а чтение файла именно таким и может быть), то это заблокирует обработку всех остальных функций и они будут ждать своей очереди.

              попробуйте выполнить что-то вроде

                setTimeout(() => {while (true) {}}, 0)
              


              это приведет к тому, что функция-callback, которая передана в setTimeout, когда до нее дойдет очередь, заблокирует основной поток навсегда (ну, до принудительного прерывания).


              1. 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);
                

                Но оказалось нет (


                1. Leg3nd
                  03.11.2017 19:20

                  Оказывается даже вот такой код:

                  setInterval(async () => {
                    let file = await new Promise((resolve, reject) => {
                      let data = fs.readFileSync(
                        '...', // путь к очень жирному файлу
                        {encoding: 'utf8'});
                      });
                  
                      resolve(data);
                  }, 0);
                  

                  Работает как первый вариант, а не как второй выше, блокируя основной поток. Офигеть…


                  1. mayorovp
                    03.11.2017 19:57

                    А что фигеть-то? Вам же сразу сказали: синхронные версии методов блокируют весь процесс целиком.


                  1. PaulMaly
                    03.11.2017 22:18

                    Тот момент, когда побежал переписывать кучу кода за последние пару лет и закрывать весь технологический долг сразу)))


        1. mayorovp
          03.11.2017 18:50

          То, что он выполняется асинхронно, еще не означает что он выполняется в каком-то другом потоке кроме основного.


          1. Leg3nd
            03.11.2017 19:04

            Уже разобрался эксперементируюя. Но вот открываю я доку ноды для readFile и readFileSync и там ни слова об этом.


            1. RidgeA
              03.11.2017 20:58

              nodejs.org/api/fs.html

              In 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.


              1. Leg3nd
                04.11.2017 14:54

                Большое спасибо всем за разъяснения. Я теперь гораздо лучше понимаю этот момнет в ноде.


  1. faiwer
    03.11.2017 16:57

    Не совсем понятна соль 4-го вопроса. Теперь я знаю, что node использует некую c-ares. И что мне с этим делать? Автор пишет, что:


    система обучения Node выстроена неправильно

    Ок. Теперь с осознанием того, что node использует c-ares всё поменялось? :)
    P.S. ответы на большую часть вопросов знал.


    1. PHmaster
      04.11.2017 04:49

      Ну следующий логичный шаг — загуглить, что такое c-ares, и за что отвечает, не?


      1. faiwer
        04.11.2017 11:40

        c-ares is a C library for asynchronous DNS requests (including name resolves)

        Ух ты. NodeJS умеет асинхронные DNS запросы.
        P.S. ушёл переписывать свой js-код под новые реалии


  1. Varim
    03.11.2017 17:34

    на один процесс Node приходится только один стек вызовов.
    Точно на один процесс, а не на один поток?


    1. mayorovp
      03.11.2017 18:46

      Да, точно. Решения с несколькими потоками существуют — но обычно в процессе ноды всего 1 видимый программисту поток.


      1. Varim
        03.11.2017 19:10

        Ага. Но я немного о другом. Поток (он же поток выполнения?) всегда имеет call stack, а возможно еще какой-то state например для работы корутин. То есть стек вызовов привязан к потоку, а не к процессу. В общем то поток это «код + стек вызовов». Ну да ладно.


        1. mayorovp
          03.11.2017 19:12

          В разделении потоков и процессов с точки зрения языка Javascript на платформе Node.js нет никакого смысла: эти слова обозначают одно и то же. Потому что у каждого процесса ровно 1 видимый поток.


          1. mwizard
            03.11.2017 20:03

            Нет, каждый активный инстанс WebWorker является отдельным нативным потоком со своим event loop.


            1. mayorovp
              03.11.2017 20:10

              А WebWorker на ноду уже завезли?


              1. mwizard
                03.11.2017 20:11

                нативно — пока еще нет, а в виде расширений давно. Но учитывая, как оно работает в браузерах (в том же хроме), то нет оснований предполагать, что реализация в node.js будет существенно отличаться по своей сути.


  1. sinh
    03.11.2017 18:28

    Сколько потоков (V8 и libuv) в сумме запускается в node.js процессе? Зависит ли это от ОС?


    1. mayorovp
      03.11.2017 18:47

      В v8 и libuv — ровно один. Еще есть пул потоков для асинхронного выполнения блокирующих системных вызовов.


      1. homm
        05.11.2017 17:12

        Ровно один плюс еще пул это не ровно один.


        1. mayorovp
          05.11.2017 18:18

          Сколько потоков (V8 и libuv) в сумме запускается в node.js процессе?


          1. homm
            05.11.2017 18:22
            -1

            Так сколько потоков?


  1. gro
    03.11.2017 18:42
    +2

    С нодой имею дело только ради поиграться и webpack.
    Ответил на всё, кроме 4-го. Вообще самые основы.

    >Если стек будет полон, процесс окажется нагружен какой-то работой.

    Простите, что?


    1. Varim
      03.11.2017 19:14
      -1

      Наверное имелось в виду не «полон», а «заполнен».


  1. mwizard
    03.11.2017 20:04
    -1

    Что значит "только один стек вызовов на процесс"? Во-первых, многопоточность — у каждого потока свой стек вызовов и свой event loop. Во-вторых, генераторы и async-функции, которые сохраняют свой call stack между вызовами.


  1. jakobz
    04.11.2017 00:59

    У меня когнитивный диссонанс. Node, и «понять как работает стек» — это как-то вообще какие-то диаметрально противоположные вселенные. Нет?


    1. Akuma
      04.11.2017 01:25

      Ну вообще полезно знать как работает то, чем пользуетесь :)

      Как минимум о цикле событий, об изолированности модулей и о том, что не нужно в однопоточном приложении блокировать поток.


      1. VolCh
        04.11.2017 11:10

        Блокировка потока точно не специфика ноды, как и работа стека.


        1. Akuma
          04.11.2017 11:16

          Там выше человек утверждает, что в синхронном чтении файлов нет ничего плохого.
          Вы точно увсерены, что «блокировка потока — не специфика ноды»? :)

          Без понимания происходящего человек огребает кучу проблем. Я все же думаю, что подобные знания полезны как минимум.


          1. VolCh
            06.11.2017 00:22

            Точно уверен. О том, что для однопоточных серверных приложений, обслуживающих несколько клиентов одновременно, желательно избегать блокировки потока, я знал ещё лет 20 назад.


            1. Akuma
              06.11.2017 00:26

              Не знаю что там было 20 лет назад. Я тогда думал как купить мороженное.

              А вот в наше время при работе с Node.js желательно знать такие вещи как «блокировка потока». Хотя бы про синхронные чтения файлов или запросы к БД.

              Ну, может это я так придираюсь и хочу слишком многого от сегодняшних пограмистов.


              1. VolCh
                06.11.2017 22:22

                В наше время желательно знать такие вещи как "блокировка потока" при программировании на любом языке. JS ничем особым не выделяется.


  1. Akuma
    04.11.2017 01:24

    Практически не использую Ноду, но смог правильно ответить на 7 вопросов из 10.

    У вас точно был полный зал разработчиков Node?


    1. Tsimur_S
      04.11.2017 13:04

      посмотрите выше на спорящих с автором и удивление пропадёт.


      1. Akuma
        04.11.2017 13:36
        +1

        Ну ладно, там человек просто считал, что асинхронность === многопоточность. (Ну как «просто» — конечно странно работать с Node и не знать этого, ну да ладно).

        Но как например можно не догадаться про ответ на вопрос 7:

        Почему в модулях переменные верхнего уровня не являются глобальными?

        Сейчас и браузерный JS так же работает. Webpack, по моему, уже в каждой подворотне и уж хотя бы раз, но человек видел итоговый JS, который он создает.


      1. Vadem
        05.11.2017 15:04

        У меня, например, похожая ситуация.
        Я Ноду не использую вообще. Немного изучал её раньше.
        Ответил на 9 из 10 вопросов.
        Так что кажется, что автор всё-таки приувеличивает количество не ответивших.
        Или просто многие не стали поднимать руки зная ответ на вопрос.


    1. deilux
      06.11.2017 03:16

      Всё верно же! Множества людей пишущих на ноде и не пишущих на ней — банально не пересекаются :))


  1. shuchkin
    04.11.2017 11:13
    +1

    за информацию про IIFE модулей отдельное спасибо, оказалось всё просто


  1. Livid
    06.11.2017 16:13
    +2

    Не будем переходить на личности, но есть у меня подозрение, что если уровень доклада был сравним со статьёй, люди, знающие ноду, просто тихонько слились поискать доклад поинтереснее. Потому что статья ну вообще ни о чём. От того, что я узнал, что библитоека, реализующая в ноде event loop, называется libuv, мне ни тепло ни холодно. Вряд ли те, кому это как раз не помешало бы, поймут, узнав название, что такое event loop вообще, и куда его прикладывать чтоб проняло.
    Про стек вызовов вообще какая-то невнятная муть написана, и непонимающим понимания она точно не прибавит, да и я что-то в собственных знаниях засомневался, уж больно мутно.