Concorrência Estruturada em Java
Trate subtarefas concorrentes como uma unidade de trabalho em Java com concorrência estruturada (StructuredTaskScope).
Concorrência estruturada trata um grupo de subtarefas concorrentes como uma única unidade de trabalho: elas são lançadas juntas, terminam juntas e, se uma falhar ou o chamador for cancelado, as demais também são canceladas — nenhuma thread órfã sobrevive ao bloco que as iniciou. O modelo é fornecido por java.util.concurrent.StructuredTaskScope (uma API de prévia introduzida no Java 21) e se apoia nas mesmas threads virtuais abordadas anteriormente nesta parte. O objetivo é simples: tornar o código concorrente tão fácil de ler, depurar e raciocinar quanto um método sequencial comum.
Este capítulo explica por que "estruturado" importa, a anatomia de um escopo de tarefa, as duas políticas de encerramento integradas, como prazos e cancelamentos se propagam, e um exemplo prático executável. Assume-se que você esteja familiarizado com o framework executor e com Callable/Future.
Por que "estruturado"?
Os pools de threads clássicos são não estruturados: você envia uma tarefa com submit para um ExecutorService compartilhado e recebe um Future cujo tempo de vida não tem relação com o método que o criou. Uma tarefa pode sobreviver ao seu chamador, um erro em uma tarefa é invisível para suas irmãs, e o cancelamento precisa ser configurado manualmente. O resultado são threads vazando e tratamento de erros confuso.
A concorrência estruturada toma emprestada a disciplina do fluxo de controle estruturado: assim como um bloco try delimita suas instruções, um escopo de tarefa confina suas subtarefas. As subtarefas ramificadas dentro de um bloco devem todas ser concluídas antes que o bloco termine. Os tempos de vida se encaixam de forma limpa, de modo que um dump de thread e um rastreamento de pilha realmente mostram quem iniciou o quê.
| Preocupação | Não estruturado (pool compartilhado ExecutorService) | Estruturado (StructuredTaskScope) |
|---|---|---|
| Tempo de vida da subtarefa | Independente do chamador | Limitado pelo bloco envolvente |
| Erro em uma subtarefa | Oculto em um Future até você chamar get | Pode interromper todo o escopo |
| Cancelamento | Manual, fácil de esquecer | Automático em caso de falha ou interrupção |
| Limpeza de recursos | Por sua conta | close() aguarda todas as subtarefas |
A forma de um escopo
Um escopo é um AutoCloseable, portanto vive em um bloco try-with-resources. Você usa fork para criar subtarefas (cada uma retorna um identificador Subtask), chama join() para aguardá-las e, em seguida, lê cada resultado. A política ShutdownOnFailure cancela as subtarefas restantes no momento em que qualquer uma delas lança uma exceção:
import java.util.concurrent.StructuredTaskScope;
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
StructuredTaskScope.Subtask<String> user = scope.fork(() -> fetchUser(id));
StructuredTaskScope.Subtask<Integer> order = scope.fork(() -> fetchOrderCount(id));
scope.join(); // wait for both branches
scope.throwIfFailed(); // rethrow if either branch failed
return new Profile(user.get(), order.get());
} // close() guarantees both subtasks have ended before we leaveSe fetchUser lançar uma exceção, ShutdownOnFailure interrompe o fetchOrderCount ainda em execução, join() retorna e throwIfFailed() relança a causa original envolvida em um ExecutionException. Nenhuma thread é vazada.
Políticas de encerramento integradas
As duas políticas fornecidas cobrem os padrões mais comuns; você cria uma subclasse de StructuredTaskScope para qualquer outra necessidade.
| Política | Termina quando | Use para |
|---|---|---|
ShutdownOnFailure | Todas têm êxito, ou uma falha | Fan-out em que você precisa de cada resultado (o caso comum) |
ShutdownOnSuccess<T> | Primeiro êxito, ou todas falham | Competição entre fontes redundantes; use a resposta mais rápida |
ShutdownOnSuccess retorna o vencedor via result() e cancela os perdedores:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
scope.fork(() -> queryMirrorA());
scope.fork(() -> queryMirrorB());
scope.join();
return scope.result(); // the first one to return; the slower is cancelled
}Prazos e cancelamentos se propagam
Um escopo pode ser aguardado com um prazo; quando ele expira, as subtarefas inacabadas são canceladas:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> slowService());
scope.joinUntil(Instant.now().plusSeconds(2)); // throws TimeoutException if late
scope.throwIfFailed();
}O cancelamento é cooperativo e flui para baixo: se a thread que possui o escopo for interrompida, todas as subtarefas são interrompidas em cascata. Como cada subtarefa é executada em sua própria thread virtual, criar milhares delas é barato — o escopo, e não um tamanho fixo de pool, é a unidade sobre a qual você raciocina.
Um exemplo prático: fan-out, falha e junção de uma lista
StructuredTaskScope é um recurso de prévia, portanto, para manter este exemplo executável em um JDK estável, modelamos a mesma ideia com um executor de thread virtual por tarefa: um bloco try-with-resources que delimita um grupo de subtarefas e só sai quando toda thread de subtarefa terminou. Ele distribui duas chamadas de forma concorrente, depois mostra como uma falha interrompe a unidade de trabalho e como invokeAll aguarda toda uma lista de uma só vez.
O que observar na execução:
- Ambas as subtarefas reportaram
is virtual : true— cadasubmitfoi executado em sua própria thread virtual, o mesmo portador leve queStructuredTaskScope.forkusa, portanto criar uma thread por subtarefa é barato. - O bloco do caminho feliz exibiu
ran concurrently (<320ms): truemesmo que as duas buscas durmam 120ms e 200ms: elas se sobrepuseram, então o tempo real acompanha o ramo mais lento (~200ms), não a soma (320ms). Essa sobreposição é exatamente o ponto do fan-out. - Ao sair do bloco try-with-resources,
close()foi chamado, bloqueando até que toda thread de subtarefa fosse encerrada — o escopo é a unidade de tempo de vida, exatamente a disciplina queStructuredTaskScopeimpõe por construção. - Na seção de falha, o programa exibiu
caught: IllegalStateException -> upstream said no: um erro lançado dentro de uma subtarefa aparece no ponto de junção envolvido emExecutionException, egetCause()devolve a exceção original. - Após capturar a falha, exibiu
sibling cancelled: true— cancelamos o ramogoodainda em execução para que nenhum órfão sobrevivesse ao bloco, que é exatamente o queShutdownOnFailurefaz automaticamente; aqui fizemos manualmente para mostrar o mecanismo.
Tópicos relacionados
- Threads virtuais — as threads leves em que cada subtarefa é executada.
- Threads virtuais modernas — padrões práticos e armadilhas.
- Framework executor — a linha de base não estruturada que este modelo substitui.
CallableeFuture— os tipos de tarefa e resultado usados no ponto de junção.CompletableFuture— composição de resultados assíncronos sem bloqueio de junções.