IO-Bound vs CPU-bound
Насколько конкурентность или параллелизм дают выигрыш, зависит от характера нагрузки. В серверных приложениях обычно выделяют два основных типа задач.
- IO-bound — задачи, в которых значительная часть времени уходит на ожидание операций ввода-вывода: сетевые запросы, обращения к базе данных, чтение и запись файлов. В такие моменты CPU простаивает.
- CPU-bound — задачи, требующие интенсивных вычислений и нагружающие процессор почти постоянно: сложные алгоритмы, обработка данных, криптография.
В последние годы большинство веб-приложений смещаются в сторону IO-bound нагрузки.
Этому способствует рост числа микросервисов, удалённых API и облачных сервисов.
Подходы вроде Frontend for Backend (BFF) и API Gateway, агрегирующие данные из нескольких источников,
усиливают этот эффект.
Современное серверное приложение также сложно представить без логирования, телеметрии и мониторинга в реальном времени. Все эти операции по своей природе являются IO-bound.
Эффективность IO-bound задач
Эффективность конкурентного выполнения IO-bound задач определяется тем,
какую долю времени задача действительно использует CPU,
а какую — ожидает завершения операций ввода-вывода.
Закон Литтла
В теории массового обслуживания одной из базовых формул является закон Литтла (закон Литтла):
\[L = \lambda \cdot W\]Где:
L— среднее количество задач, находящихся в системеλ— средняя интенсивность входящих запросовW— среднее время пребывания задачи в системе
Этот закон универсален и не зависит от конкретной реализации системы: неважно, используются ли потоки, корутины или асинхронные колбэки. Он описывает фундаментальную связь между нагрузкой, задержками и уровнем конкурентности.
При оценке конкурентности серверного приложения фактически решается задача: сколько задач должно находиться в системе одновременно, чтобы ресурсы использовались эффективно.
Для IO-bound нагрузок среднее время обработки запроса велико
по сравнению с временем активных вычислений.
Следовательно, чтобы CPU не простаивал, в системе должно
находиться достаточное количество конкурентных задач.
Именно эту величину и позволяет оценить формальный аппарат, связывая:
- время ожидания,
- пропускную способность,
- и необходимый уровень конкурентности.
Аналогичный подход используется в индустрии для расчёта оптимального размера пула потоков (см. Brian Goetz, “Java Concurrency in Practice”).
Реальные статистические данные для каждого элемента этих формул (количество SQL-запросов на HTTP-запрос, задержки БД, пропускная способность PHP-фреймворков) собраны в отдельном документе: Статистические данные для расчёта конкурентности.
Базовая утилизация CPU
Чтобы вычислить, какую долю времени процессор фактически занят полезной работой при выполнении одной задачи, можно использовать следующую формулу:
\[U = \frac{T_{cpu}}{T_{cpu} + T_{io}}\]T_cpu— время, в течение которого выполняются вычисления на CPUT_io— время ожидания операций ввода-вывода
Сумма T_cpu + T_io представляет собой полное время жизни задачи
от начала до завершения.
Значение U лежит в диапазоне от 0 до 1 и показывает степень
утилизации процессора:
U → 1характеризует вычислительно нагруженную (CPU-bound) задачуU → 0характеризует задачу, большую часть времени ожидающую I/O (IO-bound)
Таким образом, формула позволяет количественно оценить,
насколько эффективно используется CPU
и относится ли рассматриваемая нагрузка к IO-bound или CPU-bound.
Влияние конкурентности
При выполнении нескольких IO-bound задач конкурентно CPU может использовать
время ожидания I/O одной задачи для выполнения вычислений другой.
Утилизация CPU при N конкурентных задачах может быть оценена так:
Рост конкурентности увеличивает загрузку CPU,
но только до определённого предела.
Предел эффективности
Максимальный выигрыш от конкурентности ограничен соотношением
времени ожидания I/O к времени вычислений:
Практически это означает, что количество действительно полезных
конкурентных задач приблизительно равно отношению T_io / T_cpu.
Оптимальная конкурентность
\[N_{opt} \approx 1 + \frac{T_{io}}{T_{cpu}}\]Единица в формуле учитывает задачу, которая в данный момент выполняется на CPU.
При большом соотношении T_io / T_cpu (что характерно для IO-bound нагрузок)
вклад единицы пренебрежимо мал, и формулу часто упрощают до T_io / T_cpu.
Эта формула является частным случаем (для одного ядра) классической формулы оптимального размера пула потоков, предложенной Брайаном Гёцем в книге “Java Concurrency in Practice” (2006):
\[N_{threads} = N_{cores} \times \left(1 + \frac{T_{wait}}{T_{service}}\right)\]Отношение T_wait / T_service известно как blocking coefficient —
коэффициент блокировки. Чем выше этот коэффициент, тем больше конкурентных
задач может эффективно использовать одно ядро.
При таком уровне конкурентности процессор большую часть времени занят полезной работой, а дальнейшее увеличение числа задач уже не даёт заметного выигрыша.
Именно поэтому асинхронные модели исполнения
наиболее эффективно раскрываются в IO-bound веб-нагрузках.
Пример расчёта для типичного web-приложения
Рассмотрим упрощённую, но достаточно реалистичную модель среднего серверного web-приложения.
Предположим, что обработка одного HTTP-запроса сводится в основном к взаимодействию с базой данных
и не содержит вычислительно сложных операций.
Исходные предположения
- На один HTTP-запрос выполняется около 20 SQL-запросов
- Вычисления ограничиваются маппингом данных, сериализацией ответа и логированием
- База данных находится вне процесса приложения (удалённое I/O)
Откуда 20 запросов? Это медианная оценка для ORM-приложений средней сложности. Для сравнения:
- WordPress генерирует ~17 запросов на страницу,
- Drupal без кэширования — от 80 до 100,
- а типичное Laravel/Symfony-приложение — от 10 до 30.
Основной источник роста — паттерн N+1, при котором ORM подгружает связанные сущности отдельными запросами.
Оценка времени выполнения
Для оценки возьмём усреднённые значения:
- Один SQL-запрос:
- время ожидания I/O:
T_io ≈ 4 ms - время CPU-вычислений:
T_cpu ≈ 0.05 ms
- время ожидания I/O:
Суммарно на один HTTP-запрос:
T_io = 20 × 4 ms = 80 msT_cpu = 20 × 0.05 ms = 1 ms
О выбранных значениях задержки. Время I/O на один
SQL-запрос складывается из сетевой задержки (round-trip) и времени выполнения запроса на сервере БД. Сетевой round-trip в пределах одного дата-центра составляет ~0.5 ms, а для облачных окружений (cross-AZ, managed RDS) — 1–5 ms. С учётом времени выполнения запроса средней сложности, итоговые 4 ms на запрос — реалистичная оценка для облачного окружения. Время CPU (0.05 ms) покрывает маппинг результата ORM, гидрацию сущностей и базовую логику обработки.
Характер нагрузки
Соотношение времени ожидания к времени вычислений:
\[\frac{T_{io}}{T_{cpu}} = \frac{80}{1} = 80\]Это означает, что задача является скорее IO-bound: процессор большую часть времени простаивает, ожидая завершения операций ввода-вывода.
Оценка количества корутин
Оптимальное количество конкурентных корутин на одно CPU-ядро приближённо равно отношению времени ожидания I/O к времени вычислений:
\[N_{coroutines} \approx \frac{T_{io}}{T_{cpu}} \approx 80\]Иными словами, около 80 корутин на ядро позволяют практически полностью скрыть задержки ввода-вывода и поддерживать высокую загрузку CPU.
Для сравнения: Zalando Engineering
приводит пример с микросервисом, где время ответа 50 ms и время обработки 5 ms
на двухъядерной машине: 2 × (1 + 50/5) = 22 потока — тот же принцип, та же формула.
Масштабирование по числу ядер
Для сервера с C ядрами:
Например, для 8-ядерного процессора:
\[N_{total} \approx 8 \times 80 = 640 \text{ корутин}\]Это значение отражает полезный уровень конкурентности, а не жёсткий лимит.
Чувствительность к окружению
Значение 80 корутин на ядро — не универсальная константа, а результат конкретных предположений о задержке I/O. В зависимости от сетевого окружения оптимальное число конкурентных задач может существенно отличаться:
| Окружение | T_io на SQL-запрос | T_io суммарно (×20) | N на ядро |
|---|---|---|---|
| Localhost / Unix-socket | ~0.1 ms | 2 ms | ~2 |
| LAN (один дата-центр) | ~1 ms | 20 ms | ~20 |
| Облако (cross-AZ, RDS) | ~4 ms | 80 ms | ~80 |
| Удалённый сервер / cross-region | ~10 ms | 200 ms | ~200 |
Чем больше задержка — тем больше корутин нужно, чтобы полностью загрузить CPU полезной работой.
PHP-FPM vs корутины: расчёт на реальных данных
Чтобы оценить практический выигрыш от корутин, сравним две модели исполнения на одном и том же сервере с одной и той же нагрузкой.
Исходные данные
Сервер: 8 ядер, облачное окружение (cross-AZ RDS).
Нагрузка: типичный Laravel API endpoint — авторизация, Eloquent-запросы с eager loading, JSON-сериализация.
По данным бенчмарков Sevalla и Kinsta:
| Параметр | Значение | Источник |
|---|---|---|
| Laravel API throughput (30 vCPU, localhost DB) | ~440 req/s | Sevalla, PHP 8.3 |
| Число PHP-FPM воркеров в бенчмарке | 15 | Sevalla |
| Время ответа (W) в бенчмарке | ~34 ms | L/λ = 15/440 |
| Память одного PHP-FPM воркера | ~40 MB | Типичное значение |
Шаг 1: оценка T_cpu и T_io
В бенчмарке Sevalla база данных работает на localhost (задержка <0.1 ms). При ~10 SQL-запросах на эндпоинт суммарное I/O — менее 1 ms.
Дано:
- Пропускная способность: λ ≈ 440 req/s
- Число одновременно обслуживаемых запросов (PHP-FPM воркеры): L = 15
- База данных на localhost, поэтому T_io ≈ 0
По закону Литтла:
\[W = \frac{L}{\lambda} = \frac{15}{440} \approx 0.034 \, \text{s} \approx 34 \, \text{ms}\]Так как сетевое и дисковое I/O пренебрежимо мало,
полученное среднее время ответа
может интерпретироваться как оценка времени CPU-обработки одного запроса:
Значит, почти всё время ответа (~34 ms) — это CPU: фреймворк, middleware, ORM, сериализация.
Перенесём этот же эндпоинт в облачное окружение с 20 SQL-запросами:
\[T_{cpu} = 34 \text{ ms (фреймворк + логика)}\] \[T_{io} = 20 \times 4 \text{ ms} = 80 \text{ ms (ожидание БД)}\] \[W = T_{cpu} + T_{io} = 114 \text{ ms}\]Коэффициент блокировки:
\[\frac{T_{io}}{T_{cpu}} = \frac{80}{34} \approx 2.4\]Шаг 2: PHP-FPM
В модели PHP-FPM каждый воркер — отдельный процесс ОС. Во время ожидания I/O воркер блокируется и не может обрабатывать другие запросы.
Чтобы полностью утилизировать 8 ядер, нужно столько воркеров, чтобы в любой момент 8 из них выполняли CPU-работу:
\[N_{workers} = 8 \times \left(1 + \frac{80}{34}\right) = 8 \times 3.4 = 27\]| Метрика | Значение |
|---|---|
| Воркеров | 27 |
| Память (27 × 40 MB) | 1.08 GB |
| Пропускная способность (27 / 0.114) | 237 req/s |
| Утилизация CPU | ~100% |
На практике администраторы часто ставят pm.max_children = 50–100,
что выше оптимума. Лишние воркеры конкурируют за CPU,
увеличивают количество переключений контекста ОС
и расходуют память без роста пропускной способности.
Шаг 3: корутины (event loop)
В модели с корутинами один поток (на ядро) обслуживает множество запросов. Когда корутина ожидает I/O, планировщик переключается на другую за ~200 наносекунд (см. доказательную базу).
Оптимальное число корутин — то же самое:
\[N_{coroutines} = 8 \times 3.4 = 27\]| Метрика | Значение |
|---|---|
| Корутин | 27 |
| Память (27 × ~4 KiB) | 108 KiB |
| Пропускная способность | 237 req/s |
| Утилизация CPU | ~100% |
Пропускная способность та же — потому что CPU является узким местом. Но память на конкурентность: 108 KiB vs 1.08 GB — разница в 10 000 раз.
Шаг 4: а если CPU-нагрузка ниже?
Фреймворк Laravel в FPM-режиме тратит ~34 ms CPU на запрос, что включает повторную инициализацию сервисов при каждом запросе.
В stateful-рантайме (каким является True Async) эти расходы значительно сокращаются: маршруты скомпилированы, контейнер зависимостей инициализирован, пулы соединений переиспользуются.
Если T_cpu снижается с 34 ms до 5 ms (что реалистично для stateful-режима),
картина меняется радикально:
| T_cpu | Коэфф. блокировки | N (8 ядер) | λ (req/s) | Память (FPM) | Память (корутины) |
|---|---|---|---|---|---|
| 34 ms | 2.4 | 27 | 237 | 1.08 GB | 108 KiB |
| 10 ms | 8 | 72 | 800 | 2.88 GB | 288 KiB |
| 5 ms | 16 | 136 | 1 600 | 5.44 GB | 544 KiB |
| 1 ms | 80 | 648 | 8 000 | 25.9 GB | 2.6 MiB |
При T_cpu = 1 ms (лёгкий обработчик, минимальный overhead):
- PHP-FPM потребовал бы 648 процессов и 25.9 GB RAM — нереалистично
- Корутины требуют те же 648 задач и 2.6 MiB — тривиально
Шаг 5: закон Литтла — проверка через throughput
Проверим результат для T_cpu = 5 ms:
Для достижения той же пропускной способности PHP-FPM нужно 136 воркеров. Каждый занимает ~40 MB:
\[136 \times 40 \text{ MB} = 5.44 \text{ GB только на воркеры}\]Корутины:
\[136 \times 4 \text{ KiB} = 544 \text{ KiB}\]Высвобожденные 5.44 GB можно направить на кэши, пул соединений с БД или обработку большего числа запросов.
Итог: когда корутины дают выигрыш
| Условие | Выигрыш от корутин |
|---|---|
| Тяжёлый фреймворк, localhost DB (T_io ≈ 0) | Минимальный — нагрузка CPU-bound |
| Тяжёлый фреймворк, cloud DB (T_io = 80 ms) | Умеренный — экономия памяти в 10 000x при той же пропускной способности |
| Лёгкий обработчик, cloud DB | Максимальный — рост throughput до 13x, экономия памяти в 10 000x |
| Микросервис / API Gateway | Максимальный — почти чистый I/O, десятки тысяч req/s на одном сервере |
Вывод: чем больше доля I/O в общем времени запроса и чем легче CPU-обработка, тем сильнее выигрыш от корутин. Для IO-bound приложений (а это большинство современных веб-сервисов) корутины позволяют утилизировать тот же CPU в разы эффективнее, затрачивая на порядки меньше памяти.
Практические замечания
- Увеличение числа корутин выше оптимального уровня редко даёт выигрыш, но и не является проблемой: корутины легковесны, и overhead от “лишних” корутин несоизмеримо мал по сравнению со стоимостью потоков ОС
- Реальными ограничениями становятся:
- пул соединений с базой данных
- сетевая задержка
- механизмы back-pressure
- лимиты на открытые файловые дескрипторы (ulimit)
- Для таких нагрузок модель event loop + корутины оказывается существенно эффективнее классической блокирующей модели
Вывод
Для типичного современного web-приложения, в котором преобладают операции ввода-вывода, асинхронная модель исполнения позволяет:
- эффективно скрывать задержки I/O
- существенно повысить утилизацию CPU
- сократить потребность в большом количестве потоков
Именно на подобных сценариях преимущества асинхронности проявляются наиболее наглядно.
Дальнейшее чтение
- Swoole на практике: реальные замеры — production-кейсы (Appwrite +91%, IdleMMO 35M req/day), независимые бенчмарки с БД и без, TechEmpower
- Python asyncio на практике — Duolingo +40%, Super.com −90% затрат, бенчмарки uvloop, контр-аргументы
- Доказательная база: почему корутины в одном потоке работают — измерения стоимости переключения, сравнение с потоками ОС, академические исследования и промышленные бенчмарки
Ссылки и литература
- Brian Goetz, Java Concurrency in Practice (2006) — формула оптимального размера пула потоков:
N = cores × (1 + W/S) - Zalando Engineering: How to set an ideal thread pool size — практическое применение формулы Гёца с примерами и выводом через закон Литтла
- Backendhance: The Optimal Thread-Pool Size in Java — детальный разбор формулы с учётом target CPU utilization
- CYBERTEC: PostgreSQL Network Latency — измерения влияния сетевой задержки на производительность PostgreSQL
- PostgresAI: What is a slow SQL query? — ориентиры по допустимым задержкам SQL-запросов для веб-приложений