Разработка сайтов в Личисанске, ЛНР. Цикл событий Node.js: руководство разработчика по концепциям и коду

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

JavaScript является однопоточным, но ограничивает ли это Node использование современной архитектуры? Одной из самых больших проблем является работа с несколькими потоками из-за присущей им сложности. Запуск новых потоков и управление переключением контекста между ними обходится дорого. И операционная система, и программист должны проделать большую работу, чтобы предоставить решение, имеющее множество пограничных случаев. В этом примере я покажу вам, как Node справляется с этой трясиной через цикл обработки событий. Я рассмотрю каждую часть цикла событий Node.js и продемонстрирую, как это работает. Эта петля является одной из «убойных функций» в Node, потому что она решает сложную проблему радикально новым способом.

Что такое цикл событий?

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

Сам цикл является полубесконечным, то есть, если стек вызовов или очередь обратного вызова пусты, он может выйти из цикла. Думайте о стеке вызовов как о синхронном коде, который раскручивается, например console.log, до того, как цикл запрашивает дополнительную работу. Node использует libuv под прикрытием для опроса операционной системы на наличие обратных вызовов от входящих подключений.

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

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

Достаточно теории; пора посмотреть, как это выглядит в коде. Не стесняйтесь следовать в REPL или загрузить исходный код.

Полубесконечный цикл

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

Вот пример, который блокирует основной цикл:

setTimeout (

() => console.log ('Hi from the callback queue’),

5000) ; // Keep the loop alive for this long

const stopTime = Date.now () + 2000;

while (Date.now () < stopTime) {} // Block the main loop

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

Очередь обратного звонка

Что произойдет, если я заблокирую основной цикл, а затем запланирую обратный вызов? Как только цикл блокируется, он больше не ставит обратные вызовы в очередь:

const stopTime = Date.now () + 2000;

while (Date.now () < stopTime) {} // Block the main loop

// This takes 7 secs to execute

setTimeout (() => console.log ('Ran callback A’), 5000) ;

На этот раз цикл остается активным в течение семи секунд. Цикл событий глуп в своей простоте. У него нет возможности узнать, что может оказаться в очереди в будущем. В реальной системе входящие обратные вызовы ставятся в очередь и выполняются, поскольку основной цикл свободен для опроса. Цикл событий последовательно проходит несколько фаз, когда он разблокирован. Итак, чтобы пройти собеседование о петле, избегайте причудливого жаргона, такого как «эмиттер событий» или «шаблон реактора». Это скромный однопоточный цикл, параллельный и неблокирующий.

Цикл событий с async/await

Чтобы избежать блокировки основного цикла, одна из идей состоит в том, чтобы обернуть синхронный ввод-вывод вокруг async/await:

const fs = require ('fs’) ;

const readFileSync = async (path) => await fs.readFileSync (path) ;

readFileSync ('readme.md’).then ((data) => console.log (data));

console.log ('The event loop continues without blocking...') ;

Все, что идет после, awaitпоступает из очереди обратного вызова. Код читается как код синхронной блокировки, но он не блокирует. Обратите внимание, что async/await делает readFileSync thenable, что выводит его из основного цикла. Думайте обо всем, что происходит после await, как о неблокирующем вызове через обратный вызов.

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

Двигаясь дальше

Что, если я скажу вам, что в цикле событий есть нечто большее, чем стек вызовов и очередь обратного вызова? Что, если бы цикл событий состоял не из одного цикла, а из многих? А что, если у него может быть несколько потоков под обложками?

Теперь я хочу провести вас за фасадом и погрузиться во внутренние дела Node.

Фазы цикла событий

Это фазы цикла событий:

Фазы цикла событий

Источник изображения: документация libuv

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

Цикл жив? Если в цикле есть активные дескрипторы, активные запросы или дескрипторы закрытия, он активен. Как показано, ожидающие обратные вызовы в очереди поддерживают цикл в рабочем состоянии.

Выполняются таймеры выполнения. Здесь запускаются setTimeoutили обратные вызовы. setIntervalЦикл проверяет кэшированные данные на наличие активных обратных вызовов, срок действия которых истек.

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

Обработчики Idle выполняются — в основном из-за плохого именования, потому что они запускаются на каждой итерации и являются внутренними для libuv.

Подготовьте дескрипторы для setImmediateвыполнения обратного вызова в итерации цикла. Эти дескрипторы запускаются перед блокировкой цикла для ввода-вывода и подготавливают очередь для этого типа обратного вызова.

Рассчитать время ожидания опроса. Цикл должен знать, как долго он блокируется для ввода-вывода. Вот как он вычисляет время ожидания:

Если цикл вот-вот выйдет, тайм-аут равен 0.

Если нет активных дескрипторов или запросов, тайм-аут равен 0.

Если есть какие-либо незанятые дескрипторы, тайм-аут равен 0.

Если в очереди есть дескрипторы, ожидающие обработки, тайм-аут равен 0.

Если есть дескрипторы закрытия, тайм-аут равен 0.

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

Цикл блокирует ввод-вывод с продолжительностью предыдущей фазы. Обратные вызовы, связанные с вводом-выводом в очереди, выполняются в этот момент.

Проверить выполнение обратных вызовов дескриптора. На этом этапе выполняется setImmediateзапуск, и он аналогичен подготовке ручек. Любые setImmediateобратные вызовы, поставленные в очередь в середине выполнения обратного вызова ввода-вывода, запускаются здесь.

Выполняются обратные вызовы закрытия. Это расположенные активные дескрипторы из закрытых соединений.

Итерация заканчивается.

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

Вот Unix-версия этого расчета тайм-аута во всей красе C:

int uv_backend_timeout (const uv_loop_t* loop) {

if (loop→stop_flag≠ 0)

return 0;

if (! uv__has_active_handles (loop) &&! uv__has_active_reqs (loop))

return 0;

if (! QUEUE_EMPTY (&loop→idle_handles))

return 0;

if (! QUEUE_EMPTY (&loop→pending_queue))

return 0;

if (loop→closing_handles)

return 0;

return uv__next_timeout (loop) ;

}

Возможно, вы не слишком хорошо знакомы с C, но он читается как английский и делает именно то, что находится в седьмой фазе.

Поэтапная демонстрация

Чтобы показать каждую фазу в простом JavaScript:

// 1. Loop begins, timestamps are updated

const http = require ('http’) ;

// 2. The loop remains alive if there’s code in the call stack to unwind

// 8. Poll for I/O and execute this callback from incoming connections

const server = http.createServer ((req, res) => {

// Network I/O callback executes immediately after poll

res.end () ;

}) ;

// Keep the loop alive if there is an open connection

// 7. If there’s nothing left to do, calculate timeout

server.listen (8000) ;

const options = {

// Avoid a DNS lookup to stay out of the thread pool

hostname: '127.0.0.1',

port: 8000

};

const sendHttpRequest = () => {

// Network I/O callbacks run in phase 8

// File I/O callbacks run in phase 4

const req = http.request (options, () => {

console.log ('Response received from the server’) ;

// 9. Execute check handle callback

setImmediate (() =>

// 10. Close callback executes

server.close (() =>

// The End. SPOILER ALERT! The Loop dies at the end.

console.log ('Closing the server’)));

}) ;

req.end () ;

};

// 3. Timer runs in 8 secs, meanwhile the loop is staying alive

// The timeout calculated before polling keeps it alive

setTimeout (() => sendHttpRequest (), 8000) ;

// 11. Iteration ends

Поскольку обратные вызовы файлового ввода-вывода выполняются в четвертой фазе и до девятой, ожидайте, что они setImmediate () сработают первыми:

fs.readFile ('readme.md’, () => {

setTimeout (() => console.log ('File I/O callback via setTimeout ()'), 0) ;

// This callback executes first

setImmediate (() => console.log ('File I/O callback via setImmediate ()'));

}) ;

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

Пул потоков

Внутреннее устройство Node состоит из двух основных частей: JavaScript-движка V8 и libuv. Файловый ввод-вывод, поиск DNS и сетевой ввод-вывод выполняются через libuv.

Это общая архитектура:

Обзор структуры пула потоков

Источник изображения: документация libuv

Для сетевого ввода-вывода цикл обработки событий опрашивается внутри основного потока. Этот поток не является потокобезопасным, поскольку он не переключает контекст с другим потоком. Файловый ввод-вывод и поиск DNS зависят от платформы, поэтому подход заключается в том, чтобы запускать их в пуле потоков. Одна из идей состоит в том, чтобы самостоятельно выполнять поиск в DNS, чтобы оставаться вне пула потоков, как показано в приведенном выше коде. Ввод IP-адреса вместо localhost, например, выводит поиск из пула. Пул потоков имеет ограниченное количество доступных потоков, которое можно установить с помощью UV_THREADPOOL_SIZEпеременной среды. Размер пула потоков по умолчанию составляет около четырех.

V8 выполняется в отдельном цикле, очищает стек вызовов, а затем возвращает управление циклу событий. V8 может использовать несколько потоков для сборки мусора вне собственного цикла. Думайте о V8 как о движке, который принимает необработанный JavaScript и запускает его на оборудовании.

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

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

Подведение итогов

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

process.nextTick () противsetImmediate ()

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

Есть несколько причин, по которым вам может понадобиться process.nextTick ():

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

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

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

const EventEmitter = require ('events’) ;

class ImpatientEmitter extends EventEmitter {

constructor () {

super () ;

// Fire this at the end of the phase with an unwound call stack

process.nextTick (() => this.emit ('event’));

}

}

const emitter = new ImpatientEmitter () ;

emitter.on ('event’, () => console.log ('An impatient event occurred!'));

Развертывание стека вызовов может предотвратить такие ошибки, как RangeError: Maximum call stack size exceeded. Один из способов — убедиться process.nextTick (), что цикл событий не блокируется. Блокировка может быть проблематичной при рекурсивных обратных вызовах в пределах одной и той же фазы.

Вывод

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

Делитесь нашими материалами с друзьями!

 

 

Заказать разработку сайта