Correndo contra a main actor: quando o await é a condição de corrida
Esse app foi renomeado depois para Deep Cut Atlas. Abaixo ele é chamado de “Discoverer” o tempo todo, porque foi assim que se chamava no dia em que isso aconteceu.
Tudo no meu view model roda na main actor. Uma thread só, sem locks, sem data races. Então eu presumi que não havia races de jeito nenhum.
Tipo errado de race.
O problema
Minha aba History pagina pelas faixas tocadas recentemente. A revisão sinalizou isso, e agora era hora de corrigir. Dois métodos importam:
func loadMore() async {
guard hasMore, !isLoadingMore, state == .loaded else { return }
isLoadingMore = true
defer { isLoadingMore = false }
let page = try await service.fetchRecentlyPlayed(offset: tracks.count, limit: pageSize)
tracks.append(contentsOf: page)
// ...
}
E um pull-to-refresh que busca o offset 0 e substitui tracks.
Ache o bug: loadMore lê tracks.count (digamos, 8) e depois suspende no await. A main actor fica livre agora. O usuário faz pull-to-refresh; ele termina e reseta tracks para uma página nova de 8. Aí o loadMore antigo retoma e anexa a página que buscou no offset antigo à lista nova. Num feed que muda (é recently-played, muda toda vez que você toca alguma coisa), isso vira linhas duplicadas, linhas faltando, ou os dois.
A flag isLoadingMore que eu já tinha? Ela só impede o loadMore de competir consigo mesmo. Não diz nada sobre o refresh.
Essa é a parte da concorrência do Swift que mais demorou para eu internalizar: @MainActor elimina data races, não races de interleaving. Todo await é uma porta. Enquanto você está suspenso, qualquer um pode entrar e rearranjar os móveis.
A correção: um token de geração
Considerei cancelar o load-more em andamento quando um refresh começa, mas o view model nunca é dono dessas Tasks: o SwiftUI as cria a partir de .task e .refreshable. Em vez disso, seis linhas de concorrência otimista:
private var generation = 0
private func fetchFirstPage() async {
generation += 1 // any in-flight work is now stale
let gen = generation
let page = try await service.fetchRecentlyPlayed(offset: 0, limit: pageSize)
guard gen == generation else { return } // ...including me, if superseded
tracks = page
// ...
}
func loadMore() async {
// ...
let gen = generation
let offset = tracks.count // captured together, atomically (no await between)
do {
let page = try await service.fetchRecentlyPlayed(offset: offset, limit: pageSize)
guard gen == generation else { return } // stale page: discard
tracks.append(contentsOf: page)
} catch {
guard gen == generation else { return } // stale FAILURE: also discard
hasMore = false
}
}
A regra é “quem escreve por último e atrasado, perde”. A linha sutil é a que está no catch: sem ela, um load-more desatualizado que falhou definiria hasMore = false e mataria silenciosamente a paginação numa lista à qual ele nunca pertenceu.
Testando sem sleeps
A race só existe no meio da suspensão, e meu mock service retorna de forma síncrona. Testes baseados em Task.sleep são uma roleta de timing. O que funcionou: dar ao mock um hook aguardável (construindo em cima da seam de injeção de erro), e estacioná-lo numa continuation.
// in the mock
var onFetchRecentlyPlayed: (() async -> Void)?
// in the test
let gate = FetchGate() // wait() parks on a continuation
service.onFetchRecentlyPlayed = { await gate.wait() }
let loadMore = Task { await vm.loadMore() }
while !gate.entered { await Task.yield() } // it's parked mid-fetch now
service.onFetchRecentlyPlayed = nil
await vm.refresh() // resets list, bumps generation
gate.release()
await loadMore.value
#expect(vm.tracks.map(\.id) == firstPageIDs) // stale page discarded
O interleaving é forçado, não é uma corrida: o teste é determinístico e roda num milissegundo. Também fiz uma checagem de sanidade contra o código sem a correção, de cabeça: o load-more estacionado anexaria depois do refresh e a assertion falharia. Um teste de regressão que não consegue falhar não é um teste de regressão.
Lições
- Single-threaded não significa livre de races. Todo
awaitnuma actor é um ponto onde seus invariantes podem ser invalidados. Releia o que você capturou antes da suspensão. - Guardas como
isLoadingMoreprotegem um método dele mesmo. Races vivem entre métodos; coordene-os explicitamente. - Um token de geração é a coordenação mais barata que existe quando “o mais novo vence” é a política certa, e numa lista que pode ser atualizada, quase sempre é.
- Para testar uma race, não adicione delays: adicione uma seam de suspensão e estacione nela. Continuations tornam interleavings reprodutíveis.
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.
Quando @MainActor e TaskGroup não combinam no Swift 6
O checker de isolamento baseado em regiões manda eu abrir um bug, e a Task não estruturada, hoje fora de moda, acaba sendo a ferramenta certa.
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.
Você também pode achar útil
AdGuard para iOS
Bloqueio de anúncios e rastreadores em todo o sistema no iOS, sem necessidade de um servidor DNS separado.
Como afiliado da AdGuard, ganho com compras qualificadas.
Saiba maisNordPass
Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.
Como afiliado da NordPass, ganho com compras qualificadas.
Saiba maisProton 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