Les API non bloquantes demandent que le travail soit effectué, puis remettent le contrôle au thread appelant afin qu'il puisse effectuer d'autres tâches avant la fin de l'opération demandée. Ces API sont utiles lorsque le travail demandé est en cours ou peut nécessiter d'attendre la fin des E/S ou de l'IPC, la disponibilité de ressources système très disputées ou l'entrée utilisateur avant que le travail puisse commencer. Les API particulièrement bien conçues permettent d'annuler l'opération en cours et d'arrêter l'exécution de la tâche pour le compte de l'appelant d'origine, ce qui préserve l'état du système et l'autonomie de la batterie lorsque l'opération n'est plus nécessaire.
Les API asynchrones sont un moyen d'obtenir un comportement non bloquant. Les API asynchrones acceptent une forme de continuation ou de rappel qui est notifiée lorsque l'opération est terminée, ou d'autres événements pendant la progression de l'opération.
Il existe deux principales motivations pour écrire une API asynchrone:
- Exécution de plusieurs opérations simultanément, où une opération N doit être lancée avant la fin de l'opération N-1.
- Éviter de bloquer un thread appelant jusqu'à la fin d'une opération.
Kotlin promeut fortement la simultanéité structurée, une série de principes et d'API basés sur des fonctions de suspension qui dissocient l'exécution synchrone et asynchrone du code du comportement de blocage de thread. Les fonctions de suspension sont non bloquantes et synchrones.
Fonctions de suspension:
- Ne bloquez pas leur thread d'appel, mais plutôt leur thread d'exécution en tant que détail d'implémentation en attendant les résultats des opérations exécutées ailleurs.
- Exécutez de manière synchrone et n'exigez pas de l'appelant d'une API non bloquante de continuer l'exécution en même temps que le travail non bloquant lancé par l'appel d'API.
Cette page détaille un niveau minimal d'attentes que les développeurs peuvent respecter en toute sécurité lorsqu'ils travaillent avec des API non bloquantes et asynchrones, suivi d'une série de recettes pour créer des API qui répondent à ces attentes dans les langages Kotlin ou Java, sur la plate-forme Android ou dans les bibliothèques Jetpack. En cas de doute, considérez les attentes des développeurs comme des exigences pour toute nouvelle surface d'API.
Attentes des développeurs concernant les API asynchrones
Sauf indication contraire, les attentes suivantes sont rédigées du point de vue des API non suspendues.
Les API qui acceptent des rappels sont généralement asynchrones.
Si une API accepte un rappel qui n'est pas documenté comme pouvant être appelé uniquement en place (c'est-à-dire appelé uniquement par le thread appelant avant le retour de l'appel d'API), l'API est supposée être asynchrone et doit répondre à toutes les autres attentes documentées dans les sections suivantes.
Un exemple de rappel qui n'est jamais appelé en place est une fonction de mappage ou de filtrage d'ordre supérieur qui appelle un mappeur ou un prédicat sur chaque élément d'une collection avant de renvoyer.
Les API asynchrones doivent renvoyer une réponse le plus rapidement possible
Les développeurs s'attendent à ce que les API asynchrones soient non bloquantes et renvoient rapidement une réponse après avoir lancé la requête pour l'opération. L'appel d'une API asynchrone doit toujours être sécurisé, et l'appel d'une API asynchrone ne doit jamais entraîner de frames saccadées ni d'erreurs ANR.
De nombreux signaux d'opération et de cycle de vie peuvent être déclenchés par la plate-forme ou les bibliothèques à la demande. Il est impossible d'attendre d'un développeur qu'il dispose de connaissances globales sur tous les sites d'appel potentiels pour son code. Par exemple, un Fragment
peut être ajouté au FragmentManager
dans une transaction synchrone en réponse à la mesure et à la mise en page de View
lorsque le contenu de l'application doit être renseigné pour remplir l'espace disponible (par exemple, RecyclerView
). Un LifecycleObserver
répondant au rappel de cycle de vie onStart
de ce fragment peut raisonnablement effectuer des opérations de démarrage ponctuelles ici, et cela peut se trouver sur un chemin de code critique pour produire un frame d'animation sans à-coups. Un développeur doit toujours être sûr que l'appel de n'importe quelle API asynchrone en réponse à ces types de rappels de cycle de vie ne provoquera pas de frame instable.
Cela implique que le travail effectué par une API asynchrone avant de renvoyer une réponse doit être très léger. Il s'agit de créer un enregistrement de la requête et du rappel associé, puis de l'enregistrer auprès du moteur d'exécution qui effectue le travail au maximum. Si l'enregistrement pour une opération asynchrone nécessite l'IPC, l'implémentation de l'API doit prendre toutes les mesures nécessaires pour répondre à cette attente du développeur. Cela peut inclure une ou plusieurs des options suivantes:
- Implémenter un IPC sous-jacent en tant qu'appel de liaison à sens unique
- Effectuer un appel de liaison bidirectionnel dans le serveur système, où l'enregistrement ne nécessite pas de verrouillage très contesté
- Publier la requête sur un thread de travail dans le processus de l'application pour effectuer un enregistrement bloquant via IPC
Les API asynchrones doivent renvoyer void et ne générer d'exception que pour les arguments non valides
Les API asynchrones doivent signaler tous les résultats de l'opération demandée au rappel fourni. Cela permet au développeur d'implémenter un seul chemin de code pour la réussite et la gestion des erreurs.
Les API asynchrones peuvent vérifier si les arguments sont nuls et générer une exception NullPointerException
, ou vérifier que les arguments fournis se trouvent dans une plage valide et générer une exception IllegalArgumentException
. Par exemple, pour une fonction qui accepte un float
compris entre 0
et 1f
, la fonction peut vérifier que le paramètre se trouve dans cette plage et générer une exception IllegalArgumentException
s'il est en dehors de cette plage. Une String
courte peut également être vérifiée pour vérifier qu'elle est conforme à un format valide, tel que les caractères alphanumériques uniquement. (N'oubliez pas que le serveur système ne doit jamais faire confiance au processus de l'application. Tout service système doit dupliquer ces vérifications dans le service système lui-même.)
Toutes les autres erreurs doivent être signalées au rappel fourni. Cela inclut, sans s'y limiter, les éléments suivants:
- Échec définitif de l'opération demandée
- Exceptions de sécurité pour l'autorisation ou les autorisations manquantes requises pour effectuer l'opération
- Quota dépassé pour effectuer l'opération
- Le processus de l'application n'est pas suffisamment "au premier plan" pour effectuer l'opération
- Le matériel requis a été déconnecté
- Échecs du réseau
- de suspensions
- Arrêt du liaisonneur ou processus distant indisponible
Les API asynchrones doivent fournir un mécanisme d'annulation
Les API asynchrones doivent fournir un moyen d'indiquer à une opération en cours d'exécution que l'appelant ne se soucie plus du résultat. Cette opération d'annulation doit signaler deux éléments:
Les références physiques aux rappels fournis par l'appelant doivent être libérées
Les rappels fournis aux API asynchrones peuvent contenir des références physiques à de grands graphiques d'objets, et le travail en cours qui détient une référence physique à ce rappel peut empêcher la collecte des déchets de ces graphiques d'objets. En libérant ces références de rappel lors de l'annulation, ces graphes d'objets peuvent devenir éligibles à la collecte des déchets beaucoup plus rapidement que si le travail était autorisé à s'exécuter jusqu'à son terme.
Le moteur d'exécution effectuant le travail pour l'appelant peut arrêter ce travail.
Le travail déclenché par des appels d'API asynchrones peut entraîner une consommation d'énergie ou d'autres ressources système élevée. Les API qui permettent aux appelants de signaler quand cette tâche n'est plus nécessaire permettent d'arrêter cette tâche avant qu'elle ne puisse consommer d'autres ressources système.
Remarques spécifiques aux applications mises en cache ou congelées
Lorsque vous concevez des API asynchrones dont les rappels proviennent d'un processus système et sont transmis aux applications, tenez compte des points suivants:
- Processus et cycle de vie d'une application : le processus de l'application destinataire peut être à l'état mis en cache.
- Congélateur d'applications mises en cache : le processus de l'application destinataire peut être congelé.
Lorsqu'un processus d'application passe à l'état mis en cache, cela signifie qu'il n'héberge pas activement de composants visibles par l'utilisateur, tels que des activités et des services. L'application est conservée en mémoire au cas où elle redeviendrait visible par l'utilisateur, mais ne doit pas effectuer de travail en attendant. Dans la plupart des cas, vous devez suspendre l'envoi des rappels d'application lorsque cette application passe à l'état mis en cache et reprendre l'envoi lorsque l'application quitte l'état mis en cache, afin de ne pas générer de travail dans les processus d'application mis en cache.
Une application mise en cache peut également être figée. Lorsqu'une application est figée, elle ne reçoit aucun temps de processeur et ne peut effectuer aucune tâche. Tous les appels aux rappels enregistrés de cette application sont mis en mémoire tampon et envoyés lorsque l'application est dégelée.
Les transactions mises en mémoire tampon pour les rappels d'application peuvent être obsolètes au moment où l'application est dégelée et les traite. Le tampon est fini et, en cas de dépassement, l'application destinataire plante. Pour éviter de surcharger les applications avec des événements obsolètes ou de faire déborder leurs tampons, n'expédiez pas de rappels d'application lorsque leur processus est gelé.
En cours d'examen:
- Vous devriez envisager de suspendre l'envoi des rappels d'application pendant que le processus de l'application est mis en cache.
- Vous DEVEZ suspendre le traitement des rappels d'application lorsque le processus de l'application est bloqué.
Suivi de l'état
Pour suivre le moment où les applications entrent ou sortent de l'état mis en cache:
mActivityManager.addOnUidImportanceListener(
new UidImportanceListener() { ... },
IMPORTANCE_CACHED);
Pour suivre le blocage ou le déblocage des applications:
IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);
Stratégies pour reprendre l'envoi des rappels d'application
Que vous suspendiez l'envoi des rappels d'application lorsque l'application passe à l'état mis en cache ou à l'état gelé, lorsque l'application quitte l'état respectif, vous devez reprendre l'envoi des rappels enregistrés de l'application une fois que l'application quitte l'état respectif jusqu'à ce qu'elle ait désinscrit son rappel ou que le processus de l'application se termine.
Exemple :
IBinder binder = <...>;
bool shouldSendCallbacks = true;
binder.addFrozenStateChangeCallback(executor, (who, state) -> {
if (state == IBinder.FrozenStateChangeCallback.STATE_FROZEN) {
shouldSendCallbacks = false;
} else if (state == IBinder.FrozenStateChangeCallback.STATE_UNFROZEN) {
shouldSendCallbacks = true;
}
});
Vous pouvez également utiliser RemoteCallbackList
, qui s'assure de ne pas envoyer de rappels au processus cible lorsqu'il est gelé.
Exemple :
RemoteCallbackList<IInterface> rc =
new RemoteCallbackList.Builder<IInterface>(
RemoteCallbackList.FROZEN_CALLEE_POLICY_DROP)
.setExecutor(executor)
.build();
rc.register(callback);
rc.broadcast((callback) -> callback.foo(bar));
callback.foo()
n'est appelé que si le processus n'est pas gelé.
Les applications enregistrent souvent les mises à jour qu'elles ont reçues à l'aide de rappels en tant qu'instantané de l'état le plus récent. Prenons l'exemple d'une API hypothétique permettant aux applications de surveiller le pourcentage de batterie restant:
interface BatteryListener {
void onBatteryPercentageChanged(int newPercentage);
}
Imaginons qu'une application soit figée et que plusieurs événements de changement d'état se produisent. Lorsque l'application est dégelée, vous ne devez lui transmettre que l'état le plus récent et supprimer les autres modifications d'état obsolètes. Cette diffusion doit se produire immédiatement lorsque l'application est dégelée afin qu'elle puisse "rattraper". Pour ce faire, procédez comme suit:
RemoteCallbackList<IInterface> rc =
new RemoteCallbackList.Builder<IInterface>(
RemoteCallbackList.FROZEN_CALLEE_POLICY_ENQUEUE_MOST_RECENT)
.setExecutor(executor)
.build();
rc.register(callback);
rc.broadcast((callback) -> callback.onBatteryPercentageChanged(value));
Dans certains cas, vous pouvez suivre la dernière valeur envoyée à l'application afin qu'elle n'ait pas besoin d'être avertie de la même valeur une fois qu'elle est déverrouillée.
L'état peut être exprimé sous forme de données plus complexes. Prenons l'exemple d'une API hypothétique permettant aux applications d'être informées des interfaces réseau:
interface NetworkListener {
void onAvailable(Network network);
void onLost(Network network);
void onChanged(Network network);
}
Lorsque vous mettez en pause les notifications d'une application, vous devez vous souvenir de l'ensemble des réseaux et des états que l'application a vus pour la dernière fois. À la reprise, nous vous recommandons d'informer l'application des anciens réseaux perdus, des nouveaux réseaux disponibles et des réseaux existants dont l'état a changé, dans cet ordre.
Ne prévenez pas l'application des réseaux qui ont été mis à disposition, puis perdus lorsque les rappels étaient suspendus. Les applications ne doivent pas recevoir un compte-rendu complet des événements qui se sont produits lorsqu'elles étaient figées, et la documentation de l'API ne doit pas promettre de diffuser des flux d'événements sans interruption en dehors des états de cycle de vie explicites. Dans cet exemple, si l'application doit surveiller en permanence la disponibilité du réseau, elle doit rester dans un état de cycle de vie qui l'empêche d'être mise en cache ou figée.
En résumé, vous devez fusionner les événements qui se sont produits après la mise en pause et avant la reprise des notifications, et transmettre succinctement l'état le plus récent aux rappels d'application enregistrés.
Considérations concernant la documentation pour les développeurs
La diffusion d'événements asynchrones peut être retardée, soit parce que l'expéditeur a mis en pause la diffusion pendant une période donnée, comme indiqué dans la section précédente, soit parce que l'application destinataire n'a pas reçu suffisamment de ressources de l'appareil pour traiter l'événement dans les meilleurs délais.
Découragez les développeurs de faire des suppositions sur le temps écoulé entre le moment où leur application est avertie d'un événement et le moment où l'événement s'est réellement produit.
Attentes des développeurs concernant la suspension des API
Les développeurs familiarisés avec la simultanéité structurée de Kotlin s'attendent aux comportements suivants de toute API de suspension:
Les fonctions de suspension doivent terminer toutes les tâches associées avant de renvoyer ou de générer une exception.
Les résultats des opérations non bloquantes sont renvoyés en tant que valeurs de retour de fonction normales, et les erreurs sont signalées en générant des exceptions. (Cela signifie souvent que les paramètres de rappel ne sont pas nécessaires.)
Les fonctions de suspension ne doivent appeler les paramètres de rappel que sur place
Les fonctions de suspension doivent toujours terminer toutes les tâches associées avant de renvoyer une valeur. Elles ne doivent donc jamais appeler un rappel fourni ni un autre paramètre de fonction, ni conserver une référence à celui-ci après le retour de la fonction de suspension.
Les fonctions de suspension qui acceptent des paramètres de rappel doivent conserver le contexte, sauf indication contraire.
Appeler une fonction dans une fonction de suspension l'exécute dans le CoroutineContext
de l'appelant. Comme les fonctions de suspension doivent terminer tout le travail associé avant de renvoyer ou de générer une exception, et ne doivent appeler que les paramètres de rappel en place, on s'attend à ce que tous ces rappels soient également exécutés sur l'CoroutineContext
appelant à l'aide de son coordinateur associé. Si l'objectif de l'API est d'exécuter un rappel en dehors de l'CoroutineContext
appelant, ce comportement doit être clairement documenté.
Les fonctions de suspension doivent prendre en charge l'annulation des tâches kotlinx.coroutines
Toute fonction de suspension proposée doit coopérer avec l'annulation de tâche telle que définie par kotlinx.coroutines
. Si la tâche d'appel d'une opération en cours est annulée, la fonction doit reprendre avec un CancellationException
dès que possible afin que l'appelant puisse nettoyer et continuer dès que possible. Cette opération est gérée automatiquement par suspendCancellableCoroutine
et les autres API de suspension proposées par kotlinx.coroutines
. En règle générale, les implémentations de bibliothèques ne doivent pas utiliser suspendCoroutine
directement, car elles ne prennent pas en charge ce comportement d'annulation par défaut.
Les fonctions de suspension qui effectuent des tâches bloquantes en arrière-plan (thread non principal ou UI) doivent fournir un moyen de configurer le répartiteur utilisé.
Il est recommandé de faire suspendre entièrement une fonction bloquante pour changer de thread.
L'appel d'une fonction de suspension ne doit pas entraîner la création de threads supplémentaires sans permettre au développeur de fournir son propre thread ou pool de threads pour effectuer cette tâche. Par exemple, un constructeur peut accepter un CoroutineContext
qui est utilisé pour effectuer des tâches en arrière-plan pour les méthodes de la classe.
Les fonctions de suspension qui accepteraient un paramètre CoroutineContext
ou Dispatcher
facultatif uniquement pour passer à ce distributeur afin d'effectuer un travail bloquant doivent plutôt exposer la fonction de blocage sous-jacente et recommander aux développeurs appelants d'utiliser leur propre appel à withContext pour diriger le travail vers un distributeur choisi.
Classes lançant des coroutines
Les classes qui lancent des coroutines doivent disposer d'un CoroutineScope
pour effectuer ces opérations de lancement. Le respect des principes de simultanéité structurée implique les modèles structurels suivants pour obtenir et gérer cette portée.
Avant d'écrire une classe qui lance des tâches simultanées dans un autre champ d'application, envisagez d'autres modèles:
class MyClass {
private val requests = Channel<MyRequest>(Channel.UNLIMITED)
suspend fun handleRequests() {
coroutineScope {
for (request in requests) {
// Allow requests to be processed concurrently;
// alternatively, omit the [launch] and outer [coroutineScope]
// to process requests serially
launch {
processRequest(request)
}
}
}
}
fun submitRequest(request: MyRequest) {
requests.trySend(request).getOrThrow()
}
}
L'exposition d'un suspend fun
pour effectuer un travail concurrent permet à l'appelant d'appeler l'opération dans son propre contexte, ce qui évite d'avoir à gérer un CoroutineScope
par MyClass
. La sérialisation du traitement des requêtes devient plus simple, et l'état peut souvent exister sous forme de variables locales de handleRequests
au lieu de propriétés de classe qui nécessiteraient autrement une synchronisation supplémentaire.
Les classes qui gèrent des coroutines doivent exposer les méthodes close et cancel
Les classes qui lancent des coroutines en tant que détails d'implémentation doivent offrir un moyen d'arrêter proprement ces tâches simultanées en cours afin qu'elles ne fuient pas de travail simultané incontrôlé dans un champ d'application parent. En règle générale, cela se présente sous la forme de la création d'un Job
enfant d'un CoroutineContext
fourni:
private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)
fun cancel() {
myJob.cancel()
}
Une méthode join()
peut également être fournie pour permettre au code utilisateur d'attendre la fin de toute tâche en cours d'exécution par l'objet.
(Cela peut inclure le travail de nettoyage effectué en annulant une opération.)
suspend fun join() {
myJob.join()
}
Nom des opérations du terminal
Le nom utilisé pour les méthodes qui arrêtent proprement les tâches simultanées appartenant à un objet en cours d'exécution doit refléter le contrat comportemental de l'arrêt:
Utilisez close()
lorsque les opérations en cours peuvent se terminer, mais qu'aucune nouvelle opération ne peut commencer après le retour de l'appel de close()
.
Utilisez cancel()
lorsque les opérations en cours peuvent être annulées avant d'être terminées.
Aucune nouvelle opération ne peut commencer après le retour de l'appel à cancel()
.
Les constructeurs de classe acceptent CoroutineContext, et non CoroutineScope
Lorsque les objets ne sont pas autorisés à être lancés directement dans un champ d'application parent fourni, l'éligibilité de CoroutineScope
en tant que paramètre de constructeur ne fonctionne plus:
// Don't do this
class MyClass(scope: CoroutineScope) {
private val myJob = Job(parent = scope.`CoroutineContext`[Job])
private val myScope = CoroutineScope(scope.`CoroutineContext` + myJob)
// ... the [scope] constructor parameter is never used again
}
CoroutineScope
devient un wrapper inutile et trompeur qui, dans certains cas d'utilisation, peut être construit uniquement pour être transmis en tant que paramètre de constructeur, puis être supprimé:
// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))
Les paramètres CoroutineContext sont définis par défaut sur EmptyCoroutineContext
Lorsqu'un paramètre CoroutineContext
facultatif apparaît dans une surface d'API, la valeur par défaut doit être la sentinelle Empty`CoroutineContext`
. Cela permet une meilleure composition des comportements de l'API, car une valeur Empty`CoroutineContext`
d'un appelant est traitée de la même manière que l'acceptation de la valeur par défaut:
class MyOuterClass(
`CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
private val innerObject = MyInnerClass(`CoroutineContext`)
// ...
}
class MyInnerClass(
`CoroutineContext`: `CoroutineContext` = Empty`CoroutineContext`
) {
private val job = Job(parent = `CoroutineContext`[Job])
private val scope = CoroutineScope(`CoroutineContext` + job)
// ...
}