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

Цикл событий Node Программирование и разработка

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

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

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

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

Читайте также:  Константы в C

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

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

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

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

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

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

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, запускается обратный вызов, который можно обернуть вокруг обещания. Общее намерение остается в силе, потому что это снимает блокировку ввода-вывода с основного цикла.

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

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

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

  1. Метки времени обновляются. Цикл событий кэширует текущее время в начале цикла, чтобы избежать частых системных вызовов, связанных со временем. Эти системные вызовы являются внутренними для libuv.
  2. Петля живая? Если у цикла есть активные дескрипторы, активные запросы или закрывающие дескрипторы, он жив. Как показано, ожидающие обратные вызовы в очереди поддерживают цикл.
  3. Срок исполнения таймеров. Здесь setTimeoutили setIntervalвыполняются обратные вызовы. Цикл проверяет кэширование сейчасна наличие активных обратных вызовов, срок действия которых истек.
  4. Ожидающие обратные вызовы в очереди выполняются. Если предыдущая итерация отложила какие-либо обратные вызовы, они выполняются в этой точке. Опрос обычно запускает обратные вызовы ввода-вывода немедленно, но есть исключения. Этот шаг касается всех отставших от предыдущей итерации.
  5. Обработчики простоя выполняются — в основном из-за плохого именования, потому что они запускаются на каждой итерации и являются внутренними для libuv.
  6. Подготовьте дескрипторы для setImmediateвыполнения обратного вызова в итерации цикла. Эти дескрипторы выполняются перед блоками цикла для ввода-вывода и подготавливают очередь для этого типа обратного вызова.
  7. Рассчитать тайм-аут опроса. Цикл должен знать, как долго он блокируется для ввода-вывода. Вот как он рассчитывает тайм-аут:
    • Если цикл вот-вот завершится, таймаут равен 0.
    • Нет активных дескрипторов или запросов, таймаут равен 0.
    • Если есть свободные дескрипторы, таймаут равен 0.
    • Если в очереди есть ожидающие обработки дескрипторы, таймаут равен 0.
    • Есть какие-либо закрывающие дескрипторы, таймаут равен 0.
    • Если ничего из вышеперечисленного, тайм-аут устанавливается на ближайший таймер, или, если нет активных таймеров, на бесконечность.
  8. Цикл блокируется для ввода / вывода с продолжительностью из предыдущей фазы. В этот момент в очереди выполняются обратные вызовы, связанные с вводом-выводом.
  9. Выполните обратные вызовы дескриптора проверки. На этом этапе setImmediateзапускается, и он является аналогом подготовки ручек. Любые setImmediateобратные вызовы в очереди в середине ввод / вывод обратного выполнение запустить здесь.
  10. Выполняются обратные вызовы закрытия. Это удаленные активные дескрипторы закрытых соединений.
  11. Итерация заканчивается.

Вы можете задаться вопросом, почему опросные блоки для ввода-вывода, когда предполагается, что это неблокирующий режим? Цикл блокируется только тогда, когда в очереди нет ожидающих обратных вызовов и стек вызовов пуст. В 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 также использует пул потоков, поэтому сетевой ввод-вывод становится таким же дорогим, как файловый ввод-вывод.

Пул потоков

Внутреннее устройство узла состоит из двух основных частей: движка JavaScript V8 и libuv. Файловый ввод-вывод, поиск DNS и сетевой ввод-вывод выполняются через 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():

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

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

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 тратят меньше времени на погоню за асинхронными ошибками и больше времени на разработку новых функций.

Оцените статью
bestprogrammer.ru
Добавить комментарий