Swift e Objective-C

[Swift avançado #1] Como async/await funciona: não é a thread que para

No await, quem pausa é a função, não a thread. Explicamos o significado exato de suspension: a função armazena seu estado no heap e devolve a thread ao pool cooperativo, e por que await sequencial não é execução paralela.

6 min de leitura
Imagem de capa de [Swift avançado #1] Como async/await funciona: não é a thread que para

Nos artigos sobre tratamento de erros e closures, mencionei várias vezes a ressalva “depois de async/await”. Este é o artigo principal.

Este é o primeiro artigo da série Swift Concurrency que abre a série avançada. Começamos pelo que async/await realmente resolve e por qual princípio funciona.

A sintaxe em si pode ser aprendida em um dia: adicionar async à função e await à chamada.

A dificuldade vem depois. Perguntas como “a thread para no await?” e “em qual thread uma função async é executada?”

Sem respostas claras para essas perguntas, o código de concorrência continua sendo uma questão de decorar fórmulas.

O objetivo deste artigo é responder precisamente às duas perguntas.

O verdadeiro problema dos callbacks — não a indentação, mas a cegueira do compilador

Antes de async/await, o código assíncrono usava completion handlers, como vimos no artigo sobre closures.

A indentação do “inferno de callbacks” costuma ser apontada como problema, mas ela é apenas o sintoma. A raiz é que o compilador não consegue enxergar o fluxo.

func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
    fetchUser { result in
        switch result {
        case .success(let user):
            fetchAvatar(user.avatarURL) { avatarResult in
                // completion E se você esquecer de chamar?
            }
        case .failure(let error):
            completion(.failure(error))
        }
    }
}

Mesmo que você deixe de chamar completion em algum caminho, chame duas vezes ou use a thread errada, o compilador não diz nada.

Esse é o resultado de substituir o conceito básico de retorno da linguagem pela convenção de chamar uma closure. As garantias antes fornecidas pela linguagem—todo caminho retorna um valor e os erros são propagados—passaram a depender da disciplina do desenvolvedor.

async/await traz o código assíncrono de volta à jurisdição da linguagem.

func loadProfile() async throws -> Profile {
    let user = try await fetchUser()
    let avatar = try await fetchAvatar(user.avatarURL)
    return Profile(user: user, avatar: avatar)
}

Retorno é return e erro é throws. O compilador volta a verificar se todo caminho produz um valor ou lança um erro.

As regras de do-catch e propagação explicadas no artigo sobre erros continuam funcionando no código assíncrono. Não é apenas açúcar sintático; é recuperar a visão perdida do compilador.

O significado exato de suspension — quem para é a função, não a thread

Primeira pergunta: a thread para no await?

Não. A função pausa, e a thread é liberada para fazer outro trabalho.

Essa distinção é a frase mais importante de Swift Concurrency.

No ponto de await, a função armazena seu estado de progresso—variáveis locais e o ponto de execução—no heap, não na pilha, e devolve a thread que ocupava.

Isso é chamado de suspension. Quando o trabalho aguardado termina, a execução é retomada com o estado armazenado.

Não há garantia de que a retomada use a mesma thread. As linhas antes e depois de await na mesma função podem executar em threads diferentes.

Graças a esse design, o runtime de concorrência do Swift usa um pool cooperativo de threads. Ele cria aproximadamente tantas threads quanto núcleos de CPU, e as funções cedem essas threads umas às outras a cada await.

Esse é o contraste com a época do GCD (Grand Central Dispatch), em que centenas de threads eram criadas e colocadas para dormir (blocking). Como as threads não dormem, explosões de threads e trocas de contexto desperdiçadas são reduzidas estruturalmente.

Daqui surge uma regra prática: não bloqueie nas threads do pool cooperativo.

Esperar em um semáforo ou chamar sleep faz uma thread inteira do pool, projetado para ceder, dormir. Com 4 núcleos e cerca de 4 threads, fazer uma dormir equivale a parar um quarto do sistema.

“await cede; block é proibido” é a primeira regra do pool cooperativo.

Ilustração de uma função guardando seu estado em uma bolsa no ponto await e devolvendo a thread
No await, a função arruma suas coisas e a thread é devolvida para outro trabalho

Em qual thread ele roda — pergunte sobre isolamento, não sobre threads

Segunda pergunta: em qual thread uma função async é executada?

A resposta de Swift Concurrency é “abandone essa pergunta”. Threads são recursos gerenciados pelo runtime; o desenvolvedor especifica o isolamento, ou seja, “a qual contexto de execução serial este código pertence?”

O isolamento mais conhecido é MainActor. Funções e tipos marcados com @MainActor têm execução garantida na thread principal.

Isso é diferente de espalhar DispatchQueue.main.async pelo código de atualização da UI por intuição. O requisito “somente na principal” é declarado no sistema de tipos e verificado pelo compilador.

UIViewController e SwiftUI View já são declarados com @MainActor. O código de uma view recebe isolamento principal automaticamente.

Por outro lado, uma função async sem indicação de isolamento não pertence a nenhum actor específico (nonisolated) e é executada em algum ponto do pool cooperativo.

Para tirar um cálculo pesado da thread principal, você não precisa escrever código que o “envie para uma thread em segundo plano”. Em Swift, declare que o trabalho não pertence ao isolamento de MainActor.

Quando necessário, abra um novo contexto assíncrono com Task. (O uso estruturado de Task é abordado no artigo 4 desta série.)

A linguagem também fornece uma ponte para APIs de callback existentes. withCheckedThrowingContinuation permite encapsular uma API de callback como uma função async.

Chamar resume de continuation exatamente uma vez é o contrato, e a versão “Checked” detecta violações em runtime. É a primeira ferramenta de bridging que você deve dominar ao lidar com SDKs legados.

A armadilha do await sequencial — concorrência não é de graça

O código loadProfile acima contém uma armadilha de desempenho. E se a outra requisição não tiver relação com fetchUser?

// Sequencial: o avatar só começa depois que o banner termina (total de 2s)
let avatar = try await fetchAvatar()   // 1s
let banner = try await fetchBanner()   // 1s

await significa “esperar aqui”, então, escrito assim, as duas requisições ficam enfileiradas sequencialmente. Se as tarefas não dependem umas das outras, o correto é iniciá-las simultaneamente com async let.

// Concorrente: as duas requisições rodam juntas (total de 1s)
async let avatar = fetchAvatar()
async let banner = fetchBanner()
let profile = try await Profile(avatar: avatar, banner: banner)

async/await não paraleliza automaticamente. Escolher entre execução sequencial e concorrente continua sendo responsabilidade do designer.

A evolução é que essa escolha virou uma diferença de uma linha de sintaxe, em vez de composição de callbacks. O uso sistemático de async let e TaskGroup é tratado em detalhes no artigo sobre concorrência estruturada.

Diagrama de tempo comparando 2 segundos com await sequencial e 1 segundo com async let
await não paraleliza automaticamente; inicie tarefas independentes simultaneamente com async let

Resumo

  • O verdadeiro problema dos callbacks não era a indentação, mas a cegueira do compilador. async/await devolve retornos e propagação de erros à jurisdição da linguagem, restaurando as verificações do compilador.
  • No await, quem para é a função, não a thread. O estado da função é armazenado no heap, a thread é devolvida e não há garantia de retomada na mesma thread.
  • O runtime é um pool cooperativo de threads. Por isso, bloquear dentro dele—com semáforos ou sleep—é proibido.
  • A pergunta não é “em qual thread?”, mas “em qual isolamento?”. A UI é garantida por declarações @MainActor, e APIs de callback são encapsuladas com continuations.
  • await não paraleliza automaticamente. Inicie tarefas independentes simultaneamente com async let.

O próximo artigo trata do núcleo desta série: actor. Ele explica exatamente o que é uma condição de corrida de dados, como actor a transforma em um conceito de tempo de compilação e a famosa armadilha de reentrancy.

Continue lendo