Pular para o conteúdo
Development

Correndo contra a main actor: quando o await é a condição de corrida

Por Victor Da Luz
iosswiftswift-concurrencyswift-testingdev-logdeep-cut-atlas

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: loadMoretracks.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 await numa actor é um ponto onde seus invariantes podem ser invalidados. Releia o que você capturou antes da suspensão.
  • Guardas como isLoadingMore protegem 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

Você também pode achar útil

AdGuard

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 mais
NordPass

NordPass

Gerenciador de senhas da equipe por trás da NordVPN, com um plano gratuito.

Como afiliado da NordPass, ganho com compras qualificadas.

Saiba mais
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