Le API non bloccanti richiedono l'esecuzione di un'attività e poi restituiscono il controllo al thread chiamante, in modo che possa eseguire altre attività prima del completamento dell'operazione richiesta. Queste API sono utili nei casi in cui il lavoro richiesto potrebbe essere in corso o potrebbe richiedere l'attesa del completamento di I/O o IPC, la disponibilità di risorse di sistema molto contese o l'input dell'utente prima che il lavoro possa procedere. Le API ben progettate forniscono un modo per annullare l'operazione in corso e interrompere l'esecuzione del lavoro per conto del chiamante originale, preservando l'integrità del sistema e la durata della batteria quando l'operazione non è più necessaria.
Le API asincrone sono un modo per ottenere un comportamento non bloccante. Le API asincrone accettano una qualche forma di continuazione o callback che viene notificata al termine dell'operazione o di altri eventi durante l'avanzamento dell'operazione.
Esistono due motivazioni principali per scrivere un'API asincrona:
- Esecuzione simultanea di più operazioni, in cui l'ennesima operazione deve essere avviata prima del completamento dell'operazione N-1.
- Evitare di bloccare un thread di chiamata fino al completamento di un'operazione.
Kotlin promuove fortemente la concorrenza strutturata, una serie di principi e API basati su funzioni di sospensione che disaccoppiano l'esecuzione sincrona e asincrona del codice dal comportamento di blocco dei thread. Le funzioni di sospensione sono non bloccanti e sincrone.
Sospendi funzioni:
- Non bloccare il thread di chiamata e cedere invece il thread di esecuzione come dettaglio di implementazione in attesa dei risultati delle operazioni eseguite altrove.
- Esegui in modo sincrono e non richiedere al chiamante di un'API non bloccante di continuare l'esecuzione contemporaneamente al lavoro non bloccante avviato dalla chiamata API.
Questa pagina descrive una base di riferimento minima di aspettative che gli sviluppatori possono nutrire in sicurezza quando lavorano con API non bloccanti e asincrone, seguita da una serie di ricette per la creazione di API che soddisfano queste aspettative nei linguaggi Kotlin o Java, nella piattaforma Android o nelle librerie Jetpack. In caso di dubbio, considera le aspettative degli sviluppatori come requisiti per qualsiasi nuova superficie API.
Aspettative degli sviluppatori per le API asincrone
Le seguenti aspettative sono scritte dal punto di vista delle API non sospensive, se non diversamente indicato.
Le API che accettano callback sono in genere asincrone
Se un'API accetta un callback che non è documentato per essere chiamato in loco (ovvero chiamato solo dal thread chiamante prima che la chiamata API stessa restituisca), l'API viene considerata asincrona e deve soddisfare tutte le altre aspettative documentate nelle sezioni seguenti.
Un esempio di callback chiamato solo in loco è una funzione map o filter di ordine superiore che richiama un mapper o un predicato su ogni elemento di una raccolta prima di restituire.
Le API asincrone devono restituire i risultati il più rapidamente possibile
Gli sviluppatori si aspettano che le API asincrone siano non bloccanti e che vengano restituite rapidamente dopo l'avvio della richiesta per l'operazione. Dovrebbe essere sempre sicuro chiamare un'API asincrona in qualsiasi momento e la chiamata di un'API asincrona non dovrebbe mai causare frame instabili o ANR.
Molti indicatori di operazioni e ciclo di vita possono essere attivati dalla piattaforma o
dalle librerie on demand e non è sostenibile aspettarsi che uno sviluppatore abbia una conoscenza globale di tutti
i potenziali siti di chiamata per il proprio codice. Ad esempio, un Fragment
può essere aggiunto a FragmentManager
in una transazione sincrona in risposta
alla misurazione e al layout di View
quando i contenuti dell'app devono essere compilati per riempire
lo spazio disponibile (ad esempio RecyclerView
). Un LifecycleObserver
che risponde al
callback del ciclo di vita onStart
di questo frammento può ragionevolmente eseguire operazioni di avvio
una tantum qui e questo potrebbe avvenire in un percorso di codice critico per produrre
un frame di animazione privo di scatti. Uno sviluppatore deve sempre avere la certezza che
la chiamata a qualsiasi API asincrona in risposta a questi tipi di callback del ciclo di vita
non causerà un frame instabile.
Ciò implica che il lavoro eseguito da un'API asincrona prima di restituire deve essere molto leggero; al massimo, creare un record della richiesta e del callback associato e registrarlo con il motore di esecuzione che esegue il lavoro. Se la registrazione per un'operazione asincrona richiede IPC, l'implementazione dell'API deve adottare le misure necessarie per soddisfare questa aspettativa dello sviluppatore. Ciò potrebbe includere uno o più dei seguenti elementi:
- Implementazione di un IPC sottostante come chiamata binder unidirezionale
- Eseguire una chiamata binder bidirezionale al server di sistema in cui il completamento della registrazione non richiede l'acquisizione di un blocco molto conteso
- Invio della richiesta a un thread di lavoro nel processo dell'app per eseguire una registrazione bloccante tramite IPC
Le API asincrone devono restituire void e generare un'eccezione solo per argomenti non validi
Le API asincrone devono segnalare tutti i risultati dell'operazione richiesta al callback fornito. Ciò consente allo sviluppatore di implementare un unico percorso di codice per la gestione di errori e operazioni riuscite.
Le API asincrone potrebbero controllare gli argomenti per i valori null e generare NullPointerException
oppure
verificare che gli argomenti forniti rientrino in un intervallo valido e generare
IllegalArgumentException
. Ad esempio, per una funzione che accetta un float
nell'intervallo da 0
a 1f
, la funzione potrebbe verificare che il parametro rientri in questo intervallo e generare IllegalArgumentException
se non rientra nell'intervallo. In alternativa, un String
breve potrebbe essere verificato per la conformità a un formato valido, ad esempio solo alfanumerico. Ricorda che il server di sistema non deve mai considerare attendibile il processo dell'app. Qualsiasi servizio di sistema deve duplicare questi controlli nel servizio di sistema
stesso.)
Tutti gli altri errori devono essere segnalati al callback fornito. Ciò include, a titolo esemplificativo:
- Errore terminale dell'operazione richiesta
- Eccezioni di sicurezza per l'autorizzazione o le autorizzazioni mancanti necessarie per completare l'operazione
- Quota superata per l'esecuzione dell'operazione
- Il processo dell'app non è sufficientemente "in primo piano" per eseguire l'operazione
- L'hardware richiesto è stato disconnesso
- Errori di rete
- Timeout
- Interruzione del binder o processo remoto non disponibile
Le API asincrone devono fornire un meccanismo di annullamento
Le API asincrone devono fornire un modo per indicare a un'operazione in esecuzione che il chiamante non è più interessato al risultato. Questa operazione di annullamento deve segnalare due cose:
I riferimenti espliciti ai callback forniti dal chiamante devono essere rilasciati
I callback forniti alle API asincrone possono contenere riferimenti rigidi a grafici di oggetti di grandi dimensioni e il lavoro in corso che contiene un riferimento rigido a questo callback può impedire la garbage collection di questi grafici di oggetti. Se rilasciati al momento dell'annullamento, questi riferimenti di callback possono diventare idonei per la garbage collection molto prima rispetto a quando il lavoro viene eseguito fino al completamento.
Il motore di esecuzione che esegue il lavoro per il chiamante potrebbe interromperlo
Il lavoro avviato da chiamate API asincrone potrebbe comportare un costo elevato in termini di consumo energetico o altre risorse di sistema. Le API che consentono ai chiamanti di segnalare quando questo lavoro non è più necessario consentono di interromperlo prima che possa consumare ulteriori risorse di sistema.
Considerazioni speciali per le app memorizzate nella cache o bloccate
Quando progetti API asincrone in cui i callback hanno origine in un processo di sistema e vengono inviati alle app, tieni presente quanto segue:
- Processi e ciclo di vita dell'app: il processo dell'app destinataria potrebbe essere nello stato memorizzato nella cache.
- Congelamento delle app memorizzate nella cache: il processo dell'app destinataria potrebbe essere bloccato.
Quando un processo dell'app entra nello stato memorizzato nella cache, significa che non ospita attivamente componenti visibili all'utente, come attività e servizi. L'app viene mantenuta in memoria nel caso in cui torni a essere visibile all'utente, ma nel frattempo non dovrebbe funzionare. Nella maggior parte dei casi, devi mettere in pausa l'invio dei callback dell'app quando l'app entra nello stato memorizzato nella cache e riprenderlo quando l'app esce dallo stato memorizzato nella cache, in modo da non indurre lavoro nei processi dell'app memorizzata nella cache.
Un'app memorizzata nella cache potrebbe anche essere bloccata. Quando un'app è bloccata, riceve zero tempo CPU e non è in grado di svolgere alcun lavoro. Eventuali chiamate ai callback registrati dell'app vengono memorizzate nel buffer e consegnate quando l'app viene riattivata.
Le transazioni memorizzate nel buffer per i callback delle app potrebbero essere obsolete quando l'app viene riattivata ed elaborate. Il buffer è finito e, se viene superato, l'app destinataria si arresta in modo anomalo. Per evitare di sovraccaricare le app con eventi obsoleti o di riempire i buffer, non inviare callback delle app mentre il processo è bloccato.
In revisione:
- Ti consigliamo di mettere in pausa i callback dell'app di invio mentre il processo dell'app è memorizzato nella cache.
- Devi METTERE IN PAUSA i callback dell'app di invio mentre il processo dell'app è bloccato.
Monitoraggio dello stato
Per monitorare quando le app entrano o escono dallo stato memorizzato nella cache:
mActivityManager.addOnUidImportanceListener(
new UidImportanceListener() { ... },
IMPORTANCE_CACHED);
Per monitorare quando le app vengono congelate o scongelate:
IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);
Strategie per riprendere l'invio di callback dell'app
Se metti in pausa l'invio dei callback dell'app quando l'app entra nello stato memorizzato nella cache o nello stato bloccato, quando l'app esce dallo stato corrispondente devi riprendere l'invio dei callback registrati dell'app finché l'app non ha annullato la registrazione del callback o il processo dell'app non viene interrotto.
Ad esempio:
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;
}
});
In alternativa, puoi utilizzare RemoteCallbackList
, che si occupa di non
inviare callback al processo di destinazione quando è bloccato.
Ad esempio:
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()
viene richiamato solo se il processo non è bloccato.
Le app spesso salvano gli aggiornamenti ricevuti utilizzando i callback come snapshot dell'ultimo stato. Prendi in considerazione un'API ipotetica per consentire alle app di monitorare la percentuale di batteria rimanente:
interface BatteryListener {
void onBatteryPercentageChanged(int newPercentage);
}
Considera lo scenario in cui si verificano più eventi di modifica dello stato quando un'app è congelata. Quando l'app viene scongelata, devi fornire solo lo stato più recente all'app ed eliminare le altre modifiche dello stato obsolete. Questa pubblicazione deve avvenire immediatamente quando l'app viene riattivata, in modo che possa "recuperare". Per eseguire questa operazione procedi come indicato di seguito:
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));
In alcuni casi, puoi monitorare l'ultimo valore inviato all'app in modo che l'app non debba ricevere una notifica dello stesso valore una volta che è stata riattivata.
Lo stato può essere espresso come dati più complessi. Considera un'API ipotetica per le app per ricevere notifiche sulle interfacce di rete:
interface NetworkListener {
void onAvailable(Network network);
void onLost(Network network);
void onChanged(Network network);
}
Quando metti in pausa le notifiche di un'app, devi ricordare l'insieme di reti e stati che l'app ha visto l'ultima volta. Al ripristino, è consigliabile notificare all'app le vecchie reti perse, le nuove reti disponibili e le reti esistenti il cui stato è cambiato, in quest'ordine.
Non notificare all'app le reti che sono state rese disponibili e poi perse mentre i callback erano in pausa. Le app non devono ricevere un resoconto completo degli eventi che si sono verificati durante il loro blocco e la documentazione API non deve promettere di fornire flussi di eventi ininterrotti al di fuori degli stati del ciclo di vita espliciti. In questo esempio, se l'app deve monitorare continuamente la disponibilità di rete, deve rimanere in uno stato del ciclo di vita che impedisca di essere memorizzata nella cache o congelata.
Durante la revisione, devi unire gli eventi che si sono verificati dopo la sospensione e prima della ripresa delle notifiche e fornire in modo conciso lo stato più recente ai callback delle app registrate.
Considerazioni sulla documentazione per gli sviluppatori
La pubblicazione di eventi asincroni potrebbe essere ritardata, perché il mittente ha messo in pausa la pubblicazione per un periodo di tempo, come mostrato nella sezione precedente, o perché l'app destinataria non ha ricevuto risorse del dispositivo sufficienti per elaborare l'evento in modo tempestivo.
Scoraggiare gli sviluppatori dal fare ipotesi sul tempo che intercorre tra il momento in cui la loro app riceve la notifica di un evento e il momento in cui l'evento si è effettivamente verificato.
Aspettative degli sviluppatori in merito alla sospensione delle API
Gli sviluppatori che hanno familiarità con la concorrenza strutturata di Kotlin si aspettano i seguenti comportamenti da qualsiasi API di sospensione:
Le funzioni di sospensione devono completare tutto il lavoro associato prima di restituire o generare un errore
I risultati delle operazioni non bloccanti vengono restituiti come normali valori restituiti dalla funzione e gli errori vengono segnalati generando eccezioni. (Ciò spesso significa che i parametri di callback non sono necessari.)
Le funzioni di sospensione devono richiamare i parametri di callback solo in loco
Le funzioni di sospensione devono sempre completare tutto il lavoro associato prima di restituire un valore, quindi non devono mai richiamare un callback fornito o un altro parametro di funzione o conservare un riferimento dopo che la funzione di sospensione è stata restituita.
Le funzioni di sospensione che accettano parametri di callback devono conservare il contesto, se non diversamente documentato
La chiamata di una funzione in una funzione di sospensione fa sì che venga eseguita nel
CoroutineContext
del chiamante. Poiché le funzioni di sospensione devono completare tutto il lavoro associato prima di restituire o generare un errore e devono richiamare i parametri di callback solo sul posto, l'aspettativa predefinita è che tutti questi callback vengano eseguiti anche sul CoroutineContext
chiamante utilizzando il dispatcher associato. Se
lo scopo dell'API è eseguire un callback al di fuori della chiamata
CoroutineContext
, questo comportamento deve essere documentato in modo chiaro.
Le funzioni di sospensione devono supportare l'annullamento del job kotlinx.coroutines
Qualsiasi funzione di sospensione offerta deve cooperare con l'annullamento del job come definito
da kotlinx.coroutines
. Se il job di chiamata di un'operazione in corso viene
annullato, la funzione deve riprendere con un CancellationException
il prima
possibile in modo che il chiamante possa eseguire la pulizia e continuare il prima possibile. Questa
operazione viene gestita automaticamente da suspendCancellableCoroutine
e da altre API di sospensione
offerte da kotlinx.coroutines
. Le implementazioni della libreria in genere
non devono utilizzare suspendCoroutine
direttamente, in quanto non supporta questo
comportamento di annullamento per impostazione predefinita.
Le funzioni che eseguono operazioni di blocco su un thread in background (non principale o UI) devono fornire un modo per configurare il dispatcher utilizzato
Non è consigliabile che una funzione di blocco sospenda completamente per cambiare thread.
La chiamata di una funzione di sospensione non deve comportare la creazione di thread aggiuntivi senza consentire allo sviluppatore di fornire il proprio thread o pool di thread per eseguire il lavoro. Ad esempio, un costruttore può accettare un
CoroutineContext
utilizzato per eseguire il lavoro in background per i metodi della classe.
Le funzioni di sospensione che accettano un parametro CoroutineContext
o
Dispatcher
facoltativo solo per passare a quel dispatcher per eseguire il blocco
devono invece esporre la funzione di blocco sottostante e consigliare agli
sviluppatori che chiamano di utilizzare la propria chiamata a withContext per indirizzare il lavoro a un
dispatcher scelto.
Classi che avviano coroutine
Le classi che avviano le coroutine devono avere un CoroutineScope
per eseguire queste operazioni di avvio. Il rispetto dei principi di concorrenza strutturata implica i
seguenti pattern strutturali per ottenere e gestire questo ambito.
Prima di scrivere una classe che avvia attività simultanee in un altro ambito, valuta pattern alternativi:
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'esposizione di un suspend fun
per eseguire il lavoro simultaneo consente al chiamante di richiamare
l'operazione nel proprio contesto, eliminando la necessità che MyClass
gestisca un
CoroutineScope
. La serializzazione dell'elaborazione delle richieste diventa più semplice e
lo stato può spesso esistere come variabili locali di handleRequests
anziché come proprietà
della classe che altrimenti richiederebbero una sincronizzazione aggiuntiva.
Le classi che gestiscono le coroutine devono esporre i metodi close e cancel
Le classi che avviano le coroutine come dettagli di implementazione devono offrire un modo per
arrestare in modo pulito le attività simultanee in corso in modo che non perdano
il lavoro simultaneo incontrollato in un ambito principale. In genere, ciò avviene tramite la creazione di un Job
secondario di un CoroutineContext
fornito:
private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)
fun cancel() {
myJob.cancel()
}
Può essere fornito anche un metodo join()
per consentire al codice utente di attendere il completamento di qualsiasi lavoro simultaneo in sospeso eseguito dall'oggetto.
(Ciò potrebbe includere operazioni di pulizia eseguite annullando un'operazione.)
suspend fun join() {
myJob.join()
}
Denominazione delle operazioni del terminale
Il nome utilizzato per i metodi che arrestano correttamente le attività simultanee di proprietà di un oggetto ancora in corso deve riflettere il contratto comportamentale di come avviene l'arresto:
Utilizza close()
quando le operazioni in corso potrebbero essere completate, ma non possono
iniziare nuove operazioni dopo la restituzione della chiamata a close()
.
Utilizza cancel()
quando le operazioni in corso possono essere annullate prima del completamento.
Nessuna nuova operazione può iniziare dopo la restituzione della chiamata a cancel()
.
I costruttori di classi accettano CoroutineContext, non CoroutineScope
Quando il lancio diretto degli oggetti in un ambito principale fornito è vietato,
l'idoneità di CoroutineScope
come parametro del costruttore si interrompe:
// 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
diventa un wrapper non necessario e fuorviante che in alcuni
casi d'uso può essere costruito esclusivamente per essere passato come parametro del costruttore, solo per
essere scartato:
// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))
I parametri CoroutineContext hanno come valore predefinito EmptyCoroutineContext
Quando in una superficie API viene visualizzato un parametro CoroutineContext
facoltativo, il
valore predefinito deve essere il sentinel Empty`CoroutineContext`
. Ciò consente una
migliore composizione dei comportamenti dell'API, in quanto un valore Empty`CoroutineContext`
di un chiamante viene trattato allo stesso modo dell'accettazione del valore predefinito:
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)
// ...
}