IO-Bound vs CPU-bound

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

В последние годы большинство веб-приложений смещаются в сторону IO-bound нагрузки. Этому способствует рост числа микросервисов, удалённых API и облачных сервисов. Подходы вроде Frontend for Backend (BFF) и API Gateway, агрегирующие данные из нескольких источников, усиливают этот эффект.

Современное серверное приложение также сложно представить без логирования, телеметрии и мониторинга в реальном времени. Все эти операции по своей природе являются IO-bound.

Эффективность IO-bound задач

Эффективность конкурентного выполнения IO-bound задач определяется тем, какую долю времени задача действительно использует CPU, а какую — ожидает завершения операций ввода-вывода.

Закон Литтла

В теории массового обслуживания одной из базовых формул является закон Литтла (закон Литтла):

\[L = \lambda \cdot W\]

Где:

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

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

Для IO-bound нагрузок среднее время обработки запроса велико по сравнению с временем активных вычислений. Следовательно, чтобы CPU не простаивал, в системе должно находиться достаточное количество конкурентных задач.

Именно эту величину и позволяет оценить формальный аппарат, связывая:

Аналогичный подход используется в индустрии для расчёта оптимального размера пула потоков (см. Brian Goetz, “Java Concurrency in Practice”).

Реальные статистические данные для каждого элемента этих формул (количество SQL-запросов на HTTP-запрос, задержки БД, пропускная способность PHP-фреймворков) собраны в отдельном документе: Статистические данные для расчёта конкурентности.

Базовая утилизация CPU

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

\[U = \frac{T_{cpu}}{T_{cpu} + T_{io}}\]

Сумма T_cpu + T_io представляет собой полное время жизни задачи от начала до завершения.

Значение U лежит в диапазоне от 0 до 1 и показывает степень утилизации процессора:

Таким образом, формула позволяет количественно оценить, насколько эффективно используется CPU и относится ли рассматриваемая нагрузка к IO-bound или CPU-bound.

Влияние конкурентности

При выполнении нескольких IO-bound задач конкурентно CPU может использовать время ожидания I/O одной задачи для выполнения вычислений другой.

Утилизация CPU при N конкурентных задачах может быть оценена так:

\[U_N = \min\left(1,\; N \cdot \frac{T_{cpu}}{T_{cpu} + T_{io}}\right)\]

Рост конкурентности увеличивает загрузку CPU, но только до определённого предела.

Предел эффективности

Максимальный выигрыш от конкурентности ограничен соотношением времени ожидания I/O к времени вычислений:

\[E(N) \approx \min\left(N,\; 1 + \frac{T_{io}}{T_{cpu}}\right)\]

Практически это означает, что количество действительно полезных конкурентных задач приблизительно равно отношению 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-запроса сводится в основном к взаимодействию с базой данных и не содержит вычислительно сложных операций.

Исходные предположения

Откуда 20 запросов? Это медианная оценка для ORM-приложений средней сложности. Для сравнения:

  • WordPress генерирует ~17 запросов на страницу,
  • Drupal без кэширования — от 80 до 100,
  • а типичное Laravel/Symfony-приложение — от 10 до 30.

Основной источник роста — паттерн N+1, при котором ORM подгружает связанные сущности отдельными запросами.

Оценка времени выполнения

Для оценки возьмём усреднённые значения:

Суммарно на один HTTP-запрос:

О выбранных значениях задержки. Время 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 ядрами:

\[N_{total} \approx C \cdot \frac{T_{io}}{T_{cpu}}\]

Например, для 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.

Дано:

По закону Литтла:

\[W = \frac{L}{\lambda} = \frac{15}{440} \approx 0.034 \, \text{s} \approx 34 \, \text{ms}\]

Так как сетевое и дисковое I/O пренебрежимо мало, полученное среднее время ответа может интерпретироваться как оценка времени CPU-обработки одного запроса:

\[T_{cpu} \approx W \approx 34 \, \text{ms}\]

Значит, почти всё время ответа (~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):


Шаг 5: закон Литтла — проверка через throughput

Проверим результат для T_cpu = 5 ms:

\[\lambda = \frac{L}{W} = \frac{136}{0.085} = 1\,600 \text{ req/s}\]

Для достижения той же пропускной способности 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 в разы эффективнее, затрачивая на порядки меньше памяти.


Практические замечания


Вывод

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

Именно на подобных сценариях преимущества асинхронности проявляются наиболее наглядно.


Дальнейшее чтение


Ссылки и литература