Цикл событий
Что это такое
Цикл событий — механизм, благодаря которому один поток выглядит так, будто занят множеством дел сразу. Запрашивая файл или ответ по сети, код регистрирует обратный вызов и возвращает управление; цикл продолжает работу, а когда операция завершается, ставит этот вызов в очередь на исполнение. Функцию ничто не прерывает на полуслове, поэтому тормозит процесс только тот код, который долго считает и не уступает управление.
Почему это важно
На одной этой модели держится почти всё, что можно сказать о производительности серверного приложения на 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) — общая модель, одной из реализаций которой это является.