Pular para o conteúdo
Development

Quando @MainActor e TaskGroup não combinam no Swift 6

Por Victor Da Luz
iosswiftswift-concurrencydev-logdeep-cut-atlas

Esse app foi renomeado depois para Deep Cut Atlas. Abaixo ele é chamado de “Discoverer” o tempo todo, porque era assim que se chamava no dia em que isso aconteceu.

Tenho uma tela no meu app de música que é basicamente um grande diff. Ela percorre cada artista da sua biblioteca, pergunta ao Apple Music os lançamentos de cada artista, e mostra os que você ainda não tem. Pra uma biblioteca pequena isso é um punhado de chamadas de rede. Pra uma biblioteca de 200 artistas são 200, e disparar uma de cada vez faria a tela travar.

Então eu queria disparar várias de cada vez. Fácil, não? Divide os artistas em blocos, roda cada bloco concorrentemente com um task group, segue em frente. Escrevi o óbvio:

await withTaskGroup(of: [Release].self) { group in
    for artist in chunk {
        group.addTask { @MainActor in
            (try? await self.service.fetchReleases(for: artist)) ?? []
        }
    }
    // collect...
}

Meu service é uma classe @MainActor (ele mexe com MusicKit, que exige a main actor), então anotei o closure da task com @MainActor para combinar. Parece razoável. Não compila. E o erro não é o diagnóstico educado de sempre do Swift, é o compilador jogando a toalha:

pattern that the region-based isolation checker does not
understand how to check. Please file a bug
    group.addTask { @MainActor in
    ^

“Please file a bug” é uma leitura divertida no início do que você achava que seria uma tarefa de cinco minutos.

O que está realmente acontecendo

A pegadinha está no que o closure captura. group.addTask recebe um closure @Sendable, feito pra rodar no pool de threads cooperativo, então qualquer coisa capturada precisa ser segura pra atravessar limites de actor. Estou capturando self.service, que é um objeto não-Sendable, isolado na main actor.

Em teoria, marcar o closure com @MainActor deveria resolver isso: um closure na main actor capturando estado da main actor é seguro, porque tudo fica no mesmo actor. Na prática, o checker de isolamento baseado em regiões do Swift 6 não consegue seguir essa forma específica e desiste em vez de raciocinar sobre ela. Então não é que meu código esteja errado, é que o checker não consegue provar que está certo.

A correção: uma Task não estruturada

O que funciona é a ferramenta mais antiga e menos elegante: uma Task {} não estruturada.

private func fetchAll(_ artists: [Artist]) async -> [Release] {
    var out: [Release] = []
    for chunk in artists.chunked(into: 5) {
        let tasks = chunk.map { artist in
            Task { () -> [Release] in
                (try? await self.service.fetchReleases(for: artist)) ?? []
            }
        }
        for task in tasks { out.append(contentsOf: await task.value) }
    }
    return out
}

A diferença é pequena, mas é a história toda: uma Task {} criada dentro de um contexto @MainActor herda esse contexto. O corpo dela roda na main actor, então capturar self é seguro por construção, não há problema de sendability pra provar, porque nada sai do actor. TaskGroup.addTask não herda isolamento dessa forma, e é exatamente por isso que travou.

Mas espera, isso é mesmo concorrente?

Essa foi minha primeira preocupação. Se cada task roda na main actor, não estou apenas fazendo o mesmo trabalho serial com passos extras?

Não, e o motivo é a parte do async/await que é fácil de esquecer: await é um ponto de suspensão. Quando uma dessas tasks chega na chamada de rede e suspende, ela libera a main actor. A próxima task do bloco roda até seu próprio await, suspende, libera, e assim por diante. Então as cinco requisições de um bloco acabam todas em andamento ao mesmo tempo. A main actor só fica retida pra contabilidade barata entre suspensões, não pelos segundos gastos esperando a rede.

Isso também significa que o tamanho do bloco não é uma exigência do actor, é puramente um limite de educação pra eu não disparar 200 requisições nos servidores da Apple de uma vez e levar rate limit. Cinco de cada vez, depois as próximas cinco.

Mais uma surpresa de isolamento, nos testes

Esse projeto é construído com isolamento de actor padrão configurado pra MainActor, o que é cada vez mais comum em apps SwiftUI. Uma consequência que eu não esperava: structs simples e seus inicializadores também ficam isolados na main actor. Então uma suíte do Swift Testing que não está na main actor nem consegue construir meus tipos de modelo:

call to main actor-isolated initializer '...'
in a synchronous nonisolated context

A correção é uma linha só: anotar a struct de teste com @MainActor pra que ela viva no mesmo domínio de isolamento do código que está testando. Óbvio em retrospecto, mas é o tipo de coisa que te manda reler as definições dos seus modelos procurando um erro que não existe.

O que eu levo daqui

Concorrência estruturada é o padrão certo, e na maior parte do tempo um task group é o que você quer. Mas “a ferramenta moderna” e “a ferramenta que compila pra essa forma exata” nem sempre são a mesma coisa, e uma Task não estruturada não é um code smell, aqui ela foi a resposta mais limpa exatamente porque herda o contexto do actor.

E quando o checker do Swift 6 manda você abrir um bug, leve isso ao pé da letra: nem sempre é um veredito sobre o seu código. Às vezes só significa que você achou uma forma que ele ainda não sabe raciocinar, e existe um caminho perfeitamente correto a poucos caracteres de distância.

Leitura relacionada

Você também pode achar útil

Proton

Proton Mail

E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais
Airalo

eSIM Airalo

eSIM de dados local para viagens - sem necessidade de trocar um SIM físico.

Este é meu link de indicação da Airalo. Você recebe um desconto no seu primeiro eSIM e eu ganho crédito da Airalo para o meu.

Saiba mais
Proton

Proton Drive

Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.

Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).

Saiba mais