Асинхронность в любом языке программирования сложна. Такие концепции, как параллелизм, параллелизм и взаимоблокировки, вызывают дрожь даже у самых опытных инженеров. Код, который выполняется асинхронно, непредсказуем, и его трудно отследить при наличии ошибок. Проблема неизбежна, потому что современные вычисления имеют несколько ядер. В каждом отдельном ядре ЦП есть температурный предел, и ничто не становится быстрее. Это заставляет разработчика писать эффективный код, использующий преимущества аппаратного обеспечения.
JavaScript является однопоточным, но ограничивает ли это 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
Чтобы избежать блокировки основного цикла, одна из идей состоит в том, чтобы обернуть синхронный
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 выполняются — в основном
Подготовьте дескрипторы для setImmediateвыполнения обратного вызова в итерации цикла. Эти дескрипторы запускаются перед блокировкой цикла для
Рассчитать время ожидания опроса. Цикл должен знать, как долго он блокируется для
Если цикл
Если нет активных дескрипторов или запросов,
Если есть
Если в очереди есть дескрипторы, ожидающие обработки,
Если есть дескрипторы закрытия,
Если ничего из вышеперечисленного,
Цикл блокирует
Проверить выполнение обратных вызовов дескриптора. На этом этапе выполняется setImmediateзапуск, и он аналогичен подготовке ручек. Любые setImmediateобратные вызовы, поставленные в очередь в середине выполнения обратного вызова
Выполняются обратные вызовы закрытия. Это расположенные активные дескрипторы из закрытых соединений.
Итерация заканчивается.
Вы можете задаться вопросом, почему опрос блокирует
Вот
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
Поскольку обратные вызовы файлового
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 ()'));
}) ;
Сетевой
Пул потоков
Внутреннее устройство Node состоит из двух основных частей:
Это общая архитектура:
Обзор структуры пула потоков
Источник изображения: документация libuv
Для сетевого
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 тратят меньше времени на поиск асинхронных ошибок и больше времени на разработку новых функций.