Quando @MainActor e TaskGroup não combinam no Swift 6
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
A armadilha do isolamento de actor em "só tira isso da main thread"
Conformidades a Codable sintetizadas pelo compilador também herdam o isolamento padrão em MainActor, e async let tem exigências mais rígidas que a task que você já tinha.
Fechando as lacunas de teste: lógica pura, um branch de mock inalcançável, e uma virada para Swift 6
Extraindo algoritmos para fora do alcance do MusicKit, um parâmetro de mock que era silenciosamente ignorado, e dois testes que vinham passando pelo motivo errado o tempo todo.
Correndo contra a main actor: quando o await é a condição de corrida
@MainActor elimina data races, não races de interleaving. Um token de geração, uma guarda contra falha desatualizada, e um teste de race determinístico sem sleeps.
Você também pode achar útil
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 maiseSIM 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 maisProton 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