Back home

Otimização de inicialização assíncrona e fenômenos acidentais de inicialização

Geralmente não vale a pena trocar 200 ms de ganho por condições de corrida irrepetíveis e custos de solução de problemas.

O primeiro indicador da tela caiu, mas uma das falhas mais irritantes começou a aparecer online: aparecia ocasionalmente, era difícil de reproduzir e parecia metafísica.

A pilha de falhas é instável, todos os logs parecem “normais” e, ocasionalmente, pode se curar sozinho. Olhando para trás, para os registros de alterações, todos estão fazendo a mesma coisa: interrompendo a inicialização da fase de inicialização, atrasando-a, tornando-a assíncrona e tornando-a simultânea para tornar a inicialização a frio mais rápida.

O problema não é que “a lentidão desapareceu”, mas que “as dependências desapareceram”, ou mais precisamente, as dependências estão ocultas.

Neste artigo, quero explicar o julgamento mais crítico em uma investigação real: a armadilha da otimização de startups é muitas vezes colocar a primeira interação comercial em um estado semi-inicializado. **Os 200 ms economizados podem acabar sendo gastos em travamentos ocasionais, estados errados, cobertura mútua e tempo de solução de problemas da equipe.

Contexto do problema: a primeira tela é mais rápida e o primeiro clique ocasionalmente trava

A descrição da falha é muito típica:

  • A inicialização a frio do Android é mais rápida e o tempo de tela branca da primeira tela é reduzido
  • Uma pequena proporção de usuários on-line ocasionalmente enfrenta falhas ou erros no “primeiro clique após a primeira tela”
  • A pilha de falhas às vezes está no módulo de negócios, às vezes na camada de rede e às vezes no SDK.
  • É quase impossível reproduzir em ambientes locais e de teste, e a reprodução em tons de cinza também é instável.

Este tipo de problema é mais facilmente mal interpretado como “diferenças no ambiente online”, “compatibilidade de modelos” e “convulsões de SDK de terceiros”. Mas quando for altamente relevante para uma mudança de otimização de inicialização, primeiro tratarei isso como algo mais simples: **condições de corrida. **

Julgamento central: A assincronização não é um método de otimização, ela está mudando a semântica de prontidão do sistema.

Muitas intuições para otimização de startups são:

  • Coisas pesadas de IO movidas para thread em segundo plano
  • Coisas pesadas de CPU em paralelo
  • Atrasar a inicialização crítica da não primeira tela para depois da primeira tela

Quase sempre são “válidos” nas métricas.

Mas eles também fizeram algo mais perigoso: **Apagar as dependências originalmente implícitas na “execução sequencial”. **

Anteriormente em Application#onCreate() ele era inicializado sequencialmente: A -> B -> C. Mesmo que ninguém escreva o documento, o sistema padroniza este fato:

  • Quando onCreate() termina, A/B/C foi executado pelo menos

Mais tarde eles foram divididos em:

-A executar imediatamente

  • B entrega uma tarefa assíncrona -C passar para outra tarefa assíncrona

Neste momento, o final de onCreate() não significa mais “o sistema está pronto”, significa apenas “joguei a tarefa fora”.

O primeiro clique online geralmente ocorre em um momento inesperado: a primeira renderização da tela é concluída, o usuário clica imediatamente ou um comportamento automático aciona a navegação.

Portanto, a primeira interação comercial caiu em uma faixa estranha:

  • Algumas dependências foram inicializadas
  • Alguns ainda estão em execução
  • Alguns falharam, mas foram mantidos em segredo
  • Alguns ainda não começaram porque estão atrasados

Isso não é “lento”, é estado incompleto.

Processo de demonstração: Como o problema converge passo a passo para a “semi-inicialização”?

Para solucionar esses problemas ocasionais, eu não focaria primeiro na pilha de falhas. Farei três coisas primeiro para transformar “irreprodutível” em “explicável”.

1) Desenhe o diagrama de dependência de inicialização primeiro, não desenhe o diagrama do módulo

O diagrama do módulo responde “quem depende de quem”, mas a pergunta inicial responde:

  • Qual inicialização deve ser concluída antes da primeira interação
  • Quais falhas de inicialização afetarão a semântica do negócio
  • Qual inicialização é apenas a cereja do bolo

Dividirei as dependências de inicialização em três categorias de acordo com o limite da “primeira interação”:

  1. Deve estar pronto (Hard Ready): Se não estiver pronto, não poderá ser permitido inserir o caminho crítico, como estado de login, token de autenticação, tabela de roteamento, modelo de thread principal (como as restrições do thread principal/agendador de corrotina) e o conjunto mínimo de relatórios de falhas.
  2. Soft Ready: Você pode entrar no negócio se não estiver pronto, mas deve fazer o downgrade de maneira controlável, como cache recomendado, experimentos AB e campos de aprimoramento ocultos.
  3. Adiado: Pode ser feito posteriormente sem afetar a semântica da primeira interação, como aquecimento, inicialização do decodificador de imagem e SDK não crítico.

O valor desta etapa é alterar o argumento de “assíncrono ou não assíncrono” para “em que limite esta dependência deve ser concluída”.

2) Dê a cada dependência um “contrato de prontidão”, caso contrário a assincronização será equivalente a jogos de azar

O chamado contrato de prontidão visa esclarecer duas coisas:

  • Quem julgará se está pronto
  • Como proceder quando o negócio não estiver pronto

Assincronização sem contrato de prontidão, as manifestações comuns são:

  • O chamador pensa que a inicialização foi concluída e usa diretamente
  • O inicializador pensou que o chamador não o usaria tão cedo -Ambos os lados estão certos, o erro online está no “timing”

Uma das falhas mais típicas que vi foi alterar a inicialização de um singleton para preguiçoso + assíncrono.

O pseudocódigo fica assim:

object Foo {
 @Volatile private var inited = false

 fun initAsync() {
 GlobalScope.launch(Dispatchers.IO) {
  // 读配置/解密/拉取远端
  inited = true
 }
 }

 fun doWork() {
 check(inited) { "Foo not initialized" }
 // ...
 }
}

O primeiro indicador da tela ficará melhor, mas assim que o tempo de chamada do doWork() for avançado antes do final do init, ele se tornará “ocasional”.

O pior é que muitos códigos não serão check(inited), mas continuarão em execução, gerando um estado de erro, e não explodirão até mais tarde.

3) Avalie a “janela” da competição em vez de confiar em sentimentos

As condições necessárias para que a assincronização cause problemas são:

  • A primeira interação ocorre antes de alguma inicialização ser concluída

Portanto, adicionarei dois tipos de logs (observe que eles são pontos de tempo alinháveis):

  • t0: Início do processo/Início Application.onCreate
  • t1: A primeira tela é interativa (é verdadeiramente clicável)
  • t_ready(X): o momento em que cada dependência de chave está pronta

Então dê uma olhada na distribuição:

  • Qual é a proporção de t1 < t_ready(Auth)
  • Qual proporção é t1 < t_ready(Router)
  • E se estão relacionados ao modelo, rede, inicialização a quente e a frio e versão do sistema

Uma vez que esta janela possa ser quantificada, muitas “ocorrências” subitamente se tornarão não misteriosas: é apenas um evento probabilístico.

Mal-entendidos e casos de falha: quanto mais você anota, maior a probabilidade de criar problemas mais difíceis de solucionar.

Após iniciar a assincronização, a equipe naturalmente será cautelosa:

  • Se a dependência não estiver pronta, use o valor padrão
  • Se a configuração não for puxada, vá para o último cache
  • AB caiu no controle antes de conseguir.

Cada um deles faz sentido por si só, mas tem dois efeitos colaterais.

Mal-entendido 1: Transformando “falta de dependências” em “desvio semântico”

Na verdade, o travamento é fácil de solucionar, mas os estados de erro são os mais difíceis de solucionar.

Por exemplo, se o status de login não estiver pronto, ele se tornará “não logado”. Isso levará o usuário a uma página de erro quando o primeiro clique acionar um salto. Mais tarde, quando o estado de login real estiver pronto, o status da página será redefinido novamente, então “flash”, “pular para trás” e “sair ocasionalmente” aparecerão.

Você verá vários ramos “normais” no log: todos eles são cobertos pelo design. Mas a experiência do usuário é ruim e é difícil associá-la à otimização de startups.

Mal-entendido 2: Cobrir os segredos uns dos outros leva a uma cadeia de evidências quebrada para solução de problemas

A dependência A não está pronta, então segue um caminho traiçoeiro.

Ao mesmo tempo, a dependência B não está pronta e também passou por todos os tipos de truques.

No final, o negócio se comporta como o problema de B, mas a causa raiz é A.

O que é mais realista é: para “não travar”, a exceção é engolida e a falha é registrada como debug, deixando apenas um “resultado errado” online.

Esta é uma das fontes de “irreprodutibilidade”: apagar o sinal de falha da chave.

Como corrigir: altere “Assincronização” para “Limite de prontidão verificável”

Para resolver esse problema, a semântica de inicialização do sistema geralmente é reforçada novamente.

Farei três etapas do custo mais baixo para o mais alto.

1) Defina um Ready Gate executável

A dependência do Hard Ready fornece uma porta unificada:

  • Você deve passar pelo portão antes de interagir pela primeira vez
  • Se não conseguir passar, bloqueie as principais operações ou forneça um caminho claro para o downgrade.

Por exemplo, adicione uma pequena marca na entrada do primeiro clique (botão de navegação/roteamento/chave):

  • Continue quando estiver pronto
  • Exibir o carregamento se não estiver pronto ou entrar na fila primeiro

A chave para esta etapa é alterar a “dependência não pronta” de um estado de corrida implícito para um estado explícito.

2) Faça da inicialização uma “tarefa com estado” em vez de disparar e esquecer

Muitas inicializações são executadas diretamente usando GlobalScope.launch ou o pool de threads e, se falharem, falharão.

Uma abordagem mais controlável seria:

  • Cada inicialização possui status: NotStarted / Running / Ready / Failed
  • O chamador recebe um identificador que pode ser aguardado (mesmo que no final não aguarde)

Pseudocódigo:

sealed class InitState {
 data object NotStarted : InitState()
 data object Running : InitState()
 data object Ready : InitState()
 data class Failed(val error: Throwable) : InitState()
}

class Initializer {
 @Volatile private var state: InitState = InitState.NotStarted
 private val deferred = CompletableDeferred<Unit>()

 fun start() {
 if (state != InitState.NotStarted) return
 state = InitState.Running
 scope.launch(Dispatchers.IO) {
  runCatching {
  // do init
  }.onSuccess {
  state = InitState.Ready
  deferred.complete(Unit)
  }.onFailure {
  state = InitState.Failed(it)
  deferred.completeExceptionally(it)
  }
 }
 }

 suspend fun awaitReady() = deferred.await()
}

Isso torna duas coisas verdadeiras:

  • Você pode escolher onde esperar
  • Chega de “pensar que é melhor”

3) Defina limites e opções de reversão para inicialização atrasada

A inicialização lenta não é impossível, mas requer condições de contorno:

  • Quais usuários/cenários podem ser atrasados (como apenas partidas a frio ou partidas a quente também atrasadas)
  • O que fazer quando ocorre uma falha (tentar novamente, desabilitar, reverter)
  • Como observar a escala de cinza (distribuição de janelas prontas, taxa de falha, taxa de degradação)

Prefiro fazer de “Iniciar Assincronização” uma opção de política de reversão, em vez de uma alteração única de código.

Porque uma vez que um problema ocasional é descoberto on-line, a maneira mais rápida de estancar o sangramento geralmente é “reverter a assincronização”.

Limites aplicáveis: quando a assincronização é lucrativa e quando é uma perda?

A premissa de que a assincronização é lucrativa é:

  • A dependência está pronta ou adiada
  • O contrato de prontidão é claro e há uma cadeia de evidências para o fracasso
  • A janela pronta é pequena e estável e não abrange a primeira interação

Cenários típicos onde a assincronização é uma perda:

  • A dependência está Hard Ready, mas foi movida por questão de métricas
  • Encobrir o fracasso com encobrimento, levando a desvios semânticos
  • Não há portão pronto, então a condição de corrida torna-se um evento probabilístico

Resumindo em uma frase: você pode explicar quando não está pronto, e se o negócio consegue manter uma semântica consistente quando não está pronto, a assincronização é considerada otimizada. **

Resumo

A otimização da inicialização a frio é mais facilmente conduzida pelo KPI em um problema de objetivo único: tornar a primeira tela mais rápida.

Mas o que realmente precisa ser mantido em mente durante a fase de inicialização é “quando o sistema será considerado disponível?” Quanto mais a inicialização for dividida em partes, mais claramente a semântica de prontidão precisará ser escrita no código e nas observações.

Caso contrário, o que faremos é substituir a lentidão determinística por erros probabilísticos.

FAQ

What to read next

Related

Continue reading