Les API non bloquantes demandent l'exécution d'une tâche, puis redonnent 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 dans les cas où la tâche demandée peut être en cours ou nécessiter d'attendre la fin d'une E/S ou d'une IPC, la disponibilité de ressources système très sollicitées ou une entrée utilisateur avant de pouvoir continuer. Les API particulièrement bien conçues permettent d'annuler l'opération en cours et d'empêcher l'exécution de tâches au nom 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 motivations principales pour écrire une API asynchrone :
- Exécuter plusieurs opérations simultanément, où une Nième opération doit être lancée avant la fin de la N-1ème opération.
- É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 découplent l'exécution synchrone et asynchrone du code du comportement de blocage des threads. Les fonctions de suspension sont non bloquantes et synchrones.
Fonctions de suspension :
- Ne bloquez pas leur thread appelant et cédez 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-les de manière synchrone et n'obligez pas l'appelant d'une API non bloquante à continuer à s'exécuter simultanément avec une tâche non bloquante lancée par l'appel d'API.
Cette page présente une base de référence minimale des attentes que les développeurs peuvent avoir en toute sécurité lorsqu'ils travaillent avec des API non bloquantes et asynchrones, suivie 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
Les attentes suivantes sont écrites du point de vue des API non suspensives, sauf indication contraire.
Les API qui acceptent les rappels sont généralement asynchrones
Si une API accepte un rappel qui n'est pas documenté comme n'étant appelé qu' in-place (c'est-à-dire appelé uniquement par le thread appelant avant que l'appel d'API lui-même ne renvoie), l'API est considérée comme asynchrone et doit répondre à toutes les autres attentes documentées dans les sections suivantes.
Un exemple de rappel qui n'est appelé qu'in-place est une fonction de carte ou de filtre 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 le plus rapidement possible
Les développeurs s'attendent à ce que les API asynchrones soient non bloquantes et renvoient rapidement après avoir lancé la requête pour l'opération. Il doit toujours être possible d'appeler une API asynchrone à tout moment, et l'appel d'une API asynchrone ne doit jamais entraîner de frames saccadés ni d'ANR.
De nombreuses opérations et signaux de cycle de vie peuvent être déclenchés à la demande par la plate-forme ou les bibliothèques, et il est impossible pour un développeur de connaître tous les sites d'appel potentiels de 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 View lorsque le contenu de l'application doit être inséré pour occuper 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 uniques ici, et cela peut se trouver sur un chemin de code critique pour produire une frame d'animation sans à-coups. Un développeur doit toujours être sûr que l'appel d'une API asynchrone en réponse à ce type de rappels de cycle de vie ne sera pas la cause d'une frame saccadée.
Cela implique que le travail effectué par une API asynchrone avant de renvoyer doit être très léger : créer un enregistrement de la requête et du rappel associé, et l'enregistrer au maximum auprès du moteur d'exécution qui effectue le travail. Si l'enregistrement d'une opération asynchrone nécessite une IPC, l'implémentation de l'API doit prendre toutes les mesures nécessaires pour répondre à cette attente des développeurs. Cela peut inclure un ou plusieurs des éléments suivants :
- Implémenter une IPC sous-jacente en tant qu'appel de liaison unidirectionnel
- Effectuer un appel de liaison bidirectionnel au serveur système où la finalisation de l'enregistrement ne nécessite pas de verrouillage très sollicité
- Publier la requête dans un thread de travail du processus d'application pour effectuer un enregistrement bloquant via IPC
Les API asynchrones doivent renvoyer void et ne générer que des 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 gestion des succès et des erreurs.
Les API asynchrones peuvent vérifier si les arguments sont nuls et générer NullPointerException, ou vérifier que les arguments fournis se trouvent dans une plage valide et générer 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 IllegalArgumentException s'il est hors plage, ou une String courte peut être vérifiée pour s'assurer 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 d'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 :
- É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 l'exécution de l'opération
- Le processus d'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
- Délais avant expiration
- Liaison interrompue ou processus distant indisponible
Les API asynchrones doivent fournir un mécanisme d'annulation
Les API asynchrones doivent permettre d'indiquer à une opération en cours que l'appelant ne se soucie plus du résultat. Cette opération d'annulation doit signaler deux choses :
Les références matérielles aux rappels fournis par l'appelant doivent être libérées
Les rappels fournis aux API asynchrones peuvent contenir des références matérielles à de grands graphiques d'objets, et le travail en cours qui contient une référence matérielle à ce rappel peut empêcher la récupération de ces graphiques d'objets. En libérant ces références de rappel lors de l'annulation, ces graphiques d'objets peuvent devenir éligibles à la récupération de mémoire beaucoup plus tôt que si le travail était autorisé à s'exécuter jusqu'à la fin.
Le moteur d'exécution qui effectue le travail pour l'appelant peut arrêter ce travail
Le travail lancé par les appels d'API asynchrones peut entraîner un coût élevé en termes de consommation d'énergie ou d'autres ressources système. Les API qui permettent aux appelants de signaler quand ce travail n'est plus nécessaire permettent de l'arrêter avant qu'il ne consomme davantage de ressources système.
Considérations particulières pour les applications mises en cache ou bloquées
Lorsque vous concevez des API asynchrones où les rappels proviennent d'un processus système et sont transmis aux applications, tenez compte des points suivants :
- Processus et cycle de vie de l'application: le processus d'application destinataire peut être à l'état mis en cache.
- Congélateur d'applications mises en cache: le processus d'application destinataire peut être bloqué.
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 en attendant, elle ne doit pas fonctionner. 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 lorsque l'application quitte cet état, afin de ne pas induire de travail dans les processus d'application mis en cache.
Une application mise en cache peut également être bloquée. Lorsqu'une application est bloquée, elle ne reçoit aucun temps CPU et ne peut effectuer aucune tâche. Tous les appels aux rappels enregistrés de cette application sont mis en mémoire tampon et transmis lorsque l'application est débloquée.
Les transactions mises en mémoire tampon vers les rappels d'application peuvent être obsolètes au moment où l'application est débloquée et les traite. La mémoire tampon est limitée et, en cas de dépassement, l'application destinataire plante. Pour éviter de submerger les applications avec des événements obsolètes ou de dépasser la capacité de leurs mémoires tampons, n'envoyez pas de rappels d'application lorsque leur processus est bloqué.
En cours d'examen :
- Vous devez envisager de suspendre l'envoi des rappels d'application lorsque le processus de l'application est mis en cache.
- Vous DEVEZ suspendre l'envoi des rappels d'application lorsque le processus de l'application est bloqué.
Suivi de l'état
Pour savoir quand les applications passent à l'état mis en cache ou le quittent :
mActivityManager.addOnUidImportanceListener(
new UidImportanceListener() { ... },
IMPORTANCE_CACHED);
Pour savoir quand les applications sont bloquées ou débloquées :
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 bloqué, lorsque l'application quitte l'état respectif, vous devez reprendre l'envoi des rappels enregistrés de l'application une fois qu'elle a quitté l'état respectif jusqu'à ce que l'application ait annulé son enregistrement de rappel ou que le processus d'application s'arrête.
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 veille à ne pas envoyer de rappels au processus cible lorsqu'il est bloqué.
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 bloqué.
Les applications enregistrent souvent les mises à jour qu'elles ont reçues à l'aide de rappels sous forme d'instantané du dernier état. Prenons l'exemple d'une API hypothétique permettant aux applications de surveiller le pourcentage de batterie restant :
interface BatteryListener {
void onBatteryPercentageChanged(int newPercentage);
}
Prenons l'exemple d'un scénario dans lequel plusieurs événements de changement d'état se produisent lorsqu'une application est bloquée. Lorsque l'application est débloquée, vous ne devez transmettre que l'état le plus récent à l'application et supprimer les autres changements d'état obsolètes. Cette transmission doit avoir lieu immédiatement lorsque l'application est débloquée afin qu'elle puisse "rattraper son retard". 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 transmise à l'application afin qu'elle n'ait pas besoin d'être avertie de la même valeur une fois qu'elle est débloqué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 averties des interfaces réseau :
interface NetworkListener {
void onAvailable(Network network);
void onLost(Network network);
void onChanged(Network network);
}
Lorsque vous suspendez 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. Lors de la reprise, il est recommandé d'informer l'application des anciens réseaux qui ont été perdus, des nouveaux réseaux qui sont devenus disponibles et des réseaux existants dont l'état a changé, dans cet ordre.
N'informez pas l'application des réseaux qui ont été mis à disposition puis perdus pendant la suspension des rappels. Les applications ne doivent pas recevoir un compte rendu complet des événements survenus pendant leur blocage, et la documentation de l'API ne doit pas promettre de fournir des flux d'événements ininterrompus 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 bloquée.
En cours d'examen, vous devez fusionner les événements survenus après la suspension et avant la reprise des notifications, et transmettre succinctement le dernier état aux rappels d'application enregistrés.
Remarques concernant la documentation destinée aux développeurs
La transmission d'événements asynchrones peut être retardée, soit parce que l'expéditeur a suspendu la transmission 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 d'appareil pour traiter l'événement en temps voulu.
Dissuadez les développeurs de faire des hypothèses sur le délai 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 les API suspensives
Les développeurs qui connaissent la simultanéité structurée de Kotlin s'attendent aux comportements suivants de toute API suspensive :
Les fonctions de suspension doivent effectuer toutes les tâches associées avant de renvoyer ou de générer
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 qu'in-place
Les fonctions de suspension doivent toujours effectuer toutes les tâches associées avant de renvoyer. Elles ne doivent donc jamais appeler un rappel fourni ou un autre paramètre de fonction, ni conserver de référence à celui-ci après le renvoi de la fonction de suspension.
Les fonctions de suspension qui acceptent les paramètres de rappel doivent préserver le contexte, sauf indication contraire dans la documentation
L'appel d'une fonction dans une fonction de suspension entraîne son exécution dans le CoroutineContext de l'appelant. Comme les fonctions de suspension doivent effectuer toutes les tâches associées avant de renvoyer ou de générer, et ne doivent appeler les paramètres de rappel qu'in-place, l'attente par défaut est que ces rappels soient également exécutés sur le CoroutineContext appelant à l'aide de son répartiteur associé. Si l'objectif de l'API est d'exécuter un rappel en dehors du CoroutineContext appelant, ce comportement doit être clairement documenté.
Les fonctions de suspension doivent être compatibles avec l'annulation de tâche 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 appelante d'une opération en cours est annulée, la fonction doit reprendre avec une CancellationException dès que possible afin que l'appelant puisse libérer de l'espace et continuer dès que possible. Cette opération est gérée automatiquement par suspendCancellableCoroutine et d'autres API suspensives proposées par kotlinx.coroutines. En règle générale, les implémentations de bibliothèque ne doivent pas utiliser suspendCoroutine directement, car elles ne sont pas compatibles avec ce comportement d'annulation par défaut.
Les fonctions de suspension qui effectuent des tâches bloquantes sur un thread en arrière-plan (non principal ou thread UI) doivent permettre de configurer le répartiteur utilisé
Il est déconseillé de 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 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 répartiteur afin d'effectuer une tâche bloquante doivent plutôt exposer la fonction de blocage sous-jacente et recommander aux développeurs appelants d'utiliser leur propre appel à withContext pour diriger la tâche vers un répartiteur 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 une autre portée, 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'une suspend fun pour effectuer des tâches simultanées permet à l'appelant d'appeler l'opération dans son propre contexte, ce qui élimine la nécessité pour MyClass de gérer un CoroutineScope. La sérialisation du traitement des requêtes devient plus simple et l'état peut souvent exister en tant que variables locales de handleRequests au lieu de propriétés de classe qui nécessiteraient une synchronisation supplémentaire.
Les classes qui gèrent les coroutines doivent exposer des méthodes de fermeture et d'annulation
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é non contrôlé dans une portée parente. En règle générale, cela prend 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 simultanée en cours d'exécution par l'objet.
(Cela peut inclure des tâches de nettoyage effectuées en annulant une opération.)
suspend fun join() {
myJob.join()
}
Nommage des opérations terminales
Le nom utilisé pour les méthodes qui arrêtent proprement les tâches simultanées appartenant à un objet qui sont toujours en cours 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 renvoi de l'appel à close().
Utilisez cancel() lorsque les opérations en cours peuvent être annulées avant la fin.
Aucune nouvelle opération ne peut commencer après le renvoi de l'appel à cancel().
Les constructeurs de classe acceptent CoroutineContext, pas CoroutineScope
Lorsque les objets ne sont pas autorisés à se lancer directement dans une portée parente fournie, l'adéquation de CoroutineScope en tant que paramètre de constructeur est compromise :
// 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
}
Le 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, pour ensuite ê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 facultatif CoroutineContext apparaît dans une surface d'API, la
valeur par défaut doit être le Empty`CoroutineContext` sentinelle. Cela permet une meilleure composition des comportements d'API, car une valeur
d'un appelant est traitée de la même manière que l'acceptation de la valeur par défaut :Empty`CoroutineContext`
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)
// ...
}