Tarefas em Família — Structured Concurrency e Scoped Values

Tarefas em Família — Structured Concurrency e Scoped Values

Concorrência estruturada e valores com escopo no Java 25: subtarefas que não sobrevivem a quem as criou, cancelamento automático na primeira falha, joiners para escolher a política, prazos que encerram o trabalho de verdade e um contexto imutável que segue a árvore de tarefas no lugar do ThreadLocal.
Java

• • 26 min de leitura

Como organizar tarefas concorrentes numa hierarquia em que nenhuma subtarefa sobrevive à tarefa que a criou, e como passar contexto por essa hierarquia sem parâmetros nem ThreadLocal: a concorrência estruturada e os valores com escopo do Java 25.

"Um Milhão de Threads" deixou uma pergunta: se criar tarefas concorrentes ficou tão barato, quem cuida delas? Com um ExecutorService, uma tarefa pode disparar outras, e nada no código liga umas às outras. Se uma subtarefa falha, as irmãs continuam rodando. Se a tarefa principal é cancelada, as subtarefas nem ficam sabendo. Se o prazo acaba, o trabalho continua, como mostrou o exemplo do frete em "Encadeando o que Ainda Não Aconteceu". Esta aula apresenta duas ferramentas criadas junto com as threads virtuais para resolver isso. A concorrência estruturada dá às tarefas concorrentes a mesma disciplina que os blocos de código dão às instruções sequenciais, e os valores com escopo substituem o ThreadLocal para compartilhar dados imutáveis dentro dessa estrutura. A primeira ainda está em prévia no Java 25; a segunda é definitiva. Com as duas, a Fase 7 termina.

O problema: tarefas sem dono

Imagine o painel de um usuário numa loja, que precisa de duas consultas independentes: os dados do usuário e os seus pedidos. Com o que já foi visto, o código natural é este:

import java.time.Duration;
import java.util.concurrent.*;

static long t0 = System.nanoTime();
static String agora() { return String.format("[%4d ms]", (System.nanoTime() - t0) / 1_000_000); }

static String buscarUsuario() throws InterruptedException {
    Thread.sleep(Duration.ofMillis(100));
    throw new IllegalStateException("usuário não encontrado");
}

static String buscarPedidos() throws InterruptedException {
    try {
        Thread.sleep(Duration.ofSeconds(2));
        IO.println(agora() + " pedidos buscados (para ninguém)");
        return "3 pedidos";
    } catch (InterruptedException e) {
        IO.println(agora() + " busca de pedidos interrompida");
        throw e;
    }
}

void main() throws Exception {
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        Future<String> usuario = executor.submit(() -> buscarUsuario());
        Future<String> pedidos = executor.submit(() -> buscarPedidos());
        try {
            IO.println(usuario.get() + ", " + pedidos.get());
        } catch (ExecutionException e) {
            IO.println(agora() + " falhou: " + e.getCause().getMessage());
        }
    }
    IO.println(agora() + " executor fechado");
}
[ 109 ms] falhou: usuário não encontrado
[2009 ms] pedidos buscados (para ninguém)
[2011 ms] executor fechado

Aos 109 ms já se sabe que o painel não pode ser montado: sem usuário, os pedidos não servem para nada. Mas a busca dos pedidos continua por quase dois segundos, ocupando uma conexão com o banco e produzindo um resultado que ninguém vai ler. Para evitar isso, o código teria de cancelar o Future dos pedidos no catch, lembrar de fazer o mesmo em todo caminho de erro, e ainda tratar o caso contrário, em que os pedidos falham primeiro. E se a thread que monta o painel for interrompida enquanto espera no get(), as duas subtarefas continuam, porque nada as liga a ela. As tarefas não têm dono.

A ideia: a estrutura do código é a estrutura das tarefas

Em 1968, Edsger Dijkstra publicou a carta "Go To Statement Considered Harmful", que deu origem à programação estruturada. A ideia central é que blocos de código têm uma entrada e uma saída: um laço ou um if começa numa linha e termina noutra, e quem lê o código sabe que, depois da chave de fechamento, tudo o que aconteceu lá dentro terminou. O goto quebrava essa garantia, permitindo pular para qualquer lugar.

Criar uma thread solta é o goto da concorrência: a tarefa começa num ponto do código e pode continuar viva depois que o método que a criou já retornou, sem que nada no texto do programa indique isso. A concorrência estruturada, proposta por Martin Sústrik em 2016 e popularizada por Nathaniel J. Smith em 2018 com o texto "Notes on structured concurrency, or: Go statement considered harmful", aplica a mesma disciplina às tarefas: subtarefas vivem dentro de um bloco, e o bloco só termina quando todas elas terminaram. Se a estrutura do código diz que uma tarefa é filha de outra, o tempo de vida também diz.

No Java, a proposta é a JEP 453, apresentada como prévia no Java 21 e reformulada várias vezes desde então. No Java 25 ela está na quinta prévia, JEP 505, com a API usada nesta aula, e continua em prévia no Java 26 e no Java 27, agora com mudanças na API: vários nomes foram trocados, e os exemplos desta aula, escritos para o Java 25, não compilam no Java 27 sem ajustes. Por ser prévia, precisa ser habilitada explicitamente:

$ javac --release 25 --enable-preview Painel.java
$ java --enable-preview Painel
$ java --enable-preview Painel.java          # arquivo único, direto do código-fonte

Recursos em prévia estão completos e testados, mas a API pode mudar antes da versão final, e um programa compilado com prévias do Java 25 só roda no Java 25. Por isso não devem ir para produção. Eles aparecem nesta aula porque a ideia é a direção definitiva do Java concorrente, mesmo que os nomes da API ainda mudem.

StructuredTaskScope: o primeiro escopo

A peça central é a interface StructuredTaskScope. O painel, reescrito:

import java.time.Duration;
import java.util.concurrent.*;
import java.util.concurrent.StructuredTaskScope.Subtask;

static long t0 = System.nanoTime();
static String agora() { return String.format("[%4d ms]", (System.nanoTime() - t0) / 1_000_000); }

static String buscarUsuario(boolean falhar) throws InterruptedException {
    Thread.sleep(Duration.ofMillis(100));
    if (falhar) throw new IllegalStateException("usuário não encontrado");
    return "Ana";
}

static String buscarPedidos() throws InterruptedException {
    try {
        Thread.sleep(Duration.ofMillis(300));
        return "3 pedidos";
    } catch (InterruptedException e) {
        IO.println(agora() + " busca de pedidos interrompida");
        throw e;
    }
}

static String montarPainel(boolean falhar) throws InterruptedException {
    try (var escopo = StructuredTaskScope.open()) {
        Subtask<String> usuario = escopo.fork(() -> buscarUsuario(falhar));
        Subtask<String> pedidos = escopo.fork(() -> buscarPedidos());
        escopo.join();                       // espera as duas, ou a primeira falha
        return usuario.get() + ", " + pedidos.get();
    }
}

void main() throws InterruptedException {
    String painel = montarPainel(false);
    IO.println(agora() + " " + painel);
    t0 = System.nanoTime();
    try {
        montarPainel(true);
    } catch (StructuredTaskScope.FailedException e) {
        IO.println(agora() + " falhou: " + e.getCause().getMessage());
    }
}
[ 312 ms] Ana, 3 pedidos
[ 102 ms] busca de pedidos interrompida
[ 103 ms] falhou: usuário não encontrado

O open() cria um escopo, sempre dentro de um try-with-resources. O fork inicia cada subtarefa numa thread virtual nova e devolve um Subtask, um recibo parecido com o Future. O join() espera. E o bloco termina com o fechamento do escopo, que garante que nenhuma subtarefa continue viva.

No caso de sucesso, as duas buscas rodam juntas, e o painel fica pronto em 300 ms, o tempo da mais lenta. No caso de falha, acontece o que o código com executor não fazia: quando a busca do usuário falha, aos 100 ms, o escopo cancela a outra subtarefa, interrompendo sua thread, e o join() lança uma FailedException com a falha original como causa. Tudo termina em 103 ms, e nenhum trabalho inútil fica rodando.

Essa política, falhar tudo se uma falhar, é a padrão do open() sem parâmetros, e é a mais comum: a tarefa principal precisa de todos os resultados. Outras políticas são o assunto da próxima seção.

O escopo impõe algumas regras, que a JVM verifica em tempo de execução:

// 1. Ler o resultado antes do join
try (var escopo = StructuredTaskScope.open()) {
    Subtask<String> s = escopo.fork(() -> "oi");
    try { s.get(); } catch (IllegalStateException e) { IO.println("get antes do join: " + e.getMessage()); }
    escopo.join();
    IO.println("depois do join: " + s.get() + " (" + s.state() + ")");
}
// 2. Esquecer o join
try {
    try (var escopo = StructuredTaskScope.open()) {
        escopo.fork(() -> "esquecido");
    }
} catch (IllegalStateException e) {
    IO.println("close sem join: " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
// 3. fork a partir de outra thread
try (var escopo = StructuredTaskScope.open()) {
    Thread outra = Thread.ofVirtual().start(() -> {
        try { escopo.fork(() -> "intrusa"); }
        catch (WrongThreadException e) { IO.println("fork de outra thread: " + e.getClass().getSimpleName()); }
    });
    outra.join();
    escopo.join();
}
get antes do join: join not called
depois do join: oi (SUCCESS)
close sem join: IllegalStateException: Owner did not join after forking
fork de outra thread: WrongThreadException

O escopo tem um dono, a thread que o abriu, e só o dono pode criar subtarefas e esperar por elas. Um resultado só pode ser lido depois do join(), o que torna impossível ler um valor pela metade. E fechar o escopo sem ter feito join() é um erro de programação, denunciado com uma exceção, em vez de uma tarefa esquecida em segundo plano. Essas regras transformam a hierarquia de tarefas numa árvore bem formada: cada escopo pertence a uma thread, e cada subtarefa pertence a um escopo.

Joiners: escolhendo a política

O que o join() espera, e o que devolve, é decidido por um Joiner, passado ao open. A interface StructuredTaskScope.Joiner oferece as políticas mais comuns como métodos de fábrica:

import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.StructuredTaskScope.*;

static long t0;
static String agora() { return String.format("[%4d ms]", (System.nanoTime() - t0) / 1_000_000); }

static String consultar(String espelho, int ms, boolean falhar) throws InterruptedException {
    try {
        Thread.sleep(Duration.ofMillis(ms));
    } catch (InterruptedException e) {
        IO.println(agora() + " " + espelho + " cancelado");
        throw e;
    }
    if (falhar) throw new IllegalStateException(espelho + " fora do ar");
    return "cotação de " + espelho;
}

void main() throws InterruptedException {
    // 1. O primeiro sucesso vence; os outros são cancelados
    t0 = System.nanoTime();
    String vencedor;
    try (var escopo = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
        escopo.fork(() -> consultar("espelho-a", 400, false));
        escopo.fork(() -> consultar("espelho-b", 50, true));    // falha rápido: ignorada
        escopo.fork(() -> consultar("espelho-c", 150, false));
        vencedor = escopo.join();
    }   // close() só termina depois que as subtarefas canceladas terminarem
    IO.println(agora() + " vencedor: " + vencedor);

    // 2. Todos precisam dar certo; join devolve os resultados em ordem
    t0 = System.nanoTime();
    try (var escopo = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow())) {
        for (String nome : List.of("frete", "estoque", "preço"))
            escopo.fork(() -> consultar(nome, 100, false));
        List<String> todos = escopo.join().map(Subtask::get).toList();
        IO.println(agora() + " " + todos);
    }

    // 3. Prazo para o escopo inteiro
    t0 = System.nanoTime();
    try (var escopo = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow(),
                                               cfg -> cfg.withTimeout(Duration.ofMillis(200)))) {
        escopo.fork(() -> consultar("rápido", 50, false));
        escopo.fork(() -> consultar("lento", 5_000, false));
        escopo.join();
    } catch (StructuredTaskScope.TimeoutException e) {
        IO.println(agora() + " prazo esgotado: " + e.getClass().getName());
    }
}
[ 163 ms] espelho-a cancelado
[ 177 ms] vencedor: cotação de espelho-c
[ 102 ms] [cotação de frete, cotação de estoque, cotação de preço]
[ 201 ms] lento cancelado
[ 202 ms] prazo esgotado: java.util.concurrent.StructuredTaskScope$TimeoutException

O anySuccessfulResultOrThrow é o invokeAny de "Não Crie Threads, Entregue Tarefas" em forma estruturada: a falha rápida do espelho B é ignorada, o espelho C vence aos 150 ms, e o espelho A é cancelado. Repare que a mensagem "vencedor" só aparece depois do cancelamento: o fechamento do escopo espera a subtarefa cancelada terminar de fato. Essa é a garantia central: quando o bloco acaba, nada do que ele iniciou continua rodando. Se todas as subtarefas falharem, o join() lança FailedException.

O allSuccessfulOrThrow é a política do open() sem parâmetros, com uma diferença: o join() devolve um stream com as subtarefas, na ordem em que foram criadas, útil quando o número delas é variável, como no laço do exemplo.

O segundo parâmetro do open é uma função que ajusta a configuração do escopo: o prazo com withTimeout, um nome com withName, que aparece nos despejos de threads, e a fábrica de threads com withThreadFactory, para quem precisa de threads com propriedades específicas. Quando o prazo acaba, o escopo cancela todas as subtarefas ainda em andamento, interrompendo suas threads de verdade, e o join() lança StructuredTaskScope.TimeoutException. É a solução para o problema do exercício 5 de "Encadeando o que Ainda Não Aconteceu": o prazo não protege só quem espera, ele encerra o trabalho.

Há ainda o awaitAll(), que espera todas as subtarefas sem cancelar nenhuma por causa de falhas, deixando ao dono a tarefa de examinar cada Subtask, e o allUntil(predicado), que cancela as restantes assim que uma subtarefa concluída satisfizer uma condição. E quem precisar de uma política própria pode implementar a interface Joiner, cujos métodos onFork e onComplete são chamados a cada evento, e cujo result() produz o valor devolvido pelo join().

A árvore de tarefas

Escopos podem ser aninhados: uma subtarefa pode abrir o próprio escopo, com as próprias subtarefas, e a hierarquia vira uma árvore. Um painel que busca o usuário e, em paralelo, os pedidos, sendo que os pedidos por sua vez buscam itens e frete:

static String pedidosComItens() throws InterruptedException {
    try (var escopo = StructuredTaskScope.open(StructuredTaskScope.Joiner.<String>awaitAll(),
                                               cfg -> cfg.withName("pedidos"))) {
        escopo.fork(() -> esperar("itens"));
        escopo.fork(() -> esperar("frete"));
        escopo.join();
        return "ok";
    }
}

void main() throws Exception {
    try (var escopo = StructuredTaskScope.open(StructuredTaskScope.Joiner.<String>awaitAll(),
                                               cfg -> cfg.withName("painel"))) {
        escopo.fork(() -> esperar("usuário"));
        escopo.fork(() -> pedidosComItens());
        escopo.join();
    }
}

O despejo de threads em JSON de "Um Milhão de Threads", tirado enquanto as tarefas esperam, mostra a árvore exatamente como o código a descreve:

painel/...ThreadFlock$ThreadContainerImpl   pai: <root>   dono: 3    threads: 35, 37
pedidos/...ThreadFlock$ThreadContainerImpl  pai: painel   dono: 37   threads: 40, 41

O escopo "painel" pertence à thread 3, a main, e tem duas threads filhas. Uma delas, a 37, é dona do escopo "pedidos", com as suas duas filhas. Num servidor com milhares de requisições, um despejo desses mostra, para cada requisição, quais subtarefas estão pendentes e onde cada uma está esperando. Com um ExecutorService, as mesmas threads apareceriam soltas no contêiner do executor, sem nenhuma indicação de quem criou quem.

A árvore também propaga o cancelamento para baixo: se a thread dona do escopo "painel" for interrompida enquanto espera no join(), o escopo cancela suas subtarefas, e a subtarefa 37, ao ser interrompida no seu próprio join(), cancela as dela. Nenhuma folha da árvore fica órfã.

Valores com escopo: contexto sem parâmetros

O segundo recurso desta aula resolve um problema antigo. Numa aplicação web, informações como o usuário autenticado, o identificador da requisição para os logs ou a transação do banco precisam estar disponíveis em camadas profundas do código, e passá-las como parâmetro por todos os métodos do caminho polui as assinaturas. A solução tradicional é o ThreadLocal, mas "Ferramentas de Precisão" e "Um Milhão de Threads" mostraram seus três defeitos: o valor pode ser alterado por qualquer um, a qualquer momento, vive enquanto a thread viver, vazando entre tarefas de um pool, e cada thread filha que o herda precisa de uma cópia.

Os valores com escopo (scoped values), finalizados no Java 25 pela JEP 506, são a alternativa. Um ScopedValue é vinculado a um valor durante a execução de um bloco de código, e só dentro dele:

record Usuario(String nome, boolean admin) {}

// A chave: normalmente um campo static final
static final ScopedValue<Usuario> USUARIO = ScopedValue.newInstance();

// Camadas profundas leem o valor sem recebê-lo como parâmetro
static String excluirArtigo(int id) {
    Usuario u = USUARIO.get();
    if (!u.admin()) return u.nome() + " não pode excluir o artigo " + id;
    return u.nome() + " excluiu o artigo " + id;
}

static String controlador(int id) {
    return excluirArtigo(id);   // nenhum parâmetro de usuário no caminho
}

void main() {
    IO.println("vinculado fora? " + USUARIO.isBound());
    ScopedValue.where(USUARIO, new Usuario("ana", true))
               .run(() -> IO.println(controlador(7)));
    String r = ScopedValue.where(USUARIO, new Usuario("bruno", false))
               .call(() -> controlador(8));
    IO.println(r);
    IO.println("vinculado depois? " + USUARIO.isBound() + "; orElse: " + USUARIO.orElse(new Usuario("anônimo", false)).nome());
    try {
        USUARIO.get();
    } catch (java.util.NoSuchElementException e) {
        IO.println("get fora do escopo: " + e.getClass().getSimpleName());
    }

    // Revincular num escopo interno: vale só lá dentro
    ScopedValue.where(USUARIO, new Usuario("ana", true)).run(() -> {
        IO.println("externo: " + USUARIO.get().nome());
        ScopedValue.where(USUARIO, new Usuario("sistema", true))
                   .run(() -> IO.println("interno: " + USUARIO.get().nome()));
        IO.println("externo de novo: " + USUARIO.get().nome());
    });
}
vinculado fora? false
ana excluiu o artigo 7
bruno não pode excluir o artigo 8
vinculado depois? false; orElse: anônimo
get fora do escopo: NoSuchElementException
externo: ana
interno: sistema
externo de novo: ana

O ScopedValue.where(chave, valor) prepara o vínculo, e run (para um Runnable) ou call (para código que devolve valor) executa o bloco com ele. Dentro do bloco, e em qualquer método chamado a partir dele, por mais profundo que seja, USUARIO.get() devolve o valor vinculado. Quando o bloco termina, normalmente ou por exceção, o vínculo desaparece: não existe um remove() para esquecer, e o vazamento entre requisições da aula anterior se torna impossível. Fora de qualquer vínculo, get() lança NoSuchElementException; isBound() permite verificar antes, e orElse fornece um valor padrão, que no Java 25 não pode ser null.

Não existe um set. O valor não pode ser alterado dentro do bloco, e é isso que torna o recurso seguro e eficiente. O mais perto disso é revincular num bloco interno, como no último trecho: o código chamado de dentro dele vê "sistema", e ao sair, o valor externo volta a valer. É o padrão de uma operação que precisa rodar com privilégios elevados por um trecho, e depois voltar ao usuário original, sem que ninguém precise lembrar de restaurá-lo.

A chave é quase sempre um campo static final, e controlar quem pode vê-la é uma forma de controle de acesso: um campo private significa que só a própria classe pode ler e vincular aquele valor. A documentação recomenda guardar em valores com escopo apenas objetos imutáveis, como o record Usuario. Um objeto mutável poderia ser alterado através da referência, e aí os problemas de "O Cadeado e a Memória" voltariam.

As duas ideias juntas: herança pela árvore

Os dois recursos foram desenhados para trabalhar juntos. As subtarefas criadas por fork num StructuredTaskScope herdam automaticamente os vínculos de valores com escopo da thread dona:

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

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

static String log(String msg) {
    return "[req " + REQUISICAO.orElse("?") + " | " + Thread.currentThread().threadId() + "] " + msg;
}

void main() throws Exception {
    ScopedValue.where(REQUISICAO, "a41f").run(() -> {
        try (var escopo = StructuredTaskScope.open()) {
            Subtask<String> a = escopo.fork(() -> log("buscando usuário"));
            Subtask<String> b = escopo.fork(() -> log("buscando pedidos"));
            escopo.join();
            IO.println(log("pai"));
            IO.println(a.get());
            IO.println(b.get());
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        // Uma thread comum NÃO herda
        Thread t = Thread.ofVirtual().start(() -> IO.println(log("thread avulsa")));
        try { t.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    });
}
[req a41f | 3] pai
[req a41f | 35] buscando usuário
[req a41f | 37] buscando pedidos
[req ? | 39] thread avulsa

As subtarefas, em threads diferentes (35 e 37), veem o identificador da requisição, e todas as linhas de log daquela requisição podem ser reunidas depois. A thread avulsa, criada fora da estrutura, não vê nada. Essa herança é segura porque a estrutura garante que as subtarefas terminam antes do bloco do run, então o vínculo nunca é lido depois de desaparecer. E ela é barata: como o valor é imutável, as filhas compartilham a mesma referência, sem nenhuma cópia, ao contrário do InheritableThreadLocal, que copia os valores para cada thread criada. Com um milhão de threads virtuais, a diferença é grande.

O ThreadLocal não deixa de existir. Ele continua sendo a ferramenta certa para o raro caso em que cada thread precisa de uma cópia mutável e própria de um objeto, por muito tempo. Para passar contexto por uma requisição, a recomendação passa a ser ScopedValue, e os frameworks web vão, aos poucos, trocando um pelo outro.

Com esta aula termina a sétima fase, que começou com uma pergunta simples, como fazer duas coisas ao mesmo tempo, e percorreu setenta anos de respostas. Em "Fazendo Duas Coisas ao Mesmo Tempo", você criou threads à mão, viu que o sistema operacional decide a ordem em que elas rodam e aprendeu que o único jeito seguro de pedir a uma thread que pare é a interrupção cooperativa, que nunca deve ser engolida. Em "O Cadeado e a Memória", descobriu que dados compartilhados e mutáveis precisam de proteção para as duas coisas que podem dar errado, a atomicidade e a visibilidade, conheceu as regras de acontece-antes do Java Memory Model e provocou, diagnosticou e evitou um impasse.

Em "Não Crie Threads, Entregue Tarefas", separou o trabalho das threads que o executam, recebeu resultados e exceções por meio de Futures, dividiu um cálculo em partes e aprendeu a configurar um pool que resiste a picos, com fila limitada e política de rejeição. Em "Ferramentas de Precisão", trocou travas por variáveis atômicas, fez uma trava que sabe desistir, contou palavras corretamente com merge, coordenou produtores e consumidores com uma fila bloqueante e viu o ThreadLocal vazar entre tarefas. Em "Encadeando o que Ainda Não Aconteceu", descreveu com CompletableFuture o que fazer com resultados que ainda não existiam, e descobriu os limites desse estilo: código fragmentado e cancelamentos que não cancelam.

As duas últimas aulas mostraram a virada do Java moderno. Em "Um Milhão de Threads", as threads virtuais tornaram a espera quase gratuita e devolveram ao código a forma sequencial, com a condição de limitar os recursos, e não as threads. E nesta aula, a concorrência estruturada deu às tarefas um dono e um tempo de vida, e os valores com escopo deram a elas um contexto imutável que segue a estrutura.

O fio condutor da fase inteira é uma regra que vale mais que qualquer API: a forma mais segura de compartilhar dados entre threads é não compartilhar dados mutáveis. Dividir o trabalho em partes independentes, passar valores imutáveis e juntar os resultados no fim evita quase todos os problemas desta fase, e as ferramentas de proteção existem para os casos em que isso não é possível. A segunda regra é que toda espera precisa de um limite, e toda tarefa, de um dono. Tudo isso volta no projeto final, um servidor HTTP que atende cada conexão numa thread virtual, com limites, prazos e desligamento ordenado.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Compare as duas versões do painel, com ExecutorService e com StructuredTaskScope, em três situações: (a) a busca do usuário falha; (b) a busca dos pedidos falha primeiro; (c) a thread que monta o painel é interrompida enquanto espera. O que cada versão faz?

Ver resposta

✓ Resposta: (a) Com o executor, o usuario.get() lança ExecutionException aos 100 ms, mas a busca dos pedidos continua até o fim, e o fechamento do executor espera por ela: o resultado da operação sai rápido, mas o trabalho inútil segue, e o método só retorna aos 2 segundos. Com o escopo, a falha cancela os pedidos na hora, e tudo termina em 100 ms. (b) Com o executor, o código chama usuario.get() primeiro e fica esperando o usuário, mesmo que os pedidos já tenham falhado e o painel já esteja condenado; a falha só é descoberta no pedidos.get(), depois. Seria preciso reorganizar o código, por exemplo com invokeAll ou CompletableFuture, para reagir à primeira falha. Com o escopo, não importa a ordem: a primeira falha, qualquer que seja, cancela a outra e encerra o join(). (c) Com o executor, a interrupção faz o get() lançar InterruptedException, mas as duas subtarefas continuam, porque nada as liga à thread interrompida. Com o escopo, o join() interrompido cancela as subtarefas antes de propagar a exceção, e o fechamento do escopo espera que terminem. Nas três situações, a estrutura faz automaticamente o que o código com executor precisaria fazer à mão, em cada caminho.

Exercício 2

Explique por que o StructuredTaskScope exige que o join() seja chamado antes do close(), e por que proíbe o fork a partir de outra thread. O que se perderia sem essas regras?

Ver resposta

✓ Resposta: O join() é o ponto em que o dono espera as subtarefas e toma conhecimento das falhas. Se o escopo pudesse ser fechado sem ele, um erro de programação, como um return antecipado entre os forks e o join(), faria o close() cancelar e esperar as subtarefas em silêncio, e as falhas delas nunca seriam vistas. A exceção IllegalStateException: Owner did not join after forking transforma esse esquecimento num erro visível. A regra também garante que Subtask.get() só seja chamado depois do join(), quando o resultado é definitivo. A restrição do fork ao dono garante a forma de árvore: cada subtarefa tem um pai conhecido, a thread que abriu o escopo, e o conjunto de subtarefas de um escopo só muda na thread que vai esperar por elas. Se qualquer thread pudesse criar subtarefas, uma delas poderia fazê-lo depois do join(), criando uma tarefa que ninguém espera, exatamente o problema que a concorrência estruturada existe para eliminar. Sem essas regras, o StructuredTaskScope seria só mais um executor.

Exercício 3

Um portal de notícias consulta dez agências em paralelo e quer exibir as notícias de todas que responderem, ignorando as que falharem. Qual Joiner usar? Escreva o código para três agências, uma delas falhando.

Ver resposta

✓ Resposta: O allSuccessfulOrThrow não serve, porque uma falha cancelaria as outras, e o anySuccessfulResultOrThrow pararia no primeiro sucesso. O certo é o awaitAll(), que espera todas sem cancelar nenhuma por causa de falhas, e depois examinar o estado de cada subtarefa:

List<Subtask<String>> tarefas = new ArrayList<>();
try (var escopo = StructuredTaskScope.open(Joiner.<String>awaitAll())) {
    tarefas.add(escopo.fork(() -> buscar("agência A", 100, false)));
    tarefas.add(escopo.fork(() -> buscar("agência B", 50, true)));
    tarefas.add(escopo.fork(() -> buscar("agência C", 150, false)));
    escopo.join();   // espera todas, sem cancelar nada por causa de falhas
}
List<String> obtidas = tarefas.stream()
        .filter(t -> t.state() == Subtask.State.SUCCESS)
        .map(Subtask::get)
        .toList();
IO.println(obtidas);
tarefas.stream().filter(t -> t.state() == Subtask.State.FAILED)
       .forEach(t -> IO.println("ignorada: " + t.exception().getMessage()));

A saída é [notícias de agência A, notícias de agência C] e ignorada: agência B falhou. Os Subtasks continuam válidos depois do fechamento do escopo, e state() informa SUCCESS, FAILED ou UNAVAILABLE (para uma subtarefa cancelada ou não concluída). Num portal real, o escopo teria também um prazo, com cfg -> cfg.withTimeout(...), para que uma agência lenta não atrasasse a página inteira; como o prazo faz o join() lançar TimeoutException, o código trataria essa exceção e usaria as subtarefas que já tivessem terminado com sucesso.

Exercício 4

Liste três diferenças entre ThreadLocal e ScopedValue e explique por que os valores com escopo combinam melhor com threads virtuais.

Ver resposta

✓ Resposta: A primeira é a mutabilidade: um ThreadLocal pode ser alterado com set a qualquer momento, por qualquer código com acesso a ele, e o fluxo de dados fica difícil de seguir; um ScopedValue não tem set, e o valor só muda por revinculação num bloco interno, que se desfaz sozinho. A segunda é o tempo de vida: o valor de um ThreadLocal vive enquanto a thread viver, a menos que alguém chame remove(), o que causa vazamentos entre tarefas de um pool e retenção de memória; o vínculo de um ScopedValue existe só durante o run ou o call, e desaparece mesmo em caso de exceção. A terceira é a herança: o InheritableThreadLocal copia os valores para cada thread filha no momento da criação, com custo de memória proporcional ao número de threads; um ScopedValue é herdado pelas subtarefas de um StructuredTaskScope sem cópia, compartilhando a mesma referência imutável. Com threads virtuais, os três pontos pesam mais: há potencialmente milhões de threads, então cópias por thread custam caro; as threads são criadas e descartadas por tarefa, então valores que vivem com a thread não fazem sentido; e o modelo de subtarefas em árvore é justamente o que o StructuredTaskScope oferece, com a herança embutida.

Exercício 5

A concorrência estruturada está em prévia no Java 25 e continua em prévia no Java 27. Sua equipe mantém um serviço em produção no Java 25. Como você aplicaria as ideias desta aula hoje, sem habilitar recursos em prévia?

Ver resposta

✓ Resposta: Os valores com escopo são definitivos no Java 25 e podem ser usados em produção imediatamente, por exemplo para o identificador da requisição e o usuário autenticado, substituindo ThreadLocals. Só a herança automática pelas subtarefas depende do StructuredTaskScope. Para a estrutura das tarefas, as ideias podem ser aplicadas com a API estável, com disciplina: criar o executor dentro do método que usa as subtarefas, num try-with-resources, para que nenhuma sobreviva ao método (o close() espera todas); usar newVirtualThreadPerTaskExecutor(); guardar todos os Futures e, no primeiro erro, cancelar os demais com cancel(true), num finally que cubra todos os caminhos; e usar invokeAll ou invokeAny com prazo, que já cancelam as tarefas pendentes quando o prazo acaba. É mais código e mais cuidado, e é exatamente o que o StructuredTaskScope automatiza. Encapsular esse padrão num método utilitário interno facilita a migração quando a API for finalizada. Habilitar a prévia em produção seria arriscado: o programa ficaria preso a uma versão exata do Java, e a API ainda pode mudar entre as versões.

Comentários

Mais em Java

Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos
Sem Lixo no Loop — Pools, ECS e o Coletor nos Jogos

Nesta aula você mede quanto um jogo aloca por quadro, compara três jeitos de…

Comportamento como Valor — Lambdas e Interfaces Funcionais
Comportamento como Valor — Lambdas e Interfaces Funcionais

Como passar comportamento como argumento com expressões lambda em Java…

Um Valor que Pode Não Existir — Optional e a Guerra contra o null
Um Valor que Pode Não Existir — Optional e a Guerra contra o null

Por que a referência nula é o erro de um bilhão de dólares e como o Optional…