MÓDULO 04 · CONCEITO 05 DE 14

Structured concurrency

O argumento de Smith em 2018 que mudou como linguagens expõem concorrência. TaskGroup, errgroup, structured task scope — escopos léxicos, erros que não somem, cancelamento que propaga.

Tempo de leitura ~22 min Pré-requisito Conceitos 03 e 04 Próximo Goroutines e o modelo CSP

Em abril de 2018, o desenvolvedor Nathaniel J. Smith — então mantenedor da biblioteca Trio para concorrência em Python — publicou no blog pessoal um ensaio com título provocativo: Notes on Structured Concurrency, or: Go statement considered harmful. O paralelo com o famoso Goto Statement Considered Harmful que Edsger Dijkstra publicara em 1968 era deliberado. Smith argumentava que a primitiva go de Go — e os equivalentes em outras linguagens (asyncio.create_task, Thread.start, setTimeout) — sofrem dos mesmos defeitos estruturais que o goto: permitem criar fluxos de controle que pulam para fora do escopo léxico, tornando difícil ou impossível raciocinar sobre quando uma operação termina, onde os erros aparecem, e como cancelar operações pendentes.

O paralelo provocou desconforto. Goroutines são uma das primitivas mais celebradas da última década; chamá-las de "harmful" parecia heresia. Mas o argumento de Smith era preciso: não é a concorrência que está errada — é a forma como ela é exposta sintaticamente. Um go func() { ... }() em Go produz uma goroutine que vive fora do escopo da função que a criou. Se essa função retorna, a goroutine continua. Se a goroutine crasha, o pânico fica perdido a menos que alguém esteja explicitamente recuperando. Se você quer cancelar a goroutine, precisa passar um context.Context manualmente e esperar que toda a stack de chamadas honre o cancelamento. Tudo isso é resolvível, mas exige disciplina; o sistema de tipos e a estrutura léxica não ajudam.

O ensaio defendia uma alternativa elegante: amarrar concorrência ao escopo léxico, da mesma forma que blocos { } e funções amarram fluxos de controle síncronos. Você abre um nursery (Trio chamou assim, hoje a maioria diz "task group" ou "task scope"); dentro dele você inicia tarefas concorrentes; quando o nursery fecha, garante-se que todas as tarefas iniciadas terminaram, com sucesso ou com erro. Erros propagam para fora do nursery como exceções normais. Cancelamento de uma propaga para todas. Ninguém vaza.

Em 2026, structured concurrency é mainstream. Python asyncio ganhou TaskGroup em 3.11 (2022); Java Loom trouxe StructuredTaskScope em JDK 21 (2023); Kotlin coroutines têm structured concurrency embutida por design desde o lançamento estável (2018); Swift 5.5 (2021) introduziu async let e withTaskGroup. Em Go, o pacote errgroup de golang.org/x/sync oferece estrutura semelhante. C# tem suporte parcial via Task.WhenAll e Parallel.ForEachAsync. Esse conceito explora o que cada um entrega e por que vale adotar.

O que é "estruturado" — o paralelo com o goto

Antes de Dijkstra, a maioria das linguagens de programação tinha goto como primitiva primária de controle. Você podia pular de qualquer lugar para qualquer outro, e o programa virava um emaranhado difícil de seguir. A revolução da programação estruturada (Böhm-Jacopini 1966, Dijkstra 1968) foi mostrar que blocos aninhados, condicionais (if/else) e laços (while/for) bastam para expressar qualquer computação — e que código com essas estruturas é fundamentalmente mais fácil de raciocinar. Hoje, goto existe em poucas linguagens e é raramente usado mesmo onde existe.

Smith argumenta que a concorrência viveu por décadas no estado pré-estruturado. go func() {...}() em Go, como asyncio.create_task(coro) em Python, ou new Thread(...).start() em Java, são equivalentes a goto: criam um fluxo de execução que pula para fora do escopo léxico atual e continua de forma independente. Como goto, é poderoso. Como goto, gera código difícil de seguir. A pergunta "todas as tarefas iniciadas nesta função terminaram quando a função retorna?" não tem resposta sem inspecionar o código todo — equivalente a "este programa termina aqui?" em mundo com goto.

Structured concurrency aplica o mesmo princípio: tarefas concorrentes vivem dentro de blocos léxicos. Quando o bloco termina, todas as tarefas iniciadas dentro dele terminaram. Não tem mais "task que continua depois da função pai retornar". A estrutura léxica passa a refletir a estrutura de execução concorrente.

# Não-estruturado (Python pré-3.11 idiomático)
async def consultar_tudo():
    task_a = asyncio.create_task(api_a.consultar())
    task_b = asyncio.create_task(api_b.consultar())
    # Se exceção acontece aqui, task_a e task_b ficam órfãs.
    # Se a função retorna sem aguardar, idem.
    return await task_a, await task_b

# Estruturado (Python 3.11+)
async def consultar_tudo():
    async with asyncio.TaskGroup() as tg:
        task_a = tg.create_task(api_a.consultar())
        task_b = tg.create_task(api_b.consultar())
    # Saiu do bloco: garantido que ambas terminaram.
    # Exceção em qualquer uma cancela a outra e propaga ExceptionGroup.
    return task_a.result(), task_b.result()

A diferença sintática é pequena. A diferença semântica é grande. No segundo, o bloco async with garante por construção que todas as tarefas terminaram antes de você ler os resultados — você não precisa lembrar de fazer a limpeza manualmente. Erros não somem. Cancelamento se propaga.

O que se ganha — quatro propriedades essenciais

Structured concurrency entrega quatro propriedades que sistemas não-estruturados precisam reconstruir caso a caso. Vale conhecer cada uma porque elas são exatamente os pontos onde sistemas maduros costumam falhar.

1. Escopo léxico: tarefas vivem em blocos

A função pai não retorna até todas as tarefas filhas terminarem. Se você vê código que abre um TaskGroup em uma função, você sabe — sem inspecionar mais nada — que quando essa função sair, nada concorrente que ela disparou continuará pendente. Para revisar código concorrente, isso é transformacional. Você pode raciocinar localmente.

2. Erros que não somem

Em modelo não-estruturado, quando uma task dispara exceção, a exceção fica armazenada no objeto Task; se ninguém faz await nela, a exceção é silenciosamente descartada (com warning, no melhor caso). Em structured, qualquer exceção em qualquer task é propagada para fora do bloco — você não pode esquecer. Em Python 3.11+, ExceptionGroup agrega múltiplas exceções de tarefas que falharam em paralelo. Em Java Loom, StructuredTaskScope oferece políticas configuráveis (ShutdownOnFailure, ShutdownOnSuccess) que decidem o comportamento coletivo.

3. Cancelamento que propaga

Cancelamento é tema de seu próprio conceito (13), mas é central em structured concurrency. Cancelar o escopo cancela automaticamente todas as tarefas filhas. Cancelar uma tarefa filha pode (dependendo da política) cancelar suas irmãs, ou só ela mesma. A propagação hierárquica imita escopo léxico: tudo que existe dentro do bloco é cancelado quando o bloco é cancelado. Sem structured, propagação de cancelamento é trabalho manual repetido em cada estrutura concorrente.

4. Resources e cleanup

Tarefas concorrentes frequentemente usam resources (conexões, arquivos, locks). Em modelo não-estruturado, garantir cleanup em toda a árvore de tarefas é proverbialmente difícil — uma task que falha pode deixar resource aberto que só será limpo na garbage collection. Structured, ao garantir terminação no fim do bloco, simplifica enormemente o cleanup: async with do recurso pode envolver o async with do task group, e a ordem de fechamento fica óbvia.

princípio orientador

Estrutura léxica deve refletir estrutura de execução. Se você vê uma função, espera que tudo que ela inicia termine antes dela retornar. Structured concurrency é simplesmente a aplicação consistente desse princípio à concorrência.

Trio — a referência canônica em Python

A biblioteca Trio, criada por Smith em 2017, antes do TaskGroup entrar no asyncio padrão, é a implementação mais limpa e didática de structured concurrency em uso. Vale olhar mesmo que você nunca vá usá-la diretamente.

import trio

async def buscar_url(url):
    print(f"buscando {url}")
    # ... lógica real
    return f"resultado de {url}"

async def buscar_todas(urls):
    resultados = {}
    # 'nursery' é o termo do Trio para task scope.
    async with trio.open_nursery() as nursery:
        async def buscar_e_guardar(u):
            resultados[u] = await buscar_url(u)
        for url in urls:
            nursery.start_soon(buscar_e_guardar, url)
    # Aqui, garantido: todas as tarefas terminaram.
    # Se qualquer uma falhou, ExceptionGroup foi levantado e nem chegamos aqui.
    return resultados

trio.run(buscar_todas, ["a.com", "b.com", "c.com"])

Note duas escolhas de design. Primeira: open_nursery() é um context manager — sintaxe igual a abrir arquivo. Segunda: start_soon não retorna a task; o uso normal é "dispare e esqueça, dentro do nursery". Você pode pegar referência se precisar, mas o caminho fácil é o estruturado. Smith argumentou que API design importa: tornar o caminho estruturado o mais fácil força adoção do padrão correto.

O asyncio.TaskGroup da stdlib seguiu de perto o modelo de Trio quando entrou em Python 3.11 (PEP 654 introduziu ExceptionGroup para suportar múltiplos erros agregados). Em código novo de 2022 em diante, vale preferir TaskGroup sobre asyncio.gather sempre que possível — as garantias estruturadas valem o pequeno custo sintático.

Java Loom — structured concurrency para a JVM

Java passou décadas com a API de threads tradicional (1:1, kernel threads), e qualquer concorrência interessante precisava de ExecutorService com cuidado manual. Em junho de 2023, JDK 21 lançou virtual threads (Project Loom) e — junto — uma API preview de structured concurrency: StructuredTaskScope. Em 2026, a API ainda é preview, mas amplamente adotada em projetos novos modernos.

// Java 21+ (preview API)
import java.util.concurrent.StructuredTaskScope;

Resultado consultarTudo() throws Exception {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        var tarefaA = scope.fork(() -> apiA.consultar());
        var tarefaB = scope.fork(() -> apiB.consultar());

        scope.join();              // espera todas
        scope.throwIfFailed();     // propaga primeira falha

        return new Resultado(tarefaA.get(), tarefaB.get());
    }
    // try-with-resources fecha o scope:
    // garante que todas as tarefas terminaram (cancelando se necessário).
}

Java oferece duas políticas built-in. ShutdownOnFailure: se qualquer tarefa falha, todas as outras são canceladas imediatamente — apropriado para "preciso de todos os resultados, não vale a pena continuar se um falhou". ShutdownOnSuccess: retorna assim que qualquer tarefa termina com sucesso, cancela as outras — apropriado para "qualquer um destes serviços serve; vou com o primeiro que responder". Você pode estender criando políticas próprias.

Combinado com virtual threads, structured concurrency em Loom é especialmente potente. scope.fork(...) cria uma virtual thread (custo trivial); cancelamento se propaga hierarquicamente; e o limite prático de tarefas concorrentes sobe para milhões. JEP 453 detalha a motivação e o design.

Go errgroup — quase estruturado

Go não tem structured concurrency como primitiva sintática, mas o pacote golang.org/x/sync/errgroup implementa uma versão pragmática que cobre a maioria dos casos. É o padrão idiomático em Go moderno para qualquer código com mais de uma goroutine relacionada.

import "golang.org/x/sync/errgroup"

func consultarTudo(ctx context.Context) (Resultado, error) {
    g, ctx := errgroup.WithContext(ctx)
    var ra, rb Resposta

    g.Go(func() error {
        var err error
        ra, err = apiA.Consultar(ctx)
        return err
    })

    g.Go(func() error {
        var err error
        rb, err = apiB.Consultar(ctx)
        return err
    })

    if err := g.Wait(); err != nil {
        return Resultado{}, err
    }
    return Resultado{ra, rb}, nil
}

Algumas observações importantes. errgroup.WithContext cria um context filho que é cancelado quando qualquer goroutine retorna erro — o cancelamento se propaga para as outras goroutines via ctx que elas devem honrar. g.Wait() espera todas e retorna o primeiro erro observado. A estrutura é "estruturada por convenção": o pattern é claro, mas o compilador não impede que você inicie goroutine fora do g e cause vazamento. Em Java, Python e Trio, o sistema de tipos faz parte do trabalho; em Go, fica por conta da disciplina.

Kotlin — structured concurrency by design

Kotlin coroutines, lançadas estáveis em 2018, foram desenhadas desde o início com structured concurrency como princípio central — em larga medida pela influência do trabalho de Smith. Em Kotlin, toda coroutine vive em um CoroutineScope; cancelar o scope cancela tudo dentro. Isso aparece em frameworks como Android (cada Activity tem seu scope, ciclo de vida limpo automático) e Spring (controllers têm scope da request).

// Kotlin
suspend fun consultarTudo(): Resultado = coroutineScope {
    val tarefaA = async { apiA.consultar() }
    val tarefaB = async { apiB.consultar() }
    Resultado(tarefaA.await(), tarefaB.await())
}
// coroutineScope { ... } só retorna após todas as filhas terminarem.
// Se qualquer uma lançar, as outras são canceladas, exceção propaga.

Kotlin foi pioneira em fazer structured concurrency uma feature central, não um add-on. Para entender o estilo idiomático moderno em concorrência, vale estudar Kotlin coroutines mesmo sem usar a linguagem.

Por que importa: tasks órfãs e bugs invisíveis

A motivação concreta de Smith para structured concurrency veio de bugs reais em sistemas Python/Trio. O padrão recorrente: uma task é iniciada e ninguém aguarda; a exceção dela some no log; o sistema "não funciona" sem mensagem clara. Em servidores web, isso aparece como request que silenciosamente não atualiza um campo; em scrapers, como item que silenciosamente não é processado; em pipelines, como dado que silenciosamente não chega ao destino.

O caso clássico em asyncio pre-3.11:

async def processar(item):
    asyncio.create_task(persistir(item))   # fire-and-forget
    asyncio.create_task(notificar(item))   # fire-and-forget
    return "ok"
# Se persistir() ou notificar() lançar, o erro vai para o log
# como "Task exception was never retrieved" — fácil de ignorar.
# Pior: se você fizer o GC limpar a task antes dela rodar, ela some.

A versão estruturada elimina o bug por construção:

async def processar(item):
    async with asyncio.TaskGroup() as tg:
        tg.create_task(persistir(item))
        tg.create_task(notificar(item))
    # Aqui ambas terminaram. Se qualquer uma falhou, ExceptionGroup.
    return "ok"

Para sistemas em produção, a diferença não é estética. É a diferença entre erros que aparecem e erros que somem.

O mesmo padrão nas três linguagens

Para concretizar, considere um padrão comum: chamar três APIs em paralelo, agregar resultados, falhar se qualquer uma falhar. Veja como cada linguagem expressa isso de forma estruturada.

C# — Task.WhenAll com cancelamento (parcialmente estruturado)
using System.Threading;
using System.Threading.Tasks;

async Task<Resultado> ConsultarTudoAsync(CancellationToken ct = default) {
    using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);

    Task<Resposta> tarefaA = ApiA.ConsultarAsync(cts.Token);
    Task<Resposta> tarefaB = ApiB.ConsultarAsync(cts.Token);
    Task<Resposta> tarefaC = ApiC.ConsultarAsync(cts.Token);

    try {
        await Task.WhenAll(tarefaA, tarefaB, tarefaC);
    } catch {
        cts.Cancel();           // cancela as outras se uma falhar
        throw;
    }

    return new Resultado(
        await tarefaA, await tarefaB, await tarefaC);
}

C# não tem ainda primitiva de structured concurrency formal; Task.WhenAll + CancellationTokenSource aproximam o padrão. Parallel.ForEachAsync (.NET 6+) é uma forma mais estruturada para coleções. Existe discussão sobre adicionar API explícita em versões futuras do .NET, ainda sem deadline público em 2026.

Python — asyncio.TaskGroup (Python 3.11+)
import asyncio

async def consultar_tudo() -> Resultado:
    async with asyncio.TaskGroup() as tg:
        ta = tg.create_task(api_a.consultar())
        tb = tg.create_task(api_b.consultar())
        tc = tg.create_task(api_c.consultar())
    # Saiu do bloco: todas terminaram.
    # Se qualquer uma falhou, ExceptionGroup foi levantado.
    return Resultado(ta.result(), tb.result(), tc.result())

# Captura: try/except* para ExceptionGroup
try:
    res = asyncio.run(consultar_tudo())
except* ConnectionError as eg:
    for e in eg.exceptions:
        print(f"erro de conexão: {e}")

TaskGroup é a primitiva idiomática em Python moderno. Dois detalhes importantes: (1) try/except* (Python 3.11+) é a sintaxe para capturar exceções específicas dentro de um ExceptionGroup; (2) cancelamento propaga automaticamente — se api_a levantar, api_b e api_c são canceladas imediatamente.

Go — errgroup com context
package main

import (
    "context"
    "golang.org/x/sync/errgroup"
)

func consultarTudo(ctx context.Context) (Resultado, error) {
    g, ctx := errgroup.WithContext(ctx)
    var ra, rb, rc Resposta

    g.Go(func() error {
        var err error
        ra, err = apiA.Consultar(ctx)
        return err
    })
    g.Go(func() error {
        var err error
        rb, err = apiB.Consultar(ctx)
        return err
    })
    g.Go(func() error {
        var err error
        rc, err = apiC.Consultar(ctx)
        return err
    })

    if err := g.Wait(); err != nil {
        return Resultado{}, err
    }
    return Resultado{ra, rb, rc}, nil
}

errgroup.WithContext dá um context que é cancelado quando qualquer goroutine retorna erro — o cancelamento se propaga para as outras desde que elas honrem ctx. g.Wait() retorna o primeiro erro. Convenção forte, sem garantia compilada; uma goroutine iniciada fora do g não é rastreada.

Quando structured concurrency atrapalha

Structured concurrency cobre 90% dos casos onde concorrência é útil. Mas há cenários onde a estrutura não cabe naturalmente — vale conhecê-los para não tentar forçar.

Tasks com vida útil maior que o caller

Worker que vive enquanto o servidor estiver de pé; pool de conexões que sobrevive a múltiplas requests; tarefas em background que executam periodicamente. Esses casos têm escopo maior que qualquer função; o task scope correspondente fica no nível da aplicação inteira. Em Python, isso vira um único TaskGroup no main que envolve a vida do processo. Em Go, idem — uma errgroup ao nível mais alto. A estrutura ainda existe, só que é maior.

Coordenação dinâmica

Sistemas onde tarefas se criam mutuamente em tempo de execução (workers que disparam workers, recursão dinâmica, fan-out indeterminado) podem ficar awkward dentro de structured. TaskGroup aceita criação dinâmica (tg.create_task a qualquer hora dentro do bloco), mas se a árvore for muito profunda ou mutável, a estrutura léxica vai ficando difícil. Padrões: passar o TaskGroup para baixo da call stack (Python aceita, embora Trio recomende contra), ou usar canais para comunicação em vez de criação dinâmica.

APIs callback-based legadas

Se você precisa integrar com uma API antiga que aceita callback e não tem versão async/await ou Future-like, structured concurrency não te ajuda diretamente. O caminho é envolver a API em uma camada async (asyncio.Future manual, TaskCompletionSource em C#) — depois de envolvida, ela cabe no padrão estruturado.

armadilha em adoção

Migrar de asyncio.gather para asyncio.TaskGroup requer atenção: o comportamento em caso de erro é diferente. gather (com return_exceptions=False, default) cancela as outras quando uma falha mas não as agrega corretamente em log; TaskGroup levanta ExceptionGroup que precisa ser capturado com except*. Código antigo de captura de exceção pode silenciosamente deixar de funcionar. Teste cobertura de erro depois de migrar.

O futuro — structured async em todo lugar

A direção da indústria é clara. Linguagens novas (Swift, Zig) têm structured concurrency desde o design. Linguagens antigas estão adicionando: Java em 2023, Python em 2022, .NET em discussão. Mesmo Go, que historicamente resistiu a "abstrações sobre goroutines", incorporou errgroup no x/sync e tem propostas internas de structured task groups discutidas em conferences.

A lição mais ampla é a clássica de Dijkstra: estrutura léxica ajuda raciocínio; primitivas que furam essa estrutura precisam de boa justificativa para sobreviver. Goroutine como goto é provocação útil porque expõe que muito do que aceitamos como "modelo de concorrência" é, no fundo, um retrocesso para o pré-estruturado. Structured concurrency é simplesmente a aplicação consistente da programação estruturada ao domínio concorrente.

Como praticar

  1. Migre código de asyncio.gather para TaskGroup. Pegue um pedaço de código Python async que use gather. Reescreva com TaskGroup. Documente o que mudou em comportamento de erro. Force uma das tarefas a falhar e veja como ExceptionGroup aparece. Capture com except*.
  2. Construa um rate limiter usando structured concurrency. Em qualquer linguagem com TaskGroup/errgroup. Limite de N requisições simultâneas; quando uma termina, próxima inicia. Garanta limpeza correta no fim. Bonus: adicione cancelamento externo (timeout do batch inteiro).
  3. Compare comportamento de erro entre dois modelos. Em Python, escreva o mesmo programa duas vezes: uma com asyncio.create_task sem await (não-estruturado), outra com TaskGroup. Em ambas, faça uma das tarefas levantar exceção. Compare: o que aparece no log? O programa termina silenciosamente ou com erro claro? Documente o que isso implica para um sistema em produção.

Referências para aprofundar

  1. artigo Notes on Structured Concurrency, or: Go statement considered harmful — Nathaniel J. Smith (2018). vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful — O ensaio que fundou o movimento. Lê-se em meia hora; muda como você pensa em concorrência.
  2. artigo Timeouts and Cancellation for Humans — Nathaniel J. Smith (2018). vorpus.org/blog/timeouts-and-cancellation-for-humans — Companion ao ensaio anterior. Detalha por que cancelamento estruturado é necessário e como implementar bem.
  3. artigo Coroutines and Structured Concurrency — Roman Elizarov (Kotlin Conf 2019). elizarov.medium.com — Elizarov é arquiteto de Kotlin coroutines. Ensaio com perspectiva de implementador sobre por que structured é o caminho.
  4. artigo Structured Concurrency in Java — Ron Pressler (2022). inside.java/2022/06/14/state-of-loom-2 — Justificativa e design da API de Loom. Conecta com este conceito diretamente.
  5. docs Trio — Tutorial & design rationale. trio.readthedocs.io — A documentação do Trio é parte tutorial, parte ensaio sobre por que structured é o modelo certo. Mesmo sem usar Trio, vale ler.
  6. docs Python — asyncio.TaskGroup. docs.python.org/3/library/asyncio-task.html#task-groups — Documentação oficial do TaskGroup e ExceptionGroup. Curta e precisa.
  7. docs JEP 453 — Structured Concurrency (Preview). openjdk.org/jeps/453 — Proposta formal da API de Loom. Inclui motivação, exemplos e comparação com alternativas.
  8. docs PEP 654 — Exception Groups and except*. peps.python.org/pep-0654 — A PEP que tornou TaskGroup viável em Python. Detalha o desafio de agregar múltiplas exceções de tarefas concorrentes.
  9. docs Kotlin Coroutines — Structured Concurrency. kotlinlang.org/docs/coroutines-basics.html — Tutorial oficial. Mostra como o conceito permeia o design da linguagem.
  10. docs errgroup package documentation. pkg.go.dev/golang.org/x/sync/errgroup — Documentação curta, exemplos práticos. A primitiva idiomática em Go moderno.
  11. livro Programming Concurrency on the JVM — Venkat Subramaniam (2011, com revisões). Bom panorama de concorrência em Java/Scala/Clojure pré-Loom; útil para apreciar o salto que Loom representou.
  12. vídeo Structured Concurrency — Java 21 — José Paumard. YouTube. Walk-through prático da API de StructuredTaskScope com todos os modos de política. Quarenta minutos densos e claros.