IO-Bound vs CPU-bound

Наскільки конкурентність або паралелізм дають приріст продуктивності — залежить від характеру навантаження. У серверних застосунках зазвичай виділяють два основних типи завдань.

Останніми роками більшість веб-застосунків зміщуються в бік IO-bound навантажень. Цьому сприяє зростання мікросервісів, віддалених API та хмарних сервісів. Підходи на кшталт Frontend for Backend (BFF) та API Gateway, що агрегують дані з кількох джерел, посилюють цей ефект.

Сучасний серверний застосунок також важко уявити без логування, телеметрії та моніторингу в реальному часі. Усі ці операції за своєю природою є IO-bound.

Ефективність IO-bound завдань

Ефективність конкурентного виконання IO-bound завдань визначається тим, яку частку часу завдання дійсно використовує CPU, а скільки витрачає на очікування завершення операцій введення/виведення.

Закон Літтла

У теорії масового обслуговування однією з фундаментальних формул є закон Літтла (Little’s Law):

\[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.

Ця формула є окремим випадком (для одного ядра) класичної формули оптимального розміру пулу потоків, запропонованої Brian Goetz у книзі “Java Concurrency in Practice” (2006):

\[N_{threads} = N_{cores} \times \left(1 + \frac{T_{wait}}{T_{service}}\right)\]

Співвідношення T_wait / T_service відоме як коефіцієнт блокування. Чим вищий цей коефіцієнт, тим більше конкурентних завдань може ефективно використовувати одне ядро.

На цьому рівні конкурентності процесор витрачає більшу частину часу на корисну роботу, і подальше збільшення кількості завдань вже не дає помітного виграшу.

Саме тому моделі асинхронного виконання найбільш ефективні для IO-bound веб-навантажень.

Приклад розрахунку для типового веб-застосунку

Розглянемо спрощену, але досить реалістичну модель середнього серверного веб-застосунку. Припустимо, що обробка одного 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 мс, а для хмарних середовищ (cross-AZ, managed RDS) — 1–5 мс. З урахуванням часу виконання запиту середньої складності результуючі 4 мс на запит — реалістична оцінка для хмарного середовища. Час CPU (0.05 мс) покриває маппінг результатів 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 корутин на ядро дозволяють практично повністю приховати затримку I/O, зберігаючи високе завантаження CPU.

Для порівняння: Zalando Engineering наводить приклад з мікросервісом, де час відповіді становить 50 мс, а час обробки — 5 мс на двоядерній машині: 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 мс 2 мс ~2
LAN (один дата-центр) ~1 мс 20 мс ~20
Хмара (cross-AZ, RDS) ~4 мс 80 мс ~80
Віддалений сервер / cross-region ~10 мс 200 мс ~200

Чим більша затримка, тим більше корутин потрібно, щоб повністю завантажити CPU корисною роботою.

PHP-FPM vs корутини: приблизний розрахунок

Щоб оцінити практичну користь від корутин, порівняємо дві моделі виконання на тому ж сервері з тим же навантаженням.

Початкові дані

Сервер: 8 ядер, хмарне середовище (cross-AZ RDS).

Навантаження: типовий Laravel API endpoint — авторизація, Eloquent-запити з eager loading, серіалізація JSON.

На основі даних бенчмарків від Sevalla та Kinsta:

Параметр Значення Джерело
Пропускна здатність Laravel API (30 vCPU, localhost DB) ~440 req/s Sevalla, PHP 8.3
Кількість PHP-FPM воркерів у бенчмарку 15 Sevalla
Час відповіді (W) у бенчмарку ~34 мс L/λ = 15/440
Пам’ять на один PHP-FPM воркер ~40 МБ Типове значення

Крок 1: Оцінка T_cpu та T_io

У бенчмарку Sevalla база даних працює на localhost (затримка <0.1 мс). При ~10 SQL-запитах на endpoint загальний I/O менше 1 мс.

Дано:

За законом Літтла:

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

Оскільки в цьому бенчмарку база даних працює на localhost і загальний I/O менше 1 мс, отриманий середній час відповіді майже повністю відображає час обробки запиту на CPU:

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

Це означає, що в умовах localhost практично весь час відповіді (~34 мс) — це CPU: фреймворк, middleware, ORM, серіалізація.

Перенесемо той самий endpoint у хмарне середовище з 20 SQL-запитами:

\[T_{cpu} = 34 \text{ мс (фреймворк + логіка)}\] \[T_{io} = 20 \times 4 \text{ мс} = 80 \text{ мс (очікування БД)}\] \[W = T_{cpu} + T_{io} = 114 \text{ мс}\]

Коефіцієнт блокування:

\[\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 МБ) 1.08 ГБ
Пропускна здатність (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 проти 1.08 ГБ — різниця у ~20 разів.

Щодо розміру стеку корутини. Обсяг пам’яті корутини в PHP визначається зарезервованим розміром C-стеку. За замовчуванням це ~2 MiB, але його можна зменшити до 128 KiB. При стеку 128 KiB пам’ять для 27 корутин становитиме лише ~3.4 MiB.

Крок 4: Що, якщо навантаження на CPU менше?

Фреймворк Laravel у режимі FPM витрачає ~34 мс CPU на запит, що включає повторну ініціалізацію сервісів при кожному запиті.

У stateful-середовищі виконання (яким і є True Async) ці витрати значно зменшуються: маршрути скомпільовані, контейнер залежностей ініціалізований, пули з’єднань перевикористовуються.

Якщо T_cpu зменшиться з 34 мс до 5 мс (що реалістично для stateful-режиму), картина змінюється кардинально:

T_cpu Коеф. блокув. N (8 ядер) λ (req/s) Пам’ять (FPM) Пам’ять (корутини)
34 мс 2.4 27 237 1.08 ГБ 54 MiB
10 мс 8 72 800 2.88 ГБ 144 MiB
5 мс 16 136 1 600 5.44 ГБ 272 MiB
1 мс 80 648 8 000 25.9 ГБ 1.27 GiB

При T_cpu = 1 мс (легкий обробник, мінімальні накладні витрати):

Крок 5: Закон Літтла — перевірка через пропускну здатність

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

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

Для досягнення такої ж пропускної здатності PHP-FPM потребує 136 воркерів. Кожен займає ~40 МБ:

\[136 \times 40 \text{ МБ} = 5.44 \text{ ГБ лише для воркерів}\]

Корутини:

\[136 \times 2 \text{ MiB} = 272 \text{ MiB}\]

Вивільнені ~5.2 ГБ можна направити на кеші, пули з’єднань до БД або обробку більшої кількості запитів.

Підсумок: Коли корутини дають виграш

Умова Виграш від корутин
Важкий фреймворк, localhost БД (T_io ≈ 0) Мінімальний — навантаження CPU-bound
Важкий фреймворк, хмарна БД (T_io = 80 мс) Помірний — ~20x економія пам’яті при тій же пропускній здатності
Легкий обробник, хмарна БД Максимальний — зростання пропускної здатності до 13x, ~20x економія пам’яті
Мікросервіс / API Gateway Максимальний — майже чистий I/O, десятки тисяч req/s на одному сервері

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

Практичні зауваження

Висновок

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

Саме в таких сценаріях переваги асинхронності проявляються найбільш яскраво.


Додаткове читання


Джерела та література