Back home

Swift Concurrency Series 09|Problème d'invalidation sémantique d'annulation dans Swift Concurrency

Ce qui est vraiment difficile à collecter, c'est de savoir si le signal d'annulation peut traverser les limites de la tâche, de la couche de pontage et des effets secondaires, et ne pas laisser les anciens résultats être renvoyés sur la page.

Une fois que le projet a modifié le rappel en async/await, une situation courante est qu’il existe une illusion selon laquelle le problème de concurrence a été contenu.

La signature de fonction est devenue plus claire, la chaîne d’appel peut être visualisée le long de await et il y a encore moins d’avertissements dans Xcode. Mais les problèmes en ligne les plus gênants commencent souvent à apparaître à ce stade : la page a été quittée, mais la requête ne s’est pas arrêtée ; les termes de recherche ont changé et les anciens résultats sont revenus ; l’utilisateur annule manuellement le téléchargement, mais les tâches sous-jacentes continuent de s’exécuter.

Ce type de problème est plus facilement attribué à « une certaine interface est trop lente » ou « le thread principal s’actualise au mauvais moment ». Mais si vous démontez vraiment le lien et l’examinez, l’essentiel est généralement que le signal d’annulation n’est pas transmis le long de l’arborescence des tâches, de la couche pont et des limites des effets secondaires jusqu’à la fin.

Mon jugement est le suivant : ** Une fois la migration Swift Concurrency terminée, le bug de concurrence le plus courant est que les gens pensent que “la tâche parent est annulée et les tâches suivantes s’arrêteront naturellement”. En réalité, tant qu’il existe une couche non contrôlée de Task, un wrapper qui relie l’ancienne API ou un effet secondaire qui ne vérifie pas l’état d’annulation, la sémantique d’annulation sera rompue au niveau de cette couche. En fin de compte, la page semble trembler de temps en temps, mais cela signifie en fait que le statut a été bifurqué. **

Ce type de problème est généralement principalement exposé dans les commentaires de l’état

C’est la première fois que je traite systématiquement ce problème. En apparence, cela ressemble à un crash, mais en réalité, cela ressemble plus à une page de recherche. Certaines personnes rapportent toujours que « les résultats reculeront d’eux-mêmes ».

La logique de la page n’est pas compliquée :

  • L’utilisateur saisit des mots-clés ;
  • ViewModel lance la recherche ;
  • Annuler la tâche précédente lorsqu’un nouveau mot-clé arrive ;
  • Actualisez la liste après le retour de la demande.

En apparence, ce processus est tout à fait conforme à la méthode d’écriture recommandée par Swift Concurrency. Le problème est qu’un phénomène très étrange peut être observé dans l’enregistrement d’écran en ligne :

  • L’utilisateur recherche d’abord swift ;
  • Puis remplacez-le par swift concurrency ;
  • Les nouveaux résultats apparaissent en premier sur l’interface ;
  • Après une demi-seconde, les anciens résultats écrasent à nouveau la liste.

Cela ne peut pas être expliqué par une simple « demande irrecevable ». Parce que searchTask?.cancel() est clairement dans le code et que l’annulation est également visible dans le journal.

Le vrai problème réside dans : **La tâche de niveau supérieur a été annulée, mais la couche inférieure n’a pas considéré « l’annulation » comme un changement de statut qui doit être clôturé immédiatement. **

Tant qu’il existe une autre couche dans le système qui continue d’envoyer d’anciens résultats, l’interface utilisateur l’acceptera comme un résultat légitime.

De nombreuses annulations ont échoué, interrompues dans la couche la plus inoffensive du code de transition.

Le point d’arrêt le plus courant est que lors de l’encapsulation de l’ancienne API de rappel dans une fonction asynchrone, elle “attend uniquement que le résultat revienne” et ne fait pas “que faire lorsque le résultat ne doit pas revenir”.

Par exemple, une situation courante consiste à regrouper une requête réseau comme ceci :

func loadUser(id: String) async throws -> User {
  try await withCheckedThrowingContinuation { continuation in
    apiClient.loadUser(id: id) { result in
      continuation.resume(with: result)
    }
  }
}

La syntaxe est bonne et les fonctions fonctionnent. Mais ce code a deux prémisses par défaut fatales :

  1. Même si la tâche externe est annulée, la requête sous-jacente s’arrêtera d’elle-même ;
  2. Même si la couche inférieure ne s’arrête pas, le rappel n’affectera pas l’état actuel s’il revient plus tard.

Ces deux prémisses ne sont souvent pas vraies dans les projets réels.

Si apiClient se trouve toujours sous URLSessionDataTask, un SDK tiers ou sa propre couche de stockage de rappel, l’annulation de la couche externe Task ne sera pas automatiquement transférée. Le wrapper asynchrone ci-dessus modifie uniquement la méthode d’appel en await, mais ne permet pas à la couche sous-jacente d’obtenir la sémantique d’annulation.

Ce que la couche pont doit réellement faire, c’est « traduire l’annulation de la couche externe en actions d’annulation exécutables sous-jacentes ». Quelque chose comme ça :

func loadUser(id: String) async throws -> User {
  var request: Cancellable?

  return try await withTaskCancellationHandler {
    try await withCheckedThrowingContinuation { continuation in
      request = apiClient.loadUser(id: id) { result in
        continuation.resume(with: result)
      }
    }
  } onCancel: {
    request?.cancel()
  }
}

Ce code commence tout juste à se rapprocher de « l’annulation peut vraiment être répercutée ».

Mais l’écrire ici ne suffit pas, car cela résout uniquement “essayez de ne pas continuer à courir”, mais ne résout pas “comment clôturer les résultats tardifs”. Si cancel() du SDK sous-jacent n’a pas d’annulation sémantique forte, mais se termine autant que possible, le rappel peut toujours revenir dans une condition de concurrence critique. L’échelon supérieur devra continuer à faire un contrôle d’annulation avant de recevoir les résultats.

Ce qui gâche vraiment la page, c’est que les anciens résultats sont toujours considérés comme des résultats valides.

De nombreuses équipes se sentent soulagées lorsqu’elles voient Task.isCancelled, mais il ne peut répondre que « si la tâche en cours a été marquée comme annulée », mais ne peut pas répondre « ce résultat doit-il toujours figurer sur la page actuelle ? »

Dans des scénarios tels que la recherche, l’association et le changement de détails, ce qui doit vraiment être protégé, c’est la propriété des résultats.

La manière suivante d’écrire ViewModel est très courante :

final class SearchViewModel: ObservableObject {
  @Published private(set) var items: [Item] = []
  private var searchTask: Task<Void, Never>?

  func search(keyword: String) {
    searchTask?.cancel()
    searchTask = Task {
      do {
        let items = try await repository.search(keyword: keyword)
        self.items = items
      } catch {
        self.items = []
      }
    }
  }
}

Le problème semble être une seule annulation d’appel, mais ce qui manque réellement, ce sont deux niveaux de protection :

  1. Après un retour réussi, confirmez que la tâche en cours est toujours valide ;
  2. L’annulation ne peut pas être traitée comme une erreur normale en cas d’échec.

Une façon plus stable de l’écrire serait comme ceci :

final class SearchViewModel: ObservableObject {
  @MainActor @Published private(set) var items: [Item] = []
  private var searchTask: Task<Void, Never>?

  func search(keyword: String) {
    searchTask?.cancel()

    searchTask = Task { [weak self] in
      guard let self else { return }

      do {
        let items = try await repository.search(keyword: keyword)
        try Task.checkCancellation()
        await MainActor.run {
          self.items = items
        }
      } catch is CancellationError {
        // 取消不是失败,不清空 UI,不弹错误
      } catch {
        await MainActor.run {
          self.items = []
        }
      }
    }
  }
}

Ce qui compte vraiment ici, c’est l’attitude qui la sous-tend : **L’annulation est un flux de contrôle normal, pas un accident anormal. **

De nombreuses pages tremblent car le code remplace « l’utilisateur est parti » par « la demande a échoué, alors effacez l’interface utilisateur ». En conséquence, la nouvelle tâche n’a pas encore été rendue et la mauvaise branche de l’ancienne tâche renvoie d’abord la page à un état vide, ce qui ressemble visuellement à un scintillement aléatoire.

Un autre problème plus caché est que l’arborescence des tâches est interrompue depuis longtemps et que tout le monde pense être en concurrence structurée.

L’un des avantages de Swift Concurrency est que la concurrence structurée rend la relation du cycle de vie entre les tâches parent et enfant beaucoup plus claire. Mais la chose la plus facile à perdre dans le projet est le Task {} que tout le monde récupère au hasard juste pour « éviter les ennuis ».

Par exemple, lorsqu’une page de liste est ouverte pour extraire des détails, extraire des recommandations et mettre en surbrillance des faits saillants, une grande partie du code sera décomposée comme suit :

func refresh() async {
  Task {
    async let detail = repository.loadDetail()
    async let recommendation = repository.loadRecommendation()
    let result = try await (detail, recommendation)
    render(result)
  }
}

Cela semble être asynchrone/attendre, mais le problème le plus critique avec ce code est le suivant : refresh() lui-même et la couche Task {} à l’intérieur n’ont plus de relation parent-enfant structurée.

C’est à dire :

  • La couche supérieure appelant refresh() se termine immédiatement ;
  • Même si la page est détruite ;
  • Même si la tâche externe est annulée ;

Le Task récemment ouvert à cet étage peut toujours continuer à fonctionner.

C’est la raison pour laquelle de nombreuses pages continuent de faire des requêtes même après leur fermeture. C’est le code qui contourne activement la concurrence structurée.

Si ce genre de scénario vise juste à obtenir des résultats en parallèle, il suffit d’écrire directement dans le contexte asynchrone actuel :

func refresh() async throws -> ScreenData {
  async let detail = repository.loadDetail()
  async let recommendation = repository.loadRecommendation()
  return try await ScreenData(
    detail: detail,
    recommendation: recommendation
  )
}

De cette manière, la sémantique d’annulation sera collectée avec la chaîne d’appels. Celui qui l’initiera en sera responsable ; celui qui l’annule l’arrêtera ensemble.

Si la limite des effets secondaires n’est pas vérifiée pour annulation, l’état sale le plus difficile à expliquer apparaîtra.

Si la requête n’est pas arrêtée, ce n’est qu’un gaspillage de ressources. Si les effets secondaires ne sont pas arrêtés, le statut sera écrit sale.

J’ai ensuite étudié spécifiquement un type de problème difficile à reproduire : après qu’un utilisateur change rapidement de compte, les données du compte précédent apparaissent parfois dans le cache. Finalement, cela a convergé et la sémantique d’annulation s’est arrêtée avant « d’obtenir des données » et n’a pas continué jusqu’à l’étape « d’écriture des effets secondaires ».

Un code comme celui-ci est dangereux :

func refreshProfile() async throws {
  let profile = try await repository.fetchProfile()
  cache.save(profile)
  analytics.trackProfileLoaded(profile.id)
  state = .loaded(profile)
}

Si la tâche a été annulée au retour de fetchProfile(), mais qu’il n’y a pas de contrôle d’annulation, les écritures de cache ultérieures, les points enterrés et les mises à jour d’état continueront à se produire.

Ce que vous voyez sur l’interface utilisateur à ce moment-là n’est peut-être qu’un rebond occasionnel, mais à l’intérieur du système, les données sales ont été placées sur le disque et le coût du dépannage va soudainement augmenter considérablement.

Une approche plus prudente consiste généralement à effectuer une autre vérification explicite avant la limite des effets secondaires :

func refreshProfile() async throws {
  let profile = try await repository.fetchProfile()
  try Task.checkCancellation()

  cache.save(profile)
  analytics.trackProfileLoaded(profile.id)
  state = .loaded(profile)
}

Cette étape peut paraître un peu mécanique, mais elle résout un problème bien réel : **L’annulation n’annule pas seulement “l’attente”, mais annule également la “soumission”. **

Ce qui doit réellement être protégé, ce sont souvent les prochaines actions qui réécriront le vieux monde.

Le malentendu le plus courant en cas d’échec est de gérer toutes les erreurs de manière uniforme.

La raison pour laquelle de nombreuses migrations simultanées laissent de longues traînes est que les équipes aiment écrire les fermetures d’erreurs dans un modèle unifié :

do {
  let data = try await service.load()
  state = .loaded(data)
} catch {
  state = .error(error)
}

Cela ne pose aucun problème dans les scénarios d’échec ordinaires, mais une fois intégré à des scénarios tels que le changement de page à haute fréquence, la recherche Lenovo, l’anti-tremblement d’entrée et l’annulation de téléchargement, CancellationError n’est pas la même chose qu’un véritable échec commercial.

Mélanger les deux entraînera au moins trois conséquences :

  • L’utilisateur a activement quitté la page, mais cela a été enregistré comme un échec ;
  • Le taux d’erreur dans les points cachés est artificiellement élevé, trompant le jugement de stabilité ;
  • En raison de l’erreur unifiée dans l’interface utilisateur, des toasts, des états vides ou des boutons de réessai apparaissent alors qu’ils ne devraient pas apparaître.

Tant que l’annulation est considérée comme un échec une fois dans le projet, un tas de commentaires étranges et apparemment sans rapport apparaîtront plus tard :

  • La liste est effacée à plusieurs reprises lors de la recherche ;
  • Parfois, une erreur se produit une fois l’actualisation déroulante terminée ;
  • Lorsque la page revient au niveau précédent, l’état d’échec de chargement clignote.

Ces phénomènes sont très fragmentaires, mais la racine est le même problème : ** l’annulation du flux de contrôle est confondue avec une exception métier. **

Limites applicables : toutes les fonctions asynchrones n’ont pas besoin d’être remplies de contrôles d’annulation

La sémantique d’annulation est importante, mais toutes les couches ne doivent pas nécessairement écrire Task.checkCancellation() mécaniquement.

Il y a trois postes que j’apprécie davantage maintenant :

  1. Relier l’entrée à l’ancienne API : ceci est responsable de la détraduction de la couche externe vers les capacités sous-jacentes ;
  2. Points de commutation de phase pour les liaisons longues : par exemple, après avoir terminé le réseau, préparé le décodage et préparé l’écriture du cache ;
  3. Effets secondaires avant la soumission : tout endroit qui change de statut, met en cache, publie ou écrit dans la base de données mérite d’être vérifié à nouveau.

D’un autre côté, si une fonction n’est qu’un pur calcul, n’a pas de point de suspension et n’a pas d’effets secondaires, alors il ne sert à rien d’insérer spécifiquement un chèque d’annulation. Parce que la véritable solution à l’annulation a toujours été de « Ne continuez pas à écrire sur le vieux monde ».

Résumé

L’illusion la plus simple créée par Swift Concurrency est que le code a été déplacé du rappel vers await et que le système est naturellement entré dans une ère de concurrence plus fiable.

Mais les vrais projets n’obtiendront pas automatiquement une sémantique d’annulation simplement parce que la syntaxe est nouvelle.

Si la tâche parent peut toujours contrôler la tâche enfant, si la couche pont peut transmettre l’annulation et si les anciens résultats seront bloqués avant que les effets secondaires ne soient soumis. Si l’une de ces trois choses est manquée, ce que vous voyez sur la page sera un système d’état fork.

Ce qu’il faut donc vraiment examiner dans ce type de problématique, c’est à quel niveau s’arrête l’annulation. Tant qu’on ne répond pas clairement à cette question, plus la grammaire est récente, plus il est facile pour les gens de penser à tort qu’ils ont correctement écrit la concurrence.