As APIs não bloqueantes solicitam que o trabalho aconteça e, em seguida, cedem o controle de volta à linha de execução de chamada para que ela possa realizar outro trabalho antes da conclusão da operação solicitada. Essas APIs são úteis para casos em que o trabalho solicitado pode estar em andamento ou exigir que a conclusão de E/S ou IPC, a disponibilidade de recursos do sistema altamente disputados ou a entrada do usuário sejam esperadas antes que o trabalho possa prosseguir. APIs bem projetadas oferecem uma maneira de cancelar a operação em andamento e impedir que o trabalho seja realizado em nome do autor da chamada original, preservando a integridade do sistema e a duração da bateria quando a operação não for mais necessária.
As APIs assíncronas são uma maneira de alcançar um comportamento não bloqueado. As APIs assíncronas aceitam alguma forma de continuação ou callback que é notificado quando a operação é concluída ou de outros eventos durante o progresso da operação.
Há duas motivações principais para escrever uma API assíncrona:
- Executar várias operações simultaneamente, em que uma operação N precisa ser iniciada antes que a operação N-1 seja concluída.
- Evite bloquear uma linha de execução de chamada até que uma operação seja concluída.
O Kotlin promove fortemente concorrência estruturada, uma série de princípios e APIs criados com funções de suspensão que dissociam a execução síncrona e assíncrona do código do comportamento de bloqueio de linha de execução. As funções de suspensão são não bloqueantes e síncronas.
Funções de suspensão:
- Não bloqueie a linha de execução de chamada e, em vez disso, gere a linha de execução como um detalhe de implementação enquanto aguarda os resultados das operações executadas em outro lugar.
- Executar de forma síncrona e não exigir que o autor da chamada de uma API não bloqueante continue a ser executado simultaneamente com o trabalho não bloqueado iniciado pela chamada da API.
Esta página detalha uma base mínima de expectativas que os desenvolvedores podem manter com segurança ao trabalhar com APIs assíncronas e sem bloqueio, seguida por uma série de receitas para criar APIs que atendam a essas expectativas nas linguagens Kotlin ou Java, na plataforma Android ou nas bibliotecas do Jetpack. Em caso de dúvida, considere as expectativas do desenvolvedor como requisitos para qualquer nova plataforma de API.
Expectativas dos desenvolvedores em relação a APIs assíncronas
As expectativas a seguir são escritas do ponto de vista de APIs não suspensas, a menos que indicado de outra forma.
As APIs que aceitam callbacks geralmente são assíncronas.
Se uma API aceitar um callback que não está documentado para ser chamado no local (ou seja, chamado apenas pela linha de execução de chamada antes que a chamada de API seja retornada), a API será considerada assíncrona e precisa atender a todas as outras expectativas documentadas nas seções a seguir.
Um exemplo de callback que só é chamado no local é uma função de mapa ou filtro de ordem superior que invoca um mapeador ou predicado em cada item de uma coleção antes de retornar.
As APIs assíncronas precisam retornar o mais rápido possível
Os desenvolvedores esperam que as APIs assíncronas sejam não bloqueantes e retornem rapidamente após iniciar a solicitação da operação. Chamar uma API assíncrona sempre precisa ser seguro, e chamar uma API assíncrona nunca deve resultar em frames instáveis ou ANR.
Muitas operações e indicadores de ciclo de vida podem ser acionados pela plataforma ou
bibliotecas sob demanda. Esperar que um desenvolvedor tenha conhecimento global de todos
os possíveis sites de chamada do código é insustentável. Por exemplo, um Fragment
pode ser adicionado ao FragmentManager
em uma transação síncrona em resposta
à medição e ao layout do View
quando o conteúdo do app precisa ser preenchido para preencher
o espaço disponível (como RecyclerView
). Um LifecycleObserver
que responde ao
callback de ciclo de vida onStart
desse fragmento pode realizar operações de inicialização
únicas aqui, e isso pode estar em um caminho de código crítico para produzir um
frame de animação sem engasgos. Os desenvolvedores sempre precisam ter certeza de que
a chamada de qualquer API assíncrona em resposta a esses tipos de callbacks do ciclo de vida
não será a causa de um frame instável.
Isso implica que o trabalho realizado por uma API assíncrona antes do retorno precisa ser muito leve, criando um registro da solicitação e do callback associado e o registrando com o mecanismo de execução que realiza a maior parte do trabalho. Se o registro para uma operação assíncrona exigir IPC, a implementação da API precisará tomar as medidas necessárias para atender a essa expectativa do desenvolvedor. Isso pode incluir uma ou mais das seguintes opções:
- Como implementar um IPC subjacente como uma chamada de vinculação unidirecional
- Fazer uma chamada de vinculação bidirecional no servidor do sistema em que a conclusão do registro não requer uma trava altamente disputada
- Publicação da solicitação em uma linha de execução de worker no processo do app para realizar um registro de bloqueio pelo IPC
As APIs assíncronas precisam retornar void e gerar exceções apenas para argumentos inválidos
As APIs assíncronas precisam informar todos os resultados da operação solicitada ao callback fornecido. Isso permite que o desenvolvedor implemente um único caminho de código para sucesso e processamento de erros.
As APIs assíncronas podem verificar se os argumentos são nulos e gerar NullPointerException
ou
verificar se os argumentos fornecidos estão dentro de um intervalo válido e gerar
IllegalArgumentException
. Por exemplo, para uma função que aceita um float
no intervalo de 0
a 1f
, a função pode verificar se o parâmetro está dentro
deste intervalo e gerar IllegalArgumentException
se ele estiver fora do intervalo. Uma
String
curta também pode ser verificada para conformidade com um formato válido, como
apenas alfanumérico. Lembre-se de que o servidor do sistema nunca deve confiar no processo
do app. Qualquer serviço do sistema precisa duplicar essas verificações no próprio
serviço.)
Todos os outros erros precisam ser informados ao callback fornecido. Isso inclui, mas não se limita a:
- Falha terminal da operação solicitada
- Exceções de segurança para autorização ou permissões ausentes necessárias para concluir a operação
- A cota para realizar a operação foi excedida
- O processo do app não está suficientemente em primeiro plano para realizar a operação.
- O hardware necessário foi desconectado
- Falhas na rede
- Suspensões temporárias
- Binder encerrado ou processo remoto indisponível
As APIs assíncronas precisam fornecer um mecanismo de cancelamento
As APIs assíncronas precisam oferecer uma maneira de indicar a uma operação em execução que o autor da chamada não se importa mais com o resultado. Essa operação de cancelamento precisa sinalizar duas coisas:
As referências rígidas a callbacks fornecidos pelo autor da chamada precisam ser liberadas.
Os callbacks fornecidos a APIs assíncronas podem conter referências rígidas a grandes gráficos de objetos, e o trabalho em andamento que mantém uma referência rígida a esse callback pode impedir que esses gráficos de objetos sejam coletados. Ao liberar essas referências de callback no cancelamento, esses gráficos de objetos podem se tornar qualificados para a coleta de lixo muito mais cedo do que se o trabalho fosse permitido para conclusão.
O mecanismo de execução que executa o trabalho para o autor da chamada pode interromper esse trabalho.
O trabalho iniciado por chamadas de API assíncronas pode ter um alto custo no consumo de energia ou em outros recursos do sistema. APIs que permitem que os autores da chamada sinalizem quando esse trabalho não é mais necessário permitem interromper esse trabalho antes que ele consuma mais recursos do sistema.
Considerações especiais para apps em cache ou congelados
Ao projetar APIs assíncronas em que os callbacks se originam em um processo do sistema e são enviados para apps, considere o seguinte:
- Processos e ciclo de vida do app: o processo do app receptor pode estar no estado armazenado em cache.
- Freezer de apps em cache: o processo do app receptor pode ser congelado.
Quando um processo do app entra no estado armazenado em cache, isso significa que ele não está hospedando ativamente nenhum componente visível para o usuário, como atividades e serviços. O app é mantido na memória para que possa ser mostrado ao usuário novamente, mas, enquanto isso, não pode estar fazendo trabalho. Na maioria dos casos, é necessário pausar o envio de callbacks do app quando ele entra no estado armazenado em cache e retomar quando ele sai do estado armazenado em cache, para não induzir o trabalho em processos de apps armazenados em cache.
Um app em cache também pode ser congelado. Quando um app é congelado, ele recebe zero tempo de CPU e não consegue fazer nenhum trabalho. Todas as chamadas para os callbacks registrados do app são armazenadas em buffer e entregues quando o app é descongelado.
As transações em buffer para callbacks de app podem ficar desatualizadas no momento em que o app é descongelado e processado. O buffer é finito, e se o estouro ocorresse, o app receptor falharia. Para evitar sobrecarregar apps com eventos desaturados ou transbordar os buffers, não envie callbacks de app enquanto o processo estiver congelado.
Em análise:
- Considere pausar o envio de callbacks do app enquanto o processo do app está armazenado em cache.
- É PRECISO pausar o envio de callbacks do app enquanto o processo dele está congelado.
Rastreamento de estado
Para acompanhar quando os apps entram ou saem do estado em cache:
mActivityManager.addOnUidImportanceListener(
new UidImportanceListener() { ... },
IMPORTANCE_CACHED);
Para acompanhar quando os apps são congelados ou descongelados:
IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);
Estratégias para retomar o envio de callbacks de app
Se você pausar o envio de callbacks do app quando ele entrar no estado armazenado em cache ou congelado, quando o app sair do respectivo estado, será necessário retomar o envio dos callbacks registrados do app assim que ele sair do respectivo estado até que o app tenha cancelado o callback ou o processo do app seja encerrado.
Exemplo:
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, use RemoteCallbackList
, que evita
enviar callbacks para o processo de destino quando ele está congelado.
Exemplo:
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()
só é invocado se o processo não estiver congelado.
Os apps geralmente salvam as atualizações recebidas usando callbacks como um snapshot do último estado. Considere uma API hipotética para que os apps monitorem a porcentagem de bateria restante:
interface BatteryListener {
void onBatteryPercentageChanged(int newPercentage);
}
Considere o cenário em que vários eventos de mudança de estado acontecem quando um app é congelado. Quando o app for descongelado, forneça apenas o estado mais recente a ele e descarte outras mudanças de estado desaturadas. Essa entrega precisa acontecer imediatamente quando o app for descongelado para que ele possa "se atualizar". Isso pode ser feito da seguinte maneira:
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));
Em alguns casos, você pode acompanhar o último valor enviado ao app para que ele não precise ser notificado do mesmo valor quando for descongelado.
O estado pode ser expresso como dados mais complexos. Considere uma API hipotética para notificar apps sobre interfaces de rede:
interface NetworkListener {
void onAvailable(Network network);
void onLost(Network network);
void onChanged(Network network);
}
Ao pausar as notificações de um app, você precisa lembrar do conjunto de redes e estados que o app visualizou pela última vez. Ao retomar, é recomendável notificar o app sobre redes antigas que foram perdidas, novas redes que ficaram disponíveis e redes existentes cujo estado mudou, nesta ordem.
Não notifica o app sobre redes que foram disponibilizadas e depois perdidas enquanto os callbacks estavam pausados. Os apps não podem receber uma conta completa dos eventos que ocorreram enquanto estavam congelados, e a documentação da API não pode prometer enviar streams de eventos ininterruptos fora dos estados de ciclo de vida explícitos. Neste exemplo, se o app precisar monitorar continuamente a disponibilidade da rede, ele precisará permanecer em um estado de ciclo de vida que impeça que ele seja armazenado em cache ou congelado.
Na revisão, você precisa mesclar os eventos que aconteceram após a pausa e antes de retomar as notificações e transmitir o estado mais recente aos callbacks do app registrados de forma sucinta.
Considerações para a documentação do desenvolvedor
A entrega de eventos assíncronos pode ser atrasada porque o remetente pausou a entrega por um período, conforme mostrado na seção anterior, ou porque o app do destinatário não recebeu recursos suficientes do dispositivo para processar o evento em tempo hábil.
Desanime os desenvolvedores a fazer suposições sobre o tempo entre o momento em que o app é notificado de um evento e o momento em que o evento realmente ocorreu.
Expectativas dos desenvolvedores sobre a suspensão de APIs
Os desenvolvedores que conhecem a simultaneidade estruturada do Kotlin esperam os seguintes comportamentos de qualquer API de suspensão:
As funções de suspensão precisam concluir todo o trabalho associado antes de retornar ou gerar uma exceção
Os resultados das operações não bloqueantes são retornados como valores de retorno de função normais, e os erros são informados por meio de exceções. Isso geralmente significa que os parâmetros de callback são desnecessários.
As funções de suspensão só podem invocar parâmetros de callback no local
As funções de suspensão sempre precisam concluir todo o trabalho associado antes de retornar. Portanto, elas nunca podem invocar um callback fornecido ou outro parâmetro de função ou manter uma referência a ele depois que a função de suspensão retornar.
As funções de suspensão que aceitam parâmetros de callback precisam preservar o contexto, a menos que esteja documentado de outra forma
Chamar uma função em uma função de suspensão faz com que ela seja executada no
CoroutineContext
do autor da chamada. Como as funções de suspensão precisam concluir todo o
trabalho associado antes de retornar ou gerar uma exceção e precisam invocar apenas os parâmetros
de callback no local, a expectativa padrão é que todos esses callbacks sejam
também executados na CoroutineContext
de chamada usando o despachante associado. Se
o objetivo da API for executar um callback fora do CoroutineContext
chamado, esse comportamento precisará ser documentado claramente.
As funções de suspensão precisam oferecer suporte ao cancelamento de jobs do kotlinx.coroutines
Qualquer função de suspensão oferecida precisa cooperar com o cancelamento de job, conforme definido
por kotlinx.coroutines
. Se o job de chamada de uma operação em andamento for
cancelado, a função será retomada com um CancellationException
assim que
possível para que o autor da chamada possa limpar e continuar o mais rápido possível. Isso
é processado automaticamente pelo suspendCancellableCoroutine
e outras APIs de suspensão
oferecidas pelo kotlinx.coroutines
. As implementações de biblioteca geralmente
não usam suspendCoroutine
diretamente, porque ele não oferece suporte a esse
comportamento de cancelamento por padrão.
As funções de suspensão que executam trabalho de bloqueio em segundo plano (linha de execução não principal ou de interface) precisam fornecer uma maneira de configurar o despachante usado
Não é recomendável fazer com que uma função de bloqueio seja suspensa inteiramente para mudar de linha de execução.
Chamar uma função de suspensão não deve resultar na criação de linhas de execução
adicionais sem permitir que o desenvolvedor forneça a própria linha de execução ou o pool
de linhas de execução para realizar esse trabalho. Por exemplo, um construtor pode aceitar um
CoroutineContext
usado para realizar trabalhos em segundo plano para os métodos
da classe.
As funções de suspensão que aceitariam um parâmetro CoroutineContext
ou
Dispatcher
opcional apenas para alternar para esse gerenciador para executar o trabalho
de bloqueio precisam expor a função de bloqueio subjacente e recomendar que
os desenvolvedores que chamam usem a própria chamada para com o contexto para direcionar o trabalho a um
gerenciador escolhido.
Classes que iniciam corrotinas
As classes que iniciam corrotinas precisam ter um CoroutineScope
para realizar essas
operações de inicialização. Respeitar os princípios da simultaneidade estruturada implica nos
seguintes padrões estruturais para conseguir e gerenciar esse escopo.
Antes de escrever uma classe que inicia tarefas simultâneas em outro escopo, considere padrões 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()
}
}
A exposição de um suspend fun
para realizar trabalho simultâneo permite que o autor da chamada invoque
a operação no próprio contexto, eliminando a necessidade de ter MyClass
gerenciando um
CoroutineScope
. A serialização do processamento de solicitações se torna mais simples e
o estado pode existir como variáveis locais de handleRequests
em vez de propriedades
de classe que, de outra forma, exigiriam sincronização adicional.
As classes que gerenciam corrotinas precisam expor métodos de fechamento e cancelamento
As classes que iniciam corrotinas como detalhes de implementação precisam oferecer uma maneira de
encerrar essas tarefas em execução de forma limpa para que elas não vazem
trabalho simultâneo descontrolado para um escopo pai. Isso normalmente assume a forma
de criar um Job
filho de um CoroutineContext
fornecido:
private val myJob = Job(parent = `CoroutineContext`[Job])
private val myScope = CoroutineScope(`CoroutineContext` + myJob)
fun cancel() {
myJob.cancel()
}
Um método join()
também pode ser fornecido para permitir que o código do usuário aguarde a
conclusão de qualquer trabalho simultâneo pendente realizado pelo objeto.
Isso pode incluir o trabalho de limpeza realizado ao cancelar uma operação.
suspend fun join() {
myJob.join()
}
Como nomear operações de terminal
O nome usado para métodos que encerram tarefas simultâneas de um objeto que ainda estão em andamento precisa refletir o contrato comportamental de como o desligamento ocorre:
Use close()
quando as operações em andamento podem ser concluídas, mas nenhuma nova operação pode
ser iniciada depois que a chamada para close()
retornar.
Use cancel()
quando as operações em andamento possam ser canceladas antes da conclusão.
Nenhuma nova operação pode ser iniciada depois que a chamada para cancel()
retornar.
Os construtores de classe aceitam CoroutineContext, não CoroutineScope.
Quando os objetos são proibidos de serem iniciados diretamente em um escopo pai fornecido,
a adequação de CoroutineScope
como um parâmetro de construtor é dividida:
// 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
}
O CoroutineScope
se torna um wrapper desnecessário e enganoso que, em alguns
casos de uso, pode ser construído apenas para transmitir como um parâmetro do construtor e
ser descartado:
// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))
Os parâmetros CoroutineContext são padrão para EmptyCoroutineContext
Quando um parâmetro CoroutineContext
opcional aparece em uma plataforma da API, o
valor padrão precisa ser o sentinela Empty`CoroutineContext`
. Isso permite
uma melhor composição dos comportamentos da API, já que um valor Empty`CoroutineContext`
de um autor da chamada é tratado da mesma forma que a aceitação do padrão:
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)
// ...
}