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ù le travail demandé peut être 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 sollicitées ou l'entrée de l'utilisateur avant que le travail puisse se poursuivre. 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 l'un des moyens 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 au cours de la progression de l'opération.
Il existe deux principales raisons d'écrire une API asynchrone :
- Exécution simultanée de plusieurs opérations, 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 encourage fortement la concurrence structurée, une série de principes et d'API basés sur des fonctions suspend 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 d'appel 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écuter de manière synchrone et ne pas exiger que l'appelant d'une API non bloquante continue de s'exécuter simultanément avec le travail non bloquant initié par l'appel d'API.
Cette page décrit une base de référence minimale à laquelle les développeurs peuvent s'attendre lorsqu'ils travaillent avec des API non bloquantes et asynchrones. Elle est suivie d'une série de recettes pour créer des API qui répondent à ces attentes dans les langages Kotlin ou Java, dans la plate-forme Android ou 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 formulées du point de vue des API non associées à la suspension, 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é pour n'être appelé que sur place (c'est-à-dire appelé uniquement par le thread appelant avant le retour de l'appel d'API lui-même), 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 situ est une fonction map ou filter 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. 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 saccades ou d'ANR.
De nombreux signaux d'opérations et de cycle de vie peuvent être déclenchés à la demande par la plate-forme ou les bibliothèques. Il est donc impossible pour un développeur de connaître tous les sites d'appel potentiels pour son code. Par exemple, un Fragment
peut être ajouté à FragmentManager
dans une transaction synchrone en réponse à la mesure et à la mise en page View
lorsque le contenu de l'application doit être renseigné pour remplir l'espace disponible (comme 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 une image d'animation sans saccades. Un développeur doit toujours être sûr que l'appel d'une API asynchrone en réponse à ces types de rappels de cycle de vie ne provoquera pas de frame saccadé.
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 au maximum de créer un enregistrement de la requête et du rappel associé, et de l'enregistrer auprès du moteur d'exécution qui effectue le travail. Si l'enregistrement d'une opération asynchrone nécessite un IPC, l'implémentation de l'API doit prendre les mesures nécessaires pour répondre à cette attente des développeurs. Cela peut inclure un ou plusieurs des éléments suivants :
- Implémenter un IPC sous-jacent en tant qu'appel de binder unidirectionnel
- Effectuer un appel de liaison bidirectionnel au serveur système, où l'enregistrement ne nécessite pas de prendre un verrou très disputé
- Envoi de la requête à un thread de nœud de calcul dans le processus de l'application pour effectuer un enregistrement bloquant sur IPC
Les API asynchrones doivent renvoyer une valeur nulle et ne générer une 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 gestion des réussites 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
dans la plage 0
à 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. Un String
court peut être vérifié pour la conformité à un format valide tel que alphanumérique 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 :
- Échec définitif de l'opération demandée
- Exceptions de sécurité pour l'absence d'autorisation ou d'autorisations 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
- Suspensions
- Arrêt du binder ou processus à distance indisponible
Les API asynchrones doivent fournir un mécanisme d'annulation
Les API asynchrones doivent permettre 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 choses :
Les références dures aux rappels fournis par l'appelant doivent être libérées
Les rappels fournis aux API asynchrones peuvent contenir des références dures à de grands graphiques d'objets. Les travaux en cours qui contiennent une référence dure à ce rappel peuvent 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 initié 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 que ce travail n'est plus nécessaire permettent de l'arrêter avant qu'il ne consomme davantage de ressources système.
Informations spécifiques aux applications mises en cache ou figé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 d'une application : le processus de l'application destinataire peut être à l'état mis en cache.
- Mise en veille des applications mises en cache : le processus de l'application destinataire peut être mis en veille.
Lorsqu'un processus d'application entre dans l'état mis en cache, cela signifie qu'il n'héberge activement aucun composant visible par l'utilisateur, comme 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 effectuer de tâches. Dans la plupart des cas, vous devez suspendre l'envoi des rappels d'application lorsque cette application passe à l'état mis en cache, et le reprendre lorsque l'application quitte l'état mis en cache, afin de ne pas induire 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 transmis 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 planterait. Pour éviter de submerger les applications avec des événements obsolètes ou de saturer leurs tampons, n'envoyez pas de rappels d'application lorsque leur processus est figé.
En cours d'examen :
- Vous devez envisager de suspendre l'envoi des rappels d'application pendant que 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 figé.
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 savoir quand les applications sont figées ou défigées :
IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);
Stratégies pour reprendre les rappels d'application de répartition
Que vous mettiez en pause l'envoi des rappels d'application lorsque l'application passe à l'état mis en cache ou à l'état figé, vous devez reprendre l'envoi des rappels enregistrés de l'application une fois que l'application quitte l'état respectif jusqu'à ce que l'application ait désenregistré 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 empêche l'envoi de rappels au processus cible lorsqu'il est figé.
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 invoqué que si le processus n'est pas figé.
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 figée. Lorsque l'application est dégelée, vous ne devez fournir que l'état le plus récent à l'application et supprimer les autres changements d'état obsolètes. Cette diffusion doit avoir lieu immédiatement lorsque l'application est dégelé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 fournie à l'application afin qu'elle n'ait pas besoin d'être avertie de la même valeur une fois qu'elle est dégelée.
L'état peut être exprimé sous la 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 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 en dernier. Lors de la reprise, il est recommandé 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 pas notifier à l'application les réseaux qui ont été rendus disponibles, puis perdus pendant la mise en pause des rappels. Les applications ne doivent pas recevoir un compte rendu complet des événements qui se sont produits pendant qu'elles étaient figées, 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 figée.
Lors de l'examen, vous devez regrouper les événements qui se sont produits après la mise en veille et avant la reprise des notifications, et fournir brièvement le dernier état aux rappels d'application enregistrés.
Points à prendre en compte pour la documentation destinée aux développeurs
La distribution d'événements asynchrones peut être retardée, soit parce que l'expéditeur a suspendu la distribution pendant un certain temps, 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 en temps voulu.
Dissuader les développeurs de faire des hypothèses 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 qui connaissent la simultanéité structurée de Kotlin s'attendent aux comportements suivants de la part de toute API de suspension :
Les fonctions de suspension doivent effectuer tout le travail associé 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 qu'in situ
Les fonctions de suspension doivent toujours effectuer 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 de référence à celui-ci après le retour de la fonction de suspension.
Les fonctions de suspension qui acceptent les paramètres de rappel doivent préserver le contexte, sauf indication contraire.
L'appel d'une fonction dans une fonction de suspension entraîne son exécution dans le CoroutineContext
de l'appelant. Étant donné que les fonctions de suspension doivent effectuer tout le travail associé avant de renvoyer ou de générer une exception, et qu'elles ne doivent invoquer les paramètres de rappel qu'en 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 de l'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 le job appelant d'une opération en cours est annulé, la fonction doit reprendre avec un CancellationException
dès que possible afin que l'appelant puisse effectuer le nettoyage et continuer dès que possible. Cette opération est gérée automatiquement par suspendCancellableCoroutine
et d'autres API de suspension proposées par kotlinx.coroutines
. En règle générale, les implémentations de bibliothèque ne doivent pas utiliser suspendCoroutine
directement, car il ne prend pas en charge ce comportement d'annulation par défaut.
Les fonctions de suspension qui effectuent des tâches bloquantes sur un thread en arrière-plan (autre que le thread principal ou d'UI) doivent permettre de configurer le répartiteur utilisé.
Il est déconseillé de faire en sorte qu'une fonction bloquante suspende entièrement 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 ce travail. 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 un travail bloquant devraient plutôt exposer la fonction de blocage sous-jacente et recommander aux développeurs d'utiliser leur propre appel à withContext pour diriger le travail 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 ce champ d'application.
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'un suspend fun
pour effectuer un travail simultané 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é incontrôlé dans une portée parente. Cela se fait généralement en créant 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 tout travail simultané en cours effectué par l'objet.
(Cela peut inclure des tâches de nettoyage effectuées en annulant une opération.)
suspend fun join() {
myJob.join()
}
Nommer les opérations de terminal
Le nom utilisé pour les méthodes qui arrêtent proprement les tâches simultanées appartenant à un objet et qui sont toujours en cours doit refléter le contrat de comportement 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 à 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 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
}
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 CoroutineContext
facultatif apparaît dans une surface d'API, la valeur par défaut doit être le 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)
// ...
}