Back home

Optimización de inicio asincrónico e inicialización de fenómenos accidentales

Por lo general, no vale la pena cambiar 200 ms de ganancia por condiciones de carrera irrepetibles y costos de resolución de problemas.

El primer indicador de pantalla cayó, pero uno de los fallos más molestos comenzó a aparecer en línea: aparecía ocasionalmente, era difícil de reproducir y parecía metafísico.

La pila de fallos es inestable, todos los registros parecen “normales” y ocasionalmente puede curarse solo. Mirando hacia atrás en los registros de cambios, todos están haciendo lo mismo: dividir la inicialización de la fase de inicio, retrasarla, hacerla asincrónica y hacerla concurrente para acelerar el arranque en frío.

El problema no es que “la lentitud desapareció”, sino que “las dependencias desaparecieron”, o más exactamente, las dependencias están ocultas.

En este artículo, quiero explicar el juicio más crítico en una investigación real: el error de la optimización de una startup es a menudo poner la primera interacción empresarial en un estado semiinicializado. **Los 200 ms ahorrados pueden terminar desperdiciados en fallas ocasionales, estados incorrectos, cobertura mutua y tiempo de resolución de problemas en equipo.

Antecedentes del problema: la primera pantalla es más rápida y el primer clic ocasionalmente falla

La descripción del fallo es muy típica:

  • El arranque en frío de Android es más rápido y el tiempo de pantalla blanca de la primera pantalla se reduce
  • Una pequeña proporción de usuarios en línea ocasionalmente experimenta fallas o errores en el “primer clic después de la primera pantalla”.
  • La pila de fallos a veces se encuentra en el módulo empresarial, a veces en la capa de red y a veces en el SDK.
  • Es casi imposible reproducirlo en entornos locales y de prueba, y la reproducción en escala de grises también es inestable.

Este tipo de problema se interpreta erróneamente más fácilmente como “diferencias en el entorno en línea”, “compatibilidad de modelos” y “convulsiones de SDK de terceros”. Pero cuando sea muy relevante para un cambio de optimización de inicio, primero lo trataré como algo más simple: **condiciones de carrera. **

Juicio central: la asincronización no es un método de optimización, está cambiando la semántica de preparación del sistema.

Muchas intuiciones para la optimización de startups son:

  • Cosas pesadas de IO movidas al hilo de fondo
  • Cosas con mucha CPU en paralelo
  • Retrasar la inicialización crítica de la pantalla que no es la primera hasta después de la primera pantalla

Casi siempre son “válidos” según las métricas.

Pero también hicieron algo más peligroso: **Borrar las dependencias originalmente implícitas en la “ejecución secuencial”. **

Anteriormente en Application#onCreate() se inicializaba secuencialmente: A -> B -> C. Incluso si nadie escribe el documento, el sistema por defecto toma este hecho:

  • Cuando finaliza onCreate(), A/B/C al menos se ha ejecutado.

Posteriormente se dividieron en:

-A ejecutar inmediatamente

  • B entrega una tarea asincrónica -C entregar a otra tarea asincrónica

En este momento, el final de onCreate() ya no significa “el sistema está listo”, solo significa “deseché la tarea”.

El primer clic en línea a menudo ocurre en un momento inesperado: se completa la representación de la primera pantalla, el usuario hace clic inmediatamente o un comportamiento automático activa la navegación.

Así que la primera interacción comercial cayó en un rango incómodo:

  • Algunas dependencias han sido inicializadas.
  • Algunos todavía están funcionando
  • Algunos fracasaron pero se mantuvieron en secreto.
  • Algunos aún no han empezado porque están retrasados.

Eso no es “lento”, es estado incompleto.

Proceso de demostración: ¿Cómo converge el problema a la “semiinicialización” paso a paso?

Para solucionar estos problemas ocasionales, no me centraría primero en la pila de fallos. Primero haré tres cosas para convertir “irreproducible” en “explicable”.

1) Primero dibuje el diagrama de dependencia de inicio, no dibuje el diagrama del módulo

El diagrama del módulo responde “quién depende de quién”, pero la pregunta inicial responde:

  • Qué inicialización debe completarse antes de la primera interacción.
  • Qué fallas de inicialización afectarán la semántica empresarial.
  • ¿Qué inicialización es sólo la guinda del pastel?

Dividiré las dependencias de inicio en tres categorías según el límite de la “primera interacción”:

  1. Debe estar listo (Hard Ready): si no está listo, no se le puede permitir ingresar a la ruta crítica, como el estado de inicio de sesión, el token de autenticación, la tabla de enrutamiento, el modelo de subproceso clave (como las restricciones del subproceso principal/programador de rutinas) y el conjunto mínimo de informes de fallas.
  2. Soft Ready: puede ingresar al negocio si no está listo, pero debe bajar de manera controlable, como el almacenamiento en caché recomendado, los experimentos AB y los campos de mejora enterrados.
  3. Diferido: se puede realizar más tarde sin afectar la semántica de la primera interacción, como el calentamiento, la inicialización del decodificador de imágenes y el SDK no crítico.

El valor de este paso es cambiar el argumento de “asincrónico o no asincrónico” a “en qué límite se debe completar esta dependencia”.

2) Asigne a cada dependencia un “contrato de preparación”; de lo contrario, la asincronización equivale a apostar

El llamado contrato de preparación pretende aclarar dos cosas:

  • ¿Quién juzgará si está listo?
  • Cómo proceder cuando el negocio no está listo

Asincronización sin contrato de preparación, las manifestaciones comunes son:

  • La persona que llama cree que la inicialización se ha completado y usa directamente
  • El inicializador pensó que la persona que llama no lo usaría tan pronto. -Ambas partes tienen razón, el error online está en el “timing”

Uno de los fallos más típicos que he visto es cambiar la inicialización de un singleton a lazy + async.

El pseudocódigo se ve así:

object Foo {
 @Volatile private var inited = false

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

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

El primer indicador de pantalla mejorará, pero una vez que el tiempo de llamada de doWork() avance antes del final de init, se volverá “ocasional”.

Lo peor es que muchos códigos no serán check(inited), sino que continuarán ejecutándose, generando un estado de error y no explotarán hasta más tarde.

3) Mida la “ventana” de competencia en lugar de confiar en los sentimientos

Las condiciones necesarias para que la asincronización cause problemas son:

  • La primera interacción ocurre antes de que se complete alguna inicialización.

Entonces agregaré dos tipos de registros (tenga en cuenta que son puntos de tiempo alineables):

  • t0: Inicio de proceso/Inicio Application.onCreate
  • t1: La primera pantalla es interactiva (realmente se puede hacer clic en ella)
  • t_ready(X): el momento en que cada dependencia clave está lista

Entonces eche un vistazo a la distribución:

  • ¿Cuál es la proporción de t1 < t_ready(Auth)?
  • ¿Qué proporción es t1 < t_ready(Router)?
  • Y si están relacionados con el modelo, la red, el arranque en frío y en caliente y la versión del sistema.

Una vez que esta ventana pueda cuantificarse, muchos “sucesos” de repente dejarán de ser misteriosos: serán sólo un evento de probabilidad.

Malentendidos y casos de falla: cuanto más escriba, más probabilidades tendrá de crear problemas que serán más difíciles de solucionar.

Después de iniciar la asincronización, el equipo naturalmente será cauteloso:

  • Si la dependencia no está lista, use el valor predeterminado
  • Si no se extrae la configuración, vaya al último caché
  • AB cayó al control antes de conseguirlo.

Cada uno de estos tiene sentido por sí solo, pero tienen dos efectos secundarios.

Malentendido 1: Convertir la “falta de dependencias” en “deriva semántica”

En realidad, las fallas son fáciles de solucionar, pero los estados de error son los más difíciles de solucionar.

Por ejemplo, si el estado de inicio de sesión no está listo, pasará a ser “no iniciado sesión”. Esto llevará al usuario a una página de error cuando el primer clic provoque un salto. Más tarde, cuando el estado de inicio de sesión real está listo, el estado de la página se restablece nuevamente, por lo que aparecen “flash”, “saltar hacia atrás” y “ocasionalmente cerrar sesión”.

Verá un montón de ramas “normales” en el registro: todas están cubiertas por el diseño. Pero la experiencia del usuario es mala y es difícil asociarla con la optimización del inicio.

Malentendido 2: Cubrir los secretos de los demás conduce a una cadena de evidencia rota para la resolución de problemas

La dependencia A no está lista, por lo que toma un camino traicionero.

Al mismo tiempo, la dependencia B no está lista y también ha pasado por todo tipo de trucos.

Al final, el negocio se comporta como el problema de B, pero la causa raíz es A.

Lo que es más realista es: para “no fallar”, la excepción se traga y la falla se registra como debug, dejando solo un “resultado incorrecto” en línea.

Ésta es una de las fuentes de “irreproducibilidad”: borrar la señal clave de fallo.

Cómo solucionarlo: cambie “Asincronización” a “Límite de preparación verificable”

Para solucionar este problema, normalmente se vuelve a ajustar la semántica de inicio del sistema.

Haré tres pasos de menor a mayor costo.

1) Definir una puerta lista ejecutable

La dependencia de Hard Ready proporciona una puerta unificada:

  • Debes pasar la puerta antes de interactuar por primera vez.
  • Si no puede aprobarse, bloquee las operaciones clave o proporcione un camino claro para bajar de categoría.

Por ejemplo, agregue una pequeña marca en la entrada del primer clic (navegación/enrutamiento/botón clave):

  • Continuar cuando esté listo
  • Mostrar cargando si no está listo, o hacer cola primero

La clave de este paso es cambiar la “dependencia no lista” de un estado racial implícito a un estado explícito.

2) Hacer de la inicialización una “tarea con estado” en lugar de disparar y olvidar

Muchas inicializaciones se descartan directamente utilizando GlobalScope.launch o el grupo de subprocesos y, si fallan, fallan.

Un enfoque más controlable sería:

  • Cada inicialización tiene estado: NotStarted / Running / Ready / Failed
  • La persona que llama obtiene un identificador que puede esperar (incluso si al final no espera)

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()
}

Esto hace que dos cosas sean ciertas:

  • Puedes elegir dónde esperar.
  • No más “pensar que es mejor”

3) Establecer límites y conmutadores de reversión para la inicialización retrasada

La inicialización diferida no es imposible, pero requiere condiciones de contorno:

  • Qué usuarios/escenarios se pueden retrasar (como solo arranques en frío o arranques en caliente también retrasados)
  • Qué hacer cuando ocurre una falla (reintentar, deshabilitar, revertir)
  • Cómo observar la escala de grises (distribución de ventanas listas, tasa de falla, tasa de degradación)

Preferiría hacer de “Iniciar asincronización” un cambio de política de reversión en lugar de un cambio de código único.

Porque una vez que se descubre un problema ocasional en línea, la forma más rápida de detener el sangrado suele ser “revertir la asincronización”.

Límites aplicables: ¿cuándo es rentable la asincronización y cuándo es una pérdida?

La premisa de que la asincronización es rentable es:

  • La dependencia es Soft Ready o Diferida
  • El contrato de preparación es claro y existe una cadena de evidencia del fracaso.
  • La ventana lista es pequeña y estable y no abarca la primera interacción.

Escenarios típicos en los que la asincronización es una pérdida:

  • La dependencia es Hard Ready, pero se movió por motivos de métricas.
  • Encubrir el fracaso con encubrimiento, lo que lleva a una deriva semántica
  • No hay puerta lista, por lo que la condición de carrera se convierte en un evento probabilístico.

Para resumir en una oración: ¿Puede explicar cuándo no está listo y si la empresa puede mantener una semántica consistente cuando no está listo, la asincronización se considera optimizada? **

Resumen

La optimización del arranque en frío se lleva más fácilmente mediante KPI a un problema de un solo objetivo: hacer que la primera pantalla sea más rápida.

Pero lo que realmente hay que tener en cuenta durante la fase de inicio es “¿cuándo se considerará que el sistema está disponible?” Cuanto más se divide la inicialización en partes, más claramente se debe escribir la semántica de preparación en el código y en las observaciones.

De lo contrario, lo que haremos será sustituir la lentitud determinista por errores probabilísticos.

FAQ

What to read next

Related

Continue reading