Что сейчас с Project Loom? Примеры и код

от автора

Привет, Хабр!

В первой части я разобрал теорию Project Loom: virtual threads, Scoped Values и preview Structured Concurrency. Там была общая картина: зачем Loom появился, почему virtual threads не ускоряют любой код, чем ScopedValue отличается от ThreadLocal, зачем нужны Joiner и почему Structured Concurrency все еще preview.

Теперь практика.

В этой части будет меньше теории и больше кода:

  1. Как включить preview Structured Concurrency.

  2. Какие флаги нужны для javac, Maven и Gradle.

  3. Как выглядит ScopedValue для request context.

  4. Как собрать страницу из нескольких источников через StructuredTaskScope.

  5. Как использовать allSuccessfulOrThrow() и anySuccessfulOrThrow().

  6. Как задать timeout на весь scope.

  7. Как ScopedValue и StructuredTaskScope работают вместе.

Примеры ориентированы на современную preview-линию JDK 25+. На момент написания актуальный GA-релиз — Java 26. Текущий LTS-релиз — Java 25. JDK 27 уже доступен как early access и несет очередную preview-итерацию Structured Concurrency. API между preview-релизами еще меняется, поэтому старые статьи и сниппеты из incubator лучше не копировать вслепую — сначала проверяйте на своей версии JDK.

Погнали.


8. Сначала про версии и preview

Virtual threads с Java 21 уже не требуют preview-флагов:

javac Main.javajava Main

ScopedValue с JDK 25 тоже final. Если вы экспериментируете только с ним, preview-флаги не нужны.

А вот Structured Concurrency пока preview. Значит, нужны флаги и на компиляции, и на запуске.

Для JDK 27 EA с JEP 533:

javac --enable-preview --release 27 Main.javajava --enable-preview Main

Для JDK 26:

javac --enable-preview --release 26 Main.javajava --enable-preview Main

Для маленьких экспериментов я бы начал именно с одного файла и javac. Меньше магии сборщика, проще понять, где ошибка: в коде, версии JDK или флагах.

Минимальный пример:

import java.util.concurrent.StructuredTaskScope;public class Main {    public static void main(String[] args) throws Exception {        try (var scope = StructuredTaskScope.open()) {            var hello = scope.fork(() -> "Hello");            var loom = scope.fork(() -> "Loom");            scope.join();            System.out.println(hello.get() + ", " + loom.get());        }    }}

Компиляция для JDK 27 EA:

javac --enable-preview --release 27 Main.javajava --enable-preview Main

Если забыть --enable-preview, компилятор честно напомнит, что вы используете preview API. Ничего загадочного.


9. Maven

Для Maven нужны две части: compiler plugin и surefire, если вы запускаете тесты.

<plugin>    <groupId>org.apache.maven.plugins</groupId>    <artifactId>maven-compiler-plugin</artifactId>    <configuration>        <release>27</release>        <compilerArgs>            <arg>--enable-preview</arg>        </compilerArgs>    </configuration></plugin><plugin>    <groupId>org.apache.maven.plugins</groupId>    <artifactId>maven-surefire-plugin</artifactId>    <configuration>        <argLine>--enable-preview</argLine>    </configuration></plugin>

Если проект уже использует <argLine> для JaCoCo или других агентов, не надо его молча затирать. Иначе добавите preview-флаг, а coverage agent пропадет — и CI начнет вести себя странно.

В живом проекте я бы сначала вынес это в профиль:

<profiles>    <profile>        <id>loom-preview</id>        <build>            <plugins>                <plugin>                    <groupId>org.apache.maven.plugins</groupId>                    <artifactId>maven-compiler-plugin</artifactId>                    <configuration>                        <release>27</release>                        <compilerArgs>                            <arg>--enable-preview</arg>                        </compilerArgs>                    </configuration>                </plugin>                <plugin>                    <groupId>org.apache.maven.plugins</groupId>                    <artifactId>maven-surefire-plugin</artifactId>                    <configuration>                        <argLine>--enable-preview</argLine>                    </configuration>                </plugin>            </plugins>        </build>    </profile></profiles>

Запуск:

mvn -Ploom-preview test

Так проще не протащить preview в обычную сборку случайно. Особенно если это не pet project, а нормальный сервис с CI и несколькими разработчиками.


10. Gradle

Для Gradle идея та же: toolchain плюс preview-флаги для компиляции, тестов и запуска.

java {    toolchain {        languageVersion = JavaLanguageVersion.of(27)    }}tasks.withType(JavaCompile).configureEach {    options.compilerArgs += "--enable-preview"}tasks.withType(Test).configureEach {    jvmArgs += "--enable-preview"}tasks.withType(JavaExec).configureEach {    jvmArgs += "--enable-preview"}

Если используете Kotlin DSL:

java {    toolchain {        languageVersion.set(JavaLanguageVersion.of(27))    }}tasks.withType<JavaCompile>().configureEach {    options.compilerArgs.add("--enable-preview")}tasks.withType<Test>().configureEach {    jvmArgs("--enable-preview")}tasks.withType<JavaExec>().configureEach {    jvmArgs("--enable-preview")}

Проверка простая: если тесты компилируются, но падают при запуске с ошибкой про preview features, значит флаг добавили только на компиляцию. Такое бывает постоянно. Компиляция и runtime — разные места.


11. Scoped Values: request context без ThreadLocal

Теорию ScopedValue мы уже разобрали в первой части. Здесь — как это выглядит в сервисном коде. Фича уже final с JDK 25, preview-флаги для нее не нужны.

Допустим, в сервисе есть request context:

public record RequestContext(        String requestId,        String tenantId,        String userId) {}

Хранилище для scoped value:

import java.lang.ScopedValue;public final class RequestContextHolder {    public static final ScopedValue<RequestContext> REQUEST =            ScopedValue.newInstance();    private RequestContextHolder() {    }}

Endpoint или handler связывает значение с областью выполнения:

import static com.example.RequestContextHolder.REQUEST;public Response handle(Request request) throws Exception {    var context = new RequestContext(            request.id(),            request.tenantId(),            request.userId()    );    return ScopedValue            .where(REQUEST, context)            .call(() -> service.handle(request));}

Ниже по стеку можно получить контекст:

import static com.example.RequestContextHolder.REQUEST;public List<Order> loadOrders(String userId) {    RequestContext context = REQUEST.get();    log.info("load orders, requestId={}, tenant={}",            context.requestId(),            context.tenantId());    return orderClient.load(context.tenantId(), userId);}

Что важно:

  • после выхода из call() binding исчезает;

  • не нужен finally { remove(); };

  • значение нельзя внезапно переприсвоить в середине операции;

  • если вызвать REQUEST.get() вне области, будет ошибка.

Последний пункт мне нравится. Лучше упасть рядом с причиной, чем потом ловить в логах “невозможное” смешивание контекстов.

Но ScopedValue не стоит использовать везде. Если tenantId нужен бизнес-логике метода, лучше передавать его обычным параметром:

orderClient.loadRecent(tenantId, userId);

А если это технический контекст для логирования, трассировки, security context и похожих вещей, ScopedValue выглядит хорошо.


12. Первый StructuredTaskScope: собрать страницу пользователя

Теперь Structured Concurrency — та самая preview-часть Loom, которую мы обсуждали в первой части.

Самый естественный пример: нужно параллельно сходить в несколько источников и собрать страницу пользователя.

import java.util.List;import java.util.concurrent.StructuredTaskScope;import java.util.concurrent.StructuredTaskScope.Subtask;public UserPage loadUserPage(String userId) throws Exception {    try (var scope = StructuredTaskScope.open()) {        Subtask<User> user =                scope.fork(() -> userClient.load(userId));        Subtask<List<Order>> orders =                scope.fork(() -> orderClient.loadRecent(userId));        Subtask<List<Recommendation>> recommendations =                scope.fork(() -> recommendationClient.loadFor(userId));        scope.join();        return new UserPage(                user.get(),                orders.get(),                recommendations.get()        );    }}

В этом примере:

  • все три подзадачи принадлежат одному scope;

  • родительский метод явно ждет их через join();

  • после join() можно читать результаты через Subtask.get();

  • когда scope выходит из блока, дочерние задачи не должны остаться бесхозными.

Без structured scope я бы постоянно держал в голове: “а что с остальными future, если первая упала?”. Со scope эта политика становится частью конструкции.

Небольшая практическая деталь: не надо вытаскивать Subtask наружу из метода и передавать дальше по приложению. Подзадача создается внутри scope, и результат нужно читать там же, до закрытия scope.


13. Все задачи одного типа: allSuccessfulOrThrow()

В первой части мы уже видели этот joiner в теории. Здесь — полный рабочий пример.

Иногда задачи однородные. Например, нужно сходить в несколько независимых источников и получить куски одного типа.

import java.util.List;import java.util.concurrent.StructuredTaskScope;import java.util.concurrent.StructuredTaskScope.Joiner;public List<String> loadFragments(String documentId) throws Exception {    try (var scope = StructuredTaskScope.open(            Joiner.<String>allSuccessfulOrThrow())) {        scope.fork(() -> fragmentClient.load(documentId, "header"));        scope.fork(() -> fragmentClient.load(documentId, "body"));        scope.fork(() -> fragmentClient.load(documentId, "footer"));        return scope.join();    }}

Здесь join() сам возвращает результат joiner’а. Если все подзадачи завершились успешно, получаем List<String>. Если одна упала, scope отменяет оставшиеся задачи, а join() бросает исключение.

Такой код хорошо читается для сценария “или собрали все, или вся операция неуспешна”.

Но я бы не пытался натянуть этот joiner на все случаи. Если результаты разных типов, как в примере с User, Order и Recommendation, удобнее работать с отдельными Subtask<T>. И это нормально. Structured Concurrency не обязана превращать Java в функциональный DSL.


14. Первый успешный ответ: anySuccessfulOrThrow()

Второй joiner из первой части. Частый сценарий: есть несколько зеркал, реплик или провайдеров, и нас устраивает первый успешный ответ.

import java.util.concurrent.StructuredTaskScope;import java.util.concurrent.StructuredTaskScope.Joiner;public Price loadPrice(String sku) throws Exception {    try (var scope = StructuredTaskScope.open(            Joiner.<Price>anySuccessfulOrThrow())) {        scope.fork(() -> priceClientA.load(sku));        scope.fork(() -> priceClientB.load(sku));        scope.fork(() -> priceClientC.load(sku));        return scope.join();    }}

Как только есть успешный результат, остальные задачи можно отменять. Если все провайдеры упали, join() сообщает об ошибке.

В старом коде это часто превращалось в самодельные гонки на CompletableFuture.anyOf(), ручные cancel(), обработку исключений и немного магии вокруг типов. Здесь политика названа прямо: anySuccessfulOrThrow().

Тут легко забыть про идемпотентность и стоимость отмены. Если задача уже отправила внешний запрос, отмена Java-потока не обязательно мгновенно отменит работу на другой стороне. Это не минус Structured Concurrency, это реальность распределенных систем. Timeout и cancellation на клиенте должны сочетаться с timeout’ами HTTP-клиента, драйвера БД и других внешних вызовов.


15. Timeout как часть scope

Timeout — это место, где особенно видно отличие структурного подхода от “я где-то поставил таймер”.

import java.time.Duration;import java.util.concurrent.StructuredTaskScope;import java.util.concurrent.StructuredTaskScope.Joiner;public SearchResult search(String query) throws Exception {    try (var scope = StructuredTaskScope.open(            Joiner.<SearchResult>anySuccessfulOrThrow(),            cfg -> cfg                    .withName("search")                    .withTimeout(Duration.ofMillis(300)))) {        scope.fork(() -> searchClientA.search(query));        scope.fork(() -> searchClientB.search(query));        scope.fork(() -> searchClientC.search(query));        return scope.join();    }}

Если timeout истек, scope знает про все дочерние задачи и может отменить их как группу. Это принципиально лучше, чем ситуация, когда верхний уровень уже вернул ошибку пользователю, а три HTTP-вызова продолжают где-то добивать внешний сервис.

Для backend это важно на практике: под нагрузкой меньше задач продолжают выполняться после timeout.

Но тут тоже без иллюзий. Timeout scope не заменяет:

  • timeout HTTP-клиента;

  • timeout запроса к БД;

  • лимит connection pool;

  • circuit breaker;

  • нормальную обработку InterruptedException.

Structured Concurrency управляет жизненным циклом группы задач. На внешние системы это не влияет напрямую.


16. Scoped Values вместе со StructuredTaskScope

Virtual threads, Scoped Values и Structured Concurrency в первой части мы разбирали по отдельности. Здесь — полный практический пример их связки. Дочерние задачи, созданные внутри StructuredTaskScope, наследуют scoped values родительской операции.

Например, endpoint собирает страницу пользователя из нескольких сервисов. Мы не хотим протаскивать RequestContext через каждый метод руками, но и глобальную магию с ThreadLocal тащить не хочется.

import java.lang.ScopedValue;import java.util.List;import java.util.concurrent.StructuredTaskScope;import java.util.concurrent.StructuredTaskScope.Subtask;public final class UserPageService {    private static final ScopedValue<RequestContext> REQUEST =            ScopedValue.newInstance();    public UserPage handle(Request request) throws Exception {        var context = new RequestContext(                request.id(),                request.tenantId(),                request.userId()        );        return ScopedValue                .where(REQUEST, context)                .call(() -> loadUserPage(request.userId()));    }    private UserPage loadUserPage(String userId) throws Exception {        try (var scope = StructuredTaskScope.open()) {            Subtask<User> user =                    scope.fork(() -> loadUser(userId));            Subtask<List<Order>> orders =                    scope.fork(() -> loadOrders(userId));            Subtask<List<Recommendation>> recommendations =                    scope.fork(() -> loadRecommendations(userId));            scope.join();            return new UserPage(                    user.get(),                    orders.get(),                    recommendations.get()            );        }    }    private User loadUser(String userId) {        RequestContext context = REQUEST.get();        return userClient.load(context.tenantId(), userId);    }    private List<Order> loadOrders(String userId) {        RequestContext context = REQUEST.get();        return orderClient.loadRecent(context.tenantId(), userId);    }    private List<Recommendation> loadRecommendations(String userId) {        RequestContext context = REQUEST.get();        return recommendationClient.loadFor(context.tenantId(), userId);    }}

Тут контекст живет ровно столько, сколько обрабатывается запрос. Подзадачи видят тот же tenantId и requestId, но не получают права менять binding. Когда handle() завершился, контекста больше нет.

StructuredTaskScope управляет жизненным циклом дочерних задач, а ScopedValue ограничивает время жизни контекста. С ThreadLocal контекст приходится очищать вручную через remove(). Здесь граница видна прямо в коде.


17. Несколько практических правил

Я бы не начинал с переписывания всего сервиса. Лучше взять один маленький сценарий: параллельные запросы в несколько downstream-сервисов, агрегация данных, поиск первого успешного ответа, экспериментальный endpoint.

Дальше смотреть на поведение:

  • что происходит при ошибке одной подзадачи;

  • отменяются ли остальные;

  • не остаются ли хвосты во внешних системах;

  • что показывают логи и thread dump;

  • не потеряли ли вы backpressure после перехода с fixed pool на virtual threads;

  • где должны жить timeout’ы: на scope, HTTP-клиенте, БД, gateway.

И еще одно. Preview API лучше не добавлять в публичную библиотеку: при смене preview-версии ее будет сложнее обновлять. В приложении экспериментировать проще: обновили JDK, поправили несколько мест, поехали дальше. В стабильном публичном API preview-зависимость может стать неприятным долгом.

Structured Concurrency сейчас лучше изучать на небольших примерах: писать прототипы, проверять поведение на реальных сценариях и следить за JEP.


18. Частые ошибки

Первая ошибка: забыли --enable-preview на runtime. Компиляция прошла, тесты или запуск упали. Лечится добавлением флага в Test, JavaExec, surefire или команду java.

Вторая: перепутали версии JDK и примеры из старых preview. У Structured Concurrency API менялся. Код из статьи под JDK 21 может не совпасть с JDK 25, 26 или 27.

Третья: решили, что virtual threads сами заменяют backpressure. Нет. Если внешний сервис выдерживает 50 запросов, надо явно ограничивать параллелизм.

Четвертая: положили в ScopedValue mutable map “на все случаи жизни”. Получился старый глобальный контекст, только в новой упаковке.

Пятая: решили, что cancel в Java мгновенно отменяет реальную работу во внешнем мире. Иногда да, иногда нет. Проверяйте клиент, драйвер, протокол и timeout’ы.


Вывод

Практически Project Loom сейчас выглядит так:

  • virtual threads уже можно использовать без preview-флагов;

  • ScopedValue с JDK 25 тоже final;

  • Structured Concurrency все еще preview, поэтому требует --enable-preview и аккуратности с версиями JDK.

Самая полезная связка для backend-кода выглядит так: один запрос получает свой bounded context через ScopedValue, внутри него создается StructuredTaskScope, дочерние задачи наследуют контекст, выполняются параллельно и завершаются вместе с родительской операцией.

Structured Concurrency не решает все проблемы конкурентности, но делает код понятнее: в одном месте видно родительскую операцию, дочерние задачи, точку ожидания, обработку ошибки и завершение. После ошибки или timeout не остаются лишние фоновые задачи.


Если вам близки темы разработки, рефакторинга, архитектуры и стартапов, буду рад видеть вас в моём блоге.


Легких Вам Релизов!

ссылка на оригинал статьи https://habr.com/ru/articles/1038528/