Представьте ситуацию: ваш фронтенд-код делает 50 запросов к API одновременно. Сервер падает от нагрузки, браузер блокирует соединения, а пользователь видит вечный спиннер. Проблема не в скорости сети, а в том, что JavaScript is single-threaded runtime environment that handles asynchronous operations via the event loop and microtask queue. Когда вы запускаете много промисов разом, они все начинают выполняться параллельно на уровне событийного цикла, но ресурсы (сеть, CPU, память) ограничены.
Решение - внедрить Queue Pattern is a design pattern that manages task execution order and limits concurrent operations to prevent resource exhaustion. Это не просто «дождаться предыдущего». Это осозанный контроль над тем, сколько задач выполняется одновременно, в каком порядке и как обрабатываются ошибки. В этой статье разберем, как строить такие очереди с нуля, какие библиотеки использовать и когда паттерн действительно нужен, а когда - переусложнение.
Зачем нужна очередь, если есть Promise.all?
Многие разработчики думают, что Promise.all() решает проблему асинхронности. Но это не так. Promise.all() просто ждет завершения всех промисов. Если у вас 1000 изображений для загрузки, он запустит 1000 сетевых запросов сразу. Браузер может обработать только 6 одновременных соединений на домен (в Chrome), остальные поставятся в очередь на уровне HTTP стека, но ваш JS код уже создал 1000 объектов, выделил память и зарегистрировал колбэки.
Очередь задач позволяет:
- Ограничить количество одновременных операций (например, максимум 3 запроса к API).
- Управлять порядком выполнения (FIFO, приоритетные задачи).
- Обрабатывать ошибки одной задачи без падения всей системы.
- Добавить задержку между запросами (rate limiting).
Это особенно критично при работе с внешними сервисами, которые имеют лимиты по частоте вызовов (rate limits). Например, GitHub API позволяет 60 запросов в час для анонимных пользователей. Без очереди вы легко превысите лимит и получите статус 429 Too Many Requests.
Базовая реализация очереди с ограничением конкурентности
Давайте напишем простую очередь, которая выполняет задачи последовательно или с ограничением параллелизма. Ключевая идея - держать счетчик активных задач и запускать новую только когда место освободилось.
class TaskQueue {
constructor({ concurrency = 1 } = {}) {
this.tasks = [];
this.activeCount = 0;
this.concurrency = concurrency;
}
add(taskFn) {
return new Promise((resolve, reject) => {
this.tasks.push({ taskFn, resolve, reject });
this.process();
});
}
process() {
while (this.activeCount < this.concurrency && this.tasks.length > 0) {
const { taskFn, resolve, reject } = this.tasks.shift();
this.activeCount++;
Promise.resolve()
.then(() => taskFn())
.then(resolve)
.catch(reject)
.finally(() => {
this.activeCount--;
this.process();
});
}
}
}
// Пример использования
const queue = new TaskQueue({ concurrency: 3 });
for (let i = 0; i < 10; i++) {
queue.add(async () => {
console.log(`Start task ${i}`);
await new Promise(r => setTimeout(r, 1000));
console.log(`End task ${i}`);
});
}
Здесь concurrency: 3 означает, что максимум три задачи будут выполняться одновременно. Остальные ждут в массиве tasks. Метод process() проверяет, есть ли свободные слоты, и запускает следующие задачи из очереди.
Обработка ошибок: одна ошибка не должна убивать всю очередь
В базовой реализации выше, если одна задача упадет, она просто отклонит свой промис. Но что делать, если вам нужно остановить всю очередь при первой ошибке? Или продолжить выполнение остальных задач, несмотря на сбой?
Есть два подхода:
- Fail-fast: При первой ошибке останавливаем обработку новых задач. Все оставшиеся задачи отклоняются с той же ошибкой.
- Continue-on-error: Задача падает, ее промис отклоняется, но очередь продолжает работать. Вы можете подписаться на каждую задачу отдельно и обработать ошибку локально.
Для большинства UI-задач (загрузка аватаров, отправка логов) лучше подходит второй вариант. Для транзакционных операций (оплата, изменение данных в БД) - первый. Добавьте флаг stopOnError в конструктор и логику проверки в методе process.
Приоритетные задачи и динамическое управление
Иногда задачи не равны. Например, загрузка первого экрана должна иметь приоритет над подгрузкой рекомендаций внизу страницы. Обычная FIFO-очередь здесь бессильна.
Решение - использовать структуру данных с приоритетами. Вместо обычного массива можно использовать минимальную кучу (min-heap) или просто сортировать массив перед каждым запуском новой пачки задач. Но сортировка каждый раз - дорого. Лучше разделять задачи на уровни приоритета.
class PriorityTaskQueue extends TaskQueue {
add(taskFn, priority = 0) {
// priority: 0 - normal, 1 - high, 2 - critical
// Можно хранить отдельные массивы для каждого уровня
// или добавлять поле priority и выбирать самую высокую доступную задачу
}
}
В реальном коде часто проще реализовать несколько отдельных очередей для разных типов задач и управлять их запуском вручную. Это дает больше контроля и предсказуемости.
Готовые библиотеки vs код с нуля
Написать свою очередь - полезно для понимания, но в продакшене чаще используют проверенные решения. Вот основные варианты:
| Библиотека | Размер (gzip) | Конкурентность | Приоритеты | Rate Limiting | Типизация |
|---|---|---|---|---|---|
| p-queue | ~2 KB | Да | Нет | Да | TypeScript |
| async-mutex | ~1 KB | Через Mutex | Нет | Нет | TypeScript |
| bottleneck | ~15 KB | Да | Да | Да (гибко) | JS |
| custom implementation | 0 KB | Да | Да (руками) | Да (руками) | По желанию |
p-queue - популярный выбор для простых случаев. Он поддерживает ограничение конкурентности и rate limiting. Но не имеет встроенных приоритетов. Bottleneck - более мощный инструмент, который позволяет настраивать сложные стратегии ограничения (по времени, по количеству, по ресурсам). Он тяжелее, но окупается, если логика сложная.
Когда писать самому? Когда вам нужна очень специфическая логика (например, отмена задач, группировка по ключам, интеграция с Web Workers). В 80% случаев достаточно p-queue или простой реализации на 50 строк.
Частые ошибки при работе с очередями
Даже зная паттерн, легко попасть в ловушки:
- Забытый finally: Если вы не уменьшаете
activeCountпосле завершения задачи (успех или ошибка), очередь зависнет навсегда. Всегда используйте.finally(). - Рекурсия без выхода: В методе
process()убедитесь, что цикл завершается, когда очередь пуста. Иначе бесконечная рекурсия. - Изменение состояния во время итерации: Если вы удаляете элементы из массива задач во время обработки, будьте осторожны с индексами. Лучше использовать
shift()или указатель. - Неправильное использование async/await: Не забывайте, что
awaitвнутри очереди должен возвращать промис. Если функция синхронная, оберните ее вPromise.resolve().
Когда очередь не нужна
Не стоит усложнять код, если:
- Вы делаете 1-2 запроса к API.
- Запросы независимы и сервер хорошо справляется с нагрузкой.
- Используете
fetchс нативным кешированием браузера.
Очередь - это инструмент для управления ресурсами, а не замена для хорошей архитектуры. Если ваши запросы медленные из-за плохой оптимизации бэкенда, очередь просто спрячет проблему, но не решит ее.
Практические примеры из реальной жизни
Пример 1: Загрузка изображений в галерее. У вас 50 фото. Загружаете их через очередь с конкурентностью 5. Пользователь видит прогресс-бар, который заполняется плавно, а не скачками. Память не переполняется, потому что старые изображения можно освободить после загрузки новых.
Пример 2: Отправка метрик аналитики. Каждое действие пользователя генерирует событие. Вы не хотите отправлять 100 событий мгновенно. Используете очередь с rate limiting: максимум 10 событий в секунду. Остальные ждут. Это экономит трафик и не нагружает сервер аналитики.
Пример 3: Синхронизация данных с локальным хранилищем. Вы пишете данные в IndexedDB. Одновременная запись может привести к конфликтам версий. Очередь гарантирует, что записи выполняются последовательно, в правильном порядке.
Интеграция с Web Workers
Если ваши задачи CPU-intensive (расчет, обработка видео), очередь в основном потоке будет блокировать UI. В этом случае лучше перенести выполнение задач в Web Worker. Очередь остается в основном потоке, но вместо выполнения функции напрямую, она отправляет сообщение в воркер. Воркер выполняет работу и возвращает результат. Это позволяет сохранять отзывчивость интерфейса даже при тяжелых вычислениях.
Ключевой момент: сообщения между основным потоком и воркером структурированы (structured clone algorithm). Убедитесь, что передаваемые объекты сериализуемы (не содержат функций, DOM-элементов).
FAQ
Какая максимальная конкурентность безопасна для браузерного fetch?
Браузеры обычно ограничивают число одновременных соединений на один домен до 6 (HTTP/1.1) или 100+ (HTTP/2). Однако для стабильной работы и снижения нагрузки на сервер рекомендуется держать конкурентность на уровне 3-5. Этого достаточно для большинства UI-задач.
Можно ли отменить задачу в очереди?
Нативно - нет. Промисы нельзя отменить. Но можно использовать AbortController для сетевых запросов. Передайте signal в fetch и храните контроллер вместе с задачей в очереди. При необходимости вызовите controller.abort(). Для CPU-задач отмена сложнее - нужно проверять флаг отмены внутри функции.
Чем очередь отличается от мьютекса?
Мьютекс обеспечивает взаимоисключающий доступ к ресурсу (только один поток/задача может выполнять блок кода). Очередь управляет порядком и количеством задач. Мьютекс - частный случай очереди с конкурентностью 1. Но очередь может иметь конкурентность > 1 и дополнительные функции (приоритеты, rate limiting).
Стоит ли использовать очередь для запросов к REST API?
Да, если вы делаете больше 5-10 запросов одновременно. Даже если сервер выдержит нагрузку, браузер может начать таймауты или потери пакетов. Очередь с конкурентностью 3-5 стабилизирует поведение приложения и улучшает UX.
Как отладить зависшую очередь?
Проверьте три вещи: 1) Уменьшается ли activeCount после завершения каждой задачи (используйте console.log в finally). 2) Пустой ли массив tasks, когда активные задачи завершились. 3) Нет ли циклических зависимостей, где задача A ждет B, а B ждет A. Добавьте таймауты на выполнение задач, чтобы избежать вечного ожидания.