Вспомним, что такое IO-dispatcher в Kotlin корутинах.
IO-dispatcher — это планировщик, который использует пул потоков, оптимизированный под блокирующие IO операции. Верхний лимит по размеру — max(64, число ядер)
IO-пул держит 64+ потоков, и корутина отпускает поток только тогда, когда под капотом неблокирующая операция — тогда она suspend'ится.
Если же внутри suspend-функции прячется JDBC, синхронный HTTP-клиент или Thread.sleep, поток занят до конца вызова — корутинная обёртка тут ничего не меняет
Представим типичное backend-приложение на Kotlin:
API -> бизнес-логика -> Connection Pool (Hikari) -> DB
Если говорить в реалиях Kotlin, то обращение к блокирующим внешним ресурсам: БД и иным сервисам — часто выполняется в отдельном IO coroutine dispatcher.
Допустим, что все эти цепочки идут в параллель и для обращения к сервисам мы используем неблокирующий http client.
API -> бизнес-логика -> IO dispatcher -> Connection Pool(Hikari) -> DB API -> бизнес-логика -> Service (non-blocking, без IO dispatcher) API -> бизнес-логика -> IO dispatcher -> File IO / блокирующий gRPC
Под небольшой нагрузкой приложение работает, всё хорошо. Но что, если у нас много обращений к БД в секунду?
Арифметика насыщения
Если connection pool равен 10, а среднее время удержания connection — 20мс, предельная пропускная способность:
10 / 0.02 = 500 req/s
На практике из-за дисперсии времени удержания очередь начинает заметно расти уже при утилизации ~70–80% (классический результат из queueing theory), то есть около:
500 × 0.75 ≈ 375 req/s
Дальше connection'ы регулярно держатся дольше 20мс, и очередь ожидающих начинает накапливаться внутри Hikari.
А если у сервиса высокий пользовательский трафик, фоновые задачи и время удержания connection больше 20мс, то rps к БД набираются легко.
Финальный каскад
Обращения к БД через JDBC блокирующие, а идут они через IO dispatcher. Ожидание свободного коннекта в Hikari — это тоже блокировка потока: корутина висит внутри getConnection() и не suspend'ится.
В результате, чем длиннее очередь в Hikari, тем больше потоков IO-dispatcher'а одновременно заняты этим ожиданием. Если деградация БД продолжается — весь IO-пул оказывается занят ожиданием коннектов.
Каскад: БД замедлилась → забился connection pool → ожидающие коннекта корутины выедают IO-пул → любой другой блокирующий код, которому нужен IO dispatcher (файлы, блокирующий gRPC, другие БД), встаёт в очередь и деградирует вместе с БД-вызовами.
Неблокирующие вызовы при этом продолжают работать — они не используют IO dispatcher. Но это слабое утешение, если половина бизнес-сценариев ходит в БД.
Как избежать?
Чтобы не получить каскадное падение, нужно ограничить параллелизм кода, который ходит в БД, размером самого пула коннектов. Это реализация bulkhead pattern: очередь формируется на уровне корутин (дешёвых) до попытки взять коннект, а общий IO-пул остаётся свободен для всего остального кода.
// где-то на уровне приложения / DI val jdbcDispatcher = Dispatchers.IO.limitedParallelism(hikariMaxPoolSize) // или отдельный пул потоков val jdbcDispatcher = Executors .newFixedThreadPool(hikariMaxPoolSize) .asCoroutineDispatcher() // использование suspend fun req() = withContext(jdbcDispatcher) { block() }
Но стоит учесть, что limitedParallelism шарит общий IO-пул (может поднимать потоки сверх 64, то есть не откусывает от основного IO), а newFixedThreadPool — создаёт отдельный.
Будет отлично, если размер dispatcher'а совпадает с maximumPoolSize у Hikari — больше лучше не брать (коннектов всё равно не хватит), меньше — недоиспользуем пул.
Ioanna
Я бы сказала, что IO-пул держит ДО 64+ потоков, потому что он расширяется динамически под нагрузкой до верхней границы
max(64, n_cores)или даже выше (при пиках).