Swift e Objective-C

[Swift avançado #4] Concorrência estruturada: por que evitar Task

A concorrência estruturada responde quem é responsável pelo fim das tarefas assíncronas. Este artigo resume por que tarefas filhas não podem sair do escopo pai, como sinais de cancelamento são propagados e quais garantias precisam ser recuperadas manualmente ao usar Task.detached.

6 min de leitura
Imagem de capa de [Swift avançado #4] Concorrência estruturada: por que evitar Task

Resta uma última pergunta para encerrar a série sobre Concurrency. Já vimos funções async (parte 1), actor (parte 2) e Sendable (parte 3).

Este conteúdo continua o artigo anterior Swift avançado #3.

Mas ainda não tratamos direito da porta de entrada para esse mundo assíncrono: Task. E, ao lidar com Task, surge o conceito mais prático de toda a série.

É a concorrência estruturada (structured concurrency).

O nome parece grandioso, mas a pergunta é simples: depois de iniciar uma tarefa assíncrona, quem é responsável por garantir que ela termine?

O que significa «estruturada»: tarefas também têm escopo

O nome vem do antigo termo programação estruturada. Depois da época em que goto podia lançar o fluxo de controle para qualquer lugar, os blocos if e for passaram a garantir que «o controle retorna ao ponto de onde saiu».

A concorrência estruturada aplica o mesmo princípio às tarefas assíncronas. Uma tarefa filha não pode sair do escopo do pai, e o pai só termina quando todas as filhas terminam.

O async let que vimos na parte 1 foi, na verdade, o primeiro exemplo desse princípio.

func loadDashboard() async throws -> Dashboard {
    async let profile = fetchProfile()    // tarefa filha 1
    async let feed = fetchFeed()          // tarefa filha 2
    return try await Dashboard(profile: profile, feed: feed)
}   // esta função não pode retornar antes que as duas tarefas filhas sejam limpas

As filhas criadas com async let precisam ser concluídas ou canceladas antes que a função retorne. O mesmo vale para exceções.

Se feed lançar um erro, profile, que ainda está em execução, receberá automaticamente o sinal de cancelamento. A função propaga o erro depois de concluir a limpeza.

Como as tarefas estão vinculadas ao escopo, não pode existir estruturalmente uma «tarefa iniciada e esquecida».

Na seção sobre closures, escaping era o rótulo de alerta para uma «closure que vive mais que a função». A concorrência estruturada remove por padrão as «tarefas que vivem mais que a função».

Quando o número de filhas é definido dinamicamente, usamos TaskGroup. Este é o código típico para receber uma lista de URL em paralelo.

let images = try await withThrowingTaskGroup(of: (URL, Image).self) { group in
    for url in urls {
        group.addTask { (url, try await download(url)) }
    }
    var result: [URL: Image] = [:]
    for try await (url, image) in group {
        result[url] = image
    }
    return result
}

Há dois pontos importantes. Os resultados chegam na ordem de conclusão (se a ordem precisar ser preservada, armazene-os com a chave, como acima), e todas as filhas do grupo são limpas quando a closure termina.

Se async let é a sintaxe para «algumas filhas», TaskGroup é a sintaxe para «n filhas»; a garantia de escopo é a mesma.

Cancelamento: o sinal chega, mas parar é responsabilidade sua

O segundo eixo da concorrência estruturada é a propagação do cancelamento. Quando o pai é cancelado, o sinal desce pela árvore até todos os descendentes.

Quando você sai de uma tela, as requisições de rede iniciadas por ela são canceladas em cadeia graças a essa estrutura.

Mas o cancelamento do Swift é cooperativo. O sinal de cancelamento não encerra a tarefa à força.

Ele apenas ativa o sinalizador isCancelled; verificar esse sinalizador e parar é responsabilidade da própria tarefa.

func processLargeFile() async throws {
    for chunk in chunks {
        try Task.checkCancellation()   // se foi cancelada CancellationErrorlança um erro
        await process(chunk)
    }
}

Por que não forçar o encerramento? Porque, se uma tarefa morrer enquanto grava um arquivo pela metade, mantém um lock ou está no meio de uma transação, o sistema ficará inconsistente.

A lógica do cancelamento cooperativo é: «só a própria tarefa sabe qual é um bom ponto para parar».

A implicação prática é clara: loops longos devem incluir checkCancellation.

As API do sistema, como URLSession, já respeitam o cancelamento internamente, então você pode confiar nelas.

Por outro lado, um cálculo longo que ignora o cancelamento continuará executando mesmo depois de cancelado. Não é que o cancelamento falhe: ele não foi verificado.

Diagrama do sinal de cancelamento que desce do pai para as filhas e de uma lista de verificação
O sinal de cancelamento desce pela árvore, mas só a tarefa que o verifica para

Task e Task.detached: o mundo não estruturado e seu custo

Se até aqui estávamos no mundo estruturado, Task { } fica fora dele. Uma tarefa criada com Task é não estruturada (unstructured): ela não entra na árvore pai-filho.

Ela não fica vinculada a um escopo, e erros e cancelamento também não são propagados automaticamente.

Então por que ela existe? Porque precisamos de uma ponte do mundo síncrono para o assíncrono.

Um handler de toque de botão é uma função síncrona e não pode usar await, então Task { await viewModel.refresh() }abre um novo contexto assíncrono. Esse é o lugar legítimo de Task.

O modificador .task do SwiftUI acrescenta gerenciamento do ciclo de vida a essa ponte (cancelamento automático quando a view desaparece), por isso ele tem prioridade na UI.

O problema surge quando Task vira hábito: tarefas abertas dentro de funções async ou lançadas como fire-and-forget. Você precisa recuperar manualmente todas as garantias da estrutura (aguardar a conclusão, propagar erros e encadear cancelamentos).

É preciso guardar referências, chamar cancel diretamente e registrar os erros por conta própria. Sem esse código de gerenciamento, surgem erros que desaparecem silenciosamente e tarefas zumbis.

Como regra: dentro de um contexto async, async let e TaskGroup são o padrão; Task deve ser usado apenas na fronteira síncrono→assíncrono.

Task.detached está um nível mais distante. Não herda prioridade, isolamento de actor nem valores task-local: é uma tarefa completamente órfã (SE-0304).

Às vezes é usado para «fazer trabalho pesado fora do contexto de @MainActor», mas quase sempre é uma solução equivocada. Basta declarar esse trabalho como uma função async nonisolated: ela será executada automaticamente no pool cooperativo (parte 1).

Os casos realmente necessários para detached são raros: tarefas em segundo plano que precisam ser intencionalmente independentes do contexto atual. Nas palavras da documentação oficial, é o último recurso (last resort).

Síntese da série: quatro conceitos que formam uma única imagem

Para encerrar a série sobre Concurrency, vou resumir todo o panorama de uma vez.

async/await trouxe o fluxo assíncrono de volta ao campo de visão do compilador (parte 1). actor incorporou proteção serial ao estado mutável compartilhado (parte 2), e Sendable verifica a segurança dos valores que atravessam limites de isolamento (parte 3).

A concorrência estruturada vinculou o ciclo de vida das tarefas ao escopo. O que começa precisa terminar, e o cancelamento flui pela árvore (nesta parte).

Uma frase atravessa os quatro conceitos: transformar a disciplina implícita da concorrência em estruturas explícitas da linguagem.

A disciplina de gerenciar threads virou um pool cooperativo e suspension; a de gerenciar locks virou actor. O conhecimento informal sobre «posso passar este objeto entre threads?» virou Sendable, e o comentário de revisão «não esqueça de limpar a requisição» virou uma árvore de tarefas.

O princípio de segurança em primeiro lugar, visto na parte 1 da série de filosofia do Swift, foi ampliado para a área mais difícil: a concorrência. Essa é a narrativa completa do Swift Concurrency.

Ilustração que contrasta tarefas estruturadas dentro de uma cerca com um Task não estruturado flutuando do lado de fora
Um Task fora da estrutura exige recuperar manualmente as garantias de conclusão, erros e cancelamento

Resumo

  • A concorrência estruturada estabelece que «tarefas filhas não podem sair do escopo pai». async let (quantidade fixa) e TaskGroup (quantidade dinâmica) são sua sintaxe; em caso de erro, o cancelamento das irmãs e a limpeza são automáticos.
  • O cancelamento é cooperativo. O sinal se propaga pela árvore, mas quem para é a própria tarefa que inclui checkCancellation.
  • Task { }é usado somente como ponte síncrono→assíncrono. O uso habitual de Task dentro de um contexto async transforma as garantias estruturais em dívida de gerenciamento manual. Task.detached é o último recurso.
  • A ideia central da série: a disciplina implícita de gerenciar threads, locks e ciclos de vida foi transferida para as estruturas da linguagem suspension, actor, Sendable e árvore de tarefas.

A partir da próxima parte, vamos às profundezas do desempenho. Trataremos de por que final é uma palavra-chave de desempenho, onde as chamadas de protocolo ficam lentas e o que realmente são despacho estático e despacho dinâmico.

Continue lendo

Fontes e verificação

  • SE-0304: Structured ConcurrencySwift Evolution · Padrão ou especificação · Consultado 17 de agosto de 2026Evidência: Estrutura pai-filho de Task e TaskGroup; herança de cancelamento, prioridade, valores task-local e contexto de actor