As APIs sem bloqueio solicitam que o trabalho seja realizado e devolvem o controle à thread de chamada para que ela possa realizar outras tarefas 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 espera pela conclusão de E/S ou IPC, disponibilidade de recursos de sistema altamente disputados ou entrada do usuário antes que o trabalho possa prosseguir. APIs bem projetadas oferecem uma maneira de cancelar a operação em andamento e interromper o trabalho em nome do chamador original, preservando a integridade do sistema e a duração da bateria quando a operação não é mais necessária.
As APIs assíncronas são uma maneira de alcançar um comportamento não bloqueador. 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 da conclusão da operação N-1.
- Evitar o bloqueio de uma linha de execução de chamada até que uma operação seja concluída.
O Kotlin promove fortemente a concorrência estruturada, uma série de princípios e APIs criados com base em funções de suspensão que desacoplam a execução síncrona e assíncrona de código do comportamento de bloqueio de linhas de execução. As funções de suspensão são sem bloqueio e síncronas.
Funções de suspensão:
- Não bloqueie a linha de execução de chamada. Em vez disso, gere a linha de execução de execução como um detalhe de implementação enquanto aguarda os resultados das operações que estão sendo executadas em outro lugar.
- Executar de forma síncrona e não exigir que o caller de uma API sem bloqueio continue a execução simultânea com o trabalho sem bloqueio iniciado pela chamada da API.
Esta página detalha um nível mínimo de expectativas que os desenvolvedores podem ter com segurança ao trabalhar com APIs assíncronas e sem bloqueio, seguidas 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 Jetpack. Em caso de dúvida, considere as expectativas dos desenvolvedores como requisitos para qualquer nova superfície de API.
Expectativas dos desenvolvedores para APIs assíncronas
As expectativas a seguir são escritas do ponto de vista das APIs que não suspendem, 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 esteja documentado para ser chamado apenas no lugar (ou seja, chamado apenas pela linha de execução de chamada antes que a chamada de API retorne), a API será considerada assíncrona e precisará atender a todas as outras expectativas documentadas nas seções a seguir.
Um exemplo de callback que só é chamado no lugar é 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 sem bloqueio e retornem rapidamente após iniciar a solicitação da operação. Sempre deve ser seguro chamar uma API assíncrona a qualquer momento, e chamar uma API assíncrona nunca deve resultar em frames instáveis ou ANR.
Muitas operações e sinais 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 locais 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 ocupar
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 falhas. Um desenvolvedor sempre deve ter certeza de que
chamar qualquer API assíncrona em resposta a esses tipos de callbacks do ciclo de vida
não vai causar um frame instável.
Isso implica que o trabalho realizado por uma API assíncrona antes de retornar precisa ser muito leve, criando um registro da solicitação e do callback associado e registrando-o com o mecanismo de execução que realiza o trabalho no máximo. Se o registro de 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:
- Implementar um IPC subjacente como uma chamada de binder unidirecional
- Fazer uma chamada de vinculação bidirecional para o servidor do sistema, em que a conclusão do registro não exige o uso de um bloqueio altamente disputado
- Postar a solicitação em uma linha de execução de trabalho no processo do app para realizar um registro de bloqueio por IPC
As APIs assíncronas precisam retornar void e só gerar exceções 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 o sucesso e o tratamento 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 desse intervalo e gerar IllegalArgumentException
se estiver fora dele. Um String
curto pode ser verificado 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 devem 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
- Encerramento do binder ou processo remoto indisponível
As APIs assíncronas precisam fornecer um mecanismo de cancelamento
As APIs assíncronas precisam fornecer uma maneira de indicar a uma operação em execução que o chamador não se importa mais com o resultado. Essa operação de cancelamento precisa sinalizar duas coisas:
As referências fixas aos callbacks fornecidos pelo autor da chamada precisam ser liberadas.
Os callbacks fornecidos para APIs assíncronas podem conter referências fixas a gráficos de objetos grandes, e o trabalho em andamento que mantém uma referência fixa a esse callback pode impedir que esses gráficos de objetos sejam coletados como lixo. Ao liberar essas referências de callback no cancelamento, esses gráficos de objetos podem se tornar qualificados para coleta de lixo muito antes do que se o trabalho pudesse ser concluído.
O mecanismo de execução que realiza o trabalho para o caller pode interromper essa atividade.
O trabalho iniciado por chamadas de API assíncronas pode ter um alto custo no consumo de energia ou em outros recursos do sistema. As APIs que permitem aos autores da chamada sinalizar quando esse trabalho não é mais necessário permitem interromper o trabalho antes que ele consuma mais recursos do sistema.
Considerações especiais para apps armazenados em cache ou congelados
Ao criar APIs assíncronas em que os callbacks se originam em um processo do sistema e são entregues aos apps, considere o seguinte:
- Processos e ciclo de vida do app: o processo do app destinatário pode estar no estado armazenado em cache.
- Freezer de apps em cache: o processo do app destinatário pode estar congelado.
Quando um processo de 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 caso fique visível para o usuário novamente, mas, enquanto isso, não deve estar fazendo nada. Na maioria dos casos, é preciso pausar o envio de callbacks do app quando ele entra no estado armazenado em cache e retomar quando ele sai desse estado para não induzir trabalho em processos de apps armazenados em cache.
Um app em cache também pode ser congelado. Quando um app é congelado, ele não recebe tempo de CPU e não pode fazer nada. Todas as chamadas para os callbacks registrados desse app são armazenadas em buffer e entregues quando o app é descongelado.
As transações em buffer para callbacks de apps podem ficar desatualizadas quando o app é descongelado e as processa. O buffer é finito e, se houver um estouro, o app destinatário vai falhar. Para evitar sobrecarregar os apps com eventos desatualizados ou transbordar os buffers deles, não envie callbacks de apps enquanto o processo estiver congelado.
Em análise:
- Considere pausar o envio de callbacks do app enquanto o processo dele estiver em cache.
- Você PRECISA pausar o envio de callbacks do app enquanto o processo dele está congelado.
Rastreamento com estado
Para rastrear quando os apps entram ou saem do estado em cache:
mActivityManager.addOnUidImportanceListener(
new UidImportanceListener() { ... },
IMPORTANCE_CACHED);
Para rastrear quando os apps são congelados ou descongelados:
IBinder binder = <...>;
binder.addFrozenStateChangeCallback(executor, callback);
Estratégias para retomar o envio de callbacks de apps
Se você pausar o envio de callbacks do app quando ele entrar no estado em cache ou congelado, retome o envio dos callbacks registrados do app quando ele sair do estado respectivo até que o app cancele o registro do 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
entregar callbacks ao 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 estado mais recente. Considere uma API hipotética para que os apps monitorem a porcentagem restante da bateria:
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, entregue apenas o estado mais recente para o app e descarte outras mudanças de estado desatualizadas. Essa entrega deve acontecer imediatamente quando o app for descongelado para que ele possa "alcançar". Isso pode ser feito da seguinte forma:
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, é possível rastrear o último valor entregue 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, lembre-se do conjunto de redes e estados que o app viu por último. Ao retomar, é recomendável notificar o app sobre as redes antigas que foram perdidas, as novas redes que ficaram disponíveis e as redes atuais cujo estado mudou, nessa ordem.
Não notifique o app sobre redes que foram disponibilizadas e depois perdidas enquanto os callbacks estavam pausados. Os apps não devem receber uma conta completa dos eventos que aconteceram enquanto estavam congelados, e a documentação da API não deve prometer entregar fluxos de eventos ininterruptos fora dos estados explícitos do ciclo de vida. Neste exemplo, se o app precisar monitorar continuamente a disponibilidade da rede, ele precisará permanecer em um estado de ciclo de vida que o impeça de ser armazenado em cache ou congelado.
Durante a revisão, você precisa unir os eventos que aconteceram depois da pausa e antes da retomada das notificações e entregar o estado mais recente aos callbacks do app registrado de forma concisa.
Considerações sobre a documentação para desenvolvedores
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 destinatário não recebeu recursos suficientes do dispositivo para processar o evento de maneira oportuna.
Desencoraje os desenvolvedores a fazerem suposições sobre o tempo entre o momento em que o app é notificado de um evento e o momento em que ele realmente aconteceu.
Expectativas dos desenvolvedores para suspensão de APIs
Os desenvolvedores familiarizados com 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 de operações não bloqueadoras são retornados como valores de retorno de função normais, e os erros são informados gerando 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 lugar
As funções de suspensão sempre precisam concluir todo o trabalho associado antes de retornar. Portanto, elas nunca devem invocar um callback fornecido ou outro parâmetro de função nem manter uma referência a ele depois que a função de suspensão for retornada.
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 só podem invocar parâmetros
de callback no local, a expectativa padrão é que esses callbacks sejam
também executados na CoroutineContext
de chamada usando o dispatcher associado. Se a finalidade da API for executar um callback fora do CoroutineContext
de chamada, esse comportamento deverá ser claramente documentado.
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 tarefas, conforme definido
por kotlinx.coroutines
. Se o trabalho de chamada de uma operação em andamento for
cancelado, a função deverá ser retomada com um CancellationException
assim que
possível para que o autor da chamada possa limpar e continuar assim que 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 devem usar suspendCoroutine
diretamente, já que ele não oferece suporte a esse comportamento de cancelamento por padrão.
As funções de suspensão que realizam trabalho de bloqueio em segundo plano (linha de execução não principal ou de UI) precisam oferecer uma maneira de configurar o dispatcher usado.
Não é recomendável fazer uma função de bloqueio suspender totalmente para trocar linhas de execução.
Chamar uma função de suspensão não deve resultar na criação de outras
linhas de execução sem permitir que o desenvolvedor forneça a própria linha de execução ou pool de linhas de execução
para realizar esse trabalho. Por exemplo, um construtor pode aceitar um
CoroutineContext
usado para realizar trabalho 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 mudar para esse dispatcher e realizar um trabalho
de bloqueio precisam expor a função de bloqueio subjacente e recomendar que
os desenvolvedores de chamadas usem a própria chamada para withContext e direcionem o trabalho a um
dispatcher 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 os seguintes padrões estruturais para obter 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()
}
}
Expor um suspend fun
para realizar trabalho simultâneo permite que o caller invoque
a operação no próprio contexto, removendo a necessidade de MyClass
gerenciar 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 exigem 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 simultâneas em andamento para que elas não vazem
trabalho simultâneo descontrolado em um escopo principal. Normalmente, isso 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()
}
Nomenclatura de operações de terminal
O nome usado para métodos que encerram tarefas simultâneas pertencentes a um objeto ainda em andamento precisa refletir o contrato comportamental de como o encerramento ocorre:
Use close()
quando as operações em andamento puderem ser concluídas, mas nenhuma nova operação poderá
ser iniciada depois que a chamada para close()
retornar.
Use cancel()
quando as operações em andamento puderem ser canceladas antes da conclusão.
Nenhuma nova operação pode ser iniciada depois que a chamada para cancel()
retorna.
Os construtores de classe aceitam CoroutineContext, não CoroutineScope
Quando os objetos não podem ser iniciados diretamente em um escopo pai fornecido,
a adequação de CoroutineScope
como um parâmetro de construtor é interrompida:
// 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 ser transmitido como um parâmetro de construtor e depois descartado:
// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))
Os parâmetros CoroutineContext têm como padrão EmptyCoroutineContext
Quando um parâmetro CoroutineContext
opcional aparece em uma superfície de API, o
valor padrão precisa ser o sentinel Empty`CoroutineContext`
. Isso permite uma
melhor composição de comportamentos de API, já que um valor Empty`CoroutineContext`
de um caller é tratado da mesma forma que aceitar o 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)
// ...
}