Открыть сервис

Параллельные запросы

Параллельные запросы — это программная концепция и подход к организации вычислений, при котором несколько независимых операций запроса (например, к базам данных, веб-серверам, файловым системам или API) выполняются одновременно, в один и тот же интервал времени, а не последовательно (один за другим). Основная цель параллельных запросов — сокращение общего времени ожидания ответа за счёт распараллеливания операций ввода-вывода (I/O-bound tasks) или, реже, за счёт использования многоядерных процессоров для вычислений (CPU-bound tasks). В контексте разработки программного обеспечения параллельные запросы реализуются с помощью многопоточности, асинхронного программирования (async/await в C#, Python, JavaScript), корутин (в Go, Kotlin) или моделей акторов (в Erlang, Elixir).

Принцип работы

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

Механизм работы включает следующие этапы:

  1. Инициация набора запросов. Система формирует список задач, каждая из которых представляет собой запрос к внешнему или внутреннему ресурсу.
  2. Параллельное выполнение. Каждый запрос выполняется в отдельном потоке, задаче или корутине. Операционная система или среда выполнения обеспечивают переключение контекста или асинхронный ввод-вывод, что позволяет не блокировать основной поток.
  3. Ожидание завершения. Программа ждёт, пока все запущенные параллельные запросы не вернут результат или не истечёт тайм-аут. Для этого используются примитивы синхронизации: WaitAll, Task.WhenAll, Promise.all и др.
  4. Сборка результатов и обработка ошибок. После завершения всех запросов результаты агрегируются (например, собираются в массив, список или map). Если часть запросов завершилась ошибкой, система обрабатывает её в соответствии с логикой: либо игнорирует, либо генерирует исключение, либо возвращает частичный результат.

Виды и классификации

Параллельные запросы можно классифицировать по нескольким признакам:

По уровню абстракции

  • Низкоуровневые (многопоточные). Используются нативные потоки операционной системы. Требуют ручного управления пулом потоков и синхронизацией (мьютексы, семафоры). Пример: ThreadPool.QueueUserWorkItem в .NET, pthreads в C/C++.
  • Высокоуровневые (асинхронные). Используются абстракции среды выполнения: задачи (Task в C#), промисы (Promise в JavaScript), асинхронные функции с async/await, корутины в Python (asyncio), Kotlin (kotlinx.coroutines) и Go (goroutines). Эти механизмы автоматически управляют потоками (или используют легковесные потоки — goroutines), не требуя ручной синхронизации.

По типу блокировки

  • Блокирующие (синхронные). Поток или процесс блокируется на время ожидания. Пример: вызов Thread.Join() в многопоточном коде.
  • Неблокирующие (асинхронные). Поток не блокируется, вместо этого выполнение продолжается, а результат обрабатывается через коллбэки, события или await-операторы. Асинхронные запросы наиболее распространены в современных высоконагруженных веб-серверах.

По количеству источников

  • Однородные (однотипные). Все запросы направлены к одному и тому же ресурсу (например, один и тот же REST API) с одинаковыми параметрами. Часто используются для пагинации, пакетного сбора данных.
  • Гетерогенные (разнотипные). Запросы направлены к разным ресурсам (разные API, разные базы данных). Такие запросы могут выполняться параллельно, если их результаты не зависят друг от друга.

Применение

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

  1. Веб-фреймворки и бэкенд-разработка. В типичном веб-приложении обработка запроса пользователя может включать несколько обращений к разным микросервисам, базам данных или кэшам. Параллельные запросы позволяют сократить время ответа сервера с суммы времен запросов до времени самого долгого запроса. Например, в ASP.NET Core для параллельного вызова микросервиса A и микросервиса B используют Task.WhenAll. Аналогично в Node.jsPromise.all.
  1. Парсинг и скрейпинг. Сбор данных с множества веб-страниц или API естественным образом распараллеливается: запускаются десятки и сотни запросов одновременно, что в десятки раз ускоряет сбор информации. Реализуется через асинхронные библиотеки (aiohttp в Python, axios в JavaScript).
  1. Базы данных. Некоторые СУБД (например, PostgreSQL, ClickHouse) внутренне используют параллельные запросы для обработки сложных запросов: они разбивают большой запрос на несколько подзапросов, которые выполняются параллельно на разных ядрах или сегментах данных. На уровне приложений разработчики могут отправлять параллельные запросы к разным таблицам или шардам одной базы данных.
  1. Графические интерфейсы (UI). В десктопных и мобильных приложениях параллельные запросы используются для загрузки данных (изображений, списков, новостей) без блокировки основного потока интерфейса. Это предотвращает «зависание» интерфейса.
  1. Компьютерное зрение и научные расчёты. В задачах машинного обучения и обработки изображений параллельные запросы применяются для пакетной обработки данных: например, одновременно загружаются и предобрабатываются несколько изображений.

Преимущества и недостатки

Преимущества

  • Ускорение работы в I/O-bound задачах: общее время выполнения приближается к времени самого медленного запроса.
  • Более эффективное использование ресурсов ЦПУ и сети за счёт одновременного ожидания ответов от множества ресурсов.
  • Повышение отзывчивости пользовательских интерфейсов (UI не блокируется).

Недостатки и риски

  • Усложнение кода: требуется правильная обработка ошибок, тайм-аутов, частичных результатов.
  • Риск превышения лимитов ресурсов: база данных или API могут не выдержать большого числа одновременных запросов (например, лимиты на количество соединений, rate limiting). Для контроля применяют семафоры или пулы соединений.
  • Возможные deadlock’и (взаимные блокировки) при неправильной синхронизации.
  • Увеличение потребления оперативной памяти: каждый параллельный поток/корутина требует своего стека и контекста.
  • Сложность отладки и тестирования из-за недетерминированного порядка выполнения.

Реализации в популярных языках и фреймворках

  • C# (.NET): API Task.WhenAll(IEnumerable<Task>), Parallel.ForEachAsync, SemaphoreSlim для лимитирования параллелизма.
  • JavaScript (Node.js): Promise.all(promises), Promise.allSettled(), Promise.race(), библиотека p-limit для ограничения.
  • Python: asyncio.gather() для асинхронных запросов, concurrent.futures.ThreadPoolExecutor для многопоточных.
  • Go (Golang): запуск горутин через go myFunction(), ожидание через sync.WaitGroup или errgroup.Group. Встроенная модель concurrency (каналы) делает параллельные запросы идиоматичными.
  • Java: CompletableFuture.allOf(), ExecutorService.invokeAll(), ForkJoinPool.
  • Ruby: конкуренты (Thread.new), concurrent-ruby gem с Promises.zip.

Проблема управления параллелизмом (concurrency control)

При большом числе параллельных запросов (например, 10000 запросов к одному серверу) возникает необходимость ограничения одновременного выполнения. Это делается с помощью:

  • Семафоров — фиксированное число разрешений (slots).
  • Ограничителей (throttling, limiter) — библиотеки p-limit в JavaScript, SemaphoreSlim в C#, asyncio.Semaphore в Python.
  • Очередей (work queue) — задачи помещаются в очередь и обрабатываются пулом потоков.

Без таких механизмов можно либо исчерпать системные ресурсы (память, дескрипторы), либо получить временную блокировку со стороны внешнего API (HTTP 429 Too Many Requests).

Связь с параллельными вычислениями

Несмотря на название, «параллельные запросы» чаще относятся к параллелизму уровня асинхронности (concurrency), а не к истинному параллельному выполнению на нескольких ядрах (parallelism). Основное время тратится на ожидание ответов от внешних систем, а не на вычисления. Однако в некоторых сценариях (парсинг сложных структур данных, обработка изображений) часть работы действительно выполняется параллельно на нескольких ядрах.

Критика и альтернативы

  • Ненужная сложность. Для простых сценариев (например, два-три запроса) традиционный последовательный код может быть проще в написании и отладке, а выигрыш во времени незначителен.
  • Распределённые системы. В микросервисной архитектуре часто используется асинхронный обмен сообщениями (очереди RabbitMQ, Kafka, NATS), который позволяет не ждать параллельных запросов, а обрабатывать задачи в фоновом режиме. Параллельные запросы в таком случае применяются только там, где требуется синхронный ответ (например, при агрегации данных).
  • Реактивное программирование. Альтернативный подход — реактивные потоки (RxJava, ReactiveX, Project Reactor), которые вводят понятие потоков данных и ленивых вычислений, что может быть более выразительным для сложных сценариев с несколькими параллельными запросами.

Источники

  • Эрих Гамма, Ричард Хелм, Ральф Джонсон, Джон Влиссидес. Приёмы объектно-ориентированного проектирования. Паттерны проектирования (GoF).
  • Джозеф Албахари, Бен Албахари. C# 10 in a Nutshell (глава про асинхронность и параллелизм).
  • Документация asyncio Python (docs.python.org).
  • Документация goroutines и channels языка Go (go.dev).
  • Статья Microsoft Docs «Asynchronous programming with async and await».
  • Документация Promise.all в ECMAScript (MDN Web Docs).

BFOmetr — база данных и аналитика по компаниям России.

На главную BFOmetr →