Back home

Swift Concurrency Série 06|Problemas comuns na simultaneidade Swift: condições de corrida, solicitações repetidas e confusão de estado

O verdadeiro problema é que estes problemas muitas vezes se manifestam como interrupções esporádicas nos negócios, em vez de falhas explícitas.

A coisa mais frustrante sobre os bugs de simultaneidade é que eles geralmente não parecem bugs.

Manifesta-se com mais frequência online como estas questões ambíguas:

  • O usuário disse “às vezes pisca”
  • O teste diz “Ocasionalmente aparecem dados antigos”
  • O produto dizia “Acabei de cortar o filtro, por que ele saltou de novo?”
  • Não há nenhuma falha clara no log, mas o status da página está errado.

Em outras palavras, muitos problemas de simultaneidade parecem mais “exceções de negócios ocasionais” do que “obviamente tecnicamente quebrados”.

Portanto, neste artigo, não quero falar apenas sobre a definição de termos, mas focar diretamente em um cenário de página de lista mais real e detalhar os três tipos de problemas mais comuns:

  • Competição
  • Repetir solicitação
  • estado de confusão

E como eles crescem em código real.

1. Primeiro olhe para uma página que é tão real que não poderia ser mais real.

Suponha que haja uma página de lista de artigos que suporte estas operações:

  • Carregamento automático quando a página entra pela primeira vez
  • Puxe para baixo para atualizar
  • Alternar categorias
  • Insira a pesquisa por palavra-chave
  • Clique em “Tentar novamente”

Muitos projetos são escritos assim no início:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published var items: [Article] = []
  @Published var isLoading = false
  @Published var errorMessage: String?
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  let repository: ArticlesRepository

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func onAppear() {
    Task {
      await load()
    }
  }

  func refresh() {
    Task {
      await load()
    }
  }

  func retry() {
    Task {
      await load()
    }
  }

  func categoryChanged(to value: String) {
    selectedCategory = value
    Task {
      await load()
    }
  }

  func keywordChanged(to value: String) {
    keyword = value
    Task {
      await load()
    }
  }

  func load() async {
    isLoading = true
    errorMessage = nil

    do {
      items = try await repository.fetchArticles(
        category: selectedCategory,
        keyword: keyword
      )
    } catch {
      errorMessage = error.localizedDescription
    }

    isLoading = false
  }
}

Quando este código é escrito pela primeira vez, todos geralmente pensam que é “bastante suave”:

  • Sim async/await
  • O código é direto
  • Toda entrada funciona

Mas enquanto a página for realmente usada, surgirão problemas de simultaneidade em breve.

2. O primeiro tipo de problema: a condição de corrida é uma ordem padrão que não existe.

Ainda este código. Seu principal problema não é que ele abra muitos Task, mas que o padrão é que essas coisas aconteçam na ordem que você deseja:

  • A solicitação enviada primeiro será devolvida primeiro.
  • Quando a solicitação antiga retorna, as condições de filtragem atuais não foram alteradas.
  • O início e o fim do carregamento correspondem sempre um a um

Mas os sistemas assíncronos não garantem essas ordens para a equipe.

Por exemplo, o usuário opera da seguinte forma:

  1. Entre na página e solicite que A emita
  2. Mude imediatamente para a categoria “iOS” e solicite que B envie
  3. Digite a palavra-chave swift novamente para solicitar que C emita

Neste momento, se a ordem de devolução for:

  1. C volta primeiro
  2. Volte depois de A
  3. B volta por último

De acordo com o código atual, os três resultados serão alterados para items. Em outras palavras, o que é exibido na página final depende de quem volta por último, e não de quem corresponde à intenção atual do usuário.

Esta é a condição de corrida mais típica:

O código depende secretamente da ordem, mas a ordem não é restrita de forma alguma.

3. O segundo tipo de problema: A causa raiz das solicitações repetidas geralmente é que a entrada não está fechada.

Olhando para o ViewModel acima, há pelo menos cinco entradas que irão acionar load():

-onAppear -refresh -retry -categoryChanged -keywordChanged

Cada entrada possui seu próprio Task. Isto é certamente legal do ponto de vista da sintaxe, mas do ponto de vista da engenharia significa:

  • Não existe um ponto de agendamento unificado para tarefas semelhantes
  • Ninguém sabe se já existe uma tarefa semelhante em execução
  • Quando novas tarefas aparecem, as tarefas antigas não têm um destino claro

Então, os “pedidos repetidos” não são mais acidentais, mas um produto natural da estrutura.

Portanto, no gerenciamento de simultaneidade, raramente pergunto:

“Por que há um pedido extra aqui?”

Eu pergunto com mais frequência:

“Quantas entradas existem para o mesmo tipo de tarefas? Existe alguma relação de substituição entre elas?”

Se você não conseguir responder a essas duas perguntas, solicitações repetidas serão quase inevitáveis.

4. O terceiro tipo de problema: O status está desordenado, muitas vezes porque os resultados expirados ainda podem ser gravados.

Uma situação comum é que, desde que a solicitação retorne com sucesso, o resultado deverá ser aceito.

Isso geralmente funciona bem em sistemas síncronos, mas geralmente é errado em sistemas simultâneos.

Porque o problema mais crítico em um cenário simultâneo é:

**Este resultado ainda é considerado válido para a página atual? **

Por exemplo:

  • A página atual foi alterada para keyword = "swift"
  • O resultado é da solicitação antiga keyword = ""

O resultado é real, bem sucedido e no formato certo, mas expirou. Se ainda for permitido escrever a UI, o estado estará errado.

Portanto, num sistema concorrente, “o resultado está correto” e “o resultado é válido” são duas coisas diferentes. Superficialmente, muitos problemas de página parecem ser resultados errados, mas, na verdade, estão mais perto de não sermos capazes de julgar se ainda estão qualificados para serem implementados.

5. Não se apresse em usar ferramentas complexas primeiro. O primeiro passo é encerrar tarefas semelhantes.

O que o código acima mais precisa é fazer primeiro uma coisa muito simples:

**Dê a tarefas semelhantes uma entrada unificada. **

Por exemplo, primeiro carregue a lista assim:

@MainActor
final class ArticlesViewModel: ObservableObject {
  @Published private(set) var state: ViewState = .idle
  @Published private(set) var items: [Article] = []
  @Published var selectedCategory: String = "all"
  @Published var keyword: String = ""

  private let repository: ArticlesRepository
  private var loadTask: Task<Void, Never>?

  init(repository: ArticlesRepository) {
    self.repository = repository
  }

  func reload() {
    let request = RequestContext(
      category: selectedCategory,
      keyword: keyword
    )

    loadTask?.cancel()
    loadTask = Task {
      await performLoad(request: request)
    }
  }

  private func performLoad(request: RequestContext) async {
    state = .loading

    do {
      let result = try await repository.fetchArticles(
        category: request.category,
        keyword: request.keyword
      )

      guard !Task.isCancelled else { return }
      guard request.category == selectedCategory,
         request.keyword == keyword else { return }

      items = result
      state = .loaded
    } catch is CancellationError {
      // 取消不更新页面
    } catch {
      guard !Task.isCancelled else { return }
      state = .failed(error.localizedDescription)
    }
  }
}

Este código faz várias coisas muito críticas:

  • Existe apenas um ponto de espera para tarefas de carregamento semelhantes loadTask
  • Quando uma nova tarefa chegar, a tarefa antiga será cancelada primeiro
  • Congelar o “contexto atual” para RequestContext ao enviar uma solicitação
  • Após o resultado ser retornado, será verificado se ainda corresponde à página atual

Observe que o que é realmente importante aqui é que as relações entre tarefas comecem a ficar claras.

6. “Contexto de solicitação de congelamento” é muito crítico

Muitos artigos de simultaneidade falam sobre cancelamento de tarefas, mas não dão ênfase suficiente ao “instantâneo de contexto”. Mas no negócio de páginas é muito importante.

Por exemplo, ao solicitar:

-selectedCategory = "ios" -keyword = "swift"

Então, esses dois valores não devem ler dinamicamente os valores mais recentes no ViewModel atual após a solicitação ser interrompida. Caso contrário, você frequentemente obterá um estado muito estranho:

  • Ao enviar uma solicitação, é um conjunto de parâmetros
  • Outro conjunto de parâmetros é usado ao verificar os resultados

Portanto, um princípio muito prático é:

Ao iniciar uma tarefa assíncrona, congele o contexto de negócios do qual a tarefa realmente depende.

Desta forma, haverá uma base clara para julgar “se este resultado ainda é o resultado atual” posteriormente.

7. Muitos bugs de simultaneidade resultam em “muitas entradas de gravação de status”

Uma situação comum é que, ao encontrar um problema de simultaneidade, você imediatamente pense em:

  • Você quer trancá-lo?
  • Você quer ser ator?
  • Você quer mudar de assunto?

É claro que isso às vezes é importante, mas em cenários de nível de página, os problemas mais comuns são, na verdade:

  • Existem muitos lugares para escrever items
  • Muitos lugares podem ser alterados isLoading
  • Muitas entradas podem enviar solicitações diretamente

Uma vez que as entradas de gravação do estado estejam espalhadas, mesmo que não haja competição real de dados, ocorrerá o fenômeno de “a combinação está errada”.

Portanto, quando faço esse tipo de solução de problemas, geralmente faço primeiro as seguintes perguntas:

  • Quais códigos têm autoridade para alterar esse status
  • Quais tarefas têm o direito de encerrar o carregamento atual
  • Quais resultados têm o direito de substituir a lista atual

Uma vez que esses problemas não são resolvidos, geralmente é apenas uma questão de tempo até que os bugs se desenvolvam.

8. Uma sequência de evolução mais próxima do projeto real

Se você realmente quer resolver esse tipo de problema, sugiro evoluir nesta ordem ao invés de introduzir muitos mecanismos no início:

1. Feche a entrada para tarefas semelhantes

Primeiro, deixe o “carregamento da lista” ter apenas uma entrada unificada, em vez de enviar sua própria solicitação para cada evento da UI.

2. Esclareça a relação de substituição de tarefas

Quais tarefas devem ser simultâneas e quais devem cancelar tarefas antigas e manter apenas a última.

3. Congelar contexto de solicitação

Colete os principais parâmetros de negócios nos quais você confia ao fazer solicitações em um objeto claro.

4. Adicione julgamento de validade ao resultado

Nem todos os resultados retornados com sucesso são elegíveis para alterar a página atual.

5. Finalmente, considere um isolamento de estado compartilhado mais complexo

Por exemplo, cache compartilhado entre páginas, coordenação de recursos entre módulos e, em seguida, observe o ator, o coordenador unificado e outras soluções.

Essa ordem é mais estável porque resolve primeiro o relacionamento de concorrência de negócios, em vez de introduzir primeiro um vocabulário técnico mais complexo.

9. Conclusão: A essência da maioria dos problemas de simultaneidade de negócios é “nenhuma modelagem de relacionamentos de tarefas”

Condições de corrida, solicitações duplicadas e confusão de estado parecem ser três problemas, mas as causas raízes reais costumam ser muito próximas:

  • Quem tem a mesma tarefa que quem, sem modelagem
  • Novas tarefas estão chegando, o que fazer com tarefas antigas, não há modelagem
  • O resultado ainda é válido? Não há modelagem.
  • Onde posso escrever meu status sem fechá-lo?

Então, para reformular este artigo de uma forma mais curta, eu diria:

A maioria dos problemas de simultaneidade nos negócios parece ser incompetente com a sintaxe de simultaneidade, mas na verdade eles estão mais próximos de falhar ao modelar claramente relacionamentos de tarefas, validade de resultados e permissões de gravação de estado.

Assim que essas três coisas começarem a ficar claras, muita “confusão acidental” desaparecerá mais facilmente do que você pensa.