Руководство по Node.js, часть 6: цикл событий, стек вызовов, таймеры

https://medium.freecodecamp.org/the-definitive-node-js-handbook-6912378afc6e
  • Перевод
Сегодня, в шестой части перевода руководства по Node.js, мы поговорим о цикле событий, о стеке вызовов, о функции process.nextTick(), о таймерах. Понимание этих и других механизмов Node.js является одной из основ успешной разработки приложений для этой платформы.




Цикл событий


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

Я многие годы занимался разработкой на JavaScript, но не могу сказать, что полностью понимал то, как всё функционирует, так сказать, «под капотом». Программист вполне может не знать о тонкостях устройства внутренних подсистем среды, в которой он работает. Но обычно полезно иметь хотя бы общее представление о подобных вещах.

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

Фактически, JS-программисту нужно обращать внимание только на то, какие именно действия выполняет его код, и стараться при этом избежать ситуаций, вызывающих блокировку главного потока. Например — выполнения сетевых вызовов в синхронном режиме и бесконечных циклов.

Обычно в браузерах, в каждой открытой вкладке, имеется собственный цикл событий. Это позволяет выполнять код каждой страницы в изолированной среде и избегать ситуаций, когда некая страница, в коде которой имеется бесконечный цикл или выполняются тяжёлые вычисления, способна «подвесить» весь браузер. Браузер поддерживает работу множества одновременно существующих циклов событий, используемых, например, для обработки вызовов к различным API. Кроме того, собственный цикл событий используется для обеспечения работы веб-воркеров.

Самое важное, что надо постоянно помнить JavaScript-программисту, заключается в том, что его код использует собственный цикл событий, поэтому код надо писать с учётом того, чтобы этот цикл событий не заблокировать.

Блокировка цикла событий


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

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

Стек вызовов


Стек вызовов (Call Stack) в JavaScript устроен по принципу LIFO (Last In, First Out — последним вошёл, первым вышел). Цикл событий постоянно проверяет стек вызовов на предмет того, имеется ли в нём функция, которую нужно выполнить. Если при выполнении кода в нём встречается вызов некоей функции, сведения о ней добавляются в стек вызовов и производится выполнение этой функции.

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


Сообщение об ошибке в браузере

Браузер, при возникновении ошибки, сообщает о последовательности вызовов функций, сведения о которых хранятся в стеке вызовов, что позволяет обнаружить источник ошибки и понять, вызовы каких функций привели к сложившейся ситуации.

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

Цикл событий и стек вызовов


Вот код, с которым мы будем экспериментировать:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  bar()
  baz()
}
foo()

Если этот код выполнить, в консоль попадёт следующее:

foo
bar
baz

Такой результат вполне ожидаем. А именно, когда этот код запускают, сначала вызывается функция foo(). Внутри этой функции мы сначала вызываем функцию bar(), а потом — baz(). При этом стек вызовов в ходе выполнения этого кода претерпевает изменения, показанные на следующем рисунке.


Изменение состояния стека вызовов при выполнении исследуемого кода

Цикл событий, на каждой итерации, проверяет, есть ли что-нибудь в стеке вызовов, и если это так — выполняет это до тех пор, пока стек вызовов не опустеет.


Итерации цикла событий

Постановка функции в очередь на выполнение


Вышеприведённый пример выглядит вполне обычным, в нём нет ничего особенного: JavaScript находит код, который надо выполнить, и выполняет его по порядку. Поговорим о том, как отложить выполнение функции до момента очистки стека вызовов. Для того чтобы это сделать, используется такая конструкция:

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

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

Рассмотрим пример:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}
foo()

То, что выведет этот код, возможно, покажется неожиданным:

foo
baz
bar

Когда мы запускаем этот пример, сначала вызывается функция foo(). В ней мы вызываем setTimeout(), передавая этой функции, в качестве первого аргумента, bar. Передав ей в качестве второго аргумента 0, мы сообщаем системе о том, что эту функцию следует выполнить как можно скорее. Затем мы вызываем функцию baz().

Вот как теперь будет выглядеть стек вызовов.


Изменение состояния стека вызовов при выполнении исследуемого кода

Вот в каком порядке теперь будут выполняться функции в нашей программе.


Итерации цикла событий

Почему всё происходит именно так?

Очередь событий


Когда вызывается функция setTimeout(), браузер или платформа Node.js запускает таймер. После того, как таймер сработает (в нашем случае это происходит немедленно, так как мы установили его на 0), функция обратного вызова, переданная setTimeout(), попадает в очередь событий (Event Queue).

В очередь событий, если речь идёт о браузере, попадают и события, инициированные пользователем — события, вызванные щелчками мышью по элементам страницы, события, вызываемые при вводе данных с клавиатуры. Тут же оказываются обработчики событий DOM вроде onload, функции, вызываемые при получении ответов на асинхронные запросы по загрузке данных. Здесь они ждут своей очереди на обработку.

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

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

Очередь заданий ES6


В ECMAScript 2015 (ES6) была введена концепция очереди заданий (Job Queue), которой пользуются промисы (они тоже появились в ES6). Благодаря очереди заданий результатом выполнения асинхронной функции можно воспользоваться настолько быстро, насколько это возможно, без необходимости ожидания очищения стека вызовов.

Если промис разрешается до окончания выполнения текущей функции, соответствующий код будет выполнен сразу после того, как текущая функция завершит работу.

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

Рассмотрим следующий пример:

const bar = () => console.log('bar')
const baz = () => console.log('baz')
const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('should be right after baz, before bar')
  ).then(resolve => console.log(resolve))
  baz()
}
foo()

Вот что будет выведено после его выполнения:

foo
baz
should be right after baz, before bar
bar

То, что тут можно видеть, демонстрирует серьёзное различие промисов (и конструкции async/await, которая на них основана) и традиционных асинхронных функций, выполнение которых организуется посредством setTimeout() или других API используемой платформы.

process.nextTick()


Метод process.nextTick() по-особому взаимодействует с циклом событий. Тиком (tick) называют один полный проход цикла событий. Передавая функцию методу process.nextTick(), мы сообщаем системе о том, что эту функцию нужно вызвать после завершения текущей итерации цикла событий, до начала следующей. Использование данного метода выглядит так:

process.nextTick(() => {
  //выполнить какие-то действия
})

Предположим, цикл событий занят выполнением кода текущей функции. Когда эта операция завершается, JavaScript-движок выполнит все функции, переданные process.nextTick() в ходе выполнения предыдущей операции. Используя этот механизм, мы стремимся к тому, чтобы некая функция была бы выполнена асинхронно (после текущей функции), но как можно скорее, без постановки её в очередь.

Например, если воспользоваться конструкцией setTimeout(() => {}, 0) функция будет выполнена на следующей итерации цикла событий, то есть — гораздо позже, чем при использовании в такой же ситуации process.nextTick(). Этот метод стоит использовать тогда, когда нужно обеспечить выполнение некоего кода в самом начале следующей итерации цикла событий.

setImmediate()


Ещё одной функцией, предоставляемой Node.js для асинхронного выполнения кода, является setImmediate(). Вот как ей пользоваться:

setImmediate(() => {
  //выполнить некий код
})

Функция обратного вызова, переданная setImmediate(), будет выполнена на следующей итерации цикла событий.

Чем setImmediate() отличается от setTimeout(() => {}, 0) (то есть, от таймера, который должен сработать как можно скорее) и от process.nextTick()?

Функция, переданная process.nextTick() выполнится после завершения текущей итерации цикла событий. То есть, такая функция всегда будет выполняться до функции, выполнение которой запланировано с помощью setTimeout() или setImmediate().

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

Таймеры


Выше мы уже говорили о функции setTimeout(), которая позволяет планировать вызовы передаваемых ей коллбэков. Уделим некоторое время более подробному описанию её особенностей и рассмотрим ещё одну функцию, setInterval(), схожую с ней. В Node.js функции для работы с таймерами входят в модуль timer, но пользоваться ими можно, не подключая этот модуль в коде, так как они являются глобальными.

▍Функция setTimeout()


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

setTimeout(() => {
  // выполняется через 2 секунды
}, 2000)
setTimeout(() => {
  // выполняется через 50 миллисекунд
}, 50)

Здесь мы передаём setTimeout() новую функцию, тут же описываемую, но здесь можно использовать и существующую функцию, передавая setTimeout() её имя и набор параметров для её запуска. Выглядит это так:

const myFunction = (firstParam, secondParam) => {
  //выполнить некий код
}
// выполняется через 2 секунды
setTimeout(myFunction, 2000, firstParam, secondParam)

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

const id = setTimeout(() => {
  // этот код должен выполниться через 2 секунды
}, 2000)
// Программист передумал, выполнять этот код больше не нужно
clearTimeout(id)

▍Нулевая задержка


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

setTimeout(() => {
  console.log('after ')
}, 0)
console.log(' before ')

Такой код выведет следующее:

before
after

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

Если вспомнить о вышеупомянутой функции setImmediate(), то в Node.js она является стандартной, чего нельзя сказать о браузерах (в IE и Edge она реализована, в других — нет).

▍Функция setInterval()


Функция setInterval() похожа на setTimeout(), но между ними есть и различия. Вместо однократного выполнения переданного ей коллбэка setInterval() будет периодически, с заданным интервалом, вызывать этот коллбэк. Продолжаться это будет, в идеале, до того момента, пока программист явным образом не остановит этот процесс. Вот как пользоваться этой функцией:

setInterval(() => {
  // выполняется каждые 2 секунды
}, 2000)

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

const id = setInterval(() => {
  // выполняется каждые 2 секунды
}, 2000)
clearInterval(id)

Распространённой методикой является вызов clearInterval() внутри коллбэка, переданного setInterval() при выполнении некоего условия. Например, следующий код будет периодически запускаться до тех пор, пока свойство App.somethingIWait не примет значение arrived:

const interval = setInterval(function() {
  if (App.somethingIWait === 'arrived') {
    clearInterval(interval)
    // если условие выполняется - удалим таймер, если нет - выполним некие действия
  }
}, 100)

▍Рекурсивная установка setTimeout()


Функция setInterval() будет вызывать переданный ей коллбэк каждые n миллисекунд, не заботясь о том, завершилось ли выполнение этого коллбэка после его предыдущего вызова.

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


Периодически вызываемый коллбэк, каждый сеанс выполнения которого занимает одно и то же время, укладывающееся в промежуток между вызовами

Возможно, для выполнения коллбэка каждый раз требуется разное время, которое всё ещё меньше n. Если, например, речь идёт о выполнении неких сетевых операций, то такая ситуация вполне ожидаема.


Периодически вызываемый коллбэк, каждый сеанс выполнения которого занимает разное время, укладывающееся в промежуток между вызовами

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


Периодически вызываемый коллбэк, каждый сеанс выполнения которого занимает разное время, которое иногда не укладывается в промежуток между вызовами

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

const myFunction = () => {
  // выполнить некие действия
  setTimeout(myFunction, 1000)
}
setTimeout(
  myFunction()
}, 1000)

При таком подходе можно реализовать следующий сценарий:


Рекурсивный вызов setTimeout() для планирования выполнения коллбэка

Итоги


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

Уважаемые читатели! Сталкивались ли вы с ситуациями, когда вам приходилось использовать process.nextTick()?

  • +35
  • 11,6k
  • 3

RUVDS.com

805,00

RUVDS – хостинг VDS/VPS серверов

Поделиться публикацией
Комментарии 3
    0
    Ни разу не приходилось. Но знаю, так как спрашивают на собеседованиях. :)
      +3
      Технически, никто не гарантирует, что код внутри setTimeout будет выполнен через 2 секунды.
      Как минимум через 2 секунды колбэк будет поставлен в стек для исполнения в первой фазе timers следующей итерации цикла событий.
      А вот setImmediate как раз выполнится в той же итерации после фазы poll.

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


        Здесь www.youtube.com/watch?v=j4_9BZezSUA уважаемый дядька говорит, что ровно наоборот — микротаск (или «задание» в статье) дождётся очищения call-stack-а.

        Где правда?)

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое