Как HTTP-запрос проходит через Kora на виртуальных потоках¶
14 августа 2026
Типичный HTTP-эндпоинт фреймворка Kora может выглядеть почти обманчиво просто:
Нет Mono<User>, нет CompletionStage<User>, нет колбэка, нет границы корутин и нет явной передачи исполнителю. Репозиторий может синхронно вызывать JDBC и ждать базу данных. С точки зрения
приложения запрос следует обычному последовательному потоку управления Java.
И всё же под этим простым методом скрываются две очень разные области конкурентности.
На краю приложения Undertow использует небольшое число сетевых потоков ввода-вывода, чтобы принимать соединения, разбирать HTTP-трафик и реагировать на готовность сокетов. Как только Kora готов выполнить код приложения, запрос покидает этот ограниченный сетевой поток и продолжается на виртуальном потоке Java. Контроллер, сервис, репозиторий, драйвер JDBC, перехватчики, мапперы запросов и другой код уровня приложения затем выполняются, используя знакомую модель программирования «поток на запрос».
Концептуально путь выглядит так:
Сокет
↓
Поток ввода-вывода Undertow / XNIO
↓
HTTP-маршрутизация Kora
↓
диспетчеризация
↓
Виртуальный поток
↓
сгенерированный обработчик Kora
↓
перехватчики / маппинг запроса
↓
Контроллер
↓
Сервис
↓
Репозиторий
↓
JDBC
↓
База данных
Обратный путь следует примерно тому же пути в обратном порядке:
Результат базы данных
↓
JDBC
↓
Репозиторий
↓
Сервис
↓
Контроллер
↓
маппер ответа / сериализация
↓
HTTP-ответ Kora
↓
Undertow
↓
Сокет
Понимание того, где происходит переход между этими мирами, объясняет большую часть модели конкурентности Kora 2: почему блокирующий JDBC приемлем, почему коду приложения не нужны реактивные сигнатуры, что такое поток-носитель на самом деле и почему блокировка потока ввода-вывода Undertow принципиально отличается от блокировки виртуального потока.
Kora 2 явно принимает эту модель. Его документация описывает код приложения как синхронный и утверждает, что HTTP-запросы диспетчеризуются на виртуальные потоки. Контроллеры, HTTP-клиенты, репозитории
и запланированные задачи используют обычные блокирующие сигнатуры, а реактивные и Kotlin suspend-контракты удалены из модулей Kora.
У запроса больше одного потока¶
Самая лёгкая ошибка при обсуждении серверов на виртуальных потоках — сказать:
«Запрос выполняется на виртуальном потоке.»
Это направленно корректно, но неполно.
TCP-соединение не волшебным образом прибывает прямо внутрь виртуального потока. Под приложением всё ещё есть сетевой стек, и реализация HTTP-сервера Kora использует Undertow. Сам Undertow построен на
асинхронном NIO через XNIO и обрабатывает сетевые события, используя относительно небольшое число потоков ввода-вывода. HTTP-документация Kora раскрывает это разделение напрямую:
httpServer.undertow.ioThreads управляет числом сетевых потоков ввода-вывода, в то время как обработка запросов приложения не использует традиционный ограниченный блокирующий пул воркеров.
Итак, нужно различать как минимум три концепции:
Поток ввода-вывода Undertow
│
│ диспетчеризует запрос
▼
Виртуальный поток
│
│ JVM планирует выполнение
▼
Поток-носитель (платформенный)
Это не три имени одной вещи.
Поток ввода-вывода Undertow принадлежит архитектуре HTTP-транспорта. Он реагирует на готовность сети и мультиплексирует множество соединений.
Виртуальный поток представляет логическое выполнение запроса приложением.
Поток-носитель — это управляемый JVM платформенный поток, на котором виртуальный поток выполняется, пока он готов к выполнению.
Это различие фундаментально.
Этап 1: Пакет достигает Undertow¶
Представьте, что клиент отправляет:
Операционная система получает TCP-пакеты и в конце концов сигнализирует, что на сокете доступны данные. Инфраструктура XNIO в Undertow использует селекторы Java NIO и относительно небольшую группу потоков ввода-вывода, чтобы реагировать на эти события.
Релевантная архитектура примерно такова:
соединения
/ | \
/ | \
▼ ▼ ▼
сокет сокет сокет
\ | /
\ | /
▼ ▼ ▼
XNIO Selector
│
▼
Поток ввода-вывода Undertow
Один поток ввода-вывода может поэтому участвовать в обработке событий для многих соединений.
Именно поэтому блокировка здесь опасна.
Предположим, один поток ввода-вывода отвечает за события от сотен или тысяч активных соединений. Если какой-то обработчик, выполняющийся напрямую на этом потоке, выполняет:
или:
или:
и синхронно ждёт пять секунд, проблема не просто в том, что «один поток заблокирован».
Проблема в том, что один из небольшого числа потоков, отвечающих за продвижение многих сетевых соединений, перестал их продвигать.
Собственная документация Undertow явно предупреждает, что потоки ввода-вывода выполняют неблокирующие задачи и не должны выполнять блокирующие операции, потому что это мешает другим соединениям, связанным с этим потоком, продвигаться. Исторически Undertow решал это, диспетчеризуя блокирующие обработчики с потока ввода-вывода в свой пул воркеров.
Kora 2 сохраняет то же архитектурное правило, но меняет пункт назначения этой диспетчеризации.
Вместо перемещения обычной работы приложения в большой ограниченный пул платформенных потоков он перемещает её на виртуальные потоки.
Этап 2: Undertow выполняет сетевую работу¶
Прежде чем ваш контроллер выполнится, Undertow всё ещё должен выполнить транспортную работу.
Концептуально он обрабатывает такие заботы:
TCP-соединение
↓
разбор HTTP-протокола
↓
метод / URI / заголовки
↓
HttpServerExchange
↓
корневой HTTP-обработчик Kora
Точная реализация содержит больше машинной работы, чем эта упрощённая диаграмма, но архитектурная граница важнее каждого внутреннего класса.
В этот момент у Kora достаточно информации, чтобы начать обработку запроса. URL существует, HTTP-метод существует, соединение представлено абстракциями exchange Undertow, и маршрутизация может продолжаться.
Важное правило остаётся:
Kora намеренно предотвращает зависимость остальной части вашего обычного стека приложения от этого различия.
Этап 3: Kora диспетчеризует запрос на виртуальный поток¶
Это решающий переход.
HTTP-документация Kora 2 утверждает:
Обработка запросов синхронна. Каждый запрос диспетчеризуется на виртуальный поток.
Следовательно, нет настройки blockingThreads и нет переключателя, эквивалентного старому опциональному режиму virtualThreadsEnabled. Конфигурация Undertow в Kora 2 сохраняет сетевые настройки,
такие как ioThreads, но обычное выполнение запросов само использует виртуальные потоки напрямую.
Концептуально:
Поток ввода-вывода Undertow
│
│ обнаружен HTTP-запрос
│
│ диспетчеризация
▼
┌────────────────────────────┐
│ Виртуальный поток │
│ │
│ начинается обработка запроса│
└────────────────────────────┘
Здесь семантика блокировки меняется полностью.
Блокировка на потоке ввода-вывода опасна, потому что поток дефицитен и мультиплексирует несвязанные соединения.
Блокировка на виртуальном потоке обычно ожидаема, потому что виртуальный поток — это логический контекст запроса и он достаточно дёшев, чтобы создаваться в очень больших количествах.
Поэтому одна и та же операция имеет радикально разные последствия в зависимости от того, где она выполняется.
Поток сетевого ввода-вывода:
запрос A
запрос B ─┐
запрос C ├── один дефицитный поток
запрос D ─┘
БЛОК → несколько соединений могут перестать продвигаться
против:
Виртуальные потоки:
запрос A → VT-A
запрос B → VT-B
запрос C → VT-C
запрос D → VT-D
VT-B БЛОКИРУЕТСЯ → VT-B ждёт
другие VT продолжают
Это разделение — основа синхронной модели программирования Kora.
Этап 4: Виртуальному потоку нужен носитель¶
Виртуальный поток — всё ещё поток с точки зрения программиста, но он не постоянно сопоставлен с потоком операционной системы.
Когда виртуальный поток фактически выполняет инструкции Java, JVM монтирует его на платформенный поток.
Этот платформенный поток называется его носителем.
Могут существовать тысячи, сотни тысяч или потенциально миллионы виртуальных потоков, в то время как JVM нуждается только в относительно небольшом числе потоков-носителей, выполняющих готовую работу в любой момент.
Например:
VT-1 ─┐
VT-2 │
VT-3 │
VT-4 │
VT-5 ├──── планировщик JVM ──── Carrier-1 ── CPU
VT-6 │ ├─ Carrier-2 ── CPU
VT-7 │ ├─ Carrier-3 ── CPU
... │ └─ Carrier-4 ── CPU
VT-N ─┘
Только готовые к выполнению виртуальные потоки нуждаются в выполнении на CPU.
Вот почему неточно представлять поток-носитель как «поток ОС, принадлежащий запросу».
Нет постоянной связи:
Вместо этого:
Запрос 42
=
Виртуальный поток 42
Виртуальный поток 42
может выполняться на
Носителе 7
затем ждать
затем позже возобновиться на
Носителе 3
Носитель — это ресурс выполнения, а не идентичность запроса.
OpenJDK описывает ровно эту модель монтирования/размонтирования: планировщик монтирует виртуальный поток на платформенный поток, когда он готов к выполнению, и может размонтировать его, когда виртуальный поток блокируется, освобождая носитель для выполнения другого виртуального потока.
Этап 5: Выполняется сгенерированный обработчик Kora¶
После диспетчеризации сгенерированная HTTP-инфраструктура Kora берёт управление на виртуальном потоке.
Контроллер, такой как:
не зависит от рефлексивного обнаружения времени выполнения.
Kora обрабатывает HTTP-аннотации на этапе компиляции и генерирует код обработчика, соответствующий этому контракту. Обработчик выполняет необходимую транспортную работу вокруг метода: привязку маршрута, преобразование параметров, маппинг запроса, перехватчики, вызов, маппинг ответа, обработку исключений и телеметрию.
Более широкий дизайн Kora следует тому же паттерну для внедрения зависимостей, репозиториев, мапперов и AOP: метаданные фреймворка становятся обычным сгенерированным исходным кодом, а не рефлексивным движком времени выполнения.
Более детальный конвейер запроса поэтому выглядит примерно так:
Виртуальный поток
↓
сгенерированный HTTP-обработчик Kora
↓
телеметрия
↓
HTTP-перехватчики
↓
маппинг пути / запроса / заголовка
↓
маппер тела запроса
↓
метод контроллера
С этого момента вниз по стеку может продолжаться обычное синхронное выполнение Java.
Этап 6: Контроллер — это просто обычная Java¶
Контроллер выполняется на виртуальном потоке запроса.
Это означает, что это совершенно нормально:
Нет необходимости в:
или:
или Kotlin:
Kora 2 намеренно удаляет эти контракты фреймворка. Его документация утверждает, что методы контроллера, клиента, репозитория и планировщика используют прямые синхронные сигнатуры, а процессоры
отклоняют неподдерживаемые реактивные или suspend-контракты.
Стек вызовов может поэтому оставаться ровно тем, что предполагает исходник:
Это свойство важнее, чем просто экономия нескольких операторов из реактивного конвейера. Поток управления времени выполнения и поток управления исходного кода становятся почти идентичными.
Отладчик может показать:
а не заставлять разработчика реконструировать логическое выполнение из асинхронных продолжений, распределённых по издателям, колбэкам, планировщикам и циклам событий.
Этап 7: Сервису не нужно знать о потоках¶
Рассмотрим:
Здесь намеренно нет ничего специфичного для конкурентности.
Сервису не нужно знать:
- какой поток ввода-вывода Undertow принял запрос;
- какой носитель в данный момент выполняет виртуальный поток;
- какой носитель будет выполнять его после блокирующей операции;
- какой селектор владеет нижележащим клиентским сокетом;
- какой исполнитель должен содержать операции JDBC.
Сервис владеет бизнес-логикой.
Планирование потоков принадлежит инфраструктуре.
Это одно из архитектурных преимуществ виртуальных потоков, которое легко недооценить. Loom не просто делает потоки дешевле. Он позволяет механике конкурентности отступить из API приложений.
До виртуальных потоков высококонкурентная архитектура часто просачивала решения планирования вверх:
Асинхронная природа самого нижнего слоя распространялась через почти каждый API выше него.
С Kora 2:
Конкурентность всё ещё существует.
Она просто больше не доминирует в системе типов приложения.
Этап 8: Репозиторий вызывает JDBC¶
Теперь запрос достигает слоя базы данных.
Репозиторий Kora может выглядеть так:
Kora генерирует его реализацию на этапе компиляции. Сгенерированная реализация получает соединение с базой данных, подготавливает оператор, связывает параметры, выполняет запрос, читает набор результатов и маппит строку в тип возврата.
Опять же, контракт синхронен:
не:
JDBC-документация Kora 2 делает это явным: контракты репозиториев синхронны, и вызов репозитория блокирует вызывающий поток, пока база данных не ответит. Поскольку обработка запросов сервера выполняется на виртуальных потоках, это ожидание обычно не потребляет платформенный поток на всё время ожидания.
Здесь модель виртуальных потоков окупается наиболее заметно.
Что на самом деле происходит, когда JDBC блокируется?¶
Предположим, PostgreSQL отвечает за 20 миллисекунд.
С точки зрения приложения:
просто ждёт.
Виртуальный поток не может продолжиться, потому что результата ещё нет.
Концептуально:
Важный вопрос — что происходит с носителем.
При блокирующих операциях, совместимых с Loom, виртуальный поток может припарковаться и размонтироваться:
затем:
ОЖИДАНИЕ БАЗЫ ДАННЫХ
VT-42
│
└── припаркован / размонтирован
Carrier-3
│
└── доступен для других виртуальных потоков
Carrier-3 теперь может выполнять другой запрос:
Когда результат базы данных становится доступен, VT-42 снова становится готовым к выполнению.
Он может возобновиться на том же носителе:
но нет требования для этого. Он может вместо этого возобновиться как:
Виртуальный поток сохраняет логическое состояние выполнения. Носитель взаимозаменяем.
Это центральное свойство масштабируемости виртуальных потоков.
Блокировка виртуального потока — не блокировка его носителя¶
Это различие заслуживает точной формулировки, потому что «блокировка» стала почти синонимом «плохо» в реактивную эпоху.
Есть два разных утверждения:
и:
Они не эквивалентны.
Виртуальный поток, ожидающий ввода-вывода, может выглядеть логически заблокированным:
но внутри JVM может приостановить этот виртуальный поток и освободить его носитель.
Итак:
Приложение сохраняет естественную семантику блокирующего кода, в то время как рантайм может восстанавливать дефицитный ресурс платформенного потока.
Вот почему этот код снова становится разумным:
Метод по-прежнему ждёт внешние системы, но ожидание больше не подразумевает резервирование дорогого потока на базе ОС на протяжении каждой сетевой паузы.
Что носитель делает во время запроса¶
Полезная ментальная модель — проследить один запрос во времени.
Предположим, запрос тратит:
- 500 мкс на работу с CPU;
- 8 мс на ожидание PostgreSQL;
- 300 мкс на маппинг строк;
- 12 мс на ожидание другого HTTP-сервиса;
- 200 мкс на сериализацию JSON.
Виртуальный поток концептуально испытывает:
Время жизни запроса VT
CPU ОЖИДАНИЕ БД CPU ОЖИДАНИЕ HTTP CPU
███───────────────███─────────────────────██
Но носитель может испытывать что-то более похожее на:
Когда VT-42 ждёт, носителю не нужно ждать вместе с ним. Он может выполнять другие готовые виртуальные потоки.
Это резко меняет модель ресурсов.
С одним традиционным платформенным потоком на запрос:
что, как правило, непрактично.
С виртуальными потоками:
10 000 ожидающих запросов
=
10 000 виртуальных потоков
но только относительно небольшая популяция носителей
нужна для выполнения готового кода
Виртуальные потоки поэтому восстанавливают концептуальную модель «поток на запрос», не восстанавливая старую стоимость ресурсов «один поток ОС на запрос».
Java 25 меняет старую историю о закреплении¶
Много материала о виртуальных потоках в интернете всё ещё описывает важное ограничение Java 21: виртуальный поток мог оставаться закреплённым на своём носителе при блокировке внутри synchronized
-метода или блока.
Этот совет исторически точен, но его не следует повторять без оговорок для Kora 2.
Kora 2 нацелен на Java 25. Java 24 доставила JEP 491, Synchronize Virtual Threads without Pinning, который изменил обработку мониторов JVM так, что виртуальные потоки в целом могут
размонтироваться, удерживая мониторы Java. Старая рекомендация массово заменять synchronized на ReentrantLock исключительно из-за закрепления виртуальных потоков поэтому больше не уместна для
обычного кода Java 25.
Упрощённая современная картина:
против:
Это не означает, что захват носителя стал невозможным. Нативный код, операции уровня VM, необычное поведение библиотек и некоторые другие случаи всё ещё могут заставлять виртуальный поток удерживать носитель дольше, чем ожидалось. Это операционные заботы, за которыми стоит наблюдать под нагрузкой, особенно при интеграции нативных библиотек или легаси-компонентов.
Но для обычного кода сервиса Kora 2 на Java 25 правильная ментальная модель больше не «каждый synchronized-блок подозрителен».
Java-рантайм эволюционировал вместе с моделью программирования.
Блокировка — это нормально. Неограниченная конкурентность — нет.¶
Виртуальные потоки устраняют дефицит потоков.
Они не устраняют дефицит ресурсов.
Рассмотрим пул JDBC:
и внезапно 10 000 запросов выполняют:
Kora может эффективно поддерживать 10 000 виртуальных потоков запросов в ожидании.
Но у PostgreSQL всё ещё только двадцать выданных соединений, доступных через этот пул.
Концептуально:
Остальные запросы ждут.
Это может быть совершенно приемлемо с точки зрения управления потоками, но это не делает базу данных бесконечно масштабируемой.
То же применимо к:
- пулам JDBC-соединений;
- пулам HTTP-соединений;
- брокерам Kafka;
- API с ограничением скорости;
- соединениям Redis;
- файловым дескрипторам;
- CPU;
- памяти;
- ограничениям конкурентности нижестоящих систем.
Виртуальные потоки решают конкретную проблему:
Ожидание не должно требовать одного дорогого платформенного потока на каждую одновременную операцию.
Они не решают:
Каждый ресурс теперь может использоваться без ограничений.
Приложению с приоритетом виртуальных потоков всё ещё нужно противодавление на уровне архитектуры там, где дефицитные ресурсы действительно существуют.
Это часто означает ограничения пулов соединений, ограничители скорости, семафоры, ограничения очередей, дедлайны, circuit breakers или явные бюджеты конкурентности.
Работа, привязанная к CPU, иная¶
Виртуальные потоки особенно эффективны для нагрузок, которые проводят значительное время в ожидании:
HTTP-вызовы
вызовы базы данных
брокеры сообщений
удалённое хранилище
сетевые сокеты
блокировки
таймеры
Они не создают дополнительную ёмкость CPU.
Предположим, каждый запрос выполняет:
и никогда не ждёт.
Тогда виртуальный поток остаётся готовым к выполнению и нуждается в носителе для выполнения инструкций.
Для машины с восемью эффективными ядрами CPU:
Создание десяти тысяч виртуальных потоков не может превратить восемь ядер в десять тысяч ядер.
Планировщик будет просто ротировать готовую работу по доступным ресурсам выполнения.
Виртуальные потоки делают конкурентность дешёвой.
Они не делают параллельное выполнение CPU неограниченным.
Это различие особенно важно при переносе кода приложения с циклов событий. Блокирующий ввод-вывод становится естественным на виртуальных потоках, но дорогую работу с CPU по-прежнему нужно рассматривать как дорогую работу с CPU.
Почему нельзя блокировать цикл событий¶
Поэтому фраза «блокировка — это нормально с виртуальными потоками» нуждается в оговорке:
Блокировка — это нормально после того, как запрос переместился на виртуальный поток.
Блокировка всё ещё неверна на мультиплексированном цикле событий сети.
Сравните две архитектуры.
Блокировка потока ввода-вывода Undertow¶
Поток ввода-вывода 1
│
┌──────────────┼───────────────┐
│ │ │
соединение A соединение B соединение C
│ │ │
└──────────────┼───────────────┘
│
ЗАБЛОКИРОВАН
│
5 секунд
Несколько соединений могут перестать продвигаться.
Блокировка виртуального потока запроса Kora¶
соединение A → VT-A → ожидание на JDBC
соединение B → VT-B → выполняется
соединение C → VT-C → ожидание на HTTP
соединение D → VT-D → выполняется
Когда VT-A ждёт, его носитель может выполнять VT-B.
Блокирующая операция принадлежит логическому запросу, а не сетевой инфраструктуре.
Это правило стоит запомнить:
Цикл событий / поток ввода-вывода:
никогда не выполняйте блокирующую работу приложения
Виртуальный поток запроса:
блокирующий ввод-вывод — это задуманная модель программирования
Документация Undertow объясняет то же фундаментальное различие: его потоки ввода-вывода отвечают за несколько соединений и не должны выполнять блокирующую работу; блокирующая обработка должна сначала быть диспетчеризована с потока ввода-вывода.
Kora 2 делает эту диспетчеризацию частью модели запросов фреймворка.
Почему Kora не раскрывает цикл событий контроллерам¶
Фреймворк мог бы раскрывать низкоуровневую семантику транспорта напрямую:
Undertow поддерживает программирование примерно на этом уровне, и его API явно раскрывает такие концепции, как isInIoThread() и dispatch().
Но это обычно неправильная абстракция для контроллеров приложений.
Представьте, что каждый автор эндпоинта должен рассуждать о:
Я на потоке ввода-вывода?
Должна ли эта операция диспетчеризоваться?
Какой исполнитель?
Блокирует ли эта библиотека?
Вернётся ли этот колбэк в цикл событий?
Где должна выполняться сериализация JSON?
Какой планировщик использует этот издатель?
Kora вместо этого помещает это решение в HTTP-инфраструктуру.
Разработчик получает более простой контракт:
Фреймворк гарантирует подходящую среду выполнения до вызова логики приложения.
Это ровно тот вид заботы, которой должен владеть фреймворк: сквозной инвариант времени выполнения, который следует решить один раз, а не повторять внутри каждого эндпоинта.
Маппинг запросов также выполняется в контексте приложения¶
Реальный эндпоинт обычно делает больше, чем вызывает контроллер.
Например:
Запрос должен пройти через несколько частей инфраструктуры:
HTTP-запрос
↓
поиск маршрута
↓
извлечение пути / запроса / заголовка
↓
чтение тела
↓
десериализация JSON
↓
перехватчики
↓
контроллер
Kora описывает обработку запросов как синхронную и включает обработчики, перехватчики и мапперы в эту прямую модель выполнения.
Это важно, потому что такие преобразования, как десериализация JSON, — тоже работа приложения. Они не должны неожиданно выполнять длинную операцию, привязанную к CPU, на цикле событий сети просто потому, что сам транспорт использует NIO.
Внешняя архитектура ввода-вывода может оставаться асинхронной, в то время как обращённая к пользователю модель программирования остаётся синхронной.
Эти модели не противоречивы.
Они работают на разных слоях.
Ответ возвращается обратно¶
В конце концов контроллер возвращает:
Стек вызовов разворачивается:
Затем Kora маппит результат в HTTP-ответ.
Для JSON это может включать сгенерированный маппер:
Телеметрия может финализировать метрики запроса и информацию трассировки вокруг той же операции, в то время как Undertow в конце концов берёт на себя ответственность за передачу байтов ответа в сеть.
Концептуально:
Тот факт, что контроллер выполнился синхронно, не означает, что сервер должен выполнять каждую физическую сетевую операцию, используя блокирующие вызовы ОС.
Это ещё один распространённый источник путаницы.
Модель программирования приложения:
Реализация транспорта:
Модель выполнения JVM:
Все три могут сосуществовать.
Синхронный API не означает синхронную архитектуру ядра¶
Это различие центрально для Kora 2.
Синхронный API приложения говорит:
Он ничего не говорит о том, реализован ли нижележащий сетевой сервер с использованием:
- epoll;
- kqueue;
- селекторов NIO;
- io_uring;
- асинхронных каналов;
- блокирующих сокетов;
- какой-то их комбинации.
Фреймворк может использовать эффективные асинхронные механизмы внутри, представляя последовательный API снаружи.
Это даёт нам:
ПРИЛОЖЕНИЕ
Контроллер → Сервис → Репозиторий
синхронный стек вызовов
│
▼
РАНТАЙМ
Виртуальный поток
↓ ↑
park / resume
│
▼
JVM / ОС
Селекторы NIO / сокеты / потоки-носители
Реактивное программирование исторически заставляло часть этой низкоуровневой механики проникать в типы приложений, потому что платформенные потоки были слишком дороги, чтобы посвящать их ожиданию.
Виртуальные потоки меняют экономику.
Когда ожидание больше не требует постоянного занятия платформенного потока, аргумент за распространение асинхронных типов через каждый архитектурный слой становится гораздо слабее для обычных сервисов запрос/ответ.
Что происходит со 100 000 одновременных запросов?¶
Рассмотрим упрощённый сервис:
Предположим, база данных намеренно медленная, и каждый запрос занимает одну секунду.
Со 100 000 одновременных запросов концептуальное состояние может выглядеть так:
100 000 запросов
↓
100 000 виртуальных потоков
↓
большинство ждут
↓
небольшой пул носителей выполняет готовые VT
Это не подразумевает:
Большинство виртуальных потоков просто припаркованы в ожидании чего-то.
Однако быстро становится виден другой предел:
Итак, возможно:
Это всё ещё может быть относительно недорого с точки зрения ресурсов потоков, потому что эти ожидания могут парковать виртуальные потоки.
Но задержка может стать катастрофической.
Виртуальные потоки поэтому позволяют рантайму переживать уровни конкурентности, которые ранее исчерпали бы пул платформенных потоков, но выживание — не то же самое, что хорошее планирование ёмкости.
Правильная архитектура всё ещё спрашивает:
а не:
Закон Литтла всё ещё применим¶
Виртуальные потоки не отменяют теорию очередей.
Если сервис получает:
и средний запрос занимает:
то примерно:
Виртуальные потоки делают представление этих 5 000 выполняющихся запросов дешёвым.
Они не уменьшают тот факт, что есть 5 000 запросов, потребляющих память, удерживающих состояние запроса, конкурирующих за пулы и предъявляющих требования к нижестоящим системам.
Это различие важно, потому что традиционные пулы потоков случайно действовали как грубый ограничитель конкурентности.
Например:
автоматически означало, что только около 200 запросов могли одновременно выполнять блокирующий код приложения.
Виртуальные потоки устраняют это случайное ограничение.
Это в целом желательно, но контроль ёмкости следует тогда реализовывать намеренно на границе ресурсов, а не позволять ему возникать случайно из дефицита потоков.
Виртуальные потоки восстанавливают смысл стек-трейса¶
Есть также преимущество наблюдаемости.
Рассмотрим синхронный стек:
UserController.get()
UserService.getUser()
UserRepository.findById()
PreparedStatement.executeQuery()
Socket.read()
Это не просто эстетически приятно.
Это сохраняет причинную структуру операции.
Запрос вошёл в контроллер.
Контроллер вызвал сервис.
Сервис вызвал репозиторий.
Репозиторий вызвал JDBC.
JDBC ждал базу данных.
Стек потоков говорит ровно это.
Реактивные системы часто представляют ту же логическую операцию через цепочку вроде:
Инструментарий значительно улучшился, и реактивные стеки, безусловно, можно наблюдать, но структура выполнения времени выполнения остаётся более абстрактной, чем обычный последовательный поток управления.
Виртуальные потоки позволяют существующей ориентированной на потоки модели наблюдаемости JVM — стек-трейсам, отладчикам, дампам потоков, профилированию, распространению контекста — оставаться напрямую полезной при высокой конкурентности.
Это важная причина, по которой Loom — больше, чем фича производительности.
Это фича сложности.
Один запрос, один логический поток¶
Самая чистая модель HTTP-запроса Kora 2 поэтому:
Запрос может вызывать:
контроллер
сервис
репозиторий
JDBC
HTTP-клиенты
аспекты отказоустойчивости
валидацию
кеш
логирование
без смены абстракций конкурентности между слоями.
Этот поток может многократно:
в течение одного запроса.
С точки зрения приложения, однако, ни один из этих переходов не должен появляться в исходнике.
Исходник остаётся:
Это абстракция, которую Loom был создан восстановить.
Чем это отличается от старой модели «поток на запрос»¶
Соблазнительно описать Kora 2 как простое возвращение к традиционным серверам «поток на запрос».
Концептуально — да.
Механически — нет.
Старая модель выглядела примерно так:
Если 5 000 запросов одновременно ждали внешние системы:
Эта модель сталкивалась со стоимостью памяти стека, накладными расходами планировщика, переключением контекста и практическими пределами числа потоков.
Модель виртуальных потоков:
Модель программирования напоминает старую Java «поток на запрос».
Модель ресурсов времени выполнения — нет.
Поэтому историческая прогрессия — на самом деле не круг:
платформенный поток на запрос
↓
реактивная / событийная модель приложения
↓
виртуальный поток на запрос
Точнее:
простая синхронная модель
│
│ проблема масштабируемости:
│ платформенные потоки дороги
▼
асинхронная модель приложения
│
│ Loom меняет экономику потоков
▼
простая синхронная модель
с лёгкими потоками времени выполнения
API вернулся.
Реализация под ним изменилась.
Где вписывается структурированная конкурентность¶
Синхронная обработка запросов не означает, что каждый запрос должен выполнять все независимые операции последовательно.
Предположим, эндпоинту нужны три независимых вызова:
Последовательное выполнение дало бы примерно:
Когда уместно, эти операции могут выполняться одновременно.
Документация Kora указывает на структурированную конкурентность Java для таких случаев, а не на введение специфичных для фреймворка реактивных контрактов.
Концептуально:
VT запроса
│
├── дочерний VT → репозиторий пользователей
│
├── дочерний VT → сервис заказов
│
└── дочерний VT → сервис рекомендаций
│
▼
join
│
▼
ответ
Важное архитектурное различие в том, что конкурентность остаётся явной там, где конкурентность действительно нужна.
Вам не нужно делать каждый метод асинхронным просто потому, что один эндпоинт иногда нуждается в параллельной работе.
Большая часть кода может оставаться:
в то время как по-настоящему параллельная оркестрация может сказать явно:
Это держит конкурентность локальной, вместо того чтобы заражать весь граф вызовов.
Полная временная шкала запроса¶
Собирая всё вместе, рассмотрим:
и это приложение:
с:
и:
Полная концептуальная временная шкала:
1. Клиент отправляет HTTP-запрос
│
▼
2. Операционная система получает TCP-данные
│
▼
3. XNIO обнаруживает готовность сокета
│
▼
4. Поток ввода-вывода Undertow обрабатывает сетевое событие
│
▼
5. Undertow строит / обновляет HTTP-обмен
│
▼
6. HTTP-инфраструктура Kora сопоставляет маршрут
│
▼
7. Запрос диспетчеризуется с потока ввода-вывода
│
▼
8. Виртуальный поток выполняет сгенерированный обработчик Kora
│
▼
9. Выполняются телеметрия запроса / перехватчики
│
▼
10. Параметр пути «42» маппится в long
│
▼
11. UserController.get(42)
│
▼
12. UserService.get(42)
│
▼
13. UserRepository.findById(42)
│
▼
14. Сгенерированный JDBC-репозиторий получает соединение
│
▼
15. Выполняется SQL-запрос
│
▼
16. Виртуальный поток ждёт БД
│
├──── виртуальный поток паркуется
│
└──── носитель может выполнять другой VT
│
▼
17. Приходит ответ базы данных
│
▼
18. Виртуальный поток становится готовым
│
▼
19. JVM монтирует его на доступный носитель
│
▼
20. JDBC маппит ResultSet → User
│
▼
21. Репозиторий возвращает User
│
▼
22. Сервис маппит User → UserResponse
│
▼
23. Контроллер возвращает UserResponse
│
▼
24. Маппер ответа Kora сериализует JSON
│
▼
25. Телеметрия завершается
│
▼
26. Undertow записывает ответ
│
▼
27. Клиент получает HTTP-ответ
Поразительное свойство этого конвейера — как много машинной работы конкурентности отсутствует в собственном коде приложения.
Шаги с 11 по 23 могут выглядеть почти как Java-код, написанный двадцать лет назад.
Изощрённость переместилась под API.
Карта потоков¶
Ещё один полезный способ визуализировать жизненный цикл — явно разделить домены потоков:
СЕТЕВОЙ ДОМЕН
=============
Клиент
│
▼
TCP-сокет
│
▼
селектор XNIO
│
▼
поток ввода-вывода Undertow
│
│ ДЕЛАЙ:
│ - сетевые события
│ - обработку протокола
│ - лёгкую транспортную работу
│
│ НЕ ДЕЛАЙ:
│ - выполняй JDBC
│ - вызывай медленные сервисы
│ - спи
│ - выполняй блокирующую бизнес-логику
│
▼
диспетчеризация
ДОМЕН ПРИЛОЖЕНИЯ
=================
Виртуальный поток
│
├─ обработчик Kora
│
├─ перехватчик
│
├─ маппер
│
├─ контроллер
│
├─ сервис
│
├─ репозиторий
│
└─ JDBC
│
▼
блок / ожидание
│
▼
VT может размонтироваться
ДОМЕН JVM
=========
Виртуальные потоки
│
▼
планировщик VT
│
▼
платформенные потоки-носители
│
▼
планировщик ОС
│
▼
ядра CPU
Держать эти домены раздельными предотвращает большинство заблуждений о модели конкурентности Kora.
Что должно выполняться где?¶
Практическое эмпирическое правило:
| Операция | Поток ввода-вывода Undertow | Виртуальный поток запроса Kora |
|---|---|---|
| Обработка готовности сокета | Да | Нет |
| Механика HTTP-протокола | Да | Иногда граница фреймворка |
| Передача маршрута | Да | Да после диспетчеризации |
| Контроллер | Нет | Да |
| Бизнес-логика перехватчика | Нет | Да |
| Маппинг JSON | Избегайте тяжёлой работы | Да |
| JDBC | Нет | Да |
| Блокирующий HTTP-клиент | Нет | Да |
Thread.sleep() |
Нет | Технически безопасно |
| Ожидание блокировки | Нет | Да |
| Бизнес-логика | Нет | Да |
| Вычисления, привязанные к CPU | Избегайте | Возможно, но всё ещё CPU-bound |
Дело не в том, что виртуальные потоки делают каждую операцию дешёвой.
Дело в том, что код приложения принадлежит потокам приложения, в то время как циклы событий сети должны оставаться посвящёнными продвижению сетевой активности.
Архитектура, которую выбирает Kora 2¶
Модель конкурентности Kora 2 в конечном счёте сводится к довольно сильному архитектурному мнению:
Используйте асинхронный ввод-вывод там, где асинхронный ввод-вывод
является преимуществом реализации.
Не навязывайте асинхронное программирование
в API приложений просто потому,
что транспорт использует асинхронный ввод-вывод.
Undertow может оставаться на NIO.
JVM может динамически планировать виртуальные потоки.
JDBC может оставаться блокирующим.
Контроллеры могут оставаться обычными методами.
Репозитории могут оставаться обычными методами.
Сервисы могут оставаться обычными методами.
Это даёт Kora стек вроде:
а не требование:
Обе архитектуры можно сделать быстрыми.
Разница в том, где они размещают сложность конкурентности.
Kora 2 намеренно толкает эту сложность вниз, во фреймворк, JVM и транспортный слой, вместо того чтобы делать её частью сигнатуры каждого метода приложения.
Важная граница¶
Если есть одна диаграмма, которую стоит сохранить, это:
НЕ БЛОКИРУЙ
│
▼
┌──────────────────────────────────────┐
│ Поток ввода-вывода Undertow / XNIO │
│ │
│ Сокет → протокол → запрос │
└──────────────────┬───────────────────┘
│
│ диспетчеризация
▼
════════════════════════════════════════
ГРАНИЦА КОНКУРЕНТНОСТИ
════════════════════════════════════════
│
▼
┌──────────────────────────────────────┐
│ Виртуальный поток │
│ │
│ обработчик Kora │
│ ↓ │
│ перехватчик │
│ ↓ │
│ контроллер │
│ ↓ │
│ сервис │
│ ↓ │
│ репозиторий │
│ ↓ │
│ JDBC │
│ │
│ Блокировка здесь ожидаема. │
└──────────────────────────────────────┘
Выше границы потоки — это дефицитные ресурсы инфраструктуры, разделяемые сетевыми соединениями.
Ниже границы виртуальный поток представляет один логический запрос, и ожидание — часть задуманной модели программирования.
Затем JVM мультиплексирует эти логические потоки запросов поверх платформенных потоков-носителей.
Финальная перспектива¶
Kora 2 не делает сеть синхронной.
Он делает программирование приложений снова синхронным.
Undertow по-прежнему использует эффективный асинхронный NIO на краю. JVM по-прежнему планирует работу по ограниченному числу платформенных потоков. JDBC по-прежнему ждёт базы данных. HTTP-клиенты по-прежнему ждут удалённые сервисы. Система остаётся высококонкурентной.
Что меняется — кто должен управлять этой конкурентностью.
Вместо требования, чтобы каждый контроллер, сервис и API репозитория кодировали асинхронное выполнение, Kora устанавливает правильную границу выполнения один раз:
Сокет
↓
Поток ввода-вывода Undertow
↓
диспетчеризация Kora
↓
Виртуальный поток
↓
Контроллер
↓
Сервис
↓
Репозиторий
↓
JDBC
Как только запрос пересекает эту границу, обычная блокирующая Java становится нормальной моделью.
Когда JDBC ждёт, виртуальный поток ждёт.
Носитель обычно может пойти куда-то ещё.
Когда база данных отвечает, виртуальный поток становится готовым и продолжает свой обычный стек вызовов.
Это даёт Kora 2 интересную комбинацию:
асинхронная сетевая инфраструктура
+
лёгкое планирование виртуальных потоков
+
синхронный код приложения
Результат — не отказ от асинхронного ввода-вывода. Это разделение забот.
Сетевой сервер может быть событийно-ориентированным там, где событийно-ориентированная архитектура полезна.
JVM может мультиплексировать выполнение там, где мультиплексирование полезно.
И разработчики приложений могут писать:
там, где прямолинейный последовательный код полезен.
Это реальный жизненный цикл запроса в приложении Kora с приоритетом виртуальных потоков: асинхронный на транспортном краю, виртуализированный на уровне выполнения и намеренно синхронный там, где начинается бизнес-код.