Эмпирическая база: почему корутины в одном потоке работают

Утверждение о том, что однопоточная кооперативная конкурентность эффективна для IO-bound нагрузок, подтверждается измерениями, академическими исследованиями и опытом эксплуатации крупных систем.


1. Стоимость переключения: корутина vs поток ОС

Главное преимущество корутин — кооперативное переключение происходит в пользовательском пространстве, без обращения к ядру ОС.

Измерения на Linux

Метрика Поток ОС (Linux NPTL) Корутина / async-задача
Переключение контекста 1.2–1.5 µs (pinned), ~2.2 µs (unpinned) ~170 ns (Go), ~200 ns (Rust async)
Создание задачи ~17 µs ~0.3 µs
Память на задачу ~9.5 KiB (min), 8 MiB (default stack) ~0.4 KiB (Rust), 2–4 KiB (Go)
Масштабируемость ~80 000 потоков (тест) 250 000+ async-задач (тест)

Источники:

Что это означает на практике

Переключение корутины стоит ~200 наносекунд — это на порядок дешевле, чем переключение потока ОС (~1.5 µs). Но ещё важнее, что переключение корутины не вызывает косвенных расходов: сброс TLB-кэша, инвалидация branch predictor, миграция между ядрами — всё это свойственно потокам, но не корутинам в одном потоке.

Для event loop, обрабатывающего 80 корутин на ядро, суммарные расходы на переключение составляют:

80 × 200 ns = 16 µs на полный цикл обхода всех корутин

Это ничтожно мало по сравнению с 80 ms ожидания I/O.


2. Память: масштаб различий

Потоки ОС выделяют стек фиксированного размера (по умолчанию 8 MiB на Linux). Корутины хранят только своё состояние — локальные переменные и точку возобновления.

Реализация Память на единицу конкурентности
Поток Linux (default stack) 8 MiB виртуальной, ~10 KiB RSS минимум
Go goroutine 2–4 KiB (динамический стек, растёт по необходимости)
Kotlin coroutine десятки байт на heap; соотношение thread:coroutine ≈ 6:1
Rust async task ~0.4 KiB
C++ coroutine frame (Pigweed) 88–408 байт
Python asyncio coroutine ~2 KiB (vs ~5 KiB + 32 KiB stack для потока)

Источники:

Следствие для веб-серверов

Для 640 конкурентных задач (8 ядер × 80 корутин):


3. Проблема C10K и реальные серверы

Проблема

В 1999 году Дэн Кегел сформулировал проблему C10K: серверы с моделью «один поток на соединение» не способны обслужить 10 000 одновременных подключений. Причина не в аппаратных ограничениях, а в overhead потоков ОС.

Решение

Проблема была решена переходом к event-driven архитектуре: вместо создания потока на каждое соединение, один цикл событий (event loop) обслуживает тысячи соединений в одном потоке.

Именно этот подход реализуют nginx, Node.js, libuv, и — в контексте PHP — True Async.

Бенчмарки: nginx (event-driven) vs Apache (thread-per-request)

Метрика (1000 concurrent connections) nginx Apache
Запросов в секунду (статика) 2 500–3 000 800–1 200
HTTP/2 throughput >6 000 req/s ~826 req/s
Стабильность под нагрузкой Стабилен Деградация при >150 соединений

nginx обслуживает в 2–4 раза больше запросов, чем Apache, при этом потребляя значительно меньше памяти. Apache с thread-per-request архитектурой принимает не более 150 соединений одновременно (по умолчанию), после чего новые клиенты ждут в очереди.

Источники:


4. Академические исследования

SEDA: Staged Event-Driven Architecture (Welsh et al., 2001)

Мэтт Уэлш, Дэвид Каллер и Эрик Брюер из UC Berkeley предложили SEDA — серверную архитектуру, основанную на событиях и очередях между стадиями обработки.

Ключевой результат: SEDA-сервер на Java превзошёл по пропускной способности Apache (C, thread-per-connection) при 10 000+ одновременных соединений. Apache не мог принять более 150 соединений одновременно.

Welsh M., Culler D., Brewer E. SEDA: An Architecture for Well-Conditioned, Scalable Internet Services. SOSP ‘01 (2001). PDF

Сравнение архитектур веб-серверов (Pariag et al., 2007)

Наиболее тщательное сравнение архитектур провели Pariag et al. из University of Waterloo. Они сравнили три сервера на одной кодовой базе:

Ключевой результат: event-driven µserver и pipeline-based WatPipe обеспечили на ~18% больше пропускной способности, чем thread-based Knot. При этом WatPipe потребовал 25 writer-потоков для достижения той же производительности, что и µserver с 10 процессами.

Pariag D. et al. Comparing the Performance of Web Server Architectures. EuroSys ‘07 (2007). PDF

AEStream: ускорение обработки событий с помощью корутин (2022)

Исследование, опубликованное на arXiv, провело прямое сравнение корутин и потоков для обработки потоковых данных (event-based processing).

Ключевой результат: корутины обеспечили минимум 2x пропускной способности по сравнению с конвенциональными потоками при обработке потока событий.

Pedersen J.E. et al. AEStream: Accelerated Event-Based Processing with Coroutines. (2022). arXiv:2212.10719


5. Масштабируемость: 100 000 задач

Kotlin: 100 000 корутин за 100 ms

В бенчмарке TechYourChance создание и запуск 100 000 корутин заняло ~100 ms overhead. Эквивалентное количество потоков потребовало бы ~1.7 секунды только на создание (100 000 × 17 µs) и ~950 MiB памяти на стеки.

Rust: 250 000 async-задач

В бенчмарке context-switch удалось запустить 250 000 async-задач в одном процессе, в то время как потоки ОС достигли предела на ~80 000.

Go: миллионы goroutines

Go рутинно запускает сотни тысяч и миллионы горутин в production-системах. Именно это позволяет серверам вроде Caddy, Traefik и CockroachDB обрабатывать десятки тысяч одновременных соединений.


6. Сводка доказательной базы

Утверждение Подтверждение
Переключение корутин дешевле потоков ~200 ns vs ~1500 ns — 7–8x (Bendersky 2018, Blandy)
Корутины расходуют меньше памяти 0.4–4 KiB vs 9.5 KiB–8 MiB — 24x+ (Blandy, Go FAQ)
Event-driven сервер масштабируется лучше nginx 2–4x throughput vs Apache (бенчмарки)
Event-driven > thread-per-connection (академически) +18% throughput (Pariag 2007), C10K решена (Kegel 1999)
Корутины > потоки для event processing 2x throughput (AEStream 2022)
Сотни тысяч корутин в одном процессе 250K async tasks (Rust), 100K coroutines за 100ms (Kotlin)
Формула N ≈ 1 + T_io/T_cpu корректна Goetz 2006, Zalando, Little’s Law

Ссылки

Измерения и бенчмарки

Академические работы

Промышленный опыт

См. также