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,
а скільки витрачає на очікування завершення операцій введення/виведення.
Закон Літтла
У теорії масового обслуговування однією з фундаментальних формул є закон Літтла (Little’s Law):
\[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характеризує завдання, що більшу частину часу очікує на введення/виведення (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.
Ця формула є окремим випадком (для одного ядра) класичної формули оптимального розміру пулу потоків, запропонованої 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-запиту переважно полягає у взаємодії з базою даних
і не містить обчислювально складних операцій.
Початкові припущення
- На один 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 мс - Час обчислень CPU:
T_cpu ≈ 0.05 мс
- Час очікування I/O:
Загалом на один HTTP-запит:
T_io = 20 × 4 мс = 80 мсT_cpu = 20 × 0.05 мс = 1 мс
Щодо обраних значень затримки. Час 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 ядрами:
Наприклад, для 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 мс.
Дано:
- Пропускна здатність: λ ≈ 440 req/s
- Кількість одночасно оброблюваних запитів (PHP-FPM воркери): L = 15
- База даних на localhost, тому T_io ≈ 0
За законом Літтла:
\[W = \frac{L}{\lambda} = \frac{15}{440} \approx 0.034 \, \text{с} \approx 34 \, \text{мс}\]Оскільки в цьому бенчмарку база даних працює на localhost
і загальний I/O менше 1 мс,
отриманий середній час відповіді майже повністю відображає
час обробки запиту на CPU:
Це означає, що в умовах localhost практично весь час відповіді (~34 мс) — це CPU:
фреймворк, middleware, ORM, серіалізація.
Перенесемо той самий endpoint у хмарне середовище з 20 SQL-запитами:
Коефіцієнт блокування:
\[\frac{T_{io}}{T_{cpu}} = \frac{80}{34} \approx 2.4\]Крок 2: PHP-FPM
У моделі PHP-FPM кожен воркер — це окремий процес ОС.
Під час очікування I/O воркер блокується і не може обробляти інші запити.
Для повного завантаження 8 ядер потрібно достатньо воркерів,
щоб у будь-який момент 8 з них виконували CPU-роботу:
| Метрика | Значення |
|---|---|
| Воркери | 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 мс (легкий обробник, мінімальні накладні витрати):
- PHP-FPM потребуватиме 648 процесів і 25.9 ГБ RAM — нереалістично
- Корутини потребують ті ж 648 завдань і 1.27 GiB — ~20 разів менше
Крок 5: Закон Літтла — перевірка через пропускну здатність
Перевіримо результат для T_cpu = 5 мс:
Для досягнення такої ж пропускної здатності 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 в кілька разів ефективніше, споживаючи на порядки менше пам’яті.
Практичні зауваження
- Збільшення кількості корутин вище оптимального рівня рідко дає виграш, але й не є проблемою: корутини легковагі, і накладні витрати від «зайвих» корутин незрівнянно малі порівняно з вартістю потоків ОС
- Реальними обмеженнями стають:
- пул з’єднань до бази даних
- мережева затримка
- механізми back-pressure
- обмеження на відкриті файлові дескриптори (ulimit)
- Для таких навантажень модель event loop + корутини виявляється значно ефективнішою за класичну блокуючу модель
Висновок
Для типового сучасного веб-застосунку, де переважають операції введення/виведення, асинхронна модель виконання дозволяє:
- ефективно приховувати затримку 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 — детальний аналіз формули з урахуванням цільового завантаження CPU
- CYBERTEC: PostgreSQL Network Latency — виміри впливу мережевої затримки на продуктивність PostgreSQL
- PostgresAI: What is a slow SQL query? — рекомендації щодо допустимих затримок SQL-запитів у веб-застосунках