Série de concurrence Swift 04 | Limites d'utilisation des tâches
La tâche n'est pas l'entrée universelle au code asynchrone. Ce qui compte vraiment, c'est qui le crée, qui l'annule et qui est responsable des résultats.
La mauvaise habitude la plus courante que de nombreuses équipes développent lorsqu’elles découvrent Swift Concurrency à grande échelle est d’abuser de Task.
Parce que c’est tellement pratique.
Vous ne pouvez pas directement await dans le rappel du bouton, puis écrire un Task. Vous ne pouvez pas directement await dans la méthode proxy UIKit, puis écrire un Task. Si vous souhaitez vous faufiler dans le monde asynchrone avec une certaine méthode de synchronisation, le moyen le plus simple est d’écrire Task.
Au fil du temps, Task passera d’« une passerelle qui relie la synchronisation et l’asynchronisme » à « une couche de bande de concurrence couvrant tout problème ».
Cet article veut vraiment répondre à trois questions plus proches de l’ingénierie :
- Quel type de problème résout-il ?
- Dans quelles circonstances s’agit-il d’une sélection naturelle et dans quelles circonstances cache-t-elle simplement des problèmes structurels ?
- Quelles questions devez-vous vous poser en premier lorsque vous décidez d’ouvrir un
Task.
1. Tout d’abord, précisons le positionnement : Task est le point de création de tâches asynchrones, pas un outil de conception de processus.
Quand j’ai vu Task {} pour la première fois, j’ai pensé à “l’exécution asynchrone d’un morceau de code”.
Cette compréhension n’est pas fausse, mais elle ne suffit pas.
Plus précisément, ce que fait Task est :
- Créer une nouvelle tâche simultanée
- Mettre un morceau de code dans un contexte asynchrone
- Lier des responsabilités telles que l’exécution, l’annulation, la priorité, les résultats, etc. à cette tâche
Ainsi, Task ne consiste jamais simplement à « jeter le code en arrière-plan et à l’exécuter ».
Une fois que je l’ai écrit, j’ai en fait pris plusieurs décisions en même temps :
- Cette œuvre commence maintenant à être autonome
- Il peut se terminer plus tard que le point d’appel en cours
- Il peut être annulé ou non
- Son résultat est soit consommé, soit rejeté
- Il établit une certaine relation avec l’objet actuel, la page actuelle et l’action actuelle de l’utilisateur
Cela montre également que Task ne peut pas être compris uniquement grammaticalement.
Syntaxiquement, il ne s’agit que d’un bloc de code, mais d’un point de vue technique, cela signifie “le cycle de vie de la tâche est créé”.
2. Le scénario le plus adapté pour Task : il faut vraiment passer du monde synchrone au monde asynchrone
Le scénario d’utilisation le plus naturel du Task est en réalité très simple :
Le contexte actuel n’est pas
async, mais un processus asynchrone doit être déclenché.
Comme ce genre de situations.
1. Rappel d’interaction utilisateur
Button("保存") {
Task {
await viewModel.save()
}
}
Il est raisonnable d’utiliser Task ici, car l’action de Button elle-même n’est pas async, mais l’action de sauvegarde est évidemment un processus asynchrone.
2. Méthode proxy de synchronisation de UIKit/AppKit
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
Task {
await viewModel.search(keyword: searchText)
}
}
La signature de rappel du proxy est déterminée par le framework, et non par le fait qu’elle puisse être modifiée en async. Pour entrer dans un processus asynchrone, un point de pontage est requis.
3. Cycle de vie de l’application ou rappel de notification
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
Task {
await refreshIfNeeded()
}
}
La valeur de Task ici est toujours la même : convertir un événement synchrone en tâche asynchrone.
Si vous regardez ces exemples ensemble, vous découvrirez un point commun :
- Les événements proviennent de l’API de synchronisation
- Le traitement métier devrait être asynchrone
Taskn’est que l’entrée, pas le corps principal
À l’heure actuelle, Task est un bon outil.
3. Le vrai danger : Considérez Task comme une méthode de réparation consistant à « corriger toute erreur signalée »
Le problème le plus courant dans l’équipe est de “l’utiliser trop naturellement”.
Les mauvaises postures les plus courantes sont les suivantes.
1. Si le compilateur n’autorise pas await, il inclura une couche de Task
func refresh() {
Task {
await loadData()
}
}
Ce code n’est peut-être pas faux lorsqu’il est considéré isolément. Le problème est que dans de nombreux cas, refresh() lui-même peut être conçu comme async, puis la couche supérieure décide quand l’appeler.
Une fois que la pensée par défaut devient « je ne peux pas await, alors ouvrez Task », vous perdrez le contrôle des limites des tâches.
2. Déjà dans la fonction async, nous devons ajouter un autre Task
func loadPage() async {
Task {
await loadUser()
}
Task {
await loadFeed()
}
}
Le problème avec ce type de code est qu’il brise le flux de contrôle qui appartient à l’origine à une fonction.
Vous rencontrerez immédiatement plusieurs problèmes :
- Qui garantit l’ordre de fin de ces deux tâches.
- Comment gérer les échecs de manière uniforme.
- Comment l’appelant sait-il quand l’intégralité du
loadPage()est réellement terminée. - Si la tâche externe est annulée, les deux sous-tâches s’arrêteront-elles ensemble ?
Si l’intention est d’exécuter en parallèle, il est généralement plus clair de l’écrire en tant que async let ou groupe de tâches, plutôt que de créer deux Task opaques supplémentaires.
3. Lorsque je suis confronté à une concurrence de statut, je souhaite « décaler le sommet » en ouvrant quelques Task supplémentaires
Certains codes seront écrits comme ceci :
Task {
self.loading = true
}
Task {
let data = await service.fetch()
self.items = data
}
Task {
self.loading = false
}
En apparence, cela ressemble à un démontage, mais en fait, cela laisse la cohérence des états directement à la chance.
Je ne sais pas si la troisième tâche sera terminée avant la seconde, et je ne sais pas dans quel état l’interface s’arrêtera en cas d’annulation.
La cause première de ce type de problème est généralement la rupture du flux d’état qui doit être connecté.
4. Pour juger si vous devez ouvrir un Task, posez d’abord ces quatre questions
Il s’agit de l’ensemble de listes de contrôle d’ingénierie le plus utile. Bien plus utile que de mémoriser la grammaire.
1. Qui a créé cette tâche ?
Est-il créé par un clic sur un bouton ? Créé lorsque la page apparaît ? Créé lors de l’initialisation de ViewModel ? Ou a-t-il été créé secrètement par une couche de service ?
Si la réponse à la question « qui l’a créé » n’est pas claire, il sera presque impossible de déterminer « qui devrait l’annuler » plus tard.
2. À qui appartient cette tâche ?
Si la mission consiste simplement à lancer et à oublier, cela signifie généralement que personne ne la gère réellement.
Mais de nombreuses entreprises ne sont pas adaptées au feu et à l’oubli.
Par exemple, la recherche, la pagination, l’enregistrement, le téléchargement et l’interrogation, ces tâches doivent souvent être explicitement détenues par un objet :
final class SearchViewModel: ObservableObject {
private var searchTask: Task<Void, Never>?
func updateKeyword(_ keyword: String) {
searchTask?.cancel()
searchTask = Task {
await search(keyword)
}
}
}
Ce qui est vraiment précieux ici, c’est :
-Il n’y a qu’une seule entrée pour des tâches similaires
- Lorsque de nouvelles tâches apparaissent, les anciennes tâches seront annulées
- La tâche appartient à
SearchViewModel
Un Task qui ne « possède » pas deviendra généralement une mission fantôme plus tard.
3. Si l’utilisateur quitte la page, celle-ci doit-elle continuer à s’afficher ?
Cette question est particulièrement importante car elle détermine directement où doit se limiter le cycle de vie des tâches.
Par exemple :
- Page au-dessus de la demande de pliage : les utilisateurs n’ont généralement pas à continuer après avoir quitté la page
- Soumission de la commande : il peut être nécessaire de continuer à la compléter même si la page est fermée
- Prélecture d’image : la priorité peut être très faible, et elle doit être annulée en quittant la page
Différents types de tâches ont des conceptions complètement différentes.
Sans répondre à cette question au préalable, il est facile d’écrire toutes les tâches comme ceci :
Task {
await doSomething()
}
En apparence, ils sont unifiés, mais en réalité, la sémantique est complètement chamboulée.
4. Qui consommera les résultats de cette tâche ?
Les résultats de certaines tâches seront réécrits dans l’interface utilisateur, les résultats de certaines tâches devront mettre à jour le cache et certaines tâches seront simplement signalées.
Lorsque les résultats n’ont pas de destination claire, deux types de mauvaises odeurs apparaissent généralement :
- La tâche a été ouverte, mais personne n’a répondu à l’erreur
- La tâche a été terminée, mais personne ne l’a utilisée
Je ne suis donc pas un grand fan du feu et de l’oubli débridé. La plupart des tâches commerciales ne consistent pas simplement à « les envoyer ».
5. Lorsque vous êtes déjà dans le monde async, donnez la priorité à la concurrence structurée au lieu de créer des Task supplémentaires
C’est un point que de nombreux articles ne parviennent pas à aborder de manière honnête.
Déjà dans la fonction async , cela signifie que vous disposez déjà d’un flux de contrôle asynchrone. L’écriture de Task supplémentaires à ce stade contourne souvent les contraintes imposées par la concurrence structurée.
Regardez les deux comparaisons.
Tendance aux erreurs : utiliser plusieurs démolitions dures Task en parallèle
func loadDashboard() async {
let userTask = Task { await api.loadUser() }
let statsTask = Task { await api.loadStats() }
let noticesTask = Task { await api.loadNotices() }
let user = await userTask.value
let stats = await statsTask.value
let notices = await noticesTask.value
self.state = .loaded(user, stats, notices)
}
Ce code n’est pas entièrement faux, mais il n’est pas assez explicite. Parce que ce que voit l’appelant est “J’ai activement créé trois tâches” au lieu de “Il y a trois dépendances parallèles ici”.
Meilleure expression : async let
func loadDashboard() async throws {
async let user = api.loadUser()
async let stats = api.loadStats()
async let notices = api.loadNotices()
self.state = try .loaded(user: user, stats: stats, notices: notices)
}
L’avantage de ce type d’écriture est que la sémantique est plus claire :
- Ces emplois appartiennent à la fonction actuelle
- Ils sont chaînés à la même structure que l’appel en cours
- La fonction en cours attendra le résultat avant de se terminer
- Lorsque la couche externe est annulée, la concurrence interne est également annulée.
En d’autres termes, la différence entre Task et la concurrence structurée réside dans la personne responsable du cycle de vie.
6. Le désastre le plus courant au niveau de la page : ouvrez un Task pour chaque entrée
En prenant comme exemple une page de liste très réelle, il y a généralement ces points de déclenchement :
- Chargement de la première page d’entrée
- Déroulez vers le bas pour actualiser
- Rechercher des changements de mots clés
- Changer les filtres
- Cliquez sur “Réessayer”
- Passez à la page suivante pour charger automatiquement plus
Si chaque entrée est écrite seule :
Task {
await load()
}
Après une ou deux itérations, la page présentera très probablement ces phénomènes :
- Plusieurs demandes s’envolent en même temps
- Les anciens résultats écrasent les nouveaux résultats
- Évidemment, le dernier mot-clé est
swift, mais l’interface affiche les résultats deswi - Une fois que l’utilisateur a quitté la page, le rappel est toujours en cours d’écriture
loading,isRefreshing,errorse battent
Une situation courante à ce stade est de penser à tort que ce que vous rencontrez est une « concurrence complexe ».
En fait, le problème est plus spécifique : **L’entrée des tâches est trop dispersée et il n’y a pas de clôture unifiée des changements de statut. **
Une approche plus stable consiste généralement à concentrer « quelles tâches ouvrir » dans un objet d’état, plutôt que de laisser la couche de vue créer de nouvelles tâches partout.
Par exemple :
@MainActor
final class ArticlesViewModel: ObservableObject {
@Published private(set) var state: ViewState = .idle
private var reloadTask: Task<Void, Never>?
func reload() {
reloadTask?.cancel()
reloadTask = Task {
state = .loading
do {
let articles = try await repository.fetchArticles()
guard !Task.isCancelled else { return }
state = .loaded(articles)
} catch is CancellationError {
// 忽略取消
} catch {
state = .failed(error)
}
}
}
}
Il y a trois problèmes que ce code résout réellement :
- Il y a une entrée unique pour des tâches similaires
- La relation de substitution entre des tâches similaires est claire -Réécriture du statut en un seul endroit
Task est toujours utilisé ici, mais il s’agit déjà d’une « entrée contrôlée dans la gestion des tâches ».
7. Quand est-il approprié de conserver la référence Task et quand n’est-il pas nécessaire ?
C’est aussi un signal pour juger de la maturité du code.
Convient aux scénarios où des références sont conservées
- Recherche d’entrée anti-tremblement
- Tâche d’actualisation de page
- Les requêtes pouvant être déclenchées à plusieurs reprises et les nouvelles tâches doivent remplacer les anciennes tâches
- Processus d’interrogation, d’écoute et de synchronisation de longue durée
Car ces scénarios impliquent naturellement une annulation ou un remplacement.
Il n’est pas nécessaire de conserver la scène référencée
- Tâches courtes que les utilisateurs n’effectuent qu’une seule fois après avoir cliqué une fois
- C’est clairement le point d’enfouissement, le nettoyage des journaux et des caches du feu et de l’oubli
- Tâches dont le cycle de vie a été géré par le framework externe pour l’équipe
L’accent n’est pas mis sur « la nécessité ou non d’être plus avancé », mais sur la question de savoir si la tâche est correctement gérée.
Si une tâche peut être annulée, remplacée ou affecter la visibilité de l’utilisateur, elle ne doit probablement pas être déclenchée comme un feu d’artifice anonyme.
8. Task.detached est une déclaration d’isolement plus forte
Bien que cet article parle principalement de Task, de nombreuses équipes utiliseront bientôt davantage Task.detached après avoir ouvert aléatoirement Task.
Voici un petit rappel :
Task {}héritera d’une partie du contexte actuelTask.detached {}ressemble plus à "séparé du contexte actuel et exécuté indépendamment"Par conséquent, si l’attribution et l’annulation duTaskordinaire ne sont pas rectifiées, ledetachedne doit pas être utilisé pour élargir le degré de liberté.
De nombreux Task.detached finissent par être une évasion de la responsabilité.
9. Un critère de jugement pratique : Créez-vous des tâches ou échappez-vous à la modélisation ?
C’est la question la plus fréquemment posée dans mes avis.
When ready to write:
Task {
...
}
Stop for two seconds and ask yourself:
- Am I creating a task with a clear life cycle?
- Ou est-ce simplement parce qu’il est trop compliqué de le changer en
async, donc il est temporairement recouvert d’une couche ? - Est-ce que je sais quand ça se termine, qui l’annule et à qui ça se termine ?
Si vous ne pouvez pas répondre à ces questions, dans la plupart des cas, le niveau d’abstraction actuel n’a pas été corrigé.
10. Conclusion : Task vaut la peine d’être utilisé fréquemment, mais ne vaut pas la peine d’être utilisé avec désinvolture.
Task est certainement important dans Swift Concurrency et est souvent utilisé fréquemment.
But its correct value is:
- Entrez en toute sécurité dans le processus asynchrone à l’entrée synchrone
- Créer explicitement des tâches lorsqu’un cycle de vie indépendant est requis
- Fournir des limites de concurrence claires lorsque l’annulation, le remplacement et l’isolement sont nécessaires
So I prefer to understand it this way:
Taskest une déclaration explicite du cycle de vie des tâches.
Lorsqu’il est utilisé comme instruction, le code devient de plus en plus clair. Lorsque vous l’utilisez comme patch, tôt ou tard, le code deviendra “chaque couche peut s’exécuter, mais personne ne peut dire pourquoi il s’exécute comme ça”.
What to read next
Want more posts about Swift Concurrency?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to keep following #Swift Concurrency?
Tags are useful for related tools, specific problems, and similar troubleshooting notes.
View same tagWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home