Las APIs no bloqueantes solicitan que se realice el trabajo y, luego, devuelven el control al subproceso de llamada para que pueda realizar otra tarea antes de que se complete la operación solicitada. Estas APIs son útiles en los casos en que el trabajo solicitado podría estar en curso o requerir esperar a que se complete la E/S o el IPC, la disponibilidad de recursos del sistema muy disputados o la entrada del usuario antes de que se pueda continuar con el trabajo. Las APIs especialmente bien diseñadas proporcionan una forma de cancelar la operación en curso y evitar que se realice el trabajo en nombre del llamador original, lo que preserva el estado del sistema y la duración de batería cuando ya no se necesita la operación.
Las APIs asíncronas son una forma de lograr un comportamiento no bloqueador. Las APIs asíncronas Aceptan algún tipo de devolución de llamada o Continuation que se notifica cuando se completa la operación o de otros eventos durante el progreso de la operación.
Existen dos motivaciones principales para escribir una API asíncrona:
- Ejecutar varias operaciones de forma simultánea, en las que se debe iniciar una operación n antes de que se complete la operación n-1
- Evita bloquear un subproceso de llamada hasta que se complete una operación.
Kotlin promueve con fuerza la concurrencia estructurada, una serie de principios y APIs compilados en funciones de suspensión que desconectan la ejecución síncrona y asíncrona del código del comportamiento de bloqueo de subprocesos. Las funciones de suspensión son no bloqueantes y síncronas.
Funciones de suspensión:
- No bloquees su subproceso de llamada y, en su lugar, cede su subproceso de ejecución como un detalle de implementación mientras esperas los resultados de las operaciones que se ejecutan en otro lugar.
- Ejecuta de forma síncrona y no requiere que el llamador de una API no bloqueante siga ejecutándose de forma simultánea con el trabajo no bloqueador que inicia la llamada a la API.
En esta página, se detalla un modelo de referencia mínimo de expectativas que los desarrolladores pueden tener con seguridad cuando trabajan con APIs asíncronas y sin bloqueo, seguido de una serie de recetas para crear APIs que cumplan con estas expectativas en los lenguajes Kotlin o Java, en la plataforma de Android o en las bibliotecas de Jetpack. Cuando tengas dudas, considera las expectativas de los desarrolladores como requisitos para cualquier nueva plataforma de API.
Expectativas de los desarrolladores para las APIs asíncronas
Las siguientes expectativas se escriben desde el punto de vista de las APIs que no suspenden, a menos que se indique lo contrario.
Las APIs que aceptan devoluciones de llamada suelen ser asíncronas.
Si una API acepta una devolución de llamada que no está documentada para que solo se la llame en su lugar (es decir, que solo la llama el subproceso de llamada antes de que se devuelva la llamada a la API), se supone que la API es asíncrona y debe cumplir con todas las demás expectativas documentadas en las siguientes secciones.
Un ejemplo de una devolución de llamada a la que solo se llama de forma in situ es una función de filtro o mapa de orden superior que invoca un asignador o predicado en cada elemento de una colección antes de mostrarlo.
Las APIs asíncronas deben mostrarse lo más rápido posible.
Los desarrolladores esperan que las APIs asíncronas sean no bloqueantes y se muestren rápidamente después de iniciar la solicitud de la operación. Siempre debe ser seguro llamar a una API asíncrona en cualquier momento, y llamar a una API asíncrona nunca debe generar fotogramas irregulares ni errores de ANR.
La plataforma o las bibliotecas a pedido pueden activar muchos indicadores de operaciones y ciclo de vida, y esperar que un desarrollador tenga conocimiento global de todos los posibles sitios de llamada para su código es insostenible. Por ejemplo, se puede agregar un Fragment
a FragmentManager
en una transacción síncrona en respuesta a la medición y el diseño de View
cuando se debe propagar el contenido de la app para llenar el espacio disponible (como RecyclerView
). Un LifecycleObserver
que responde a la devolución de llamada de ciclo de vida onStart
de este fragmento puede realizar operaciones de inicio únicas de manera razonable, y esto puede estar en una ruta de código crítica para producir un fotograma de animación sin interrupciones. Un desarrollador siempre debe tener la seguridad de que llamar a cualquier API asíncrona en respuesta a este tipo de devoluciones de llamada de ciclo de vida no será la causa de un fotograma inestable.
Esto implica que el trabajo que realiza una API asíncrona antes de mostrar un resultado debe ser muy ligero, es decir, crear un registro de la solicitud y la devolución de llamada asociada, y registrarlo con el motor de ejecución que realiza el trabajo como máximo. Si el registro para una operación asíncrona requiere IPC, la implementación de la API debe tomar las medidas necesarias para cumplir con esta expectativa del desarrollador. Esto puede incluir uno o más de los siguientes elementos:
- Cómo implementar un IPC subyacente como una llamada de Binder unidireccional
- Realizar una llamada de Binder de dos vías al servidor del sistema en la que completar el registro no requiere tomar un bloqueo muy disputado
- Publica la solicitud en un subproceso de trabajo en el proceso de la app para realizar un registro de bloqueo a través de IPC.
Las APIs asíncronas deben mostrar un valor nulo y solo arrojar errores para argumentos no válidos.
Las APIs asíncronas deben informar todos los resultados de la operación solicitada a la devolución de llamada proporcionada. Esto permite al desarrollador implementar una sola ruta de código para el manejo correcto de errores.
Las APIs asíncronas pueden verificar si los argumentos son nulos y arrojar NullPointerException
, o verificar que los argumentos proporcionados estén dentro de un rango válido y arrojar IllegalArgumentException
. Por ejemplo, para una función que acepta un float
en el rango de 0
a 1f
, la función puede verificar que el parámetro esté dentro de este rango y arrojar IllegalArgumentException
si está fuera de rango, o se puede verificar un String
corto para verificar la conformidad con un formato válido, como solo caracteres alfanuméricos. (Recuerda que el servidor del sistema nunca debe confiar en el proceso de la app). Cualquier servicio del sistema debe duplicar estas verificaciones en el servicio del sistema.)
Todos los demás errores se deben informar a la devolución de llamada proporcionada. Esto incluye, entre otros, lo siguiente:
- Falla grave de la operación solicitada
- Excepciones de seguridad por falta de autorización o permisos necesarios para completar la operación
- Se superó la cuota para realizar la operación
- El proceso de la app no está lo suficientemente en "primer plano" para realizar la operación.
- Se desconectó el hardware necesario
- Fallas de red
- Tiempos de espera
- Cierre de Binder o proceso remoto no disponible
Las APIs asíncronas deben proporcionar un mecanismo de cancelación
Las APIs asíncronas deben proporcionar una forma de indicarle a una operación en ejecución que al llamador ya no le importa el resultado. Esta operación de cancelación debe indicar dos aspectos:
Se deben liberar las referencias duras a las devoluciones de llamada que proporciona el emisor.
Las devoluciones de llamada proporcionadas a las APIs asíncronas pueden contener referencias duras a gráficos de objetos grandes, y el trabajo en curso que contiene una referencia dura a esa devolución de llamada puede evitar que esos gráficos de objetos se recopilen como basura. Si se liberan estas referencias de devolución de llamada en la cancelación, estos gráficos de objetos pueden ser aptos para la recolección de basura mucho antes que si se permitiera que el trabajo se ejecutara hasta completarse.
Es posible que el motor de ejecución que realiza el trabajo para el llamador detenga ese trabajo.
El trabajo iniciado por llamadas a la API asíncronas puede tener un alto costo en el consumo de energía o en otros recursos del sistema. Las APIs que permiten que los emisores indiquen cuándo ya no se necesita este trabajo permiten detenerlo antes de que pueda consumir más recursos del sistema.
Consideraciones especiales para apps almacenadas en caché o inmovilizadas
Cuando diseñes APIs asíncronas en las que las devoluciones de llamada se originan en un proceso del sistema y se entregan a las apps, ten en cuenta lo siguiente:
- Ciclos de vida de procesos y apps: Es posible que el proceso de la app receptora esté en el estado almacenado en caché.
- Congelación de apps almacenadas en caché: Es posible que el proceso de la app receptora esté congelado.
Cuando un proceso de app entra en el estado almacenado en caché, significa que no aloja de forma activa ningún componente visible para el usuario, como actividades y servicios. La app se mantiene en la memoria en caso de que vuelva a ser visible para el usuario, pero, mientras tanto, no debería estar realizando ninguna tarea. En la mayoría de los casos, debes pausar el envío de devoluciones de llamada de la app cuando esa app entra en el estado almacenado en caché y reanudar cuando la app sale del estado almacenado en caché para no inducir trabajo en los procesos de la app almacenados en caché.
Una app almacenada en caché también puede estar inmovilizada. Cuando una app está inmovilizada, no recibe tiempo de CPU y no puede realizar ningún trabajo. Todas las llamadas a las devoluciones de llamada registradas de esa app se almacenan en búfer y se entregan cuando se descongela la app.
Es posible que las transacciones almacenadas en búfer para las devoluciones de llamadas de la app estén inactivas cuando se desbloquee la app y las procese. El búfer es finito y, si se desborda, hará que la app receptora falle. Para evitar sobrecargar las apps con eventos inactivos o superar sus búferes, no envíes devoluciones de llamada de la app mientras su proceso está frenado.
En revisión:
- Considera pausar el envío de devoluciones de llamada de la app mientras el proceso de la app se almacena en caché.
- DEBES pausar el envío de devoluciones de llamada de la app mientras el proceso de la app esté inmovilizado.
Seguimiento de estado
Para hacer un seguimiento de cuándo las apps entran o salen del estado almacenado en caché, haz lo siguiente:
mActivityManager.addOnUidImportanceListener(
new UidImportanceListener() { ... },
IMPORTANCE_CACHED);
Para hacer un seguimiento de cuándo se congelan o descongelan las apps, haz lo siguiente:
IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);
Estrategias para reanudar el envío de devoluciones de llamada de la app
Ya sea que detengas el envío de devoluciones de llamada de la app cuando esta entre en el estado almacenado en caché o en el estado congelado, cuando la app salga del estado correspondiente, debes reanudar el envío de las devoluciones de llamada registradas de la app una vez que esta salga del estado correspondiente hasta que la app haya cancelado su devolución de llamada o se cierre el proceso de la app.
Por ejemplo:
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;
}
});
Como alternativa, puedes usar RemoteCallbackList
, que se encarga de no enviar devoluciones de llamada al proceso de destino cuando está inmovilizado.
Por ejemplo:
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()
se invoca solo si el proceso no está inmovilizado.
Las apps suelen guardar las actualizaciones que recibieron con devoluciones de llamada como una instantánea del estado más reciente. Considera una API hipotética para que las apps supervisen el porcentaje de batería restante:
interface BatteryListener {
void onBatteryPercentageChanged(int newPercentage);
}
Considera la situación en la que ocurren varios eventos de cambio de estado cuando una app está inmovilizada. Cuando se desbloquee la app, debes entregar solo el estado más reciente a la app y descartar otros cambios de estado inactivos. Esta entrega debe ocurrir de inmediato cuando se desbloquea la app para que esta pueda “ponerse al día”. Esto se puede lograr de la siguiente manera:
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));
En algunos casos, puedes hacer un seguimiento del último valor que se entregó a la app para que no se le notifique el mismo valor una vez que se desbloquee.
El estado se puede expresar como datos más complejos. Considera una API hipotética para que las apps reciban notificaciones sobre las interfaces de red:
interface NetworkListener {
void onAvailable(Network network);
void onLost(Network network);
void onChanged(Network network);
}
Cuando pauses las notificaciones de una app, debes recordar el conjunto de redes y estados que la app vio por última vez. Cuando se reanuda, se recomienda notificar a la app las redes anteriores que se perdieron, las nuevas que estuvieron disponibles y las existentes cuyo estado cambió, en este orden.
No notifiques a la app las redes que se pusieron a disposición y, luego, se perdieron mientras se pausaban las devoluciones de llamada. Las apps no deben recibir un registro completo de los eventos que ocurrieron mientras estaban inhabilitadas, y la documentación de la API no debe prometer entregar flujos de eventos sin interrupciones fuera de los estados del ciclo de vida explícitos. En este ejemplo, si la app necesita supervisar de forma continua la disponibilidad de la red, debe permanecer en un estado de ciclo de vida que evite que se almacenen en caché o se congelen.
En resumen, debes unir los eventos que ocurrieron después de pausar y antes de reanudar las notificaciones, y entregar el estado más reciente a las devoluciones de llamada de la app registradas de forma concisa.
Consideraciones para la documentación para desarrolladores
La entrega de eventos asíncronos puede retrasarse, ya sea porque el remitente detuvo la entrega durante un período, como se muestra en la sección anterior, o porque la app receptora no recibió suficientes recursos del dispositivo para procesar el evento de forma oportuna.
Desalienta a los desarrolladores a hacer suposiciones sobre el tiempo que transcurre entre el momento en que su app recibe una notificación de un evento y el momento en que este realmente ocurrió.
Expectativas de los desarrolladores sobre la suspensión de APIs
Los desarrolladores familiarizados con la simultaneidad estructurada de Kotlin esperan los siguientes comportamientos de cualquier API de suspensión:
Las funciones de suspensión deben completar todo el trabajo asociado antes de devolver o arrojar
Los resultados de las operaciones no bloqueantes se muestran como valores normales de la función, y los errores se informan mediante la generación de excepciones. (Esto suele significar que los parámetros de devolución de llamada son innecesarios).
Las funciones de suspensión solo deben invocar parámetros de devolución de llamada en su lugar
Las funciones de suspensión siempre deben completar todo el trabajo asociado antes de mostrarse, así que nunca deben invocar una devolución de llamada proporcionada ni ningún otro parámetro de función ni retenido una referencia a ella después de que se muestra la función de suspensión.
Las funciones de suspensión que aceptan parámetros de devolución de llamada deben conservar el contexto, a menos que se documente lo contrario.
Llamar a una función en una función de suspensión hace que se ejecute en el CoroutineContext
del llamador. Como las funciones de suspensión deben completar todo el trabajo asociado antes de mostrar el resultado o arrojar, y solo deben invocar los parámetros de devolución de llamada en su lugar, la expectativa predeterminada es que todas esas devoluciones de llamada se también ejecuten en el CoroutineContext
que realiza la llamada con su despachador asociado. Si el propósito de la API es ejecutar una devolución de llamada fuera de la CoroutineContext
de llamada, este comportamiento debe documentarse claramente.
Las funciones de suspensión deben admitir la cancelación de trabajos de kotlinx.coroutines.
Cualquier función de suspensión que se ofrezca debe cooperar con la cancelación de trabajos como lo define kotlinx.coroutines
. Si se cancela la tarea de llamada de una operación en curso, la función debería reanudarse con un CancellationException
lo antes posible para que el llamador pueda limpiar y continuar lo antes posible. suspendCancellableCoroutine
y otras APIs de suspensión que ofrece kotlinx.coroutines
se encargan de esto automáticamente. Por lo general, las implementaciones de bibliotecas no deben usar suspendCoroutine
directamente, ya que no admiten este comportamiento de cancelación de forma predeterminada.
Las funciones de suspensión que realizan tareas de bloqueo en segundo plano (no en el subproceso principal ni de IU) deben proporcionar una forma de configurar el despachador que se usa.
No se recomienda que una función de bloqueo se suspenda por completo para cambiar de subproceso.
Llamar a una función de suspensión no debería generar la creación de subprocesos adicionales sin permitir que el desarrollador proporcione su propio subproceso o grupo de subprocesos para realizar ese trabajo. Por ejemplo, un constructor puede aceptar un CoroutineContext
que se usa para realizar tareas en segundo plano para los métodos de la clase.
Las funciones de suspensión que aceptarían un parámetro CoroutineContext
o Dispatcher
opcional solo para cambiar a ese despachador y realizar el trabajo de bloqueo deberían exponer la función de bloqueo subyacente y recomendar que los desarrolladores que llaman usen su propia llamada a withContext para dirigir el trabajo a un despachador elegido.
Clases que inician corrutinas
Las clases que inician corrutinas deben tener un CoroutineScope
para realizar esas operaciones de inicio. Respetar los principios de simultaneidad estructurada implica los siguientes patrones estructurales para obtener y administrar ese alcance.
Antes de escribir una clase que inicie tareas simultáneas en otro alcance, considera los siguientes patrones alternativos:
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()
}
}
Exponer un suspend fun
para realizar trabajo simultáneo permite que el llamador invoque
la operación en su propio contexto, lo que elimina la necesidad de que MyClass
administre un
CoroutineScope
. Serializar el procesamiento de solicitudes se vuelve más simple y el estado a menudo puede existir como variables locales de handleRequests
en lugar de como propiedades de clase que, de otro modo, requerirían una sincronización adicional.
Las clases que administran corrutinas deben exponer métodos de cierre y cancelación.
Las clases que inician corrutinas como detalles de implementación deben ofrecer una forma de cerrar de forma ordenada esas tareas simultáneas en curso para que no filtren trabajo simultáneo no controlado en un alcance superior. Por lo general, esto se hace creando un Job
secundario de un CoroutineContext
proporcionado:
private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)
fun cancel() {
myJob.cancel()
}
También se puede proporcionar un método join()
para permitir que el código del usuario espere la finalización de cualquier trabajo simultáneo pendiente que realice el objeto.
(Esto puede incluir el trabajo de limpieza que se realiza cuando se cancela una operación).
suspend fun join() {
myJob.join()
}
Nombres de las operaciones de la terminal
El nombre que se usa para los métodos que cierran de forma limpia las tareas simultáneas que pertenecen a un objeto que aún está en curso debe reflejar el contrato de comportamiento de cómo se produce el cierre:
Usa close()
cuando las operaciones en curso puedan completarse, pero no se puedan iniciar operaciones nuevas después de que se devuelva la llamada a close()
.
Usa cancel()
cuando las operaciones en curso puedan cancelarse antes de completarse.
No se pueden iniciar operaciones nuevas después de que se devuelve la llamada a cancel()
.
Los constructores de clases aceptan CoroutineContext, no CoroutineScope.
Cuando se prohíbe que los objetos se inicien directamente en un alcance superior proporcionado, la idoneidad de CoroutineScope
como parámetro de constructor se descompone:
// 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
se convierte en un wrapper innecesario y engañoso que, en algunos casos de uso, se puede construir solo para pasar como un parámetro de constructor y, luego, descartarse:
// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))
Los parámetros de CoroutineContext se establecen de forma predeterminada en EmptyCoroutineContext
Cuando aparece un parámetro CoroutineContext
opcional en una plataforma de API, el valor predeterminado debe ser el centinela Empty`CoroutineContext`
. Esto permite una mejor composición de los comportamientos de la API, ya que un valor Empty`CoroutineContext`
de un llamador se trata de la misma manera que aceptar el valor predeterminado:
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)
// ...
}