---
title: "Concorrência Estruturada Parece Igual em Quatro Runtimes — Até um Filho Falhar"
url: https://tiarebalbi.com/pt-br/blog/structured-concurrency-failure-semantics-runtimes
markdown: https://tiarebalbi.com/pt-br/blog/structured-concurrency-failure-semantics-runtimes.md
description: "Java 25, Kotlin, Swift e Python têm a mesma API de concorrência estruturada. Só o Python agrega cada falha. Armadilhas de cancelamento e exceção, comparadas."
author: "Tiarê Balbi Bonamini"
locale: pt-br
published: 2026-06-28
updated: 2026-06-28
category: "Engineering"
tags: ["concorrência-estruturada", "jep-505", "structuredtaskscope", "kotlin", "swift", "python", "asyncio", "jvm", "concorrência"]
translation: https://tiarebalbi.com/en/blog/structured-concurrency-failure-semantics-runtimes
---
# Concorrência Estruturada Parece Igual em Quatro Runtimes — Até um Filho Falhar

A concorrência estruturada chegou na conversa do mainstream Java em setembro de 2025, quando o Java 25 lançou a [JEP 505](https://openjdk.org/jeps/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](https://openjdk.org/jeps/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](https://openjdk.org/jeps/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:

```java
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:

```kotlin
coroutineScope {
    val user  = async { findUser() }
    val order = async { fetchOrder() }
    Response(user.await(), order.await())
}
```

Swift:

```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:

```python
async with asyncio.TaskGroup() as tg:
    user  = tg.create_task(find_user())
    order = tg.create_task(fetch_order())
# ambos aguardados no fim do bloco
```

Quatro 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](https://softwaremill.com/critique-of-jep-505-structured-concurrency-fifth-preview/), 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.

​

![](https://rso2zax703psuq0y.public.blob.vercel-storage.com/postscontent/1780889725782-ux8bdxkrrv.jpeg)

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](https://peps.python.org/pep-0654/), 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:

```python
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 `TaskGroup` do 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 `withThrowingTaskGroup` não consumido reporta sucesso enquanto um filho falhou. Itere com `for try await` mesmo 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-preview` até o Java 26 (sexto preview, [JEP 525](https://openjdk.org/jeps/525)), com um sétimo na fila para o JDK 27 ([JEP 533](https://openjdk.org/jeps/533)) que muda os parâmetros de tipo do `Joiner` e 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.
