Виртуальные потоки в продакшене: закрепление, потоки-носители и всплески нагрузки¶
10 сентября 2026
Виртуальные потоки делают блокирующую Java значительно более масштабируемой, но они не делают конкурентность бесплатной.
Это различие легко упустить. Как только приложение может создавать сотни тысяч виртуальных потоков, не создавая сотни тысяч потоков операционной системы, возникает соблазн подумать, что традиционные ограничения вокруг пулов потоков исчезли и что приложение может просто принимать столько конкурентной работы, сколько присылают клиенты. В действительности виртуальные потоки устраняют одно важное узкое место: дефицит и стоимость платформенных потоков. Они не устраняют ограничения CPU, ёмкость базы данных, пулы соединений, квоты удалённых сервисов, давление памяти, ограничения сокетов, поведение нативного кода или эффекты очередей.
Kora 2 делает это различие особенно важным, потому что виртуальные потоки — не опциональная модель программирования, наложенная поверх реактивных API. Они часть обычной модели исполнения Kora Framework. Код приложения Kora синхронен: контроллеры, HTTP-клиенты, репозитории и запланированные задачи используют обычные блокирующие сигнатуры, а фреймворк выполняет работу приложения на виртуальных потоках. HTTP-документация Kora явно говорит, что каждый запрос диспетчеризуется на виртуальный поток и что обработчики могут блокироваться обычным образом.
Это даёт приложениям Kora очень привлекательную модель программирования:
HTTP-запрос
↓
виртуальный поток
↓
контроллер
↓
сервис
↓
JDBC / HTTP-клиент / файловая система / другой блокирующий API
Код выглядит почти как традиционная Java «поток на запрос», при этом JVM может мультиплексировать очень большое число этих потоков запросов поверх сравнительно небольшого числа потоков операционной системы.
Поэтому производственный вопрос меняется. Вместо вопроса о том, исчерпает ли блокирующий код пул из 200 рабочих потоков, нам нужно спрашивать, какие ресурсы на самом деле ограничивают конкурентность, когда виртуальный поток может освободить свой носитель, что происходит, когда он не может, и как всплеск трафика распространяется по системе.
Эти вопросы важны гораздо больше, чем сырое число виртуальных потоков.
Виртуальные потоки не выполняются сами по себе¶
Виртуальный поток — всё ещё Java-Thread, но он не постоянно связан с потоком операционной системы. Когда он выполняет Java-код, JVM монтирует его на платформенный поток, известный как *
поток-носитель (carrier thread)*. Носитель — это то, что операционная система на самом деле планирует на CPU.
Концептуально выполнение выглядит так:
Виртуальный поток A ─┐
Виртуальный поток B ─┼── планировщик JVM ──> Носитель 1 ──> CPU
Виртуальный поток C ─┤ └─> Носитель 2 ──> CPU
Виртуальный поток D ─┘ └─> Носитель 3 ──> CPU
В любой конкретный момент только виртуальные потоки, смонтированные на носителях, действительно выполняются. Тысячи других виртуальных потоков могут существовать, но быть приостановленными, ожидая сокеты, ответы базы данных, таймеры, блокировки или другие события.
Именно здесь возникает преимущество масштабируемости. При традиционной архитектуре «платформенный поток на запрос» запрос, заблокированный на вводе-выводе базы данных, по-прежнему владеет своим потоком операционной системы. Если 500 запросов ожидают PostgreSQL, процесс может легко требовать примерно 500 потоков приложения лишь для представления этого ожидания.
С виртуальными потоками блокирующая операция, которую JVM понимает, обычно может приостановить виртуальный поток и размонтировать его с носителя:
VT-42 выполняет код приложения
↓
смонтирован на Носителе-3
↓
чтение JDBC блокируется
↓
VT-42 приостановлен
↓
Носитель-3 становится доступным
↓
VT-193 выполняется на Носителе-3
Oracle описывает это как центральный механизм виртуальных потоков: когда виртуальный поток блокируется на поддерживаемом вводе-выводе, рантайм может приостановить его и освободить лежащий в основе носитель для другого виртуального потока. Поэтому виртуальные потоки предназначены для повышения пропускной способности приложений, проводящих значительное время в ожидании, а не для ускорения отдельных операций.
Вот почему синхронный JDBC снова становится архитектурно разумным в высококонкурентных Java-сервисах. Блокировка виртуального потока обычно дёшева. Блокировка носителя — совсем другое дело.
Блокировка виртуального потока — не то же самое, что блокировка носителя¶
Самая простая мысленная модель — разделять логическую конкурентность и физическую ёмкость исполнения.
Виртуальный поток представляет единицу работы приложения:
Носитель представляет дефицитную физическую ёмкость исполнения.
Предположим, у сервера восемь эффективных ядер CPU. У него может быть 50 000 живых виртуальных потоков, но это не значит, что 50 000 фрагментов Java-кода выполняются одновременно. Лишь относительно небольшое число может выполнять инструкции CPU в любой момент.
Это значит, что обычная блокировка обычно безвредна только тогда, когда JVM может припарковать или приостановить виртуальный поток и переиспользовать носитель.
Рассмотрим запрос:
Если JDBC-драйвер ждёт 20 мс ответа PostgreSQL, виртуальный поток может провести большую часть этого интервала приостановленным. Носитель может делать полезную работу в другом месте.
Упрощённая временная шкала могла бы выглядеть так:
Запрос VT-1
CPU ожидание JDBC CPU
│──────│ │──────────────────────│ │────│
↑ ↑
размонтирование повторное монтирование
Носитель-1
VT-1 VT-7 VT-12 VT-91 VT-1
│──────│ │───│ │────│ │──────│ │────│
Логический запрос длится, возможно, 25 мс, но он занимает носитель только в сравнительно короткие интервалы, когда Java-код действительно выполняется.
Это различие — фундамент модели программирования виртуальных потоков. Оно также объясняет, почему закрепление заслуживает внимания.
Что на самом деле означает закрепление¶
Виртуальный поток закреплён, когда JVM не может размонтировать его с текущего носителя, даже когда виртуальный поток достигает операции, которая блокируется.
Вместо этого:
виртуальный поток блокируется
↓
виртуальный поток размонтируется
↓
носитель становится переиспользуемым
мы получаем:
виртуальный поток блокируется
↓
виртуальный поток остаётся смонтированным
↓
носитель блокируется вместе с ним
Сам виртуальный поток по-прежнему лёгкий, но это больше не помогает, потому что он временно захватил один из дефицитных платформенных потоков под планировщиком виртуальных потоков.
Несколько коротких событий закрепления обычно не имеют значения. Производственные проблемы появляются, когда закрепление одновременно и частое, и достаточно длительное, так что многие носители становятся недоступными в одно и то же время.
Представьте восемь эффективных носителей:
Носитель 1 → закреплён 800 мс
Носитель 2 → закреплён 600 мс
Носитель 3 → закреплён 1.2 с
Носитель 4 → закреплён 700 мс
Носитель 5 → закреплён 900 мс
Носитель 6 → закреплён 500 мс
Носитель 7 → выполнимая работа
Носитель 8 → выполнимая работа
У приложения могут быть тысячи выполнимых виртуальных потоков, но только два носителя доступны для их выполнения. Со стороны сервис может внезапно выглядеть голодающим по CPU, даже если сам CPU может быть не полностью загружен.
Это голодание носителей (carrier starvation): выполнимая работа виртуальных потоков существует, но доступно недостаточно носителей для её выполнения.
Поэтому важное производственное наблюдение не просто «у нас много виртуальных потоков». Оно в том, проводят ли эти виртуальные потоки время либо в коротком выполнении, либо в эффективном размонтировании во время ожидания.
Совет по закреплению в synchronized изменился¶
Многие ранние статьи о виртуальных потоках содержат правило примерно такого вида:
Не выполняйте блокирующие операции внутри
synchronized, потому что виртуальный поток закрепит свой носитель.
Это было корректно для реализации виртуальных потоков, поставляемой в Java 21. JEP 444 документировал две основные ситуации закрепления: выполнение внутри блока или метода synchronized и выполнение
нативного или внешнего (foreign) кода. Он также предупреждал, что планировщик не компенсирует потерю носителей, вызванную закреплением.
Теперь этот совет зависит от версии.
JDK 24 поставил JEP 491, Synchronize Virtual Threads without Pinning. JVM была изменена так, что виртуальные потоки могут обычно размонтироваться, удерживая мониторы Java, ожидая входа в
synchronized и ожидая через операции монитора. Oracle явно перечисляет это как значимое изменение JDK 24.
Для современных развёртываний Kora на JDK 24 или JDK 25 это значит, что такой код, как:
не должен автоматически классифицироваться как проблема закрепления Loom просто потому, что client.call() блокируется.
Это не значит, что код обязательно хорош. Удержание монитора через сетевой ввод-вывод по-прежнему может сериализовать вызывающих на сотни миллисекунд, вызывать конкуренцию за блокировку, увеличивать хвостовую задержку и порождать эффекты конвоя. Семантическая проблема конкурентности остаётся, даже если проблема закрепления носителя на уровне JVM в значительной степени устранена.
Это различие важно:
Java 21–23
synchronized + блокирующая операция
→ возможное закрепление носителя
→ проблема масштабируемости виртуальных потоков
Java 24+
synchronized + блокирующая операция
→ в целом нет закрепления носителя, вызванного монитором
→ но обычная конкуренция за блокировку всё ещё может быть катастрофической
Поэтому производственная проверка должна учитывать версию JDK, прежде чем механически применять рекомендации эпохи Loom.
Нативный код всё ещё другой¶
Нативный и внешний (foreign) код остаются более фундаментальной проблемой, потому что JVM не всегда может безопасно отсоединить продолжение от лежащего в основе нативного стека вызовов.
Текущая документация Oracle по-прежнему описывает виртуальный поток как закреплённый, когда он выполняет нативный метод или внешнюю функцию.
Это важно, потому что Java-приложения часто выполняют нативный код косвенно. Приложение может никогда не объявлять собственный native метод, при этом используя библиотеки, которые в конечном счёте
пересекают границу JNI
или внешних функций.
Концептуально:
Контроллер Kora
↓
Java-библиотека
↓
JNI / FFM / нативная библиотека
↓
блокирующий системный вызов или нативная операция
Если виртуальный поток блокируется, когда не может размонтироваться, его носитель может оставаться занятым на всё это время.
Не каждый нативный вызов опасен. Короткая нативная операция, длящаяся микросекунды, на практике нерелевантна. Исследования заслуживает нативная граница вокруг чего-то, что может ждать долго или непредсказуемо: операции файловой системы в необычных библиотеках, нативные клиенты баз данных, библиотеки сжатия с внутренней блокировкой, модули безопасности, нативные реализации DNS, аппаратные интеграции или сторонние библиотеки, чья модель потоков была спроектирована задолго до появления виртуальных потоков.
Поэтому производственный вопрос не «использует ли эта зависимость JNI?», а:
Может ли она блокироваться, пока виртуальный поток не может размонтироваться?
×
Как часто это происходит?
×
Как долго это может длиться?
Редкое закрепление на 100 мкс — шум. Тысячи одновременных закреплений по 500 мс могут стать аварией.
Голодание носителей — не единственный способ проиграть¶
Было бы ошибкой делать закрепление центром каждого производственного расследования виртуальных потоков. На современных JDK, особенно после JEP 491, обычное насыщение ресурсов, вероятно, будет иметь значение чаще.
Рассмотрим сервис, где каждый запрос выполняет один JDBC-запрос:
Клиент
↓
виртуальный поток HTTP-запроса Kora
↓
Сервис
↓
JDBC-репозиторий
↓
пул соединений Hikari
↓
PostgreSQL
Kora может легко создать достаточно виртуальных потоков, чтобы представить 20 000 конкурентных запросов. PostgreSQL, вероятно, не может эффективно выполнять 20 000 запросов из одного экземпляра приложения, а пул соединений всё равно этого не позволит.
JDBC-модуль Kora намеренно держит этот ресурс ограниченным. В Kora 2 JDBC опирается на пул соединений Hikari; maxPoolSize по умолчанию равен 10, а connectionTimeout управляет тем, как долго
вызывающие
могут ждать получения соединения. Телеметрия JDBC также может раскрывать метрики уровня пула, такие как время получения соединения.
Предположим:
Максимум примерно десять удерживающих соединение операций с базой данных из этого пула могут выполняться одновременно. Остальные запросы, привязанные к базе данных, ждут.
Виртуальные потоки делают это ожидание дешёвым с точки зрения потоков JVM, что отлично. Но они не заставляют его исчезнуть.
Очередь просто переместилась.
Виртуальные потоки устраняют ограничение потоков, а не ограничение конкурентности¶
До виртуальных потоков пул рабочих потоков часто служил двум целям одновременно.
Он был механизмом исполнения:
но также был случайным механизмом контроля приёма:
Как только приложение переключается на один виртуальный поток на запрос, эта случайная граница конкурентности может исчезнуть.
Это обычно желательно, потому что доступность платформенных потоков — плохой способ выражать бизнес- или инфраструктурную ёмкость. Но тогда приложение должно стать явным в отношении ограничений, которые на самом деле важны.
Например:
Виртуальные потоки фактически изобильны
CPU ограничен
JDBC-соединения ограничены
Ёмкость PostgreSQL ограничена
Конкурентность удалённых API ограничена
Разделы Kafka ограничены
Файловые дескрипторы ограничены
Память ограничена
Руководство Oracle по виртуальным потокам проводит то же различие: виртуальные потоки в целом не следует пулить лишь для ограничения их числа. Когда доступ к ограниченному ресурсу нужно сдерживать, используйте явный механизм конкурентности, такой как семафор, а не превращайте виртуальные потоки обратно в традиционный фиксированный пул потоков.
Поэтому исполнитель виртуальных потоков отвечает на:
Как мне представлять конкурентные задачи?
Он не отвечает на:
Сколько операций моя база данных, платёжный провайдер, GPU, файловая система или нижестоящий сервис должны получать одновременно?
Это разные проблемы.
Пул соединений становится явной границей противодавления¶
Пул JDBC-соединений — это больше, чем оптимизация, избегающая повторного открытия TCP-соединений. Это граница конкурентности между приложением и базой данных.
С десятью соединениями:
Только десять вызывающих могут владеть соединениями одновременно.
С платформенными потоками запросы, ожидающие эти соединения, потребляли рабочие потоки, поэтому истощение пула потоков часто появлялось вскоре после истощения пула соединений.
С виртуальными потоками тысячи вызывающих могут ждать дёшево:
Это может быть большим улучшением, потому что ёмкость исполнения приложения больше не тратится впустую только потому, что запросы ждут дефицитный ресурс.
Но это также создаёт новый режим отказа: огромная дешёвая очередь.
Дешёвое не значит безвредное. Каждый ожидающий запрос всё ещё имеет связанные объекты, состояние запроса, состояние трассировки, буферы, дедлайны, состояние сокета и, возможно, вышестоящих вызывающих, ожидающих ответ. Более важно, длинная очередь увеличивает задержку и может сохранять работу, которую уже следовало отклонить.
Предположим, база данных может устойчиво завершать 1 000 запросов в секунду, а внезапно приходит 10 000 запросов.
Разрешение всем 10 000 стать виртуальными потоками не увеличивает пропускную способность базы данных:
скорость поступления 10 000 запросов/с
ёмкость базы данных 1 000 запросов/с
разница +9 000 запросов/с
Очередь растёт примерно на 9 000 запросов каждую секунду, пока перегрузка продолжается.
Виртуальные потоки позволяют JVM удерживать эту очередь дольше. Они не делают систему стабильной.
Вот почему таймауты, bulkhead'ы, ограничения скорости, сброс нагрузки, ограниченные очереди и явные средства контроля конкурентности остаются первостепенными производственными заботами в архитектуре виртуальных потоков.
Закон Литтла по-прежнему побеждает¶
Одна из самых полезных формул для рассуждения об этих системах — закон Литтла:
где:
L = среднее число операций в системе
λ = пропускная способность / скорость поступления
W = среднее время, которое каждая операция проводит в системе
В практической терминологии бэкенда:
Предположим, сервис обрабатывает 2 000 запросов в секунду со средней сквозной задержкой 50 мс:
В среднем в полёте должно находиться примерно 100 запросов.
Виртуальные потоки делают представление этих 100 запросов лёгким. Они также делают технически возможным представление 1 000 или 10 000. Но закон Литтла говорит нам нечто более важное: конкурентность растёт автоматически, когда растёт задержка.
Предположим, скорость поступления остаётся 2 000 RPS, но база данных замедляется и сквозная задержка становится 500 мс:
Сервису теперь нужно примерно в десять раз больше запросов в полёте, чтобы поддерживать ту же пропускную способность.
Ничего в клиентском трафике не изменилось.
Изменилась только задержка.
Это один из механизмов каскадной перегрузки:
нижестоящая система замедляется
↓
растёт задержка запросов
↓
растёт конкурентность в полёте
↓
заполняются пулы и очереди
↓
растёт время ожидания
↓
задержка растёт дальше
↓
таймауты / повторы
↓
ещё больше работы
Виртуальные потоки решают шаг «у нас закончились Java-потоки на базе ОС». Они не могут решить саму систему очередей.
Закон Литтла применительно к пулу базы данных¶
Закон Литтла становится ещё полезнее, если применить его конкретно к интервалу, в течение которого запрос владеет соединением с базой данных.
Предположим:
Тогда средняя конкурентность соединений приблизительно:
Это говорит о том, что для этой рабочей нагрузки в среднем одновременно требуется примерно 12 соединений, до учёта дисперсии, всплесков, транзакций, хвостовой задержки и эксплуатационного запаса.
Теперь предположим, что регрессия базы данных увеличивает время удержания соединения с 8 мс до 40 мс:
Требуемая конкурентность выросла в пять раз, хотя трафик не изменился.
Если пул содержит 20 соединений, запросы начинают ждать получения соединения. Как только они ждут, растёт сквозная задержка. Если у вызывающих агрессивные повторы, может поступать дополнительный трафик. Система может быстро перейти из нормальной работы в режим перегрузки.
Это иллюстрирует, почему слепое изменение:
на:
не обязательно является исправлением.
Это может просто перенести очередь из приложения в PostgreSQL. Если база данных может эффективно выполнять только 30 конкурентных запросов для этой рабочей нагрузки, предоставление ей 200 соединений может увеличить конкуренцию за CPU, использование памяти, конкуренцию за блокировки, давление на кеш и задержку запросов.
Поэтому правильный размер пула не «настолько большой, насколько конкурентность виртуальных потоков».
Это управляемый интерфейс к ёмкости базы данных.
Всплеск нагрузки через сервис Kora¶
Рассмотрим сервис Kora, получающий внезапный всплеск трафика.
В нормальных условиях:
Всё здорово.
Затем трафик прыгает до 6 000 RPS.
Поскольку Kora может создавать виртуальные потоки дёшево, приём запросов не сразу исчерпывает обычный пул HTTP-рабочих:
Если PostgreSQL и пул действительно могут выдержать новую пропускную способность, система может масштабироваться прекрасно. Это сценарий, для которого предназначены виртуальные потоки: многие запросы проводят большую часть своего времени в ожидании, а носители остаются доступными для полезного выполнения Java.
Теперь предположим, что PostgreSQL может устойчиво выдавать только 2 500 из этих операций в секунду.
Архитектура становится:
Каждую секунду приходит примерно на 3 500 запросов больше, чем путь базы данных может завершить. Через десять секунд в системе может быть десятки тысяч дополнительных запросов, ожидающих где-то в пути запроса.
JVM может выглядеть удивительно здоровой. Может не быть взрыва 10 000 платформенных потоков. CPU может изначально не выглядеть катастрофически. Именно поэтому производственный мониторинг виртуальных потоков должен включать очереди ресурсов, а не только число потоков.
Приложение может быть перегружено, пока механизм виртуальных потоков работает идеально.
Как выглядит голодание носителей во время всплеска¶
Другой сценарий возникает, если путь запроса входит в блокирующую нативную операцию, которая закрепляет носители.
Представьте приложение с восемью полезными потоками-носителями и вызовом внешней нативной библиотеки:
При обычном трафике этот путь одновременно использует только один-два запроса, поэтому ничего необычного не происходит.
Во время всплеска восемь вызовов блокируются внутри нативного кода:
Носитель 1 → нативный вызов → заблокирован
Носитель 2 → нативный вызов → заблокирован
Носитель 3 → нативный вызов → заблокирован
Носитель 4 → нативный вызов → заблокирован
Носитель 5 → нативный вызов → заблокирован
Носитель 6 → нативный вызов → заблокирован
Носитель 7 → нативный вызов → заблокирован
Носитель 8 → нативный вызов → заблокирован
Тем временем:
Теперь у системы есть готовая к выполнению работа приложения, но недостаточно ёмкости носителей.
Симптомы могут включать резко растущую задержку ответов, неожиданно низкую полезную пропускную способность, накапливающиеся выполнимые виртуальные потоки и поведение, выглядящее несовместимым с обычным насыщением CPU.
Это сценарий, в котором оправдан анализ закрепления.
Но он принципиально отличается от:
В последнем случае планировщик делает ровно то, что должен. Узкое место — граница конкурентности базы данных, а не носители.
Различение этих двух ситуаций критично во время реагирования на инцидент.
Работа, привязанная к CPU, имеет тот же фундаментальный предел¶
Виртуальные потоки оптимизированы для рабочих нагрузок, содержащих ожидание. Они не увеличивают доступное количество CPU.
Предположим, запрос выполняет 100 мс чистых вычислений CPU:
С восемью ядрами создание 10 000 виртуальных потоков, выполняющих эти вычисления, не даёт полезного параллелизма на 10 000 путей.
Оно порождает конкуренцию примерно за восемь ядер:
Пропускная способность по-прежнему определяется в первую очередь ёмкостью CPU.
Если один запрос потребляет 100 мс одного ядра, грубая идеализированная верхняя граница:
Создание большего числа виртуальных потоков не может изменить арифметику.
Фактически, неконтролируемое создание привязанных к CPU виртуальных потоков может ухудшить хвостовую задержку, потому что больше выполнимых задач конкурируют за одну и ту же фиксированную ёмкость исполнения. Это ещё один случай, когда явное ограничение конкурентности может быть уместным.
Правильная концептуальная модель:
Конкурентность запросов и конкурентность ресурсов следует разделять¶
Полезная архитектура виртуальных потоков разделяет два вопроса.
Первый — сколько логических запросов может существовать:
Второй — сколько конкурентности должна получать каждая дефицитная подсистема:
база данных → пул соединений
удалённый API партнёра → семафор / bulkhead
алгоритм, тяжёлый по CPU → ограничение конкурентности
GPU → явная очередь
файловая система → ограничение, специфичное для рабочей нагрузки
Это точнее, чем использование одного глобального пула рабочих потоков для управления всем.
Традиционные системы часто имели:
и случайно использовали это число как предел для запросов к базе данных, HTTP-вызовов, работы CPU, операций файловой системы и каждой другой нижестоящей зависимости.
Виртуальные потоки поощряют лучшую архитектуру:
много логических ВТ запросов
├── bulkhead БД: 20
├── платёжный API: 50
├── поисковый бэкенд: 100
└── тяжёлая работа CPU: 8
Каждая граница теперь может отражать ёмкость ресурса, который она защищает.
Это значительное архитектурное преимущество, но только если эти ограничения действительно спроектированы.
Пулы соединений остаются пулами не просто так¶
Частый вопрос — должны ли пулы JDBC-соединений стать намного больше теперь, когда виртуальные потоки делают блокировку дешёвой.
Обычно такого общего правила не существует.
Виртуальные потоки меняют стоимость ожидания соединения. Они не меняют стоимость самого соединения и не делают базу данных бесконечно параллельной.
Соединение PostgreSQL по-прежнему представляет реальное серверное состояние и ёмкость исполнения. Транзакция по-прежнему может удерживать блокировки. Долгие запросы по-прежнему могут потреблять CPU. Слишком много активных сессий по-прежнему может конкурировать за кеши и память.
Поэтому пул остаётся полезным по нескольким причинам:
переиспользование дорогих соединений с БД
+
ограничение конкурентности БД
+
создание очереди перед базой данных
+
предоставление таймаутов получения
+
раскрытие метрик насыщения
Значения по умолчанию JDBC в Kora делают эти средства контроля явными. Конфигурация JDBC в Kora 2 раскрывает maxPoolSize, connectionTimeout, настройки валидации, настройки времени жизни,
обнаружение утечек и метрики драйвера.
Виртуальные потоки просто делают ожидание вызывающего за этим пулом намного дешевле, чем был бы платформенный поток.
Это улучшение эффективности реализации, а не разрешение игнорировать планирование ёмкости.
Таймауты становятся важнее, а не менее¶
Поскольку виртуальные потоки делают ожидание недорогим, системы могут выдерживать гораздо большее число ожидающих операций, прежде чем истощение потоков JVM выведет проблему на поверхность.
Это делает дедлайны и таймауты более важными.
Рассмотрим:
Ёмкость БД временно рушится
↓
10 000 виртуальных потоков ждут
↓
БД восстанавливается
↓
все выжившие запросы конкурируют за продолжение
Если эти запросы уже бесполезны, потому что вышестоящие клиенты сдались несколько секунд назад, их продолжение лишь порождает устаревшую работу.
Поэтому производственная система должна определять, как долго работа остаётся ценной на каждой границе.
Для JDBC получение соединения не должно ждать вечно. Kora предоставляет для этого connectionTimeout.
Для HTTP-клиентов дедлайны запросов должны отражать сквозной бюджет вызывающего, а не произвольный очень большой таймаут сокета.
Для структурированного разветвления дочерние операции должны оставаться ограниченными дедлайном запроса.
Таймауты — не просто обработка отказов. Они часть контроля перегрузки, потому что определяют, как быстро устаревшая работа покидает очереди.
Повторы могут превратить задержку в аварию¶
Виртуальные потоки также делают циклы повторов синтаксически безвредными:
Стоимость потока может быть низкой, но стоимость ниже по потоку — нет.
Если сервис уже получает 5 000 RPS и его зависимость начинает давать таймауты, три немедленных повтора могут превратить предпринимаемую нагрузку во что-то приближающееся к:
Зависимость становится медленнее, закон Литтла увеличивает конкурентность в полёте, больше запросов получают таймаут, и появляется ещё больше трафика повторов.
Виртуальные потоки не имеют отношения к предотвращению этой петли обратной связи.
Политики отказоустойчивости по-прежнему нуждаются в ограниченных повторах, экспоненциальной паузе, джиттере, дедлайнах, размыкании цепи и сбросе нагрузки.
Более широкий урок в том, что виртуальные потоки улучшают механику ожидания, не меняя экономику ресурсов, которых ожидают.
Диагностика закрепления вместо догадок¶
Когда производственный сервис показывает неожиданное поведение масштабируемости виртуальных потоков, закрепление следует измерять, а не выводить из стиля кода.
JDK предоставляет учитывающую виртуальные потоки диагностику через Java Flight Recorder и дампы потоков. Oracle документирует JFR-события для виртуальных потоков и jcmd Thread.dump_to_file, которые
могут показывать и платформенные,
и виртуальные потоки.
На старых JDK диагностика закрепления была особенно важна для поиска блокирующих операций, выполняемых при удержании мониторов. На текущих JDK нативные или внешние фреймы заслуживают большего внимания, потому что обычное закрепление на основе мониторов удалено из обычной реализации блокировок.
Производственное расследование должно коррелировать информацию о потоках с телеметрией ресурсов. Полезный вопрос не просто в том, существует ли много виртуальных потоков, а в том, в каком они состоянии и какого ресурса ждут.
Например:
много припаркованных ВТ
+ пул JDBC активен = макс
+ растёт задержка получения JDBC
→ насыщение базы данных/пула
много выполнимых ВТ
+ CPU около 100%
→ насыщение CPU
много выполнимых ВТ
+ носители заблокированы в нативных фреймах
+ CPU неожиданно низкий
→ возможное голодание носителей / закрепление
много ожидающих ВТ
+ p99 удалённого HTTP взрывается
→ насыщение нижестоящей системы
много ВТ
+ нормальная задержка
+ нормальные очереди
→ вероятно, полностью здорово
Поэтому сырое число виртуальных потоков само по себе — слабый эксплуатационный сигнал.
Что мониторить в сервисе Kora¶
Сервис Kora, ориентированный на виртуальные потоки, следует наблюдать как цепочку очередей и границ ресурсов, а не как один гигантский пул потоков.
Наиболее полезная эксплуатационная картина сочетает:
- скорость HTTP-запросов, активные запросы, задержку p50/p95/p99, таймауты и отклонённую работу; диагностику виртуальных потоков и носителей, когда подозреваются проблемы планировщика; использование CPU и давление выполнимых задач; метрики JDBC: активные, простаивающие, ожидающие, время получения и таймауты; задержку запросов к базе данных и серверные метрики CPU/блокировок/сессий; задержку и конкурентность нижестоящего HTTP; события отказоустойчивости, такие как таймауты, повторы, размыкания circuit breaker и насыщение bulkhead; кучу JVM, скорость выделения, GC и давление сокетов/файловых дескрипторов.
Kora уже интегрирует метрики и трассировку во всех своих модулях, а её JDBC-интеграция может раскрывать статистику пула Hikari, такую как размер пула и время получения соединения.
Важный сдвиг — перестать рассматривать «число потоков» как основную метрику ёмкости. В сервисе виртуальных потоков большое число потоков может быть совершенно нормальным.
Глубина очереди и насыщение рассказывают гораздо более полезную историю.
Почему неограниченные виртуальные потоки не должны означать неограниченный приём¶
Одно из лучших свойств виртуальных потоков в том, что разработчикам больше не нужно беречь потоки, как будто они соединения с базой данных.
Одно из самых опасных заблуждений о виртуальных потоках — вывод, что ограничение конкурентности больше нигде не требуется.
Эти две идеи совместимы:
виртуальные потоки:
создавайте свободно для логических задач
дефицитные ресурсы:
ограничивайте намеренно
Поэтому сервер может принимать HTTP-запрос в виртуальный поток, не резервируя тяжёлый поток операционной системы, при этом всё ещё отказывая или откладывая операции, когда конкретная подсистема достигает своего безопасного предела конкурентности.
Например:
Семафор не компенсирует дорогие Java-потоки. Он выражает реальное доменное ограничение:
Это различие ровно то, что Oracle рекомендует концептуально: не пулите виртуальные потоки просто для ограничения их числа; используйте явные средства контроля, когда дефицитный ресурс требует ограниченной конкурентности.
Это даёт более чистую архитектуру, потому что ограничения конкурентности живут рядом с ресурсами, чью ёмкость они описывают.
Kora делает границы ресурсов легче увидеть¶
Синхронная модель Kora 2 особенно подходит для этого стиля рассуждений, потому что путь выполнения остаётся прямым.
Вызов репозитория выглядит как вызов репозитория:
Вызов HTTP-клиента выглядит как вызов HTTP-клиента:
Транзакция выглядит как синхронная транзакционная работа.
Фреймворк не требует переводить каждую блокирующую границу в цепочку издателей, обратных вызовов или приостановленных функций. Kora явно описывает свои контроллеры, клиенты, репозитории и планировщики как обычные синхронные API, выполняемые на виртуальных потоках.
В результате о производственной архитектуре можно рассуждать в той же форме, что и об исходном коде:
ВТ запроса
↓
контроллер
↓
сервис
├── JDBC → пул Hikari → PostgreSQL
├── HTTP → bulkhead удалённого сервиса → удалённый сервис
└── работа CPU → CPU
Модель конкурентности не скрыта. Каждая операция блокируется естественно, при этом каждый действительно дефицитный ресурс сохраняет свою собственную явную границу ёмкости.
Это, пожалуй, более важное следствие виртуальных потоков, чем просто «Java может создавать много потоков».
Лучшая мысленная модель для всплесков нагрузки¶
Неверная модель:
Лучшая модель:
Виртуальные потоки дёшевы
→ запросы могут ждать, не потребляя по одному платформенному потоку каждый
→ приложение может представлять гораздо больше конкурентности
→ поэтому реальные пределы ресурсов становятся более видимыми и более важными
Во время всплеска проследите запрос через каждую границу ёмкости:
Входящий трафик
↓
Приём HTTP
↓
Виртуальный поток
↓
CPU
↓
Пул соединений БД
↓
База данных
↓
Удалённая зависимость
На каждом этапе задавайте два вопроса:
Какова устойчивая пропускная способность?
Сколько очереди мы готовы допустить, прежде чем отклонять работу?
Если поступление превышает устойчивое завершение, где-то растёт очередь. Виртуальные потоки могут сделать эту очередь дешёвой с точки зрения потоков операционной системы, но теория очередей не изменилась.
В конце концов должно произойти одно из четырёх:
ёмкость увеличивается
работа завершается быстрее
новая работа отклоняется
или задержка растёт без границ
Нет пятого исхода, в котором виртуальные потоки каким-то образом поглощают неограниченную перегрузку.
Производственный пример: здоровое масштабирование виртуальных потоков¶
Рассмотрим сервис Kora, развёрнутый на восьми ядрах.
Нормальная нагрузка:
Закон Литтла даёт:
Каждый запрос выполняет около 2 мс работы CPU и ждёт примерно 30 мс на сетевом вводе-выводе или вводе-выводе базы данных.
Это отличная рабочая нагрузка для виртуальных потоков. Большая часть времени жизни каждого запроса — ожидание, поэтому виртуальные потоки часто размонтируются, а носители остаются доступными.
Трафик удваивается:
Если база данных и нижестоящие сервисы могут справиться с увеличением, ожидаемая средняя конкурентность становится примерно:
Представление 240 потоков запросов тривиально для виртуальных потоков. Большой пул платформенных потоков не требуется, и синхронный код может продолжать масштабироваться эффективно.
Это случай успеха.
Производственный пример: насыщение базы данных¶
Теперь используем тот же сервис, но предположим, что база данных может выдерживать только около 3 500 операций в секунду.
Трафик достигает 6 000 RPS.
Создание виртуальных потоков всё ещё в порядке. Поведение носителей всё ещё в порядке. Может быть ноль проблемных закреплений.
Тем не менее:
Вокруг пула соединений формируется очередь.
Задержка получения соединения растёт с:
до:
Сквозная задержка растёт вместе с ней. Затем закон Литтла предсказывает больше запросов в полёте. Больше состояния запросов остаётся в памяти. В конце концов истекают дедлайны получения.
Это не отказ Loom.
Это обычная перегрузка базы данных, выраженная через архитектуру виртуальных потоков.
Исправление может включать снижение стоимости запросов, добавление ёмкости базы данных, кеширование, уменьшение операций БД на запрос, настройку размера пула в пределах реальной ёмкости базы данных, добавление сброса нагрузки или масштабирование топологии приложения/базы данных.
Увеличение числа виртуальных потоков ничего бы не дало, потому что доступность виртуальных потоков никогда не была узким местом.
Производственный пример: голодание носителей¶
Наконец, рассмотрим нативную библиотеку, которая иногда блокируется на две секунды.
При нормальном трафике:
Во время всплеска:
Выполнимые виртуальные потоки начинают накапливаться, хотя пулы ниже по потоку не насыщены.
Именно здесь замена или изоляция нативной операции может иметь значение. В зависимости от библиотеки и рабочей нагрузки может быть уместно обновить зависимость, изменить используемый API, переместить проблемную работу за ограниченный выделенный исполнитель, перепроектировать интеграцию или иным образом предотвратить захват блокирующим нативным участком носителей, используемых для обычной обработки запросов.
Важно, что смягчение должно нацеливаться на фактическую закреплённую операцию, а не вводить общий фиксированный пул рабочих вокруг всего кода приложения и тем самым выбрасывать преимущества виртуальных потоков.
Не восстанавливайте старую архитектуру пулов потоков¶
Частая ошибка миграции выглядит так:
внутри приложения, которое уже выполняет запрос на виртуальном потоке.
Намерение часто — безопасность: «мы должны ограничить виртуальные потоки».
Но если реальный защищаемый ресурс — PostgreSQL, исполнитель из 200 потоков — косвенный и неточный способ выразить это ограничение.
Лучшая граница обычно — пул соединений базы данных или другой явный механизм конкурентности.
Точно так же, если только одна подсистема, тяжёлая по CPU, должна выполнять восемь задач одновременно, мало причин ограничивать несвязанное ожидание HTTP, доступ к кешу или ожидания JDBC теми же восемью слотами.
Виртуальные потоки позволяют нам перестать использовать дефицит рабочих потоков как универсальный механизм контроля трафика.
Это функция, которую стоит сохранить.
Практические правила¶
Для производственных приложений Kora полезная рабочая модель — держать код запросов синхронным и позволять модели виртуальных потоков Kora делать то, для чего она спроектирована, при этом относясь к
каждому дефицитному
внешнему или физическому ресурсу как к отдельной проблеме ёмкости. Блокирующие вызовы JDBC и HTTP не подозрительны по своей сути. Долгая нативная блокировка заслуживает расследования. synchronized
заслуживает обычной
проверки дизайна блокировок, но на JDK 24+ его больше не следует автоматически рассматривать как опасность закрепления Loom, описанную в материалах эпохи Java 21. Пулы баз данных следует настраивать
по размеру в соответствии с рабочей нагрузкой и
ёмкостью базы данных, а не числом виртуальных потоков, а средства контроля перегрузки должны предотвращать рост дешёвых очередей виртуальных потоков без полезных границ.
Самое важное — наблюдайте очередь и насыщение, а не считайте потоки. Если задержка удваивается при постоянной скорости поступления, закон Литтла говорит, что давление конкурентности тоже удваивается. Это остаётся верным независимо от того, использует ли реализация платформенные потоки, виртуальные потоки, корутины, цикл событий или реактивные потоки.
Заключение¶
Виртуальные потоки фундаментально улучшают экономику блокирующей Java.
Они позволяют Kora 2 использовать простую синхронную модель программирования, не возвращаясь к старому пределу масштабируемости, когда каждый заблокированный запрос монополизировал дорогой поток операционной системы. Контроллер Kora может вызывать репозиторий, репозиторий может выполнять JDBC, а виртуальный поток запроса может ждать естественно, пока его носитель переиспользуется для другой работы. Это возвращает программирование «поток на запрос» в высококонкурентные бэкенд-системы, не требуя организации кода приложения вокруг асинхронного потока управления.
Но виртуальные потоки решают очень специфическую проблему ресурсов.
Они делают потоки изобильными.
Они не делают изобильными циклы CPU, соединения с базой данных, слоты исполнения базы данных, ёмкость удалённых сервисов, память, сокеты, нативное исполнение или время.
Закрепление — один из способов, которым эта абстракция может протекать. На Java 21–23 блокировка при удержании монитора могла закреплять виртуальный поток на его носителе; начиная с JDK 24, JEP 491 удалил это закрепление, вызванное монитором, из обычной реализации блокировок, сделав большую часть старого совета Loom устаревшей. Нативные и внешние операции всё ещё могут закреплять, поэтому длительная нативная блокировка остаётся тем, что стоит находить и измерять.
Однако для большинства производственных сервисов более важной проблемой, вероятно, будет обычное насыщение ресурсов. Приложение Kora может быть вполне способно создать ещё 50 000 виртуальных потоков, пока его десять JDBC-соединений уже заняты, а PostgreSQL находится на своём полезном пределе конкурентности. В этой ситуации большая конкурентность — не дополнительная ёмкость. Это дополнительное ожидание в очереди.
Это центральный производственный урок:
Виртуальные потоки устраняют потолок масштабируемости «поток на запрос».
Они не устраняют потолок ёмкости системы.
Поэтому хорошо спроектированный сервис виртуальных потоков сочетает изобильную логическую конкурентность с намеренно ограниченной физической конкурентностью. Kora поставляет простую модель исполнения; пулы соединений, дедлайны, политики отказоустойчивости, bulkhead'ы, планирование ёмкости базы данных и теория очередей определяют, как эта модель переживает реальный трафик.
Как только это различие ясно, о виртуальных потоках становится гораздо легче рассуждать в продакшене. Вопрос больше не в том, опасна ли сама блокировка.
Вопрос в том, какого ресурса ждёт запрос, сколько конкурентности этот ресурс может безопасно выдержать и что система делает, когда спрос превышает это.