Цикл событий

Что это такое

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

Почему это важно

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

Ключевые идеи

Операции регистрируют, а не ждут

Чтение файла, отправка запроса, обращение к базе данных — всё устроено одинаково: вызов запускает операцию и сразу возвращает управление, а когда та завершится, выполняется обратный вызов. Между этими двумя моментами поток свободен и успевает обслужить другие соединения. Синхронная версия того же вызова держит поток всё время работы — потому асинхронная форма и существует, даже если синхронная выглядит проще.

чтение файла асинхронно:  старт -> возврат -> ... другая работа ... -> обратный вызов
чтение файла синхронно:   старт -> [поток заблокирован] -> значение

У цикла есть фазы, и микрозадачи лезут вперёд

Таймеры, обратные вызовы ввода-вывода и другие фазы идут по порядку, а промисы разрешаются в очереди между ними, поэтому разрешённый промис выполняется раньше очередного таймера. Отсюда два следствия: await в цикле оказывается то эффективным, то узким местом — смотря по тому, независимы ли операции; и код после await попадает в отдельный ход цикла, а не продолжается немедленно.

Долгое вычисление блокирует всё

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

Ошибки нужно обрабатывать на каждой асинхронной границе

Исключение внутри обратного вызова не доходит до того кода, который запустил операцию: оно попадает в обработчик необработанных ошибок процесса, а тот по умолчанию процесс завершает. Поэтому каждой асинхронной операции нужен собственный путь ошибки — параметр обратного вызова, отклонённый промис или блок try вокруг await. Если пути нет, вместо ответа 500 вы получите падение.

Обратное давление и лимиты конкурентности — на вас

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

Пул потоков — скрытый общий ресурс

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

Практика

Посмотрите, как цикл блокируется

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

Готово, когда: вы можете описать задержку, которую понёс быстрый запрос, и указать строку, её вызвавшую.

Ограничьте конкурентность пакета

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

Готово, когда: пиковое число одновременных операций не превышает ваш лимит, а скрипт завершается, не исчерпав дескрипторы.

Частые ошибки

  • Синхронный вызов на пути запроса — процесс замирает у всех остальных пользователей; исправить: возьмите асинхронную форму или уберите работу из цикла.
  • Уверенность, что await ничего не блокирует — долгий цикл между await всё равно блокирует; исправить: периодически уступайте управление или перенесите вычисление в рабочий поток.
  • Запуск неограниченного числа конкурентных операций — дескрипторы и память кончаются; исправить: задайте лимит конкурентности и обрабатывайте очередь.
  • Ошибка, вылетающая из обратного вызова — процесс завершается вместо возврата ответа; исправить: обрабатывайте ошибки на каждой асинхронной границе.
  • Отладка задержки в предположении о потоках — причина в заблокированном цикле, а не в соперничестве потоков; исправить: сначала ищите блокирующую работу в главном потоке.

Что читать дальше

  • Node.js (05-backend-data/02-nodejs) — среда исполнения, поведение которой объясняет этот цикл, поэтому читайте её первой.
  • Модули и среда исполнения (05-backend-data/02-nodejs/02-modules-and-the-runtime) — инструменты, доступные для организации кода вокруг этого цикла.
  • Конкурентность и асинхронное программирование (01-cs-fundamentals/05-concurrency-async-programming) — общая модель, одной из реализаций которой это является.