Swift e Objective-C

[Swift avançado #2] actor do Swift: guia completo para evitar condições de corrida

actor é o quarto tipo do Swift e protege o próprio estado. Veja como serializar o acesso permite ao compilador detectar condições de corrida, incluindo a conhecida armadilha da reentrada.

5 min de leitura
Imagem de capa de [Swift avançado #2] actor do Swift: guia completo para evitar condições de corrida

O protagonista da segunda parte da série Swift Concurrency é actor, um quarto tipo, diferente de class e struct.

A necessidade de um novo tipo indica um problema sério. Vamos começar pelas condições de corrida.

Condições de corrida — o pior tipo de bug

As condições são claras: duas ou mais threads acessam a mesma memória ao mesmo tempo, e pelo menos um acesso escreve.

Quando isso ocorre, o comportamento é indefinido. Valores podem ser corrompidos, o app pode travar ou nada parecer acontecer.

O resultado depende da sorte do escalonamento naquele dia.

final class Counter {
    var value = 0
    func increment() { value += 1 }  // etapas de ler-somar-escrever, 3
}
// quando duas threads chamam simultaneamente increment()
// 1000mesmo que seja chamado valuepode não ser 1000

value += 1não é atômico. Se outra thread entrar entre ler, somar e escrever, o incremento desaparece.

Esse bug é perverso porque é difícil reproduzir. Depende do timing: os testes passam e, depois do lançamento, chegam relatos de travamentos intermitentes.

Na parte sobre tipos por valor, vimos que eles não são compartilhados e eliminam a premissa da corrida. Restam os tipos por referência, feitos para compartilhar.

A solução tradicional era usar locks, semáforos e uma DispatchQueue serial.

Todos funcionam, mas dependem totalmente da disciplina do desenvolvedor.

Basta esquecer o lock em um lugar; o compilador não consegue enxergar esse erro.

Esse cenário é familiar. Assim como a checagem de nil na parte de optionals e os ciclos de referência na parte de ARC, Swift leva ao sistema de tipos o que dependia de disciplina.

Agora é a vez das condições de corrida.

actor — o tipo que protege o próprio estado

Em uma frase, actor é um tipo por referência com proteção de estado integrada.

actor Counter {
    var value = 0
    func increment() { value += 1 }
}

Trocar class por actor garante que apenas uma execução por vez acesse suas propriedades armazenadas.

Cada actor tem um executor serial; as chamadas entram em fila e são processadas uma a uma. Nada interrompe os três passos de increment, então a corrida não ocorre.

O ponto central é a imposição pelo compilador. Fora do actor, acessar seu estado ou métodos exige await para compilar.

let counter = Counter()
await counter.increment()          // fora await obrigatório
print(await counter.value)

await existe pelo mesmo conceito de suspensão explicado na parte 1. Se o actor estiver ocupado, a solicitação entra na fila e cede sem prender uma thread.

Não é como um lock, que deixa a thread dormindo. A função é suspensa e retomada quando chega sua vez.

Dentro do actor, o código acessa tudo livremente sem await, pois já está isolado.

Essa fronteira entre dentro e fora é o isolamento apresentado na parte 1.

@MainActor é a versão global: transforma a thread principal em um contexto serial e isola o estado da UI.

O princípio é igual ao de um actor personalizado.

Diagrama comparando um cristal quebrado por acesso concorrente com acesso seguro em fila
Ao enfileirar o acesso concorrente, a corrida não ocorre

reentrada — a armadilha mais conhecida de actor

actor parece万能, mas tem uma armadilha de projeto: permite reentrada.

Ao encontrar await dentro de um método de actor, ele é suspenso e o actor fica disponível. Outra chamada pode entrar e executar nesse intervalo.

Quando o método original retoma, o estado do actor pode estar diferente de antes da suspensão.

actor ImageCache {
    var cache: [URL: Image] = [:]

    func image(for url: URL) async -> Image {
        if let cached = cache[url] { return cached }
        let image = await download(url)   // ponto de suspensão — outra chamada pode entrar nesse intervalo
        cache[url] = image                // o mesmo URLpode já estar armazenado
        return image
    }
}

Se duas solicitações para a mesma URL chegarem quase juntas, ambas veem cache miss e fazem o download. Os dados não se corrompem —o actor impede isso—, mas a lógica executa duas vezes.

A regra é: actor impede condições de corrida de baixo nível, mas não garante consistência lógica através de await.

Ainda é preciso rever as suposições de estado antes e depois de await. Aqui, guardar o Task de download em andamento no cache evita duplicidade.

Por quê? Proibir reentrada exigiria bloquear o actor durante await, possibilitando deadlock eterno entre dois actors que esperam um pelo outro.

Swift escolhe um sistema sem deadlock e deixa a consistência lógica para o desenvolvedor. Entender essa troca torna a armadilha previsível.

Uso prático — onde usar actor

actor serve como proprietário de estado mutável compartilhado por vários contextos assíncronos.

Caches, pools de conexão, gerenciadores de download e armazenamentos de sessão. Onde houver a pergunta “quem protege este estado?”, actor é candidato.

Também há casos inadequados. Primeiro: estado não compartilhado.

Para um view model usado em uma tela, uma classe @MainActor é adequada. Um actor personalizado só adiciona await desnecessários ao acesso à UI.

Segundo: dados imutáveis. Sem corrida, struct ou let bastam — prioridade para tipos por valor.

Terceiro: hot paths extremos. Atravessar o limite do actor custa serialização; centenas de milhares de chamadas por segundo pedem uma revisão do projeto.

Mais um ponto: actor pode ser exagero para proteger um único contador ou sinalizador atômico.

Nesses casos, uma ferramenta de baixo nível como Mutex, do módulo Synchronization do Swift 6, é mais leve e dispensa await. actor brilha quando estado e lógica formam um conjunto.

Ilustração de reentrada alterando o estado quando outra chamada entra durante await
Reentrada: outra chamada pode entrar durante await e alterar o estado

Resumo

  • Uma condição de corrida é comportamento indefinido causado por acesso concorrente e pelo menos uma escrita; é perversa porque não reproduz facilmente. Locks funcionam, mas dependem de disciplina.
  • actor é um tipo por referência com proteção integrada. Seu executor serializa o acesso, e o compilador exige await fora dele.
  • @MainActor transforma a thread principal em um actor global e é o padrão para isolamento de UI.
  • actor permite reentrada. O estado pode mudar através de await; a consistência lógica ainda precisa ser garantida manualmente.
  • Use actor como proprietário de estado mutável compartilhado. É exagero para estado não compartilhado, dados imutáveis e hot paths extremos.

A próxima parte aborda Sendable, a última peça do isolamento: tipos seguros ao cruzar limites e como interpretar os avisos de strict concurrency do Swift 6 no código existente.

Continue lendo