Виртуальные потоки прежде всего: почему Kora 2 снова синхронная¶
9 сентября 2026
Более десяти лет высоконагруженная серверная разработка на JVM, казалось, двигалась в одном направлении: от блокирующего кода к асинхронному, реактивному, управляемому событиями программированию. Аргумент был убедительным. Традиционные Java-серверы часто назначали один поток операционной системы каждому активному запросу. Потоки были относительно дорогими, пулы потоков неизбежно ограниченными, а блокировка потока в ожидании базы данных или другого сервиса означала, что дорогой ресурс не делает полезной работы. Если сервису нужно было держать десятки тысяч запросов в полёте, обычная модель «поток на запрос» могла стать ограничивающим фактором.
Реактивное программирование атаковало это ограничение, меняя модель программирования. Вместо того чтобы позволять запросу занимать поток, пока он ждёт, приложения выражали работу как колбэки, фьючерсы, потоки, продолжения или машины состояний корутин. Небольшое количество потоков event-loop или воркеров могло затем мультиплексировать гораздо большее число конкурентных операций.
Проект Loom изменил компромисс.
Виртуальные потоки позволяют сохранить прямую, последовательную модель программирования обычной Java, допуская при этом очень большие количества конкурентных задач. Важный сдвиг не в том, что блокировка вдруг стала бесплатной. Он в том, что блокировка виртуального потока обычно больше не означает блокировку дефицитного потока операционной системы, который его выполняет.
Фреймворк Kora строит свою модель конкурентности в Kora 2 вокруг этого различия.
Его обращённые к приложению API снова намеренно синхронны. HTTP-контроллеры возвращают обычные значения. HTTP-клиенты возвращают ответы напрямую. JDBC-репозитории используют обычные блокирующие
JDBC-контракты. Планируемые методы — обычные методы. Документация Kora явно утверждает, что код приложения выполняется синхронно на виртуальных потоках и что модули Kora не предоставляют реактивных
или Kotlin suspend-контрактов. Для HTTP конкретно каждый запрос диспетчеризуется на виртуальный поток, поэтому контроллеры, перехватчики, мапперы, вызовы клиентов и вызовы базы данных можно писать в
прямом стиле, не протаскивая асинхронные типы через всё приложение.
Это не значит, что Kora возвращается к старой архитектуре «платформенный поток на запрос». Это значит, что Kora возвращается к модели программирования «поток на запрос», заменяя дорогой примитив выполнения под ней.
Историческую дугу поэтому лучше описывать так:
платформенный поток на запрос
↓
асинхронное / реактивное мультиплексирование
↓
виртуальные потоки
↓
снова модель программирования «поток на запрос»
без стоимости платформенного потока на запрос
Это различие — ключ к пониманию того, почему Kora 2 может быть синхронной, не повторяя просто ограничения масштабируемости, которые изначально подтолкнули индустрию к реактивному программированию.
Изначальная модель «поток на запрос» была концептуально превосходной¶
Традиционное Java-веб-программирование было легко понять, потому что модель выполнения соответствовала структуре бизнес-операции.
Приходил запрос, ему назначался поток, и код выполнялся сверху вниз:
Стек рассказывал историю запроса. Исключения распространялись естественно. Локальные переменные представляли локальное состояние. try/finally выражало очистку. Транзакции можно было ограничивать
вокруг обычных вызовов методов. Отладчики могли остановиться на строке и показать всю логическую операцию. Профайлеры и дампы потоков достаточно хорошо соответствовали тому, что разработчики думали,
что программа делает.
Проблема была не в этой модели программирования.
Проблема была в реализации Thread.
Исторически Java Thread был по сути привязан к потоку операционной системы на всё время жизни. Потоки операционной системы не невозможно дорогие, но они достаточно дорогие, чтобы создание сотен
тысяч из них не было практичной стратегией конкурентности. Каждый поток потребляет память под стек и метаданные времени выполнения, операционная система должна его планировать, а большие наборы
работающих потоков создают накладные расходы на переключение контекста и планирование.
Поэтому серверные фреймворки использовали пулы потоков.
Вместо создания неограниченных потоков сервер мог иметь несколько десятков или несколько сотен воркер-потоков:
входящие запросы
↓
+--------------------+
| пул воркер-потоков |
| T1 T2 T3 ... T200 |
+--------------------+
↓
бизнес-логика
Это работает очень хорошо, пока многие из этих воркеров не тратят время на ожидание.
Рассмотрим сервис, выполняющий три сетевые операции:
запрос
↓
JDBC-запрос 8 мс ожидания
↓
HTTP-вызов 40 мс ожидания
↓
JDBC-обновление 5 мс ожидания
↓
ответ
Запросу может понадобиться лишь небольшое количество реального времени CPU, и всё же его воркер-поток остаётся занятым во время каждого блокирующего вызова. Если все 200 воркеров ждут удалённые системы, запрос 201 не может начать обработку приложения, даже если CPU почти простаивает.
Это классическая проблема масштабируемости, резюмируемая законом Литтла:
Если сервис обрабатывает 10 000 запросов в секунду и каждый запрос остаётся в полёте 100 мс, примерно 1 000 запросов должны существовать одновременно.
С медленными нижестоящими системами, длиннохвостой задержкой, большим fan-out или множеством одновременных соединений требования к конкурентности могут быстро превысить комфортно размеренный пул платформенных потоков.
Прямая модель программирования была привлекательной. Модель ресурсов под ней — нет.
Реактивное программирование решило реальную проблему¶
Легко карикатуризировать реактивное программирование как ненужную сложность, введённую авторами фреймворков. Исторически это несправедливо.
Реактивные и асинхронные серверные архитектуры решали конкретное ограничение: ожидание не должно требовать удержания одного потока операционной системы на операцию.
Основная идея прямолинейна. Вместо написания:
и оставления текущего потока заблокированным, пока сеть не ответит, асинхронный API инициирует операцию и устраивает выполнение продолжения позже:
запусти запрос
↓
зарегистрируй продолжение
↓
верни поток в event-loop
↓
приходит сетевой ответ
↓
запланируй продолжение
Небольшое количество потоков может поэтому управлять гораздо большим числом конкурентных сокетов и запросов.
Эта модель особенно естественна на транспортном уровне. Современные веб-серверы, операционные системы и сетевые библиотеки уже используют ввод-вывод на основе готовности или завершения внутри. Netty, Vert.x, Undertow, асинхронные HTTP-клиенты и драйверы баз данных могут избегать выделения платформенного потока сокету, который просто ждёт байты.
При достаточной конкурентности это резко более ресурсоэффективно, чем назначение платформенного потока каждой активной операции.
Проблема в том, что код приложения тоже должен был стать асинхронным.
Как только контроллер не может блокироваться, всё под ним должно соблюдать то же правило:
Если HTTP-обработчик работает на event-loop, а репозиторий выполняет обычный блокирующий JDBC-вызов, вызов базы данных блокирует сам поток event-loop. Это может остановить многие несвязанные запросы, обрабатываемые тем же циклом.
В результате семантика асинхронного выполнения склонна распространяться через границы API.
Java-приложение, которое когда-то выглядело так:
может стать:
или:
в то время как Kotlin может использовать:
Это распространение не случайно. Это то, как система типов сообщает вызывающему, что операция не завершается синхронно.
Реактивные системы поэтому решили дефицит платформенных потоков, сделав асинхронность частью модели программирования.
Цена кодирования конкурентности в каждый API¶
Реактивное программирование может быть элегантным, когда сама проблема естественно является потоком или асинхронным конвейером. Но для обычной бизнес-логики запрос/ответ асинхронный тип часто описывает механизм выполнения больше, чем домен.
Сравните эти контракты:
Первый контракт говорит, что делает метод.
Второй и третий также раскрывают, как представлено завершение.
Это различие распространяется.
Синхронная цепочка вызовов — обычная композиция:
Реактивная цепочка может стать чем-то вроде:
Для простых примеров это управляемо. Реальные приложения добавляют обработку ошибок, контекст трассировки, повторные попытки, транзакции, таймауты, условные ветви, fan-out, частичные сбои, очистку и специфичные для фреймворка операторы. Модель выполнения становится ещё одной абстракцией, которую должен понимать каждый разработчик.
Результатом может быть семантический разрыв между бизнес-алгоритмом и его представлением в коде.
Синхронный алгоритм таков:
Реактивная реализация может вместо этого требовать рассуждения о:
подписке
выполнении publisher
выборе планировщика
семантике операторов
распространении контекста
отмене
противодавлении
каналах ошибок
перескоках потоков
Ни одна из этих концепций не плоха сама по себе. Они ценны, когда приложение действительно в них нуждается. Архитектурный вопрос в том, должен ли каждый поиск в репозитории, вызов HTTP-клиента, контроллер и метод сервиса быть вынужден их выражать лишь потому, что блокировка потока операционной системы слишком дорога.
Ответ проекта Loom: всё чаще нет.
Loom меняет единицу конкурентности¶
Виртуальные потоки были финализированы в JDK 21 через JEP 444. Виртуальный поток — всё ещё java.lang.Thread, но, в отличие от платформенного потока, он не привязан постоянно к одному потоку
операционной системы.
JVM может запускать много виртуальных потоков на гораздо меньшем наборе платформенных потоков, называемых потоками-носителями.
Концептуально:
Виртуальные потоки
V1 V2 V3 V4 V5 V6 V7 V8 ... V100000
\ | / \ | / \ |
\ | / \ | / \ |
+------+ +------+ +------+
| C1 | | C2 | | C3 | потоки-носители
+------+ +------+ +------+
\ | /
+-------------------------+
операционная система
Виртуальный поток монтируется на носитель, когда выполняет Java-код.
Когда виртуальный поток достигает поддерживаемой блокирующей операции, среда выполнения может приостановить этот виртуальный поток, сохранить его состояние и размонтировать его с носителя. Носитель затем доступен для запуска другого виртуального потока.
Позже, когда блокирующая операция может продолжиться, исходный виртуальный поток снова становится работающим и может быть смонтирован на том же носителе или на другом.
Приложению не нужно вручную кодировать продолжение.
Оно может просто написать:
Исходный код по-прежнему выглядит блокирующим.
Виртуальный поток заблокирован.
Но поток-носитель обычно может продолжать делать полезную работу в другом месте.
Это центральная идея Loom.
Блокировка виртуального потока — не то же самое, что блокировка носителя¶
Слово блокировка стало перегруженным в реактивную эпоху, и это создаёт путаницу при обсуждении виртуальных потоков.
Есть как минимум два разных вопроса:
- Ждёт ли логическая задача?
- Вынужден ли также ждать платформенный поток, выполняющий эту задачу?
В традиционной модели это обычно было одно и то же.
С виртуальными потоками их можно разделить:
Виртуальный поток V1
↓
блокирующее чтение сокета
↓
V1 ждёт
Носитель C1
↓
V1 размонтируется
↓
C1 выполняет V2
Поэтому это утверждение полностью разумно в приложении Loom:
Эта операция JDBC или HTTP блокирует поток запроса.
Важное продолжение:
Поток запроса — виртуальный поток.
Блокировка логического запроса ожидаема. Запрос не может продвинуться, пока база данных не ответит, в любом случае. Для масштабируемости важно то, монополизирует ли эта ожидающая операция ненужно дефицитный поток-носитель.
Это различие позволяет Java восстановить полезное свойство исходной модели «поток на запрос»: одна логическая конкурентная активность может снова быть представлена одним потоком.
Поток становится продолжением.
Стек становится машиной состояний.
JVM выполняет мультиплексирование.
Коду приложения больше не нужно вручную преобразовывать поток управления в колбэки или publisher'ы только для того, чтобы не тратить потоки операционной системы.
Loom не сделал блокировку бесплатной¶
Виртуальные потоки устраняют одну важную стоимость блокировки, не каждую.
Если 50 000 запросов одновременно ждут базу данных, у приложения всё ещё концептуально 50 000 незавершённых операций базы данных. База данных не обязательно может их выполнить. Пул соединений не обязательно может их поддержать. Память всё ещё потребляется. Очереди всё ещё могут расти. Нижестоящие системы всё ещё могут рухнуть.
Виртуальные потоки поэтому — механизм конкурентности, а не механизм управления ёмкостью.
Это различие огромное.
Пул платформенных потоков исторически выполнял две работы одновременно:
Пул из 100 потоков означал, что не более примерно 100 блокирующих операций могли выполняться через этот пул одновременно.
Виртуальные потоки намеренно устраняют потребность в таком маленьком пуле выполнения. Создание 100 000 виртуальных потоков может быть совершенно разумным. Но если все 100 000 немедленно попытаются выполнить запрос к базе данных, база данных всё равно конечна.
Конкурентность поэтому нужно ограничивать на ресурсе, у которого действительно ограниченная ёмкость.
Для JDBC это уже делает пул соединений:
Только доступные соединения могут выдавать запросы одновременно. Другие виртуальные потоки могут ждать дёшево.
Для внешнего сервиса ограничением может вместо этого быть:
- пул HTTP-соединений;
- семафор;
- ограничитель скорости;
- bulkhead;
- очередь;
- автоматический выключатель;
- собственный лимит конкурентности нижестоящего сервиса.
Это более здоровая концептуальная модель, потому что ёмкость выполнения и ёмкость ресурсов больше не представлены случайно одним и тем же пулом потоков.
Работа, ограниченная CPU, всё ещё ограничена CPU¶
Виртуальные потоки спроектированы в первую очередь для улучшения масштабируемости рабочих нагрузок, которые проводят значительное время в ожидании.
Они не создают дополнительные ядра CPU.
Если 10 000 виртуальных потоков все начинают дорогие JSON-преобразования, обработку изображений, криптографию, сжатие или численные вычисления, у JVM всё ещё есть только фактически доступные процессоры, на которых их выполнять.
Для рабочих нагрузок, ограниченных CPU:
Планировщик носителей будет мультиплексировать работающие виртуальные потоки по доступным процессорам, но общая ёмкость CPU неизменна. Избыточная работающая работа может увеличить задержку через планирование и конкуренцию.
Вот почему корректная ментальная модель не:
Виртуальные потоки делают неограниченную конкурентность безопасной.
Она:
Виртуальные потоки делают очень большое количество в основном ожидающих конкурентных задач практичным, сохраняя при этом обычную модель программирования потоков.
Хорошо спроектированное приложение Kora должно по-прежнему понимать, какие ресурсы дефицитны, какие операции тяжёлые по CPU и где требуется явный контроль конкурентности.
Что Kora 2 фактически выбирает¶
Kora 2 не просто поддерживает виртуальные потоки как опциональную оптимизацию. Её публичная модель программирования спроектирована вокруг них.
Документация Kora 2 утверждает:
Kora выполняет код приложения синхронно на виртуальных потоках.
Затем она делает следствия явными: контроллеры, HTTP-клиенты, репозитории и планируемые задачи используют обычные блокирующие сигнатуры, в то время как реактивные и suspend-контракты отсутствуют в
модулях Kora.
Этот дизайн последовательно появляется во всём фреймворке.
HTTP-сервер¶
Документация HTTP-сервера Kora говорит, что обработка запросов синхронна и что каждый запрос диспетчеризуется на виртуальный поток. Контроллеры, перехватчики и мапперы возвращают значения напрямую, а
не возвращают CompletionStage, типы Reactor или Kotlin suspend-функции.
Поэтому контроллер выглядит как обычная Java:
В API нет асинхронной обёртки, потому что фреймворк предоставляет конкурентность, выбирая поток выполнения.
Kora использует Undertow под капотом, а сам Undertow построен на асинхронном неблокирующем вводе-выводе. Поэтому Kora не отвергает внутренности управляемого событиями транспорта. Она помещает важную границу между реализацией транспорта и моделью программирования приложения.
Это разделение можно проиллюстрировать так:
сокет / NIO / внутренности Undertow
↓
диспетчеризация запроса
↓
виртуальный поток
↓
синхронный обработчик
↓
синхронное приложение
Сетевой слой может оставаться оптимизированным вокруг NIO, в то время как код приложения получает прямую блокирующую модель.
Конфигурация Undertow в Kora 2 отражает эту архитектуру. Старый выбор между ограниченным блокирующим пулом воркеров и опциональным выполнением на виртуальных потоках исчез. Документация отмечает, что
обработка запросов больше не использует ограниченный блокирующий пул; соединения диспетчеризуются на виртуальные потоки, поэтому старые настройки blockingThreads и virtualThreadsEnabled больше не
присутствуют.
Виртуальные потоки — не режим, наслоенный рядом с другой моделью программирования. Это допущение выполнения.
HTTP-клиенты¶
Контракт HTTP-клиента Kora 2 также синхронен:
Результат возвращается напрямую.
Документация явно утверждает, что все вызовы HTTP-клиента Kora синхронны и блокирующие и что конкурентность ожидается от виртуальных потоков, а не от реактивных типов или типов корутин.
Метод HTTP-клиента suspend отклоняется генератором.
Это крупное архитектурное заявление. Фреймворк говорит, что асинхронные типы возвращаемого значения больше не являются механизмом по умолчанию, через который код приложения сообщает о масштабируемости.
JDBC-репозитории¶
Та же идея появляется в доступе к данным.
Kora 2 использует JDBC-репозитории с обычными синхронными сигнатурами:
Документация говорит напрямую, что контракты JDBC-репозиториев синхронны и что нет асинхронных или реактивных сигнатур репозиториев.
Исторически реактивная архитектура часто подталкивала команды к R2DBC или другому неблокирующему API базы данных, потому что JDBC блокировал бы поток event-loop.
С моделью приложения на виртуальных потоках обычный JDBC снова становится архитектурно жизнеспособным, потому что ожидание JDBC больше не обязательно означает потребление одного платформенного воркера на всё время ожидания.
Это имеет значительные следствия. JDBC зрел, чрезвычайно хорошо понятен, поддерживается огромной экосистемой и напрямую соответствует модели взаимодействия с реляционной базой данных, используемой большинством Java-команд. Поэтому Kora может сохранять тонкую абстракцию, которую она хочет вокруг JDBC, вместо введения реактивной модели доступа к данным лишь для сохранения живости event-loop.
Планирование¶
API планирования Kora следуют тому же направлению. Планируемые методы — обычные методы, и Kotlin-планируемые методы не должны быть suspend.
Опять же, фреймворк не просит приложение кодировать асинхронное выполнение в сигнатуре метода.
Kora синхронна на границе API, но не наивна в отношении ввода-вывода¶
Называть Kora 2 «блокирующей» без уточнения может вводить в заблуждение.
Лучшее описание:
Kora использует синхронные контракты приложения поверх среды выполнения, спроектированной так, чтобы делать ожидание масштабируемым.
Фреймворк может использовать эффективные транспортные реализации внутри, намеренно предоставляя прямые API коду приложения.
Это важное архитектурное разделение.
Рассмотрим HTTP-запрос:
сетевое событие
↓
Undertow / NIO
↓
сгенерированный обработчик Kora
↓
виртуальный поток
↓
контроллер
↓
сервис
↓
JDBC
↓
HTTP-клиент
На нескольких уровнях реализация может использовать неблокирующий ввод-вывод, события готовности, пулы соединений или внутреннюю асинхронную механику.
Но бизнес-операция остаётся одним последовательным стеком вызовов.
Это не противоречие. Это ровно то, что виртуальные потоки предназначены обеспечивать.
Среда выполнения и библиотеки могут оптимизировать как реализуется ожидание, не требуя, чтобы каждый бизнес-метод раскрывал этот механизм.
Почему блокирующий API снова становится хорошим API¶
До Loom блокирующий API нёс неявное архитектурное предупреждение:
Вызов этого метода может потреблять один дефицитный платформенный поток до завершения.
Это предупреждение оправдывало асинхронные API, даже когда сама доменная операция была естественно запрос/ответ.
С виртуальными потоками значение меняется.
Блокирующий метод теперь в первую очередь говорит:
Текущая логическая операция не может продолжаться, пока этот результат не существует.
Для многих бизнес-операций это ровно правда.
Если заказ не может быть оценён, пока сервис ценообразования не ответит, то бизнес-поток последователен:
Нет концептуальной выгоды в притворстве, что зависимость не существует.
Синхронная сигнатура выражает зависимость напрямую:
JVM может обработать следствие планирования.
Это восстанавливает несколько полезных свойств обычной Java.
Поток управления виден в исходном коде¶
Разработчик может прочитать:
и немедленно понять порядок выполнения.
Между бизнес-алгоритмом и его реализацией меньше случайного словаря фреймворка.
Исключения используют обычную модель Java¶
Сбои могут распространяться через стек естественно:
Нет отдельного реактивного канала ошибок и нет необходимости помнить, какой оператор преобразует, возобновляет, оборачивает или задерживает ошибку.
Очистка ресурсов остаётся лексической¶
try-with-resources работает ровно как задумано:
Код, который приобретает ресурс, и код, который его освобождает, остаются структурно связанными.
Транзакции остаются интуитивными¶
Синхронная транзакция может соответствовать обычному скоупу вызова:
Нет необходимости гарантировать, что контекст транзакции переживёт изменения планировщика или границы реактивной подписки.
Отладчики видят логический стек¶
Когда запрос приостановлен в блокирующей операции, его стек виртуального потока по-прежнему представляет логическую цепочку вызовов.
Это одно из самых важных инженерных преимуществ Loom. Среда выполнения предоставляет масштабируемую конкурентность, не заставляя приложение стирать стек, который объясняет, что оно делает.
Стек — это функция, а не накладные расходы, которые нужно устранять¶
Реактивные архитектуры часто заменяют глубокий блокирующий стек явным графом продолжений.
Loom занимает другую позицию: приостановленный стек может быть эффективным представлением приостановленного вычисления.
Это имеет крупные следствия для наблюдаемости и отладки.
Предположим, запрос застрял в ожидании нижестоящего сервиса. В ориентированной на потоки модели логический стек может выглядеть примерно так:
OrderController.get()
OrderService.load()
PricingService.price()
PricingHttpClient.getPrice()
socketRead()
Этот стек немедленно отвечает на ценный вопрос:
Почему этот запрос ждёт?
В системах, управляемых колбэками, та же логическая причинность может быть разбита между задачами, очередями, операторами или границами планировщика. Современный реактивный инструментарий может реконструировать большую часть этого контекста, но приложению требуется специализированная наблюдаемость, чтобы восстановить информацию, которую исходный стек вызовов предоставлял естественно.
Виртуальные потоки снова делают ориентированную на потоки диагностику полезной даже при высокой конкурентности.
Это хорошо согласуется с более широкой философией Kora: сгенерированный код, прямые контракты, явная структура приложения и минимум скрытого поведения времени выполнения.
Виртуальные потоки и структурированная конкурентность¶
Удаление реактивных типов возвращаемого значения поднимает очевидный вопрос:
Что, если одному запросу нужно выполнить несколько независимых операций одновременно?
Последовательный блокирующий код:
не становится параллельным просто потому, что каждый вызов выполняется на виртуальном потоке. Второй вызов всё ещё начинается после завершения первого.
Естественный ответ эпохи Loom — структурированная конкурентность.
Собственная документация Kora указывает на StructuredTaskScope для случаев, когда несколько независимых операций должны выполняться параллельно.
Концептуально:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>awaitAllSuccessfulOrThrow())) {
var profile = scope.fork(() -> profileClient.get(userId));
var recommendations = scope.fork(() -> recommendationClient.get(userId));
scope.join();
return new Dashboard(
profile.get(),
recommendations.get()
);
}
StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow<Any>()
).use { scope ->
val profile = scope.fork<Any> { profileClient.get(userId) }
val recommendations = scope.fork<Any> { recommendationClient.get(userId) }
scope.join()
return Dashboard(
profile.get(),
recommendations.get()
)
}
Важна не точная версия API — структурированная конкурентность всё ещё эволюционирует в JDK — а модель.
Родительская операция создаёт дочерние задачи:
Конкурентная работа остаётся лексически ограниченной запросом.
Это принципиально отличается от превращения всего слоя сервиса в неструктурированную коллекцию фьючерсов.
Структурированная конкурентность пытается заставить конкурентный код подчиняться тем же принципам рассуждения, что и структурированное программирование:
- дочерние задачи принадлежат родительскому скоупу;
- время жизни ограничено этим скоупом;
- отмена может распространяться согласованно;
- обработка сбоев централизована;
- родитель ждёт детей согласно явной политике.
Другими словами, предпочтительная модель Kora не «всё должно быть последовательным». Она:
Держи API по умолчанию синхронным. Вводи конкурентность явно там, где бизнес-операция действительно содержит параллельную работу.
Это гораздо меньшая концептуальная поверхность, чем делать асинхронной каждую потенциально ожидающую операцию.
Реактивное программирование и виртуальные потоки оптимизируют разные слои¶
Было бы ошибкой утверждать, что виртуальные потоки делают реактивное программирование устаревшим.
Они решают пересекающиеся, но не идентичные проблемы.
Реактивное программирование не только об избегании заблокированных платформенных потоков. Reactive Streams также определяет сигнализацию спроса и противодавление между производителями и потребителями. Обработка потоков событий, бесконечные потоки, высокообъёмные конвейеры и системы, где противодавление является первоклассным доменным требованием, всё ещё могут выигрывать от реактивных абстракций.
Аналогично, системы событий UI, конвейеры сообщений и некоторая сетевая инфраструктура естественно ориентированы на колбэки или потоки.
Виртуальные потоки особенно убедительны для ориентированных на задачи серверных рабочих нагрузок:
Это описывает большую долю бэкенд-бизнес-сервисов.
Kora намеренно оптимизирована под этот класс ПО.
Поэтому дизайн-решение не:
Оно ближе к:
Не навязывай ориентированную на потоки или асинхронную модель программирования
обычному бизнес-коду запрос/ответ лишь для решения
дефицита платформенных потоков.
Когда проблема действительно является потоком, абстракция потока всё ещё может быть уместна.
Когда проблема — транзакция, RPC-вызов, поиск в репозитории или обработчик запроса, обычный метод часто является более верной абстракцией.
Kora 2 устраняет проблему двойной модели программирования¶
Фреймворки, эволюционировавшие через реактивный переход, часто поддерживают несколько моделей выполнения одновременно.
Одна экосистема может предлагать:
блокирующий MVC
реактивный HTTP
JDBC
реактивный SQL
Future / CompletionStage
Reactor
Kotlin suspend
Kotlin Flow
пулы воркеров
event-loop'ы
виртуальные потоки
Поддержка нескольких стилей может быть ценной для совместимости, но она также расширяет пространство архитектурных состояний.
Команды должны решать:
- Какую модель контроллера нам использовать?
- Можно ли вызвать этот репозиторий из этого потока?
- Разрешено ли этому перехватчику блокироваться?
- Какой планировщик активен здесь?
- Нужны ли нам
publishOnилиsubscribeOn? - Сохраняется ли контекст транзакции?
- Можно ли вызвать эту библиотеку из корутины?
- Мы на event-loop или на воркер-потоке?
- Должен ли этот метод возвращать
Mono<T>,CompletionStage<T>,Tили бытьsuspend? - Что происходит, когда встречаются блокирующие и реактивные компоненты?
Kora 2 выбирает схлопывание большей части этой матрицы.
Для обычных модулей фреймворка ответ синхронен:
Платформа выполнения поставляет виртуальные потоки.
Это важно за пределами синтаксиса. Фреймворк с одной моделью конкурентности может делать более сильные допущения во всём своём сгенерированном коде, телеметрии, перехватчиках, транзакциях, тестах, документации и примерах.
Фреймворк становится более согласованным, потому что коду приложения не нужно выбирать диалект конкурентности на каждой границе.
Kotlin тоже становится проще¶
Kotlin-корутины предоставляют отличную модель конкурентности, особенно в экосистемах, спроектированных вокруг suspend-API. Но они также вводят вторую абстракцию конкурентности рядом с Java-потоками.
Kotlin-бэкенд иначе может оказаться рассуждающим одновременно о:
Java-платформенных потоках
виртуальных потоках
корутинах
CoroutineContext
Dispatchers
границах suspend
структурированных скоупах корутин
распространении ThreadLocal
блокирующих библиотеках
Kora 2 намеренно избегает требования этой двойной модели в своих контрактах фреймворка.
Kotlin-контроллер остаётся:
@Component
@HttpController
class OrderController(
private val service: OrderService
) {
@HttpRoute(method = HttpMethod.GET, path = "/orders/{id}")
fun get(@Path id: Long): Order {
return service.get(id)
}
}
HTTP-клиент остаётся:
@HttpClient
interface PricingClient {
@HttpRoute(method = HttpMethod.GET, path = "/prices/{id}")
fun getPrice(@Path id: Long): Price
}
Примитив конкурентности времени выполнения — абстракция потока JVM, которую Java и Kotlin уже разделяют.
Это имеет практическое организационное преимущество: Java- и Kotlin-сервисы могут следовать одной и той же архитектуре. Java-разработчику не нужно учить специфичную для корутин версию каждого модуля фреймворка, а Kotlin-разработчику не нужно решать, должна ли каждая граница быть блокирующей поток или suspend.
Kotlin остаётся полезным для своих языковых функций, не требуя, чтобы корутины стали моделью выполнения фреймворка.
Почему это соответствует философии «тонких абстракций» Kora¶
Посадочная страница Kora подчёркивает тонкие абстракции вокруг знакомых технологий, таких как JDBC, HTTP, Kafka и gRPC.
Виртуальные потоки усиливают эту философию.
Без Loom фреймворк, нацеленный на очень высокую конкурентность, имел стимул оборачивать технологии в специфичные для фреймворка асинхронные представления:
С виртуальными потоками Kora может оставаться ближе к исходному API:
Это уменьшает семантическую дистанцию между приложением и технологией под ним.
Проблему JDBC можно расследовать как проблему JDBC.
Проблему HTTP можно расследовать как проблему HTTP.
Стек-трейс обычно напоминает исходный код.
Сгенерированный клиент или репозиторий — всё ещё обычный код Java/Kotlin.
Стратегия конкурентности не должна заражать каждую абстракцию.
Это особенно важно для фреймворка, чьи заявленные цели включают прозрачность и генерацию на этапе компиляции. Виртуальные потоки позволяют Kora упростить модель времени выполнения в то же время, когда обработка на этапе компиляции упрощает механику фреймворка.
Почему это соответствует генерации на этапе компиляции¶
Kora выполняет связку зависимостей, генерацию HTTP-обработчиков, генерацию репозиториев, маппинг, AOP и другую работу фреймворка на этапе компиляции.
Её подход «виртуальные потоки прежде всего» дополняет эту архитектуру, потому что оба решения устраняют косвенность времени выполнения по разным причинам.
Генерация на этапе компиляции убирает механику фреймворка, такую как:
- рефлексию времени выполнения;
- динамические прокси;
- обнаружение графа времени выполнения;
- общую рефлексивную диспетчеризацию.
Виртуальные потоки убирают механику конкурентности уровня приложения, которая иначе была бы нужна, чтобы избегать блокировки платформенных потоков:
- обвязку колбэков;
- реактивные обёртки вокруг обычных вызовов;
- переключение планировщика для базовой обработки запросов;
- корутинные контракты через каждую границу фреймворка.
Результат — приложение, чей путь времени выполнения может быть удивительно обычным:
сгенерированный HTTP-обработчик
↓
метод контроллера
↓
метод сервиса
↓
сгенерированный JDBC-репозиторий
↓
JDBC-драйвер
Важная изощрённость времени выполнения по-прежнему присутствует, но она переместилась в JVM и сгенерированную инфраструктуру, а не выражается повторно в бизнес-коде.
Это повторяющийся паттерн дизайна Kora:
Помещай сложность туда, где её можно реализовать один раз и ясно понять, а не требуй, чтобы каждый метод приложения в ней участвовал.
Потоки-носители: деталь, которую разработчики должны понимать¶
Виртуальные потоки упрощают код приложения, но разработчики всё равно должны понимать модель носителей, потому что она объясняет оставшиеся режимы отказа.
Виртуальный поток выполняется, пока смонтирован на носителе.
Если виртуальный поток выполняет поддерживаемую блокирующую операцию, он обычно может размонтироваться:
Это желаемый случай.
Однако не каждый вид блокировки всегда может быть преобразован в дешёвую парковку виртуального потока. Операции, включающие нативный код, вызовы foreign-функций, некоторые взаимодействия с операционной системой или внутренности JVM, могут держать носитель занятым.
Вот почему корректный производственный вопрос не просто:
Используем ли мы виртуальные потоки?
Он:
Хорошо ли ведут себя библиотеки и блокирующие операции, используемые нашими горячими путями, на виртуальных потоках?
Это особенно важно при интеграции нативных библиотек или необычных драйверов.
JDK предоставляет события JFR и диагностику виртуальных потоков, чтобы помочь расследовать ситуации, где виртуальные потоки остаются закреплёнными за носителями.
Важная поправка для JDK 24+: synchronized больше не старая ловушка Loom¶
Многие ранние статьи о виртуальных потоках повторяют это правило:
Никогда не блокируйся внутри
synchronized, потому что виртуальный поток закрепит свой носитель.
Этот совет описывал раннюю реализацию Loom, но устарел для базового уровня JDK Kora 2.
JEP 491, поставляемый в JDK 24, изменил JVM так, что виртуальные потоки в целом могут размонтироваться, удерживая Java-мониторы или ожидая их приобретения. Kora 2 RC1 требует JDK 25, поэтому обычное
использование synchronized не является автоматически той опасностью для масштабируемости виртуальных потоков, какой оно было на JDK 21.
Это важный пример того, почему руководство по виртуальным потокам должно быть привязано к конкретной версии JDK.
На JDK 25 оставшаяся проблема закрепления в основном вокруг нативных методов и вызовов foreign-функций, а не обычного владения Java-мониторами.
Это не значит, что синхронизация не может навредить масштабируемости. Конкуренция за блокировки — всё ещё конкуренция за блокировки. Если тысячи виртуальных потоков конкурируют за один и тот же монитор, они всё равно сериализуются вокруг этого ресурса.
Различие:
Виртуальные потоки не устраняют плохую архитектуру блокировок. JEP 491 устраняет ограничение реализации, которое раньше заставляло определённые синхронизированные блокирующие области ненужно потреблять носители.
Пулы баз данных становятся яснее, а не ненужными¶
Распространённое заблуждение — что виртуальные потоки делают пулы соединений устаревшими.
Они этого не делают.
Соединение с базой данных — не просто обходной путь для стоимости потоков. Это дефицитный ресурс на стороне базы данных.
Предположим, приложение может создать 100 000 виртуальных потоков, но база данных здорова только с 50 активными соединениями.
Корректная архитектура может быть:
Виртуальный поток, ожидающий соединение, может парковаться дёшево.
Это на самом деле чистое разделение забот:
- виртуальные потоки представляют конкурентные задачи приложения;
- пул соединений представляет ёмкость базы данных.
В старой модели команды иногда настраивали пул воркеров частично, чтобы предотвратить слишком много вызовов к базе данных. С виртуальными потоками специфичные для ресурса контроли становятся более явными.
JDBC-ориентированный дизайн Kora естественно вписывается в эту модель, потому что пулирование JDBC-соединений остаётся значимой формой противодавления, даже если выполнение запросов больше не нуждается в маленьком пуле воркер-потоков.
Пул HTTP-соединений всё ещё важен¶
То же правило применимо к исходящему HTTP.
Виртуальные потоки делают дешёвым наличие многих вызывающих, ожидающих ответы, но они не дают внешнему сервису бесконечную ёмкость.
Если сервис Kora внезапно запускает 20 000 параллельных запросов к одной зависимости, возможные узкие места включают:
- доступные TCP-соединения;
- лимиты потоков HTTP/2;
- ёмкость воркеров удалённого сервера;
- пропускную способность сети;
- стоимость TLS;
- ограничения скорости;
- задержку в очередях;
- усиление повторными попытками.
Поэтому хорошо спроектированный клиентский стек всё ещё нуждается в управлении соединениями, таймаутах, осторожно используемых повторных попытках, автоматических выключателях и иногда явных лимитах конкурентности.
Виртуальные потоки устраняют потребность в том, чтобы дефицит потоков был главным контролем конкурентности.
Они не устраняют потребность в самом контроле конкурентности.
Таймауты становятся ещё важнее¶
Дёшевое ожидание может облегчить переносимость многих блокирующих операций, но дёшевое ожидание — всё ещё ожидание.
Виртуальный поток, застрявший навсегда на сломанном нижестоящем вызове, потребляет меньше ёмкости выполнения, чем платформенный поток, но он всё ещё представляет:
- незавершённый запрос;
- сохранённое состояние приложения;
- сохранённые буферы или объекты;
- клиентское соединение;
- возможно, транзакцию базы данных;
- состояние телеметрии;
- видимую пользователю задержку.
Поэтому таймауты остаются первоклассным производственным требованием.
Виртуальные потоки должны изменить эту ментальную модель:
на:
блокировка приемлема, когда ожидание семантически необходимо,
но ожидание всё равно должно быть ограничено политикой системы
Это гораздо более точный способ рассуждать о распределённых системах.
Повторные попытки всё ещё могут создавать взрывы конкурентности¶
Реактивные или синхронные, повторные попытки опасны, когда зависимость уже отказывает.
Виртуальные потоки могут сделать стоимость выполнения большого количества ожидающих повторных попыток меньше, что полезно, но это не защищает зависимость.
Представь:
Тот факт, что эти попытки используют лёгкие виртуальные потоки, не делает нижестоящую систему способной их обслуживать.
Механизмы отказоустойчивости Kora — таймауты, повторные попытки, автоматические выключатели, fallback'и — остаются необходимыми, потому что Loom решает локальные накладные расходы планирования, а не динамику сбоев распределённых систем.
Виртуальные потоки упрощают код, использующий эти политики. Они не заменяют политики.
ThreadLocal снова становятся полезными — но их всё равно следует использовать намеренно¶
Реактивные системы усложнили ThreadLocal, потому что логический запрос мог перемещаться через множество воркер-потоков. Поэтому фреймворки ввели явные механизмы распространения контекста.
Виртуальные потоки восстанавливают более сильную связь между логической операцией и её Thread.
У каждого виртуального потока есть собственное состояние thread-local, независимое от потока-носителя. Запрос может перемещаться между носителями, сохраняя идентичность и состояние thread-local самого виртуального потока.
Это снова делает знакомые техники, ограниченные потоком, более жизнеспособными.
Однако виртуальные потоки могут существовать в очень больших количествах, поэтому хранение тяжёлых объектов в состоянии thread-local может стать дорогим. Дизайн, который был безвреден со 100 воркер-потоками, может быть дорогим со 100 000 виртуальных потоков.
Правило становится:
Контекст thread-local снова семантически удобен, но его стоимость памяти на поток важнее.
Современная Java также предлагает scoped values как структурированную альтернативу для некоторых случаев распространения контекста.
Более широкий момент остаётся: Loom позволяет контексту запроса следовать за логическим потоком, не требуя прикладной реактивной абстракции контекста.
«Не пулируйте виртуальные потоки»¶
Платформенные потоки достаточно дороги, чтобы приложения исторически переиспользовали их в пулах.
Виртуальные потоки предназначены быть достаточно дешёвыми, чтобы в целом создаваться на задачу.
Это различие фундаментально.
Старая модель:
Модель Loom:
Виртуальный поток ближе к объекту запроса, чем к дефицитному воркеру.
Пулирование виртуальных потоков может случайно вернуть то самое искусственное узкое место конкурентности, которое Loom устраняет.
Ограничения ресурсов вместо этого должны выражаться там, где ресурс действительно конечен: пулы соединений, семафоры, ограничения скорости, очереди или политики сервисов.
HTTP-дизайн Kora следует этой идее, диспетчеризуя запросы на виртуальные потоки, а не поддерживая старый ограниченный пул блокирующих воркеров запросов.
Что «виртуальные потоки прежде всего» означает архитектурно¶
Для Kora виртуальные потоки — больше, чем опция производительности. Они формируют дизайн API.
Фреймворк, который относится к виртуальным потокам как к опциональному исполнителю, может сохранять все свои старые реактивные и блокирующие варианты:
Kora 2 вместо этого использует виртуальные потоки как причину упростить публичную модель.
Это даёт более сильное архитектурное заявление:
Механизм конкурентности:
виртуальные потоки
Контракт приложения по умолчанию:
синхронные методы
Параллельная композиция:
явная структурированная конкурентность
Защита ресурсов:
пулы / таймауты / отказоустойчивость / лимиты
Внутренности транспорта:
могут оставаться асинхронными / на основе NIO
У каждой заботы есть ясное место.
Это, пожалуй, важнее сырых характеристик производительности виртуальных потоков.
Исторический цикл на самом деле не круг¶
На первый взгляд индустрия, кажется, вернулась туда, где начала:
Но финальная архитектура материально отличается от первой.
Старый «поток на запрос»¶
Масштабируемость была ограничена потоками операционной системы.
Реактивное¶
Масштабируемость улучшилась, но код приложения должен был участвовать в мультиплексировании.
Модель запросов на виртуальных потоках¶
1 запрос
≈
1 виртуальный Java Thread
много виртуальных потоков
≈
маленький набор потоков-носителей ОС
Приложение возвращает себе абстракцию «один запрос — один поток», не возвращаясь к стоимости ресурсов «один запрос — один поток ОС».
Поэтому траектория — не откат.
Это улучшение абстракции.
Модель программирования возвращается к чему-то простому, потому что среда выполнения стала достаточно изощрённой, чтобы эффективно её поддерживать.
Почему это важно для дизайна фреймворков¶
До Loom авторы фреймворков должны были выбирать между двумя непривлекательными вариантами для высокой конкурентности.
Вариант A: сохранить синхронные API¶
Преимущества:
- простой Java;
- лёгкая отладка;
- зрелые блокирующие библиотеки;
- прямолинейные транзакции.
Недостаток:
- каждый ожидающий запрос потребляет платформенный воркер-поток.
Вариант B: предоставить асинхронные API¶
Преимущества:
- отличная масштабируемость ввода-вывода;
- маленький след платформенных потоков.
Недостатки:
- модель конкурентности распространяется по API приложения;
- блокирующие библиотеки требуют специальной обработки;
- отладка и распространение контекста становятся сложнее;
- команды должны учить специфичную для фреймворка асинхронную композицию.
Виртуальные потоки создают третий вариант:
Вариант C: синхронные API на лёгких потоках¶
Преимущества:
- прямой код;
- большая конкурентность ввода-вывода;
- обычная экосистема библиотек;
- обычные исключения и стеки;
- более лёгкая интеграция с JDBC и блокирующими клиентами.
Компромиссы всё ещё существуют, но старый вынужденный выбор между простым блокирующим кодом и масштабируемым вводом-выводом стал гораздо слабее.
Kora 2 спроектирована вокруг варианта C.
Почему это может сократить код фреймворка¶
Реактивная поддержка не бесплатна и для фреймворка.
Если каждый модуль поддерживает несколько моделей выполнения, фреймворку могут понадобиться варианты для:
- синхронных обработчиков;
- обработчиков на основе future;
- обработчиков Reactor;
- обработчиков Kotlin-корутин;
- распространения контекста;
- адаптеров телеметрии;
- транзакционных обёрток;
- обёрток отказоустойчивости;
- обёрток кеша;
- путей сгенерированного кода;
- утилит тестирования.
AOP особенно затронут. Аспект таймаута, повторной попытки, кеша, трассировки или автоматического выключателя должен понимать семантику завершения каждого поддерживаемого типа возвращаемого значения.
С синхронным базовым уровнем аспект часто может оставаться концептуально простым:
Виртуальные потоки поэтому имеют потенциал упростить и реализацию фреймворка, и код приложения.
Это согласуется с философией «одного ясного пути» Kora. Меньший набор моделей выполнения означает меньше комбинаций для реализации, документирования, тестирования и сопровождения.
Почему это может снизить сложность тестов приложения¶
Синхронные методы легко вызывать в тестах.
Тест сервиса может просто написать:
Нет необходимости:
- подписываться;
- ждать future;
- запускать тестовый скоуп корутин;
- настраивать планировщик;
- использовать реактивного тестового подписчика;
- рассуждать о ленивом против нетерпеливого выполнения.
Интеграционные тесты также больше напоминают производственный код, потому что используется тот же синхронный путь вызова.
Kora уже подчёркивает быстрое компонентное и интеграционное тестирование, обеспечиваемое быстрым запуском приложения. Синхронная модель выполнения усиливает этот цикл обратной связи.
Ценность не в том, что асинхронный код нетестируем. Она в том, что асинхронной инфраструктуре больше не нужно появляться в тестах для операций, чья доменная семантика фундаментально синхронна.
Почему это может улучшить отладку¶
Рассмотрим исключение из глубоко вложенного вызова сервиса.
В синхронной цепочке вызовов стек-трейс естественно записывает причинность:
В асинхронных системах логическая причинность может пересекать границы задач. Библиотеки вложили много усилий в assembly traces, восстановление стеков корутин, распространение контекста и поддержку отладчиков, чтобы улучшить эту ситуацию, но это компенсирующие механизмы.
Виртуальные потоки позволяют JVM сохранять обычную ориентированную на стек модель при гораздо большей конкурентности.
Для Kora это сочетается со сгенерированным исходным кодом особенно полезным образом:
запрос
↓
сгенерированный обработчик
↓
контроллер
↓
сервис
↓
сгенерированный репозиторий/клиент
↓
реальная интеграционная библиотека
Разработчик может шагать по обычному коду, а не реконструировать скрытую механику времени выполнения.
Это ровно тот тип прозрачности, который архитектура компиляции Kora предназначена предоставлять.
Почему это может улучшить наблюдаемость¶
Наблюдаемость фундаментально о реконструкции причинности.
Трассировка спрашивает:
Логи спрашивают:
Метрики спрашивают:
Модель «поток на задачу» даёт системам наблюдаемости естественную единицу выполнения.
Это не устраняет необходимость распространения контекста OpenTelemetry — распределённые вызовы всё ещё пересекают границы процессов, и конкурентные дочерние задачи всё ещё нуждаются в контексте — но локальный поток управления становится проще связать с логическим запросом.
Поэтому модули наблюдаемости Kora могут инструментировать прямые синхронные вызовы без отдельной стратегии инструментирования для цепочек Reactor, фьючерсов и продолжений корутин в каждом модуле.
Опять же, выгода приходит от сокращения количества диалектов конкурентности, которые фреймворк должен понимать.
Виртуальные потоки делают знакомые Java-библиотеки более стратегически ценными¶
Экосистема Java содержит десятилетия зрелых блокирующих API.
Примеры включают:
- JDBC-драйверы;
- классические HTTP-клиенты;
- файловые API;
- многие SDK;
- библиотеки баз данных;
- RPC-клиенты;
- корпоративные интеграции.
В реактивную эпоху использование таких библиотек внутри архитектуры event-loop часто требовало:
или замены библиотеки неблокирующим эквивалентом.
Виртуальные потоки меняют расчёт затрат и выгод.
Если блокирующая библиотека хорошо ведёт себя с виртуальными потоками, приложение может использовать её напрямую, сохраняя высокую конкурентность запросов.
Это снова делает зрелость экосистемы ценной.
Решение Kora сосредоточиться на JDBC, а не поддерживать параллельные миры JDBC и реактивных баз данных, — хороший пример. Вместо вопроса, есть ли у каждой интеграции специальный реактивный драйвер, фреймворк может спросить, работает ли установленный Java-API корректно и эффективно на виртуальных потоках.
Это может значительно снизить сложность интеграции.
Но совместимость нужно измерять, а не предполагать¶
«Блокирующий API» не автоматически означает «дружелюбный к виртуальным потокам».
Библиотека может:
- выполнять длинные нативные вызовы;
- удерживать глобальные блокировки;
- скрывать свой собственный маленький блокирующий пул;
- использовать thread-local кеши с большим следом памяти;
- сериализовать работу внутри;
- зависеть от идентичности потока неожиданными способами;
- выполнять тяжёлую по CPU работу, пока вызывающие предполагают, что она связана с вводом-выводом.
Виртуальные потоки не могут исправить эти архитектурные ограничения.
Это делает производственную диагностику важной.
Команды должны измерять:
- использование потоков-носителей;
- события закрепления виртуальных потоков;
- очереди нижестоящих систем;
- время ожидания пула базы данных;
- насыщение HTTP-пула;
- насыщение CPU;
- скорость аллокаций;
- хвостовую задержку;
- объём повторных попыток.
Модель виртуальных потоков упрощает код приложения, но она не освобождает систему от инженерии производительности.
Блокировка может быть более честной абстракцией¶
Один из самых сильных аргументов за дизайн Kora — семантический, а не связанный с производительностью.
Метод, выполняющий RPC, часто является блокирующей зависимостью в бизнес-операции.
Предположим, этот код не может произвести результат, пока не завершится скоринг мошенничества:
Представление вызова как:
не делает бизнес-зависимость асинхронной. Оно только меняет то, как представлено ожидание.
Платёжная операция всё равно не может завершиться, пока не существует оценка мошенничества.
Виртуальные потоки позволяют реализации выражать эту истину напрямую, не обязательно платя один платформенный поток за ожидание.
Вот почему Loom можно рассматривать как ремонт абстракции.
Он позволяет исходному коду описывать логические зависимости, а не механику планирования.
Влияние на дизайн API слоя сервисов¶
Как только границы фреймворка синхронны, API сервисов становятся гораздо чище.
Сервис Kora может предоставлять ориентированные на домен методы:
а не ориентированные на транспорт типы конкурентности:
или:
Это создаёт более сильное разделение между доменными контрактами и механикой выполнения.
Это также делает сервисы проще для переиспользования из:
- HTTP-контроллеров;
- Kafka-консьюмеров;
- планируемых задач;
- тестов;
- инструментов командной строки;
- других компонентов.
Методу сервиса всё равно, пришёл ли его вызывающий из HTTP-запроса или планируемой задачи. Он просто вызывается на потоке, соответствующем окружающему времени выполнения.
Это классическая архитектура приложения, снова сделанная масштабируемой JVM.
Влияние на ревью кода и сопровождение¶
Прямую цепочку вызовов проще ревьюить, потому что поток управления явный.
Ревьюер может спросить:
Что может выйти из строя?
Что может блокироваться?
Что последовательно?
Что параллельно?
Где транзакция?
Где таймаут?
не интерпретируя одновременно цепочку операторов планировщика и композиции.
Это особенно полезно для больших команд, где не каждый разработчик является экспертом в фреймворке конкурентности.
Это также снижает вероятность случайных ошибок, таких как:
- выполнение блокирующей работы на event-loop;
- забывание подписаться;
- неправильное переключение планировщиков;
- потеря контекста;
- неправильная композиция вложенных реактивных типов;
- использование неверного диспетчера корутин;
- смешивание несовместимой семантики отмены.
Компилятор Kora уже агрессивно проверяет структуру фреймворка. Более простая модель выполнения дополнительно снижает количество ошибок времени выполнения, которые компилятор не может легко обнаружить.
Влияние на введение в проект¶
Java-разработчик, присоединяющийся к сервису Kora 2, может сильно полагаться на обычные знания JVM.
Ему нужно понимать виртуальные потоки, но ему не нужно учить совершенно отдельный стиль программирования, прежде чем читать базовый бизнес-код.
Концептуальный путь:
а не:
Для организаций с множеством сервисов и ротацией разработчиков между командами это сокращение требуемого контекста может быть ценнее небольших различий в бенчмарках.
Модель конкурентности — не только выбор времени выполнения. Это стоимость обучения и сопровождения, которую платит каждый инженер, касающийся системы.
Влияние на разработку с помощью нейро-инструментов¶
Те же свойства, которые помогают людям-разработчикам, помогают агентам, генерирующим и анализирующим код.
Модель на основе нейро-технологий с большей вероятностью корректно рассуждает о:
чем о специфичной для фреймворка цепочке, чья корректность зависит от тонкой семантики планировщика, подписки, контекста и жизненного цикла.
Это не значит, что Нейро-технологии не могут писать реактивный код. Это значит, что пространство поиска больше.
Kora 2 сужает модель выполнения:
обычный метод
обычный тип возвращаемого значения
обычное исключение
обычный сгенерированный исходный код
виртуальный поток под ним
Затем компилятор проверяет структурные части, которые Kora может валидировать.
Эта комбинация — простая семантика исходного кода плюс сильная обратная связь на этапе компиляции — особенно подходит для итеративной разработки, управляемой агентами.
Когда разработчикам Kora всё ещё следует явно думать о конкурентности?¶
Виртуальные потоки позволяют разработчикам игнорировать многие детали планирования, но не всю конкурентность.
Есть несколько случаев, где конкурентность всё равно следует проектировать явно.
Параллельный fan-out¶
Если запросу нужны три независимых удалённых вызова и важна задержка, выполняй их одновременно с помощью структурированной конкурентности, а не последовательно.
Общее мутабельное состояние¶
Виртуальные потоки — всё ещё потоки. Гонки данных, правила видимости, блокировки и атомарность всё ещё важны.
Дефицитные нижестоящие ресурсы¶
Используй пулы соединений, ограничения скорости, семафоры, bulkhead'ы и другие контроли ёмкости.
Работа, тяжёлая по CPU¶
Не предполагай, что больше виртуальных потоков увеличивает пропускную способность CPU.
Длинные нативные вызовы¶
Исследуй закрепление носителей и поведение нативной интеграции.
Стриминг¶
Если проблема фундаментально является неограниченным или управляемым спросом потоком, абстракция потока всё ещё может быть лучше, чем сбор всего в блокирующий API запрос/ответ.
Отмена¶
Распределённая отмена остаётся системной заботой. Структурированная конкурентность и прерывание могут помочь локально, но внешние вызовы и библиотеки должны корректно сотрудничать.
Цель — не устранить рассуждение о конкурентности.
Она в том, чтобы конкурентность появлялась там, где система действительно в ней нуждается, а не кодировалась в каждую сигнатуру метода.
Лучшая ментальная модель блокировки в Kora 2¶
В старых высоконагруженных Java-архитектурах разработчиков часто учили:
Блокировка — это плохо.
Это правило было полезно, но неточно.
Лучшее правило Kora 2:
Блокировка виртуального потока обычно допустима. Блокировка дефицитного носителя или потока event-loop — нет. Перегрузка конечного нижестоящего ресурса — всё ещё недопустима.
Это разделяет три разные проблемы:
логическое ожидание
↓
обычно приемлемо на виртуальных потоках
занятость носителя / event-loop
↓
может снизить масштабируемость времени выполнения
насыщение ресурса
↓
может перегрузить базу данных / сервис / CPU
Как только они разделены, дизайн системы становится яснее.
Стек конкурентности Kora¶
Архитектуру Kora 2 можно резюмировать по слоям.
┌──────────────────────────────────────────────┐
│ Код приложения │
│ контроллеры / сервисы / репозитории │
│ обычный синхронный Java/Kotlin │
├──────────────────────────────────────────────┤
│ Сгенерированный слой Kora │
│ обработчики / клиенты / репозитории / AOP │
│ сгенерировано на этапе компиляции, │
│ строго типизировано │
├──────────────────────────────────────────────┤
│ Виртуальные потоки │
│ один лёгкий поток на логическую задачу │
├──────────────────────────────────────────────┤
│ Потоки-носители │
│ маленький набор платформенных потоков, │
│ планируемых JVM │
├──────────────────────────────────────────────┤
│ Слой транспорта / драйверов │
│ Undertow / NIO / JDBC / HTTP-библиотеки │
├──────────────────────────────────────────────┤
│ Операционная система │
└──────────────────────────────────────────────┘
Ключевая граница дизайна — между слоем приложения и механикой выполнения.
Код приложения видит прямые методы.
Среда выполнения обрабатывает мультиплексирование.
От цветных функций обратно к обычным функциям¶
Асинхронные экосистемы иногда используют термин раскраска функций для описания API, где асинхронные функции имеют другой тип и модель композиции, чем синхронные.
Например:
Как только одна функция становится «цветной», вызывающие часто должны принять тот же цвет.
Реактивный репозиторий поощряет реактивный сервис, который поощряет реактивный контроллер.
Suspend-клиент поощряет suspend-сервис, который поощряет suspend-контроллер.
Loom снижает это давление, потому что ожидающий вызов может оставаться обычным вызовом.
Kora 2 агрессивно использует эту возможность.
Её позиция фактически:
Если функция концептуально возвращает
T, пусть она возвращаетT. Пусть среда выполнения решает проблему ожидания.
Это одно из самых глубоких архитектурных следствий виртуальных потоков.
Что Kora отдаёт, делая этот выбор¶
Каждое архитектурное упрощение удаляет варианты.
Не предоставляя реактивные и suspend-контракты в своих основных модулях, Kora 2 менее подходит для команд, которые специально хотят построить всю свою архитектуру приложения вокруг API Reactor или
корутин.
Команда может предпочесть реактивную композицию, потому что:
- её домен сильно ориентирован на потоки;
- у неё уже есть большая кодовая база Reactor;
- противодавление центрально для её дизайна приложения;
- она полагается на реактивные библиотеки от начала до конца;
- её разработчики глубоко опытны в этой модели.
Аналогично, Kotlin-организация может намеренно стандартизировать корутины как свою универсальную абстракцию конкурентности.
Дизайн Kora 2 не пытается удовлетворить каждое такое предпочтение.
Это согласуется с более широкой философией Kora: меньше моделей программирования, меньше конкурирующих абстракций и один категоричный путь, оптимизированный для обычных серверных сервисов.
Выгода — согласованность.
Цена — сниженный плюрализм.
Это осознанный компромисс фреймворка, а не случайное ограничение.
Почему «снова синхронная» — важная фраза¶
Слово снова важно, потому что синхронный дизайн Kora 2 основан не на игнорировании истории реактивных систем.
Он основан на том факте, что нижележащая JVM изменилась.
До Loom:
часто был реальным компромиссом.
После Loom этот компромисс значительно уменьшен.
Поэтому Kora может пересмотреть исходную прямую модель программирования с другим субстратом выполнения.
Фреймворк не утверждает, что индустрия потратила время впустую, исследуя асинхронные системы. Эти системы выявили реальное ограничение времени выполнения и продемонстрировали ценность мультиплексирования многих конкурентных операций на нескольких потоках ОС.
Loom интериоризирует большую часть этого мультиплексирования в JVM.
То, что раньше требовало машины состояний уровня приложения, часто может стать управляемым JVM виртуальным потоком.
Это прогресс именно потому, что уроки реактивных систем переместились вниз по стеку.
Более глубокий архитектурный урок¶
Историю серверной конкурентности можно читать как последовательность того, где живёт продолжение.
Эпоха платформенных потоков¶
Продолжение представлено Java-потоком на основе ОС.
Реактивная эпоха¶
Продолжение представлено явно в объектах приложения/библиотеки.
Эпоха Loom¶
Продолжение снова представлено Java-потоком и стеком, но JVM может приостанавливать его дёшево.
Это объясняет, почему исходный код снова становится синхронным, не отказываясь от масштабируемого мультиплексирования.
Продолжение никогда не исчезало. Оно просто перемещалось между слоями.
Kora 2 выбирает версию, где JVM владеет этой сложностью.
Практический запрос Kora 2¶
Рассмотрим типичный эндпоинт:
@Component
public final class OrderService {
private final OrderRepository orders;
private final CustomerClient customers;
private final FraudClient fraud;
public OrderService(
OrderRepository orders,
CustomerClient customers,
FraudClient fraud
) {
this.orders = orders;
this.customers = customers;
this.fraud = fraud;
}
public OrderView get(long orderId) {
var order = orders.findById(orderId);
var customer = customers.get(order.customerId());
var score = fraud.score(customer, order);
return new OrderView(order, customer, score);
}
}
@Component
class OrderService(
private val orders: OrderRepository,
private val customers: CustomerClient,
private val fraud: FraudClient
) {
fun get(orderId: Long): OrderView {
val order = orders.findById(orderId)
val customer = customers.get(order.customerId)
val score = fraud.score(customer, order)
return OrderView(order, customer, score)
}
}
На уровне исходного кода это обычный блокирующий Java.
Во время выполнения запрос может вести себя примерно так:
виртуальный поток V42 запускается
↓
orders.findById()
↓
ожидание JDBC
↓
V42 паркуется / носитель переиспользуется
↓
база данных отвечает
↓
V42 возобновляется
↓
customers.get()
↓
ожидание HTTP
↓
V42 паркуется / носитель переиспользуется
↓
ответ приходит
↓
V42 возобновляется
↓
fraud.score()
↓
...
↓
вернуть ответ
↓
V42 заканчивается
Приложение видит одну последовательную операцию.
JVM видит несколько периодов, в течение которых носитель можно использовать для другой работы.
Это практическое значение модели «виртуальные потоки прежде всего» Kora.
А когда вызовы независимы¶
Если данные клиента и метаданные мошенничества можно загружать независимо, сервис может ввести конкурентность намеренно:
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>awaitAllSuccessfulOrThrow())) {
var customerTask =
scope.fork(() -> customers.get(order.customerId()));
var fraudTask =
scope.fork(() -> fraud.metadata(order.id()));
scope.join();
return combine(
order,
customerTask.get(),
fraudTask.get()
);
}
StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow<Any>()
).use { scope ->
val customerTask =
scope.fork<Any> { customers.get(order.customerId) }
val fraudTask =
scope.fork<Any> { fraud.metadata(order.id) }
scope.join()
return combine(
order,
customerTask.get(),
fraudTask.get()
)
}
Теперь исходный код видимо говорит:
а не заставляет каждый отдельный клиентский метод рекламировать асинхронное выполнение.
Это, пожалуй, более точное использование конкурентности в модели типов и потока управления.
Производительность — не только запросы в секунду¶
Ценность этой архитектуры не следует сводить к пропускной способности бенчмарков.
Модель конкурентности влияет на:
- сложность реализации;
- время отладки;
- реагирование на инциденты;
- введение в проект;
- читаемость тестов;
- выбор библиотек;
- генерацию кода;
- наблюдаемость;
- стоимость обновлений;
- когнитивную нагрузку.
Реактивные системы могут давать отличную пропускную способность, но если обычному сервису не нужна семантика потоков, заставлять каждого разработчика рассуждать о реактивном графе выполнения может быть организационной стоимостью.
Виртуальные потоки позволяют фреймворку обменять изощрённость JVM на простоту приложения.
Kora 2 явно выбирает этот обмен.
Выгода в производительности поэтому не только в том, что сервис может эффективно обрабатывать много конкурентных запросов. Она в том, что фреймворк может получить эту конкурентность без того, чтобы синтаксис конкурентности доминировал в приложении.
Реальный смысл «виртуальные потоки прежде всего»¶
«Виртуальные потоки прежде всего» не следует интерпретировать как:
Kora использует
Thread.ofVirtual()где-то внутри.
Это более фундаментально.
Это значит, что Kora готова проектировать свои API в предположении, что лёгкое выполнение «поток на задачу» — обычная модель JVM.
Это допущение приводит к:
- синхронным контроллерам;
- синхронным HTTP-клиентам;
- синхронным JDBC-репозиториям;
- обычным планируемым методам;
- отсутствию общефреймворкового реактивного контракта;
- отсутствию общефреймворкового
suspend-контракта; - явной структурированной конкурентности для реального параллелизма;
- специфичным для ресурса лимитам ёмкости вместо маленьких пулов воркеров;
- прямым стекам вызовов Java/Kotlin для диагностики.
Виртуальный поток — не деталь реализации, добавленная после проектирования API.
Это одна из причин, почему API вообще может быть спроектирован так.
Заключение¶
Историю серверной конкурентности на JVM часто рассказывают как состязание между блокирующим и неблокирующим программированием. Эта рамка теперь слишком упрощена.
Реальным историческим ограничением была связка между логическим потоком и дорогим потоком операционной системы.
Реактивное программирование разорвало эту связку, переместив продолжения в абстракции уровня приложения.
Проект Loom разрывает её внутри JVM.
Это позволяет Kora 2 восстановить самое сильное свойство традиционной Java-серверной модели: *бизнес-код можно писать как обычный последовательный код, чей стек вызовов отражает логическую операцию *.
Но она восстанавливает эту модель, не возвращаясь к старому допущению, что каждый конкурентный запрос требует выделенного потока операционной системы.
Результат — не старая архитектура «поток на запрос».
Это новая её версия:
один запрос
↓
один виртуальный поток
↓
обычный синхронный код
↓
блокировка, когда бизнес-операция должна ждать
↓
носитель освобождается, когда среда выполнения может его освободить
Реактивное программирование было рациональным ответом на дефицит платформенных потоков. Loom перемещает большую часть механики, необходимой для решения этой проблемы, в JVM. Kora 2 делает следующий архитектурный шаг и убирает эту механику из своих контрактов приложения.
Вот почему Kora 2 снова синхронная.
Не потому, что масштабируемость перестала иметь значение.
Потому что JVM наконец сделала простую модель программирования достаточно масштабируемой, чтобы снова стать выбором по умолчанию.