Когда вы начинаете писать программное обеспечение в серверной среде с помощью JavaScript, это может быть одним из самых запутанных опытов (по крайней мере, для меня это было).

Если вы не используете структурный подход, чтобы понять, как действительно работает JavaScript «за дверями», вы испытаете что-то вроде этого: вы можете подумать, что действительно хорошо понимаете этот язык.

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

А потом снова… и снова!

А потом… Снова! :О

Цель и назначение этого поста — попытаться облегчить вам, читателю, понимание того, как на самом деле обрабатывается код JavaScript в среде Node.

«Цикл событий так прост для понимания!» — буквально никто никогда этого не говорил.

Если у вас есть некоторый опыт работы с JavaScript в Node, вы можете сразу перейти к разделу «».

Небольшая предпосылка

В этом посте будут сделаны некоторые предположения, чтобы упростить чтение. Понять, что происходит «за кулисами» NodeJS, как и следовало ожидать, очень сложно. Это работа, проделанная очень увлеченными и умными людьми, и мы не можем позволить себе думать, чтобы понять все это быстро.

Итак, все объяснения, которые я здесь дам, основаны на других материалах, которые я нашел в Интернете или обнаружил лично. Все ссылки будут указаны в разделе «Ссылки» поста.

Мы попытаемся понять некоторые технические аспекты на «высоком уровне», которые помогут нам лучше понять, когда мы пишем код для NodeJS.

Общий обзор архитектуры Node.JS

Вот самые важные части Node.JS. Кратко:

  • Node.JS: кто сделал возможным написание кода JavaScript в контексте серверной части. До Node JavaScript выполнялся только в веб-браузерах.
    Это среда, которая разрешает и обрабатывает выполнение кода JavaScript, используемого на внутренних компьютерах. Это действительно похоже на то, как веб-браузеры читают и интерпретируют код JavaScript внутри себя.
  • JavaScript: язык сценариев, созданный Бренданом Эйхом. Сегодня JavaScript реализует стандарт ECMAscript (в настоящее время это версия ECMAscript-2021, выпущенная в июне 2021 года). [Википедия]
    Фактически, сначала был создан JavaScript, а затем, после его первой реализации, он превратился в стандарт, который является стандартом ECMAscript. Итак, сегодня JavaScript реализует то, что диктуют выпуски ECMAScript.
    Для любопытных людей стандарт ECMAScript не говорит о циклах событий. В нем указаны некоторые сведения о поведении Promise/async, но не говорится о деталях реализации.
  • Приложение: это код JavaScript, содержащий наше письменное приложение (исходный код JavaScript).
  • v8::Engine: тот же движок v8 в Google Chrome. Он оптимизирует код JavaScript, выводя высокопроизводительный оптимизированный байт-код, ускоряя выполнение кода.
  • Привязки Nodejs: код C++, который связывает API JavaScript Node с низкоуровневыми внешними библиотеками C++, используемыми узлом.
  • Цикл событий: изюминка в мире NodeJS. А также, на мой взгляд, менее известная часть этой среды. Мы постараемся лучше понять это в этом посте.
  • Libuv: это платформа, предназначенная для асинхронной обработки операций ввода-вывода, абстрагирующая операционную систему от реализации API-интерфейсов, предоставляемых самой структурой. Он используется узлом для обработки всех асинхронных операций.

Копаем до глубины души

Для меня всегда было поразительно видеть, что подавляющее большинство (я имею в виду по своему опыту) разработчиков Node, которых я знаю, действительно не имеют представления о процессе выполнения кода в этой среде, за исключением того, что с ключевыми словами Promises и async вы можете выполнять код в порядке, отличном от написанного.

Итак, что такое циклическое событие?

Как следует из названия, это действительно «комплексный цикл», реализованный в libuv.

Это реализация цикла событий, запрошенного в спецификациях Стандарт HTML.

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

Мы постараемся увидеть и понять только общее представление о том, что такое этот цикл событий.

Фазы цикла событий libuv внутри NodeJS

Итак, вкратце, есть такие этапы:

  • Фаза таймеров: на этом этапе будет проверено, есть ли таймеры, которые были «удовлетворены» (это означает, что их порог истек). Если это так, их обратный вызов должен быть выполнен здесь.
  • Ожидание ввода-вывода||фаза отложенных обратных вызовов: иногда во время выполнения другой фазы может случиться так, что обратный вызов будет отложен для выполнения на этой фазе.
  • Этапы Idle&Prepare: две фазы используются для внутреннего использования, поэтому мы не будем их рассматривать.
  • Этап опроса: на этом этапе будет рассчитано подходящее время ожидания. После этого будут «опрашиваться» (epool, windows, mac) события, связанные с ОС (например, завершение записи файла), а затем будут выполняться соответствующие обратные вызовы.
  • Фаза проверки: здесь будут выполняться все обратные вызовы, переданные в setImmediate().
  • Этап обратных вызовов закрытия: все обратные вызовы закрытия (например, обратные вызовы, связанные с событиями закрытия) будут выполняться на этом этапе (например, socket.on(‘close’)).

Две важные очереди

Нам нужно добавить на картинку еще два элемента, чтобы лучше понять весь процесс: очередь микрозадач и очередь следующих тиков [https://nodejs.org/api/process.html#when-to-use- очередьмикротаск-против-процессаследующий тик].

В первой очереди есть обратные вызовы от Promises, MutationObservers, queueMicrotask().

Во втором есть все обратные вызовы, заданные в качестве параметра функции process.nextTick().

Эти две очереди обрабатываются/очищаются между концом одной фазы и началом следующей фазы. И nextTickQueue обрабатывается до MicroTaskQueue.

Имейте в виду, что Promise может использовать process.nextTick(), а process.nextTick может использовать Promise. Итак, эти две очереди снова будут заполнены некоторыми обратными вызовами. Их разработка полностью прекратится, когда они оба опустеют.

В следующей главе мы попытаемся лучше понять этот процесс с помощью двух диаграмм.

Это все, что мне нужно, чтобы понять синхронизирующий/асинхронный поток и поведение кода?

Нет, не совсем, недостаточно.

Нам нужно добавить на картинку еще два элемента, чтобы лучше понять весь процесс: очередь микрозадач и очередь следующих тиков [https://nodejs.org/api/process.html#when-to-use- очередьмикротаск-против-процессаследующий тик].

В первой очереди находятся обратные вызовы от Promises, MutationObservers, queueMicrotask().

Во втором есть все обратные вызовы, которые заданы в качестве параметра функции process.nextTick().

NextTickQueue обрабатывается перед MicroTaskQueue.

Начиная с версии Node 11, реализовано исправление, в котором MicroTaskQueue и NextTickQueues запускаются также после функций setImmediate и setTimeout. Так что не только после фаз Event Loop. ["СВЯЗЬ"]

До выпуска Node 11

До Node 11 две очереди обрабатывались между «пересечением границ между Node и C++». Это означает, что были обработаны от одной фазы к другой фазе.

После выпуска Node 11

После узла 11 две очереди также обрабатываются во время разработки таймеров и фаз setImmediate для каждого разработанного обратного вызова.

Это изменение было сделано, чтобы лучше соответствовать поведению текущих основных веб-браузеров (ССЫЛКА на исходную проблему).

Легкий пример с некоторым кодом

Это пример кода, который выводит порядок выполнения функций.

‹script src=""https://gist.github.com/LolloTech/8e76ef740006852227732a3c6aeebd8b.js"'›‹/script›

До выпуска Node 11

Выход:

:17 outside setTimeout -> sync 0
:17 outside setTimeout -> sync 1
:3 setTimeout -> Timeout 1, cycle  0
:13 setTimeout -> Timeout 2, cycle  0
:3 setTimeout -> Timeout 1, cycle  1
:13 setTimeout -> Timeout 2, cycle  1
:14 -> setTimeout process tick, cycle: 0
:14 -> setTimeout process tick, cycle: 1
:6 Promise 1, cycle  0
:6 Promise 1, cycle  1
:10 Promise inside p.resolve(), cycle  0
:10 Promise inside p.resolve(), cycle  1
:7 Process.nextTick 1, cycle 0
:7 Process.nextTick 1, cycle 1
:15 setTimeout -> setImmediate, cycle 0
:15 setTimeout -> setImmediate, cycle 1

После выпуска Node 11

Выход:

:17 outside setTimeout -> sync 0
:17 outside setTimeout -> sync 1
:3 setTimeout -> Timeout 1, cycle  0
:13 setTimeout -> Timeout 2, cycle  0
:14 -> setTimeout process tick, cycle: 0
:6 Promise 1, cycle  0
:10 Promise inside p.resolve(), cycle  0
:7 Process.nextTick 1, cycle 0
:3 setTimeout -> Timeout 1, cycle  1
:13 setTimeout -> Timeout 2, cycle  1
:14 -> setTimeout process tick, cycle: 1
:6 Promise 1, cycle  1
:10 Promise inside p.resolve(), cycle  1
:7 Process.nextTick 1, cycle 1
:15 setTimeout -> setImmediate, cycle 0
:15 setTimeout -> setImmediate, cycle 1

Различия между до и после Node 11

Мы видим, что до Node 11 выполнялись таймер 1 и таймер 2 (они были двумя setTimeout), а затем будут обрабатываться nextTickQueue и microTaskQueue.

Начиная с узла 11, после разработки таймера 1, все найденные элементы nextTickQueue вместо этого обрабатывались во время той же фазы цикла события. То же самое с элементами в microTaskQueue. Только после того, как эти две очереди опустеют, обратные вызовы таймера 2 будут выбраны и обработаны.

Таким образом, фактически, после обработки одного элемента на фазе таймеров, немедленно будет выполнено выполнение элементов nextTickQueue и microTaskQueue. И сразу после этого будет обработан следующий элемент фазы Timers.

То же самое произойдет с обратными вызовами setImmediate (этап проверки).

Порядок выполнения доузла 11:

A - execution of sync code; so, we have two times the output of line 17.
// Timers phase
B - two times execution, of two setTimeout: line 3 and 13, cycle 0 and 1.
// End of timers phase
C - execution of two callbacks from nextTickQueue, line 14.
D - execution of four promises from the microTaskQueue, line 6 and 10.
E - execution of newly filled nextTickQueue from the previous Promises, line 7.
// Check phase
F - execution of the check phase, executing two setImmediate callback, line 15.
// End of check phase

Порядок выполнения после узла 11:

A - execution of sync code; so, we have two times the output of line 17.
// Timers phase, first callback
B - two times execution, of two setTimeout: line 3 and 13, cycle 0.
// End of execution of the first callback
C - execution of one element in the nextTickQueue inserted from the precedent code, line 14, cycle 0.
D - execution of two elements in the microTaskQueue inserted from the precedent code, line 6 and 10, cycle 0.
E - execution of one element in the nextTickQueue inserted from the precedent code, line 7, cycle 0.
// Timers phase, first callback
F - two times execution, of two setTimeout: line 3 and 13, cycle 1.
// End of the execution of the second callback
G - execution of one element in the nextTickQueue inserted from the precedent code, line 14, cycle 1.
H - execution of two elements in the microTaskQueue inserted from the precedent code, line 6 and 10, cycle 1.
I - execution of one element in the nextTickQueue inserted from the precedent code, line 7, cycle 1.
// End of timers phase
// Check phase
J - execution of the check phase, executing two setImmediate callback, line 15.
// End of check phase

Более подробный пример с некоторым кодом

Это пример кода, который выводит порядок выполнения функций.

‹script src=""https://gist.github.com/LolloTech/2b825b5ccb1d6a4a1952119bcb9def87.js"'›‹/script›

До выпуска Node 11

Выход:

:22 outside setTimeout -> sync 0 //kickstart
:22 outside setTimeout -> sync 1 //kickstart
:2 nextTick() inside the for 0 //kickstart
:2 nextTick() inside the for 1 //kickstart
:4 setTimeout -> Timeout 1, cycle  0 //Timer 0 phase
:18 setTimeout -> Timeout 2, cycle  0 //Timer 0 phase
:4 setTimeout -> Timeout 1, cycle  1 //Timer 1 phase
:18 setTimeout -> Timeout 2, cycle  1 //Timer 1 phase
// end timers phase
:19 -> setTimeout process tick, cycle: 0 //nextTickQueue after Timers
:19 -> setTimeout process tick, cycle: 1 //nextTickQueue after Timers
:7 Promise 1, cycle  0 //microTaskQueue after Timers
:7 Promise 1, cycle  1 //microTaskQueueafter Timers
:11 Promise inside p.resolve(), cycle  0 //microTaskQueueafter Timers
:15 Promise 2, cycle   0 //microTaskQueueafter Timers
:11 Promise inside p.resolve(), cycle  1 //microTaskQueueafter Timers
:15 Promise 2, cycle   1 //microTaskQueue after Timers
:8 Process.nextTick 1, cycle 0 //nextTickQueue after Timers
:8 Process.nextTick 1, cycle 1 //nextTickQueue after Timers
:16 Process.nextTick 2, cycle 0 //nextTickQueue after Timers
:16 Process.nextTick 2, cycle 1 //nextTickQueue after Timers
// start of check phase
:20 setTimeout -> setImmediate, cycle 0 //Check phase
:20 setTimeout -> setImmediate, cycle 1 //Check phase

После выпуска Node 11

Выход:

:22 outside setTimeout -> sync 0
:22 outside setTimeout -> sync 1
:2 nextTick() inside the for 0
:2 nextTick() inside the for 1
:4 setTimeout -> Timeout 1, cycle  0
:18 setTimeout -> Timeout 2, cycle  0
:19 -> setTimeout process tick, cycle: 0 //nextTickQueue immediate after timer 0 callback
:7 Promise 1, cycle  0 //microTaskQueue immediate after timer 0  callback
:11 Promise inside p.resolve(), cycle  0 //microTaskQueue immediate after timer 0 callback
:15 Promise 2, cycle   0 //microTaskQueue immediate after timer 0
:8 Process.nextTick 1, cycle 0
:16 Process.nextTick 2, cycle 0
:4 setTimeout -> Timeout 1, cycle  1 
:18 setTimeout -> Timeout 2, cycle  1
:19 -> setTimeout process tick, cycle: 1 //nextTickQueue immediate after timer 0 callback
:7 Promise 1, cycle  1 //microTaskQueue immediate after timer 1
:11 Promise inside p.resolve(), cycle  1 //microTaskQueue immediate after timer 1
:15 Promise 2, cycle   1 //microTaskQueue immediate after timer 1
:8 Process.nextTick 1, cycle 1
:16 Process.nextTick 2, cycle 1
:20 setTimeout -> setImmediate, cycle 0 //Check phase
:20 setTimeout -> setImmediate, cycle 1 //Check phase

Различия между до и после Node 11

Мы видим, что до Node 11 выполнялись таймер 1 и таймер 2 (они были двумя setTimeout), а затем будут обрабатываться nextTickQueue и microTaskQueue.

Начиная с узла 11, после разработки таймера 1, все найденные элементы nextTickQueue вместо этого обрабатывались во время той же фазы цикла события. То же самое с элементами в microTaskQueue. Только после того, как эти две очереди опустеют, обратные вызовы таймера 2 будут выбраны и обработаны.

Таким образом, фактически, после обработки одного элемента на фазе таймеров, немедленно будет выполнено выполнение элементов nextTickQueue и microTaskQueue. И сразу после этого будет обработан следующий элемент фазы Timers.

То же самое произойдет с обратными вызовами setImmediate (этап проверки).

Заключение

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

Спасибо, что прочитали этот пост!