Concorrência Estruturada Parece Igual em Quatro Runtimes — Até um Filho Falhar
Escrevi o mesmo fan-out quatro vezes — Java 25 StructuredTaskScope, Kotlin coroutineScope, Swift withThrowingTaskGroup, Python asyncio.TaskGroup — e a API de superfície é quase intercambiável. As semânticas de cancelamento e agregação de exceções não são. Estas são minhas notas sobre o que diverge no caminho de falha e por que apenas Python te entrega todas as falhas por padrão.
A concorrência estruturada chegou na conversa do mainstream Java em setembro de 2025, quando o Java 25 lançou a JEP 505. Vale ser preciso sobre o que aterrissou: a JEP 505 é o quinto preview do recurso, não uma API finalizada. A referência da Oracle ainda traz o banner de preview, então você compila com --enable-preview e deve esperar que a superfície mude novamente — e ela mudou. A JEP 525 chegou como sexto preview no Java 26 (março de 2026) com renomeações e mudanças de tipo de retorno em Joiner (notavelmente allSuccessfulOrThrow retornando uma List em vez de um stream, e anySuccessfulResultOrThrow renomeado para anySuccessfulOrThrow), e a JEP 533 está na fila como sétimo preview para o JDK 27, adicionando um terceiro parâmetro de tipo a Joiner e envolvendo falhas de subtask em ExecutionException. A manchete que continuei vendo ("concorrência estruturada agora está estável no Java") está errada. O que é verdadeiro é mais interessante: com o preview da JEP 505, todos os quatro runtimes que costumo usar — Java, Kotlin, Swift, Python — agora trazem o mesmo padrão, e pela primeira vez consegui colocar o comportamento de falha deles lado a lado.
Então fiz isso. Escrevi a mesma carga de trabalho toy quatro vezes: fan out de três chamadas, falhar a unidade inteira se qualquer uma falhar, nunca vazar uma thread. A API de superfície é próxima o suficiente para que você quase possa copiar um modelo mental de um runtime para o próximo. As semânticas de falha não são. Essa lacuna é todo o ponto deste post, porque é invisível no caminho feliz e é exatamente o que morde quando uma requisição atravessa mais de um runtime.
O mesmo fan-out, quatro vezes
Aqui está o formato em cada runtime. Estou mostrando apenas a espinha — bifurcar alguns filhos, esperar, combinar — porque essa é a parte que parece idêntica.
Java 25, scope padrão:
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(this::findUser);
var order = scope.fork(this::fetchOrder);
scope.join(); // lança na primeira falha
return new Response(user.get(), order.get());
}Kotlin:
coroutineScope {
val user = async { findUser() }
val order = async { fetchOrder() }
Response(user.await(), order.await())
}Swift:
try await withThrowingTaskGroup(of: Part.self) { group in
group.addTask { try await findUser() }
group.addTask { try await fetchOrder() }
var parts: [Part] = []
for try await part in group { parts.append(part) }
return combine(parts)
}Python:
async with asyncio.TaskGroup() as tg:
user = tg.create_task(find_user())
order = tg.create_task(fetch_order())
# ambos aguardados no fim do blocoQuatro idiomas, uma ideia: o bloco léxico é dono dos filhos, e o bloco não sai até que todo filho tenha terminado. Nenhum trabalho destacado escapa. Essa garantia é real em todos os quatro, e é a razão pela qual o padrão vale a pena adotar. O problema começa no momento em que um filho lança.
Onde o cancelamento realmente diverge
Quando um filho falha, cada um desses runtimes cancela os irmãos restantes. Essa frase esconde três mecanismos diferentes, e o mecanismo decide se o cancelamento realmente acontece.
O Java cancela por interrupção de thread. O scope interrompe as virtual threads que executam as subtasks não finalizadas, e a interrupção emerge como InterruptedException dentro de qualquer chamada bloqueante. A pegadinha: uma interrupção só aterrissa em um ponto interruptível. Uma subtask girando em um loop CPU apertado, ou uma que captura InterruptedException e engole, nunca percebe. O scope ainda vai esperar por ela, que é a garantia funcionando como projetada — mas "cancelado" aqui significa "pedido para parar", não "parado".
Kotlin, Swift e Python cancelam cooperativamente. Kotlin lança CancellationException no próximo ponto de suspensão; Swift seta uma flag que o filho lê através de Task.isCancelled ou Task.checkCancellation(); Python lança CancelledError no próximo await. Em todos os três, um filho que nunca alcança um checkpoint — ou que captura o sinal de cancelamento e continua — derrota o cancelamento inteiramente. Confirmei o modo de falha da mesma forma em cada: um loop while true sem await é incancelável em todos os lugares, e um try/except que come o cancel transforma "parar agora" em "nunca parar".
Nenhum dos quatro consegue interromper uma computação em execução. Essa é a primeira coisa que eu diria a alguém tratando essas APIs como intercambiáveis: a garantia estruturada é sobre esperar por filhos, não sobre forçá-los a morrer.
O Java tem uma armadilha extra que os outros três não têm. Em sua crítica à JEP 505, Adam Warski aponta que quando o scope cancela, a interrupção alcança as subtasks mas não o corpo do scope em si. Se o corpo do seu scope está agindo como um coordenador — bloqueado em uma fila, esperando os filhos lhe alimentarem trabalho — uma falha de filho cancela os filhos e deixa o corpo estacionado em queue.take() para sempre. O padrão que parece mais seguro (um loop driver coordenando workers) é o que trava. Não há forma limpa de interromper o corpo sob o design atual, então você está de volta à disciplina manual: capture falhas dentro dos filhos e sinalize o corpo explicitamente.
O que é engolido na falha
O cancelamento decide o que para. A agregação decide o que você fica sabendo. Aqui é onde os quatro runtimes mais se separam, e é a diferença pela qual eu realmente escolheria um runtime.
O diagrama abaixo contrasta o que chega ao seu bloco catch quando dois filhos falham ao mesmo tempo. Olhe quantas exceções sobrevivem à viagem de volta ao chamador em cada faixa.
A política padrão do Java lança StructuredTaskScope.FailedException envolvendo a primeira subtask que falhou, e cancela o scope. O joiner awaitAllSuccessfulOrThrow faz o mesmo — ele emerge a exceção da primeira falha. A segunda e a terceira falhas se foram. Você pode recuperá-las escrevendo um Joiner customizado que inspeciona cada Subtask depois de join(), mas fora da caixa, a contagem de falhas que você consegue ver é um.
O coroutineScope do Kotlin re-lança a exceção do primeiro filho e cancela os irmãos. Exceções posteriores de outros filhos são anexadas como suppressed na primeira, então tecnicamente são acessíveis através de Throwable.getSuppressed() — mas apenas se você for procurar, e a maioria do código de logging e tratamento de erro nunca procura. (Se você quiser que filhos falhem independentemente em vez de derrubar o scope, é para isso que serve o supervisorScope; ele isola cada falha em vez de agregar qualquer coisa.)
O withThrowingTaskGroup do Swift re-lança o primeiro erro que vê enquanto você itera o grupo, marca o resto como cancelado, espera por eles e então completa. As outras falhas são descartadas. O Swift adiciona uma armadilha que os outros não têm: o grupo só re-lança quando você consome seus resultados. Se você dispara filhos com addTask e nunca itera o grupo com for try await, um erro lançado é silenciosamente descartado e o grupo completa como se nada tivesse falhado. Descobri que essa é a forma mais fácil de escrever um Swift task group que parece correto e reporta sucesso enquanto um filho explodiu. A correção é mecânica — sempre drene o grupo — mas nada te força a isso.
Python é o outlier, e é a razão pela qual eu iria buscar asyncio.TaskGroup quando realmente preciso saber tudo o que quebrou. Quando tasks falham, o grupo cancela o resto e então levanta um ExceptionGroup carregando cada falha não-cancelamento (esta é a PEP 654, lançada na 3.11), que você desempacota com a sintaxe except*. É o único dos quatro que é lossless por padrão.
Aqui está o menor programa que mantive que prova isso. Uma barreira alinha três workers no mesmo instante; dois deles então lançam sem mais await, então ambas as falhas são registradas antes que o cancelamento possa intervir:
import asyncio
async def worker(name: str, barrier: asyncio.Barrier, fail: bool) -> str:
await barrier.wait() # todos os três se alinham no mesmo instante
if fail: # então lança sem mais ponto de await
raise RuntimeError(f"{name} failed")
return f"{name} ok"
async def fan_out() -> None:
barrier = asyncio.Barrier(3)
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(worker("A", barrier, fail=True))
tg.create_task(worker("B", barrier, fail=True))
tg.create_task(worker("C", barrier, fail=False))
except* RuntimeError as eg: # note o asterisco
names = sorted(str(e) for e in eg.exceptions)
print(f"aggregated {len(eg.exceptions)} failures: {names}")
asyncio.run(fan_out())Execute com python3 fanout.py no Python 3.11 ou mais recente. Rodei cinco vezes seguidas e imprimiu aggregated 2 failures: ['A failed', 'B failed'] toda vez. A barreira está fazendo o trabalho que torna isso determinístico: sem ela, A falharia primeiro, o cancelamento alcançaria B no meio de um await, e B voltaria como um CancelledError em vez de um RuntimeError — deixando você com uma falha real no grupo em vez de duas. Esse detalhe é todo o comportamento em miniatura: a agregação só captura falhas que realmente lançaram antes que o cancelamento varresse o resto.
As semânticas, em uma tela
Esta é a tabela que agora mantenho ao lado dos quatro samples de código. As três linhas de cima parecem iguais em todos os runtimes; as três de baixo são onde os corpos estão enterrados.
| Comportamento | Java 25 StructuredTaskScope | Kotlin coroutineScope | Swift withThrowingTaskGroup | Python asyncio.TaskGroup |
|---|---|---|---|---|
| Bloco espera por todos os filhos | sim | sim | sim | sim |
| Primeira falha cancela irmãos | sim | sim | sim | sim |
| Mecanismo de cancelamento | interrupção de thread | CancellationException | flag cooperativa | CancelledError |
| Falhas que o chamador vê | apenas a primeira | primeira (+ suppressed) | apenas a primeira | todas (ExceptionGroup) |
| Bug de engolida silenciosa mais fácil | corpo coordenador travado | CancellationException capturada | resultados nunca consumidos | CancelledError capturado |
| Maturidade | preview (--enable-preview) | estável | estável | estável (3.11+) |
Escolhendo um runtime sabendo do trade-off
A razão pela qual isso importa em um backend é falha parcial. Quando você faz fan out para três serviços downstream e dois deles estão fora, a diferença entre "loguei um timeout" e "loguei um connection refused e um 503" é a diferença entre perseguir a dependência errada às 2h da manhã e consertar a certa. Três desses quatro runtimes te entregam a primeira falha e silenciosamente descartam o resto. Se você está em Java, Kotlin ou Swift e se importa com cada falha, você tem que escrever o código que as coleta — um Joiner customizado em Java, lendo suppressed exceptions em Kotlin, drenando e acumulando em Swift. Não é difícil, mas não é o padrão, e padrões são o que vai para produção.
Algumas coisas em que eu agiria:
- Trate agregação como um recurso que você opta por ter, não um que você ganha. Apenas o
TaskGroupdo Python emerge cada falha por padrão. Em todos os outros lugares, assuma que a primeira exceção é tudo o que você verá a menos que tenha escrito o código para ver mais. - Audite seus checkpoints de cancelamento. Cancelamento cooperativo (Kotlin, Swift, Python) é um no-op contra um filho que nunca suspende ou que engole o sinal de cancelamento. Um filho CPU-bound é incancelável em todos os quatro — a interrupção do Java também não ajuda lá.
- No Swift, sempre drene o grupo. Um
withThrowingTaskGroupnão consumido reporta sucesso enquanto um filho falhou. Itere comfor try awaitmesmo quando não precisa dos valores. - No Java, não bloqueie o corpo do scope em seus próprios filhos. Se o corpo coordena os workers através de uma fila, uma falha de filho pode deixar o corpo estacionado enquanto o scope é cancelado ao redor dele.
- Não envie concorrência estruturada do Java como se fosse estável. Continua sendo um preview atrás de
--enable-previewaté o Java 26 (sexto preview, JEP 525), com um sétimo na fila para o JDK 27 (JEP 533) que muda os parâmetros de tipo doJoinere o envolvimento de exceção. O formato vai continuar se mexendo até a finalização.
Quando buscar o padrão: toda vez que você faz fan out de I/O concorrente e quer um único ponto que possua o tempo de vida e a falha. É uma melhoria estrita sobre futures feitos à mão e tasks destacadas. Quando ter cuidado: no momento em que seus filhos compartilham estado, coordenam através de uma fila, ou incluem uma task background de longa vida que nunca completa por conta própria — esses são os casos onde a API de superfície convergente esconde quatro runtimes genuinamente diferentes por baixo, e aquele que você ignora é aquele que trava ou engole.
A superfície realmente parece a mesma nos quatro. O caminho de falha é onde eles param de concordar, e o caminho de falha é o único que importa sob carga.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Capturando uma Race Condition de Retry com Uma Seed: Simulação Determinística em Rust usando turmoil
Eu tinha três testes de retry flaky que ninguém conseguia reproduzir em um laptop. Reescrevi um deles em Rust em cima do turmoil, o simulador determinístico do Tokio, e uma única seed de 8 bytes fixou a race condition de partição byte por byte. Estas são minhas anotações sobre o que a seed realmente controla, o que escapa dela e quando o teste de simulação determinística vale a pena.
Two-Phase Commit na JVM: O Problema de Bloqueio Que Ninguém Coloca no Diagrama
Eu derrubei de propósito um coordenador de Two-Phase Commit em uma pequena simulação Kotlin para medir por quanto tempo os participantes ficam travados quando o coordenador desaparece entre as fases. O resultado é a parte do 2PC que os diagramas nunca mostram — e a razão pela qual eu modelaria a maior parte das escritas cross-service como uma saga em vez disso.
Auditando um serviço Scala contra as quatro restrições regenerativas de Chad Fowler
Levei um serviço Scala de processamento de pedidos das minhas anotações pelas quatro restrições regenerativas de Chad Fowler. Duas passaram de graça, duas forçariam um redesign de verdade. Aqui está o que aprendi sobre onde "módulo fracamente acoplado" termina e "componente regenerativo" começa, e quais partes do redesign eu de fato pagaria.
