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 — время, в течение которого выполняются вычисления на CPU
  • T_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 конкурентных задачах может быть оценена так:

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

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

  • На один 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

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

  • T_io = 20 × 4 ms = 80 ms
  • T_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 ядрами:

$$ 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 ms2 ms~2
LAN (один дата-центр)~1 ms20 ms~20
Облако (cross-AZ, RDS)~4 ms80 ms~80
Удалённый сервер / cross-region~10 ms200 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/sSevalla, PHP 8.3
Число PHP-FPM воркеров в бенчмарке15Sevalla
Время ответа (W) в бенчмарке~34 msL/λ = 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} $$

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

$$ T_{cpu} \approx W \approx 34 , \text{ms} $$

Значит, в условиях localhost почти всё время ответа (~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 × ~2 MiB)54 MiB
Пропускная способность237 req/s
Утилизация CPU~100%

Пропускная способность та же — потому что CPU является узким местом. Но память на конкурентность: 54 MiB vs 1.08 GB — разница в ~20 раз.

О размере стека корутины. Объём памяти корутины в PHP определяется размером резервируемого C-стека. По умолчанию это ~2 MiB, однако этот размер может быть уменьшен до 128 KiB. При стеке 128 KiB память на 27 корутин составила бы всего ~3.4 MiB.

Шаг 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 ms2.4272371.08 GB54 MiB
10 ms8728002.88 GB144 MiB
5 ms161361 6005.44 GB272 MiB
1 ms806488 00025.9 GB1.27 GiB

При T_cpu = 1 ms (лёгкий обработчик, минимальный overhead):

  • PHP-FPM потребовал бы 648 процессов и 25.9 GB RAM — нереалистично
  • Корутины требуют те же 648 задач и 1.27 GiB — в ~20 раз меньше

Шаг 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 2 \text{ MiB} = 272 \text{ MiB} $$

Высвобожденные ~5.2 GB можно направить на кэши, пул соединений с БД или обработку большего числа запросов.

Итог: когда корутины дают выигрыш

УсловиеВыигрыш от корутин
Тяжёлый фреймворк, localhost DB (T_io ≈ 0)Минимальный — нагрузка CPU-bound
Тяжёлый фреймворк, cloud DB (T_io = 80 ms)Умеренный — экономия памяти в ~20x при той же пропускной способности
Лёгкий обработчик, cloud DBМаксимальный — рост throughput до 13x, экономия памяти в ~20x
Микросервис / API GatewayМаксимальный — почти чистый I/O, десятки тысяч req/s на одном сервере

Вывод: чем больше доля I/O в общем времени запроса и чем легче CPU-обработка, тем сильнее выигрыш от корутин. Для IO-bound приложений (а это большинство современных веб-сервисов) корутины позволяют утилизировать тот же CPU в разы эффективнее, затрачивая на порядки меньше памяти.

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

  • Увеличение числа корутин выше оптимального уровня редко даёт выигрыш, но и не является проблемой: корутины легковесны, и overhead от "лишних" корутин несоизмеримо мал по сравнению со стоимостью потоков ОС
  • Реальными ограничениями становятся:
    • пул соединений с базой данных
    • сетевая задержка
    • механизмы back-pressure
    • лимиты на открытые файловые дескрипторы (ulimit)
  • Для таких нагрузок модель event loop + корутины оказывается существенно эффективнее классической блокирующей модели

Вывод

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

  • эффективно скрывать задержки I/O
  • существенно повысить утилизацию CPU
  • сократить потребность в большом количестве потоков

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


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


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