Blog

Virtual threads, scoped values e concorrência estruturada em Java

O Java consegue rodar cem mil tarefas bloqueantes em poucas threads do sistema operacional. Veja como virtual threads montam e desmontam, o que ainda as prende, por que ScopedValue substitui ThreadLocal e como StructuredTaskScope cancela o trabalho que falha.

Uma virtual thread é uma java.lang.Thread que custa mais ou menos o mesmo que um objeto comum. Você pode iniciar cem mil delas, deixar cada uma bloquear numa chamada lenta, e a JVM roda todas em poucas threads do sistema operacional. O Java 21 as tornou finais, e elas mudam o jeito de escrever servidores: uma thread simples e bloqueante por requisição volta a ser uma boa escolha.

Este post trata de virtual threads e de como elas montam em carrier threads, de quando elas ajudam e do que ainda as deixa presas. Depois trata de ScopedValue, que substitui a maioria dos usos de ThreadLocal, e de StructuredTaskScope, que ainda é um recurso em preview no Java 25. Todos os programas abaixo rodaram no Java 25, e a saída foi colada da execução. Para rodar um deles, salve como Main.java e rode java Main.java.

Threads de plataforma são caras

Uma Thread clássica do Java é uma thread de plataforma: um invólucro Java fino em volta de uma thread do sistema operacional. O sistema operacional reserva uma stack para cada uma (o padrão da JVM no Linux x64 é 1 MB, o que conferimos com -XX:+PrintFlagsFinal), e criar uma é uma chamada de sistema. Então um servidor aguenta milhares delas, não milhões.

Uma virtual thread também é uma Thread, com os mesmos métodos. Os builders da parte sobre threads têm uma versão virtual:

void main() throws InterruptedException {
    Thread platform = Thread.ofPlatform().start(() -> IO.println("hello from a platform thread"));
    platform.join();

    Thread first = Thread.ofVirtual().start(() -> IO.println("hello from a virtual thread"));
    first.join();

    Thread second = Thread.startVirtualThread(() -> IO.println("and from another one"));
    second.join();

    IO.println("platform.isVirtual() = " + platform.isVirtual());
    IO.println("first.isVirtual()    = " + first.isVirtual());
    IO.println("second.isVirtual()   = " + second.isVirtual());
    IO.println("main is virtual: " + Thread.currentThread().isVirtual());
}

Ele imprime:

hello from a platform thread
hello from a virtual thread
and from another one
platform.isVirtual() = false
first.isVirtual()    = true
second.isVirtual()   = true
main is virtual: false

Thread.ofVirtual().start(...) e Thread.startVirtualThread(...) fazem a mesma coisa. isVirtual() diz em que tipo de thread você está, e main sempre roda numa thread de plataforma. O resto é a API de Thread que você já conhece: join, interrupt, getState.

Também compilamos o programa com javac --release 20. Ele falha com ofVirtual() is a preview API and is disabled by default. Com --release 21 ele compila, então virtual threads são finais desde o Java 21.

Cem mil tarefas bloqueantes

A maior parte do código não inicia threads uma de cada vez. Executors.newVirtualThreadPerTaskExecutor() inicia uma virtual thread nova para cada tarefa que você envia. Aqui, 100.000 tarefas dormem um segundo cada, no lugar de uma chamada de rede lenta:

void main() {
    var completed = new AtomicInteger();

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 100_000; i++) {
            executor.submit(() -> {
                Thread.sleep(Duration.ofSeconds(1));  // stands in for a slow network call
                completed.incrementAndGet();
                return null;
            });
        }
    }  // close() waits for every task to finish

    IO.println("tasks completed: " + completed.get());
}

Ele imprime:

tasks completed: 100000

O close() de um executor espera todas as tarefas enviadas, então a contagem só é lida depois que as 100.000 rodaram. A lambda retorna null para ser um Callable, que pode lançar a InterruptedException que sleep declara.

Quanto tempo levou? Medimos. Esses tempos variam de uma execução para outra e de uma máquina para outra, então trate-os como uma ideia aproximada:

$ time java Main.java
tasks completed: 100000

real	0m3.823s

Isso inclui compilar o arquivo. Depois mudamos uma linha para iniciar uma thread de plataforma por tarefa, Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory()). Ele também imprimiu 100000, mas levou 54 segundos. Criar 100.000 threads do sistema operacional é lento. A versão virtual passou a maior parte do tempo com as 100.000 tarefas dormindo ao mesmo tempo.

Montar e desmontar

Uma virtual thread só precisa de uma thread do sistema operacional enquanto roda código Java, e devolve essa thread sempre que bloqueia. As threads do sistema operacional que rodam virtual threads se chamam carrier threads. Aqui está uma imagem simplificada com duas carriers e quatro virtual threads:

carrier 1 carrier 2 livre livre prontas heap stack salva, esperando IO VT4 VT3 VT2 VT1 quatro virtual threads prontas, e as duas carriers estão livres VT1 e VT2 montam: cada uma roda numa carrier thread VT1 bloqueia em IO: desmonta, e sua stack fica guardada no heap a carrier 1 está livre: VT3 monta nela o IO de VT1 termina: ela está pronta e espera uma carrier livre VT2 termina, e VT1 monta de novo na carrier 2, não na do começo

Simplificado: duas carrier threads atendem quatro virtual threads. Uma virtual thread que bloqueia devolve a sua carrier, e a stack dela espera no heap. Quando ela pode rodar de novo, monta na carrier que estiver livre. O scheduler real costuma ter uma carrier por núcleo de CPU e muito mais virtual threads.

Aqui estão esses passos em palavras, caso a animação não rode para você:

  1. Quatro virtual threads, de VT1 a VT4, estão prontas para rodar. A carrier 1 e a carrier 2 estão livres.
  2. VT1 monta na carrier 1 e VT2 monta na carrier 2. As duas rodam código Java. VT3 e VT4 esperam.
  3. VT1 começa uma leitura de rede que precisa esperar. Ela desmonta: os frames da sua stack são guardados no heap, e a carrier 1 fica livre.
  4. A carrier 1 não fica parada. VT3 monta nela e roda. VT4 continua esperando.
  5. A leitura de VT1 termina. VT1 está pronta para rodar de novo, então entra na fila por uma carrier.
  6. VT2 termina e a carrier 2 fica livre. VT1 monta na carrier 2 e continua de onde parou, numa carrier diferente daquela em que começou.

Explicado como se você tivesse dez anos

Uma cidade tem alguns caminhões grandes de entrega. Eles são as threads de plataforma. Cada caminhão é caro, então a cidade só pode ter uns poucos.

Virtual threads são milhares de entregadores de bicicleta. Um entregador só sobe num caminhão enquanto um pacote está de fato se movendo. Quando precisa esperar numa porta até alguém assinar, ele desce, e outro entregador sobe no caminhão. Quando a porta abre, o primeiro entregador sobe no próximo caminhão que passar.

Então uns poucos caminhões mantêm milhares de entregadores ocupados, desde que a maior parte do trabalho seja esperar em portas.

A versão precisa

Uma virtual thread é um objeto Thread cuja stack não é um bloco fixo de memória do sistema operacional. A JVM a roda montando a thread numa carrier thread, e as carriers são threads de plataforma num ForkJoinPool que pertence ao JDK. Por padrão existe uma carrier por processador disponível.

Quando uma virtual thread bloqueia dentro do JDK, por exemplo em Thread.sleep, numa leitura de socket, em CountDownLatch.await ou num lock, o JDK a desmonta. Ele copia os frames da stack da thread para objetos no heap e libera a carrier. Quando acontece o que ela esperava, a thread volta para o scheduler e é montada em qualquer carrier livre. Seu código não vê nada disso. A chamada simplesmente retorna mais tarde. Por isso as 100.000 tarefas dormindo saíram baratas: cada uma era um pequeno objeto no heap, não uma thread do sistema operacional parada.

Onde a analogia falha: um entregador decide descer. Uma virtual thread não decide. O JDK a desmonta, e só nos pontos de bloqueio que ele conhece. Se a stack não pode ser movida, a thread fica no caminhão enquanto espera. Isso se chama pinning, e tem uma seção só dele mais abaixo. Além disso, um caminhão leva muitos pacotes, mas uma carrier roda exatamente uma virtual thread de cada vez.

Quando virtual threads ajudam, e quando não ajudam

Virtual threads ajudam código que passa a maior parte do tempo esperando. O caso clássico é um servidor web que atende cada requisição chamando um banco de dados e mais dois serviços, uma chamada bloqueante depois da outra. Você pode escrevê-lo no estilo simples de uma thread por requisição e ainda atender um número muito grande de requisições ao mesmo tempo, porque uma requisição que está esperando não segura nenhuma thread do sistema operacional.

Elas não deixam o processamento mais rápido. Uma tarefa que passa o tempo somando números nunca bloqueia, então nunca desmonta, e o número de carriers continua sendo o número de núcleos. Dez mil virtual threads presas à CPU não recebem mais CPU do que um pool de threads de plataforma do tamanho da sua máquina.

Dois hábitos das threads de plataforma estão errados para virtual threads:

  • Não coloque virtual threads num pool. Um pool existe para reaproveitar algo caro. Virtual threads são baratas, então crie uma por tarefa e deixe que ela termine.
  • Não limite a concorrência com um pool pequeno. Se um serviço downstream só aguenta 10 chamadas ao mesmo tempo, limite as chamadas com um Semaphore.
void main() {
    var permits = new Semaphore(10);
    var running = new AtomicInteger();
    var peak = new AtomicInteger();
    var completed = new AtomicInteger();

    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        for (int i = 0; i < 1_000; i++) {
            executor.submit(() -> {
                permits.acquire();  // waits here while 10 tasks hold a permit
                try {
                    peak.accumulateAndGet(running.incrementAndGet(), Math::max);
                    Thread.sleep(Duration.ofMillis(10));  // the call to the limited service
                    completed.incrementAndGet();
                } finally {
                    running.decrementAndGet();
                    permits.release();
                }
                return null;
            });
        }
    }

    IO.println("tasks completed: " + completed.get());
    IO.println("never more than 10 at once: " + (peak.get() <= 10));
}

Ele imprime:

tasks completed: 1000
never more than 10 at once: true

As 1.000 tarefas ganharam uma virtual thread na hora, mas acquire() só deixou passar 10 de cada vez. As outras 990 esperaram a custo baixo, desmontadas. O release() fica no finally, então uma chamada que falha não vaza uma permissão. A parte sobre java.util.concurrent trata de Semaphore e dos executors.

Pinning: quando uma virtual thread não consegue soltar

Uma virtual thread fica presa (pinned) quando bloqueia mas não consegue desmontar, então segura a sua carrier durante toda a espera. Com threads presas suficientes, todas as carriers ficam travadas e nada mais roda.

No Java 21, bloquear dentro de synchronized prendia a thread. O Java 24 mudou isso (JEP 491), então no Java 25 uma virtual thread que espera segurando um monitor desmonta como qualquer outra. Alguns casos ainda prendem. O programa abaixo testa dois deles. Ele pede uma única carrier com a propriedade de sistema jdk.virtualThreadScheduler.parallelism, o que equivale a passar -D na linha de comando. Com uma só carrier, uma thread presa bloqueia todas as outras virtual threads:

static final CountDownLatch configReleased = new CountDownLatch(1);

static class Config {
    static final String NAME = load();

    static String load() {
        awaitQuietly(configReleased);  // blocks inside a static initializer
        return "loaded";
    }
}

final Object lock = new Object();

void main() throws InterruptedException {
    // One carrier thread for every virtual thread. Set it before the first one starts.
    System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");

    var lockReleased = new CountDownLatch(1);
    Thread inLock = Thread.ofVirtual().start(() -> {
        synchronized (lock) {
            awaitQuietly(lockReleased);  // blocks while holding a monitor
        }
    });
    IO.println("blocked in synchronized, others run: " + othersRun(lockReleased));
    inLock.join();

    Thread inInit = Thread.ofVirtual().start(() -> Config.NAME.length());
    IO.println("blocked in a static initializer, others run: " + othersRun(configReleased));
    inInit.join();
}

// Starts a second virtual thread that releases the first. Reports whether it got to run.
boolean othersRun(CountDownLatch release) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(200));  // give the first thread time to block
    Thread other = Thread.ofVirtual().start(release::countDown);
    boolean ran = other.join(Duration.ofSeconds(1));
    release.countDown();  // if it couldn't run, release the first thread from here
    other.join();
    return ran;
}

static void awaitQuietly(CountDownLatch latch) {
    try {
        latch.await();
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    }
}

Ele imprime:

blocked in synchronized, others run: true
blocked in a static initializer, others run: false

No primeiro teste, uma virtual thread espera enquanto segura lock. Ela desmonta, a única carrier roda a segunda thread, e essa thread libera a primeira. No segundo teste, a thread espera dentro do inicializador estático de Config. Ela continua presa, a segunda thread nunca pega a carrier, e join desiste depois de um segundo. Rodamos 20 vezes e recebemos as mesmas duas linhas todas as vezes.

Para ver pinning num programa de verdade, grave-o com o Java Flight Recorder. O JDK tem um evento jdk.VirtualThreadPinned para isso. Rodamos o mesmo programa com uma gravação e imprimimos os eventos (cortados; a duração varia):

$ java -XX:StartFlightRecording:filename=pinned.jfr Main.java
$ jfr print --events jdk.VirtualThreadPinned pinned.jfr
jdk.VirtualThreadPinned {
  duration = 1.20 s
  blockingOperation = "LockSupport.park"
  pinnedReason = "VM call to Main$Config.<clinit> on stack"
  ...
}

Houve exatamente um evento, o do inicializador estático. A espera em synchronized não gerou nenhum. Também testamos uma chamada nativa: uma função C, qsort, chamada pela foreign function API, com um comparador que chama de volta o Java e dorme. Isso também prendeu, com o motivo "Native or VM frame on stack".

Uma surpresa: artigos mais antigos mandam rodar com -Djdk.tracePinnedThreads=full. No Java 25 isso não faz nada. Passamos a opção para o mesmo programa e nada a mais foi impresso, nem para a thread presa. Use o evento do JFR.

ThreadLocal e seus problemas

Um ThreadLocal dá a cada thread a sua própria cópia de uma variável. Frameworks usam isso há muito tempo para levar coisas como o usuário atual ao longo de uma requisição sem passá-las para cada método. Ele tem três problemas, e virtual threads pioram os três.

Ele é mutável, então qualquer código na thread pode chamar set e mudar o valor sem que os outros saibam. Ele vive tanto quanto a thread, a não ser que alguém se lembre de remove(). E cada thread guarda a sua própria cópia, então um milhão de virtual threads significa um milhão de cópias. O problema do tempo de vida é o mais fácil de mostrar:

static final ThreadLocal<String> USER = new ThreadLocal<>();

void main() throws Exception {
    try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
        pool.submit(() -> {
            USER.set("ana");
            IO.println("request 1 runs as " + USER.get());
            // forgot USER.remove()
        }).get();

        pool.submit(() -> IO.println("request 2 runs as " + USER.get())).get();
    }
}

Ele imprime:

request 1 runs as ana
request 2 runs as ana

As duas requisições rodaram na única thread do pool. A primeira definiu o usuário e nunca o removeu, então a segunda requisição roda como Ana. Num servidor de verdade, isso é um usuário vendo os dados de outro.

ScopedValue: um valor pela duração de uma chamada

Um ScopedValue fica ligado a um valor durante uma chamada, e todo método que essa chamada alcança pode lê-lo. Quando a chamada retorna, a ligação some. Ele virou final no Java 25. Conferimos: javac --release 24 o rejeita como API em preview.

static final ScopedValue<String> USER = ScopedValue.newInstance();

void main() {
    ScopedValue.where(USER, "ana").run(() -> handleRequest());
    IO.println("after run, bound: " + USER.isBound());

    ScopedValue.where(USER, "bo").run(() -> {
        audit("outer");
        ScopedValue.where(USER, "admin").run(() -> audit("inner"));
        audit("outer again");
    });
}

void handleRequest() {
    IO.println("handling, bound: " + USER.isBound());
    loadOrders();
}

void loadOrders() {
    audit("loading orders");  // three calls deep, no parameter passed
}

void audit(String action) {
    IO.println(USER.get() + ": " + action);
}

Ele imprime:

handling, bound: true
ana: loading orders
after run, bound: false
bo: outer
admin: inner
bo: outer again

ScopedValue.where(USER, "ana").run(...) liga USER enquanto a lambda roda. audit o lê três chamadas abaixo, e ninguém o passou adiante. Depois que run retorna, isBound() é false.

Não existe método set. O único jeito de mudar o valor é ligá-lo de novo para uma chamada menor, como faz a ligação "admin". Quando essa chamada interna retorna, o valor antigo "bo" volta. Então o valor ligado não vaza para uma requisição seguinte, e o código que você chama não consegue mudá-lo por baixo de você.

Ler um scoped value que não está ligado lança uma exceção:

static final ScopedValue<String> USER = ScopedValue.newInstance();

void main() {
    IO.println("bound: " + USER.isBound());
    IO.println("with a fallback: " + USER.orElse("guest"));
    IO.println("user: " + USER.get());
}

Ele imprime e para:

bound: false
with a fallback: guest
Exception in thread "main" java.util.NoSuchElementException: ScopedValue not bound

Verifique com isBound(), ou use orElse quando existir um padrão que faça sentido.

Concorrência estruturada (preview)

Concorrência estruturada quer dizer que tarefas iniciadas juntas terminam juntas. Se você divide uma requisição em subtarefas, nenhuma delas dura mais que a requisição, e uma falha numa para as outras. A API do Java para isso, StructuredTaskScope, é um recurso em preview no Java 25. Ela ainda pode mudar antes de virar final, e já mudou. Muitos artigos mostram new StructuredTaskScope.ShutdownOnFailure(). Conferimos com javac --release: essa classe existe no Java 21 e no 24, e no 25 o mesmo código falha com cannot find symbol. A API não compila sem uma flag, então você roda esses programas com java --enable-preview Main.java.

O problema de um fan-out sem estrutura

Um ExecutorService deixa você iniciar duas chamadas em paralelo, mas nada liga uma à outra. Aqui, uma chamada é lenta e a outra falha:

void main() throws InterruptedException {
    var neverOpens = new CountDownLatch(1);
    var executor = Executors.newVirtualThreadPerTaskExecutor();

    Future<String> user = executor.submit(() -> {
        neverOpens.await();  // a slow call that is still going
        return "ana";
    });
    Future<Integer> orders = executor.submit(() -> {
        throw new IllegalStateException("orders service is down");
    });

    try {
        int count = orders.get();  // ask for the failing one first
        IO.println(user.get() + " has " + count + " orders");
    } catch (ExecutionException e) {
        IO.println("request failed: " + e.getCause().getMessage());
    }
    IO.println("user task still running: " + !user.isDone());

    executor.shutdownNow();  // interrupts it; close() here would wait forever
    IO.println("stopped after shutdownNow: " + executor.awaitTermination(1, TimeUnit.SECONDS));
}

Ele imprime:

request failed: orders service is down
user task still running: true
stopped after shutdownNow: true

A requisição falhou, mas a tarefa do usuário continuou rodando. Nada mandou ela parar, então ela vazou até desligarmos o executor na mão. Repare também no comentário em orders.get(). Nossa primeira versão chamava user.get() antes, e travou: main esperou a chamada lenta e nunca soube que a outra já tinha falhado.

StructuredTaskScope: fork, depois join

Um StructuredTaskScope é aberto num bloco try-with-resources, e toda subtarefa iniciada com fork dentro dele precisa terminar antes de o bloco acabar:

import java.util.concurrent.StructuredTaskScope.Subtask;

record Page(String user, int orders) {}

void main() throws InterruptedException {
    try (var scope = StructuredTaskScope.open()) {
        Subtask<String> user = scope.fork(() -> findUser(42));
        Subtask<Integer> orders = scope.fork(() -> countOrders(42));

        scope.join();  // waits for both

        IO.println(new Page(user.get(), orders.get()));
    }
}

String findUser(int id) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(100));
    return "ana";
}

int countOrders(int id) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(50));
    return 3;
}

Ele imprime:

Page[user=ana, orders=3]

Estas são as chamadas do Java 25:

  • StructuredTaskScope.open() abre um escopo. Cada fork inicia a subtarefa numa virtual thread nova.
  • scope.join() espera as subtarefas.
  • Subtask.get() retorna o resultado de uma subtarefa. Só é permitido depois de join: chamar antes lançou IllegalStateException: join not called.

As regras são rígidas. Fechar um escopo que fez fork mas nunca fez join lançou IllegalStateException: Owner did not join after forking quando testamos.

Subtask é um tipo aninhado, então o programa o importa. Os imports automáticos de um arquivo-fonte compacto cobrem os tipos top-level de java.util.concurrent, mas não os aninhados.

Uma falha cancela o resto

Com open() sem argumentos, o escopo espera que todas as subtarefas deem certo. Se uma falha, ele cancela as outras. Este programa garante a ordem: a subtarefa que falha espera até a outra ter começado, e a outra espera num latch que nunca abre.

import java.util.concurrent.StructuredTaskScope.FailedException;

void main() throws InterruptedException {
    var userStarted = new CountDownLatch(1);
    var neverOpens = new CountDownLatch(1);
    var userSaw = new AtomicReference<String>("nothing");

    try (var scope = StructuredTaskScope.open()) {
        scope.fork(() -> {
            userStarted.countDown();
            try {
                neverOpens.await();  // a slow call that would never finish
                userSaw.set("finished");
            } catch (InterruptedException e) {
                userSaw.set("interrupted");
            }
        });
        scope.fork(() -> {
            userStarted.await();  // fail only once the other subtask is waiting
            throw new IllegalStateException("orders service is down");
        });

        scope.join();
        IO.println("both succeeded");
    } catch (FailedException e) {
        IO.println("request failed: " + e.getCause().getMessage());
    }
    IO.println("the user subtask saw: " + userSaw.get());
}

Ele imprime:

request failed: orders service is down
the user subtask saw: interrupted

Quando a segunda subtarefa lançou a exceção, o escopo interrompeu a primeira. join() lançou uma StructuredTaskScope.FailedException, cuja causa é a exceção original. Quando o bloco try acabou, o escopo já tinha esperado a subtarefa interrompida terminar, então userSaw já estava definido quando main o leu. Compare com a versão do executor, em que a tarefa lenta continuou rodando.

Outros joiners

Um joiner decide o que join() espera e o que ele retorna. Você passa um para open:

import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;

void main() throws InterruptedException {
    var slowWasCancelled = new AtomicBoolean();

    try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
        scope.fork(() -> {
            throw new IllegalStateException("mirror A is down");
        });
        scope.fork(() -> {
            try {
                new CountDownLatch(1).await();  // mirror C never answers
                return "mirror C";
            } catch (InterruptedException e) {
                slowWasCancelled.set(true);
                throw e;
            }
        });
        scope.fork(() -> "mirror B");

        String first = scope.join();
        IO.println("first good answer: " + first);
    }
    IO.println("slow mirror cancelled: " + slowWasCancelled.get());

    try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
        for (int n = 1; n <= 4; n++) {
            int x = n;
            scope.fork(() -> x * x);
        }
        List<Integer> squares = scope.join().map(Subtask::get).toList();
        IO.println("squares: " + squares);
    }
}

Ele imprime:

first good answer: mirror B
slow mirror cancelled: true
squares: [1, 4, 9, 16]

anySuccessfulResultOrThrow() ignora falhas enquanto outra subtarefa ainda pode dar certo. Assim que uma retorna, join() retorna esse resultado e as outras são canceladas. allSuccessfulOrThrow() faz join() retornar um Stream das subtarefas na ordem em que você fez fork delas, então os quadrados saem em ordem.

O Java 25 também tem awaitAll(), que espera tudo e nunca lança exceção, e awaitAllSuccessfulOrThrow(), que dá o mesmo comportamento de open() sem argumentos. Com awaitAll(), uma das nossas subtarefas falhou, join() retornou normalmente, e o state() dessa subtarefa era FAILED. Um segundo argumento de open configura o escopo. Com cf -> cf.withTimeout(Duration.ofMillis(100)), uma subtarefa lenta fez join() lançar StructuredTaskScope.TimeoutException.

Scoped values chegam às subtarefas

Uma subtarefa iniciada com fork num escopo enxerga os scoped values que estavam ligados quando o escopo foi aberto:

static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

void main() {
    ScopedValue.where(REQUEST_ID, "req-7").run(() -> {
        try (var scope = StructuredTaskScope.open()) {
            var user = scope.fork(() -> log("find user"));
            var orders = scope.fork(() -> log("count orders"));
            scope.join();
            IO.println(user.get());
            IO.println(orders.get());

            String[] fromPlain = new String[1];
            Thread.ofVirtual().start(() -> fromPlain[0] = log("plain thread")).join();
            IO.println(fromPlain[0]);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
}

String log(String what) {
    String id = REQUEST_ID.isBound() ? REQUEST_ID.get() : "no request id";
    return "[" + id + "] " + what;
}

Ele imprime:

[req-7] find user
[req-7] count orders
[no request id] plain thread

As duas subtarefas leram req-7 nas suas próprias threads. A virtual thread comum, iniciada no mesmo lugar, não enxergou o valor. Só um escopo repassa scoped values, porque um escopo garante que suas subtarefas terminam antes da ligação. Nada precisa ser copiado nem limpo.

Depurando virtual threads

Um thread dump lista todas as threads e o que cada uma está esperando, mas o clássico jcmd <pid> Thread.print deixa as virtual threads de fora. Conferimos, e nenhuma das nossas virtual threads apareceu nele. Use Thread.dump_to_file. Rodamos um programa que faz fork de duas subtarefas, cada uma dormindo 15 segundos, e geramos o dump enquanto ele esperava (bem cortado):

$ java --enable-preview Main.java &
$ jcmd <pid> Thread.dump_to_file -format=json threads.json
$ cat threads.json
...
        "container": "java.util.concurrent.StructuredTaskScopeImpl@bef2d72",
        "parent": "<root>",
        "owner": "3",
        "threads": [
          {
            "tid": "26",
            "virtual": true,
            "state": "TIMED_WAITING",
            "stack": [
              ...
              "java.base\/java.lang.Thread.sleep(Thread.java:601)",
              "Main.fetch(Main.java:11)",
              "Main.lambda$main$0(Main.java:3)",
              ...

As threads são agrupadas por container. As duas subtarefas ficam dentro do escopo, e o owner dele é a thread 3, que é main. Então o dump mostra a estrutura do seu código: qual thread abriu o escopo e quais subtarefas pertencem a ele. Com um executor comum, as virtual threads ficam agrupadas sob o executor, sem owner.

O que lembrar

  • Uma virtual thread é uma Thread barata que monta numa carrier thread só enquanto roda. Crie uma por tarefa com Executors.newVirtualThreadPerTaskExecutor(). Elas são finais desde o Java 21.
  • Virtual threads ajudam código bloqueante, que espera IO. Trabalho preso à CPU não ganha nada com elas.
  • Não coloque virtual threads num pool. Limite o acesso a um recurso escasso com um Semaphore.
  • Desde o Java 24, synchronized não prende mais na maioria dos casos. Uma thread bloqueada num inicializador estático ou sob um frame nativo ainda fica presa, e o evento do JFR jdk.VirtualThreadPinned mostra onde.
  • ScopedValue, final no Java 25, liga um valor imutável pela duração de uma chamada. Diferente de um ThreadLocal, ele não pode ser mudado pelo código que você chama e não vaza para a próxima tarefa.
  • StructuredTaskScope está em preview no Java 25. Subtarefas iniciadas com fork num escopo terminam antes de ele fechar, uma falha cancela as outras, e os scoped values chegam até elas.
  • Encontre virtual threads com jcmd <pid> Thread.dump_to_file -format=json, não com Thread.print.

Escreva o código bloqueante simples, e dê a cada tarefa a sua própria virtual thread.

Quanto este post te ajudou?

Clique em um coração para avaliar!

Média das avaliações 0 / 5. Total de votos: 0

Nenhum voto até agora. Seja o primeiro a avaliar este post.