As APIs não bloqueadoras solicitam que o trabalho seja realizado e, em seguida, retornam o controle para a 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 pode exigir a conclusão de E/S ou IPC, a disponibilidade de recursos de sistema altamente disputados ou a entrada do usuário antes que o trabalho possa continuar. As APIs bem projetadas oferecem uma maneira de cancelar a operação em andamento e interromper o trabalho 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 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 que a operação N-1 seja concluída.
- Evitar o bloqueio de uma linha de execução de chamada até que uma operação seja concluída.
O Kotlin promove fortemente simultaneidade estruturada, uma série de princípios e APIs criadas em funções de suspensão que desvinculam a execução síncrona e assíncrona do código do comportamento de bloqueio de linhas de execução. As funções de suspensão são não bloqueadoras e síncronas.
Funções de suspensão:
- Não bloqueie a linha de execução de chamada e, em vez disso, produza a linha de execução como um detalhe de implementação enquanto aguarda os resultados de operações executadas em outro lugar.
- Execute de forma síncrona e não exija que o autor da chamada de uma API não bloqueadora continue a execução simultaneamente com o trabalho não bloqueador iniciado pela chamada de API.
Esta página detalha uma linha de base mínima de expectativas que os desenvolvedores podem manter com segurança ao trabalhar com APIs não bloqueadoras e assíncronas, seguida de 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 do desenvolvedor como requisitos para qualquer nova superfície de API.
Expectativas do desenvolvedor para 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 esteja documentado para ser chamado apenas no local (ou seja, chamado apenas pela linha de execução de chamada antes que a própria chamada de API retorne), a API será considerada assíncrona e deverá atender a todas as outras expectativas documentadas nas seções a seguir.
Um exemplo de callback que é chamado apenas 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 bloqueadoras 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, e esperar que um desenvolvedor tenha conhecimento global de todos os possíveis locais de chamada para o 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 de 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 do 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 livre de instabilidade. Um desenvolvedor sempre deve ter confiança de que chamar qualquer API assíncrona em resposta a esses tipos de callbacks de 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 de retornar precisa ser muito leve: criar um registro da solicitação e do callback associado e registrá-lo no 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 um ou mais dos seguintes:
- Implementar um IPC subjacente como uma chamada de vinculador unidirecional
- Fazer uma chamada de vinculador 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 worker no processo do app para realizar um registro de bloqueio por IPC
As APIs assíncronas precisam retornar um valor nulo 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 tratamento de sucesso e erros.
As APIs assíncronas podem verificar se há argumentos 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 do intervalo. 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 precisam ser informados ao callback fornecido. Isso inclui, entre outros:
- 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
- Cota excedida para realizar a operação
- 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
- Tempo limite
- Processo remoto indisponível ou de vinculador
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 autor da chamada não se importa mais com o resultado. Essa operação de cancelamento precisa sinalizar duas coisas:
Referências fixas a callbacks fornecidos pelo autor da chamada precisam ser liberadas
Os callbacks fornecidos às APIs assíncronas podem conter referências fixas a grandes gráficos de objetos, 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 qualificar para a coleta de lixo muito mais cedo do que se o trabalho fosse permitido para ser concluído.
O mecanismo de execução que realiza 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 outros recursos do sistema. As APIs que permitem que os autores da chamada sinalizem quando esse trabalho não é mais necessário permitem interromper o trabalho antes que ele possa consumir mais recursos do sistema.
Considerações especiais para apps armazenados em cache ou congelados
Ao projetar APIs assíncronas em que os callbacks se originam em um processo do sistema e são entregues a apps, considere o seguinte:
- Processos e ciclo de vida do app: o processo do app destinatário pode estar no estado armazenado em cache.
- Congelador de apps armazenados 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 ao usuário, como atividades e serviços. O app é mantido na memória caso se torne visível ao usuário novamente, mas, enquanto isso, não deve estar funcionando. Na maioria dos casos, é necessário pausar o envio de callbacks do app quando ele entra no estado armazenado em cache e retomar quando o app sai do estado armazenado em cache, para não induzir o trabalho em processos de app armazenados em cache.
Um app armazenado em cache também pode ser congelado. Quando um app é congelado, ele recebe tempo de CPU zero e não pode fazer nenhum trabalho. Todas as chamadas para os callbacks registrados desse app são armazenadas em buffer e entregues quando o app é descongelado.
As transações armazenadas em buffer para callbacks de apps podem estar desatualizadas quando o app é descongelado e as processa. O buffer é finito e, se estourado, causaria a falha do app destinatário. Para evitar sobrecarregar os apps com eventos desatualizados ou estourar os buffers, não envie callbacks de apps enquanto o processo estiver congelado.
Em análise:
- É recomendado pausar o envio de callbacks de apps enquanto o processo do app está armazenado em cache.
- É OBRIGATÓRIO pausar o envio de callbacks de apps enquanto o processo do app está congelado.
Rastreamento com estado
Para rastrear quando os apps entram ou saem do estado armazenado 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 de apps quando o app entra no estado armazenado em cache ou congelado, quando o app sair do estado respectivo, retome o envio dos callbacks registrados do app assim que 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, você pode usar RemoteCallbackList, que não entrega 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() é invocado apenas 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 ocorrem quando um app está congelado. Quando o app é descongelado, você precisa entregar apenas o estado mais recente ao app e descartar outras mudanças de estado desatualizadas. Essa entrega precisa acontecer imediatamente quando o app é descongelado para que ele possa "alcançar". 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, é possível rastrear o último valor entregue ao app para que ele não precise ser notificado do mesmo valor depois de ser descongelado.
O estado pode ser expresso como dados mais complexos. Considere uma API hipotética para que os apps sejam notificados das interfaces de rede:
interface NetworkListener {
void onAvailable(Network network);
void onLost(Network network);
void onChanged(Network network);
}
Ao pausar as notificações para um app, lembre-se do conjunto de redes e estados que o app viu pela última vez. Ao retomar, é recomendável notificar o app sobre redes antigas que foram perdidas, novas redes que ficaram disponíveis e redes atuais cujo estado mudou, nessa ordem.
Não notifique o app sobre redes que foram disponibilizadas e perdidas enquanto os callbacks estavam pausados. Os apps não precisam receber uma conta completa de eventos que aconteceram enquanto estavam congelados, e a documentação da API não precisa prometer entregar fluxos 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 o impeça de ser armazenado em cache ou congelado.
Em análise, você precisa unir eventos que aconteceram após a pausa e antes de retomar as notificações e entregar o estado mais recente aos callbacks de apps registrados de forma sucinta.
Considerações sobre a documentação para desenvolvedores
A entrega de eventos assíncronos pode ser atrasada, seja porque o remetente pausou a entrega por um período de tempo, conforme mostrado na seção anterior, ou porque o app destinatário não recebeu recursos de dispositivo suficientes para processar o evento de maneira oportuna.
Desencoraje 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 aconteceu.
Expectativas do desenvolvedor para APIs de suspensão
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 exceções
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ó precisam 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 precisam 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 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 exceções e só precisam invocar parâmetros de callback no local, a expectativa padrão é que esses callbacks também sejam executados no CoroutineContext de chamada usando o dispatcher associado. Se o objetivo da API for executar um callback fora do CoroutineContext de chamada, esse comportamento precisará ser claramente documentado.
As funções de suspensão precisam oferecer suporte ao cancelamento de jobs kotlinx.coroutines
Qualquer função de suspensão oferecida precisa cooperar com o cancelamento de jobs, conforme definido por kotlinx.coroutines. Se o job de chamada de uma operação em andamento for cancelado, a função precisará ser retomada com uma CancellationException assim que possível para que o autor da chamada possa limpar e continuar o mais rápido possível. Isso é processado automaticamente por suspendCancellableCoroutine e outras APIs de suspensão oferecidas por kotlinx.coroutines. As implementações de biblioteca geralmente não precisam usar suspendCoroutine diretamente, porque ele não oferece suporte a esse comportamento de cancelamento por padrão.
As funções de suspensão que realizam trabalhos de bloqueio em uma linha de execução em segundo plano (não principal ou de IU) precisam fornecer uma maneira de configurar o dispatcher usado
Não é recomendável fazer com que uma função bloqueadora seja suspensa inteiramente para mudar de linha de execução.
Chamar uma função de suspensão não precisa 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 pool de linhas de execução para realizar esse trabalho. Por exemplo, um construtor pode aceitar um CoroutineContext usado para realizar o 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 para realizar o trabalho de bloqueio precisam expor a função de bloqueio subjacente e recomendar que os desenvolvedores de chamada usem a própria chamada para withContext para direcionar 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 de simultaneidade estruturada implica os seguintes padrões estruturais para receber 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 uma suspend fun para realizar trabalhos simultâneos permite que o autor da chamada 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 geralmente pode existir como variáveis locais de handleRequests, em vez de como 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 corretamente essas tarefas simultâneas em andamento para que elas não vazem trabalhos simultâneos não controlados em um escopo pai. 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 que esteja sendo realizado pelo objeto.
Isso pode incluir o trabalho de limpeza realizado pelo cancelamento de uma operação.
suspend fun join() {
myJob.join()
}
Nomeação de operação terminal
O nome usado para métodos que desligam corretamente tarefas simultâneas pertencentes a 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 puderem ser concluídas, mas nenhuma nova operação poderá ser iniciada após o retorno da chamada para close().
Use cancel() quando as operações em andamento puderem ser canceladas antes da conclusão.
Nenhuma nova operação poderá ser iniciada após o retorno da chamada para cancel().
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 é 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, apenas para ser descartado:
// Don't do this; just pass the context
val myObject = MyClass(CoroutineScope(parentScope.`CoroutineContext` + Dispatchers.IO))
Os parâmetros CoroutineContext são definidos como EmptyCoroutineContext por padrão
Quando um parâmetro opcional CoroutineContext aparece em uma superfície de API, o
valor padrão precisa ser o sentinela Empty`CoroutineContext`. Isso permite uma
melhor composição de comportamentos de API, já que um Empty`CoroutineContext` valor
de um autor da chamada é 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)
// ...
}