Optimisation du démarrage asynchrone et phénomènes accidentels d'initialisation
Cela ne vaut généralement pas la peine d’échanger 200 ms de gain contre des conditions de course irremplaçables et des coûts de dépannage.
Le premier indicateur d’écran a chuté, mais l’un des problèmes les plus ennuyeux a commencé à apparaître en ligne : il apparaissait occasionnellement, était difficile à reproduire et semblait métaphysique.
La pile de crash est instable, les journaux semblent tous “normaux” et elle peut occasionnellement se réparer d’elle-même. En regardant les enregistrements de modifications, tout le monde fait la même chose : interrompre l’initialisation de la phase de démarrage, la retarder, la rendre asynchrone et la rendre simultanée pour accélérer le démarrage à froid.
Le problème n’est pas que « la lenteur a disparu », mais que « les dépendances ont disparu », ou plus précisément, les dépendances sont cachées.
Dans cet article, je souhaite expliquer le jugement le plus critique d’une véritable enquête : le piège de l’optimisation d’une startup est souvent de mettre la première interaction commerciale dans un état semi-initialisé. **Les 200 ms économisées peuvent finir par être dépensées en crashs occasionnels, en mauvais états, en couverture mutuelle et en temps de dépannage en équipe.
Contexte du problème : le premier écran est plus rapide et le premier clic plante parfois
La description du défaut est très typique :
- Le démarrage à froid d’Android est plus rapide et le temps d’écran blanc du premier écran est réduit
- Une petite proportion d’utilisateurs en ligne rencontrent occasionnellement des plantages ou des erreurs lors du “premier clic après le premier écran”.
- La pile de crash se trouve tantôt dans le module métier, tantôt dans la couche réseau, et tantôt dans le SDK.
- Il est presque impossible de reproduire dans des environnements locaux et de test, et la reproduction en niveaux de gris est également instable.
Ce type de problème est plus facilement mal jugé en tant que « différences dans l’environnement en ligne », « compatibilité des modèles » et « convulsions du SDK tiers ». Mais lorsqu’il est très pertinent pour un changement d’optimisation de startup, je le traiterai d’abord comme quelque chose de plus simple : **conditions de course. **
Jugement de base : l’asynchronisation n’est pas une méthode d’optimisation, elle modifie la sémantique de préparation du système.
De nombreuses intuitions pour l’optimisation des startups sont :
- Les éléments d’E/S lourds ont été déplacés vers le fil d’arrière-plan
- Des trucs gourmands en CPU en parallèle
- Retarder l’initialisation critique d’un autre écran après le premier écran
Celles-ci sont presque toujours « valides » sur les métriques.
Mais ils ont aussi fait quelque chose de plus dangereux : **Effacer les dépendances initialement implicites dans “l’exécution séquentielle”. **
Auparavant, dans Application#onCreate(), il était initialisé séquentiellement : A -> B -> C. Même si personne n’écrit le document, le système utilise par défaut ce fait :
- Lorsque
onCreate()se termine, A/B/C est au moins exécuté
Plus tard, ils ont été décomposés en :
-A exécuter immédiatement
- B confie une tâche asynchrone -C passer le relais à une autre tâche asynchrone
À l’heure actuelle, la fin de onCreate() ne signifie plus « le système est prêt », cela signifie seulement « j’ai abandonné la tâche ».
Le premier clic en ligne se produit souvent à un moment inattendu : le premier rendu d’écran est terminé, l’utilisateur clique immédiatement ou un comportement automatique déclenche la navigation.
Ainsi, la première interaction commerciale s’est déroulée dans une fourchette délicate :
- Certaines dépendances ont été initialisées
- Certains fonctionnent encore
- Certains ont échoué mais ont été gardés secrets
- Certains n’ont pas encore démarré car ils ont du retard
Ce n’est pas “lent”, c’est un état incomplet.
Processus de démonstration : Comment le problème converge-t-il vers la « semi-initialisation » étape par étape ?
Pour résoudre de tels problèmes occasionnels, je ne me concentrerais pas d’abord sur la pile de crash. Je vais d’abord faire trois choses pour transformer « non reproductible » en « explicable ».
1) Dessinez d’abord le diagramme de dépendances de démarrage, ne dessinez pas le diagramme de module
Le diagramme du module répond « qui dépend de qui », mais la question de démarrage répond :
- Quelle initialisation doit être effectuée avant la première interaction
- Quels échecs d’initialisation affecteront la sémantique métier
- Quelle initialisation n’est que la cerise sur le gâteau
Je diviserai les dépendances du démarrage en trois catégories selon la limite de la « première interaction » :
- Doit être prêt (Hard Ready) : s’il n’est pas prêt, il ne peut pas être autorisé à entrer dans le chemin critique, tel que l’état de connexion, le jeton d’authentification, la table de routage, le modèle de thread clé (tel que les contraintes du planificateur de thread principal/coroutine) et l’ensemble minimum de rapports d’erreur.
- Soft Ready : vous pouvez entrer dans l’entreprise si vous n’êtes pas prêt, mais vous devez rétrograder de manière contrôlable, comme la mise en cache recommandée, les expériences AB et les champs d’amélioration enterrés.
- Différé : cela peut être effectué ultérieurement sans affecter la sémantique de la première interaction, telle que l’échauffement, l’initialisation du décodeur d’image et le SDK non critique.
L’intérêt de cette étape est de changer l’argument de “asynchrone ou non asynchrone” en “à quelle limite cette dépendance doit-elle être complétée”.
2) Donnez à chaque dépendance un “contrat de préparation”, sinon l’asynchronisation équivaut à du jeu
Le soi-disant contrat de préparation vise à clarifier deux choses :
- Qui jugera si c’est prêt
- Comment procéder lorsque les affaires ne sont pas prêtes
Asynchronisation sans contrat de préparation, les manifestations courantes sont :
- L’appelant pense que l’initialisation est terminée et utilise directement
- L’initialiseur pensait que l’appelant ne l’utiliserait pas si tôt -Les deux côtés ont raison, l’erreur en ligne est dans le “timing”
L’un des crashs les plus typiques que j’ai vu consiste à changer l’initialisation d’un singleton en paresseux + asynchrone.
Le pseudocode ressemble à ceci :
object Foo {
@Volatile private var inited = false
fun initAsync() {
GlobalScope.launch(Dispatchers.IO) {
// 读配置/解密/拉取远端
inited = true
}
}
fun doWork() {
check(inited) { "Foo not initialized" }
// ...
}
}
Le premier indicateur d’écran s’améliorera, mais une fois que le timing d’appel de doWork() sera avancé avant la fin de l’initialisation, il deviendra “occasionnel”.
Ce qui est pire, c’est que de nombreux codes ne seront pas check(inited), mais continueront à s’exécuter, générant un état d’erreur, et n’exploseront que plus tard.
3) Mesurer la « fenêtre » de compétition plutôt que de se fier au ressenti
Les conditions nécessaires pour que l’asynchronisation pose des problèmes sont :
- La première interaction se produit avant la fin d’une initialisation
J’ajouterai donc deux types de journaux (notez qu’ils sont des points temporels alignables) :
t0: Démarrage du processus/DémarrageApplication.onCreatet1: Le premier écran est interactif (il est véritablement cliquable)t_ready(X): le moment où chaque dépendance de clé est prête
Jetez ensuite un œil à la distribution :
- Quelle est la proportion de
t1 < t_ready(Auth) - Quelle proportion est
t1 < t_ready(Router) - Et s’ils sont liés au modèle, au réseau, au démarrage à chaud et à froid et à la version du système
Une fois que cette fenêtre pourra être quantifiée, de nombreuses « occurrences » deviendront soudainement sans mystère : il ne s’agit que d’un événement probable.
Malentendus et cas d’échec : plus vous écrivez, plus vous risquez de créer des problèmes plus difficiles à résoudre.
Après avoir démarré l’asynchronisation, l’équipe sera naturellement prudente :
- Si la dépendance n’est pas prête, utilisez la valeur par défaut
- Si la configuration n’est pas extraite, allez dans le dernier cache
- AB est tombé sous le contrôle avant de l’obtenir.
Chacun de ces éléments est logique en soi, mais ils ont deux effets secondaires.
Malentendu 1 : Transformer le “manque de dépendances” en “dérive sémantique”
Les crashs sont en fait faciles à résoudre, mais les états d’erreur sont les plus difficiles à résoudre.
Par exemple, si le statut de connexion n’est pas prêt, il deviendra « non connecté ». Cela amènera l’utilisateur à une page d’erreur lorsque le premier clic déclenchera un saut. Plus tard, lorsque l’état de connexion réel est prêt, l’état de la page est à nouveau réinitialisé, donc “flash”, “revenir en arrière” et “déconnexion occasionnelle” apparaissent.
Vous verrez un tas de branches « normales » dans le journal : elles sont toutes couvertes par le design. Mais l’expérience utilisateur est mauvaise et il est difficile de l’associer à l’optimisation des startups.
Malentendu 2 : se cacher les secrets de chacun conduit à une chaîne de preuves brisée pour le dépannage
La dépendance A n’est pas prête, elle emprunte donc un chemin semé d’embûches.
Dans le même temps, la dépendance B n’est pas prête et a également subi toutes sortes d’astuces.
En fin de compte, l’entreprise se comporte comme le problème de B, mais la cause première est A.
Ce qui est plus réaliste, c’est que pour « ne pas planter », l’exception est avalée et l’échec est enregistré comme debug, ne laissant qu’un seul « mauvais résultat » en ligne.
C’est une des sources de « l’irreproductibilité » : l’effacement du signal clé de défaillance.
Comment résoudre le problème : remplacez « Asynchronisation » par « Limite de préparation vérifiable »
Pour résoudre ce problème, la sémantique de démarrage du système est généralement à nouveau renforcée.
Je ferai trois étapes du coût le plus bas au coût le plus élevé.
1) Définir une Ready Gate exécutable
La dépendance à Hard Ready donne une porte unifiée :
- Vous devez passer la porte avant d’interagir pour la première fois
- S’il ne peut pas être adopté, bloquez les opérations clés ou fournissez un chemin clair pour rétrograder.
Par exemple, ajoutez une petite coche à l’entrée du premier clic (navigation/routage/bouton clé) :
- Continuez lorsque vous êtes prêt
- Afficher le chargement s’il n’est pas prêt, ou faire la queue en premier
La clé de cette étape est de changer la « dépendance non prête » d’un état de concurrence implicite à un état explicite.
2) Faites de l’initialisation une “tâche avec état” au lieu de lancer et d’oublier
De nombreuses initialisations sont rejetées directement à l’aide de GlobalScope.launch ou du pool de threads, et si elles échouent, elles échouent.
Une approche plus contrôlable consisterait à :
- Chaque initialisation a le statut :
NotStarted / Running / Ready / Failed - L’appelant obtient un handle qui peut être attendu (même s’il n’attend finalement pas)
Pseudo-code :
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()
}
Cela rend deux choses vraies :
- Vous pouvez choisir où attendre
- Fini de “penser que c’est mieux”
3) Définir des limites et des commutateurs de restauration pour une initialisation retardée
L’initialisation paresseuse n’est pas impossible, mais elle nécessite des conditions aux limites :
- Quels utilisateurs/scénarios peuvent être retardés (par exemple, uniquement les démarrages à froid ou les démarrages à chaud également retardés)
- Que faire en cas d’échec (réessayer, désactiver, restaurer)
- Comment observer les niveaux de gris (distribution des fenêtres prêtes, taux d’échec, taux de dégradation)
Je préférerais faire de “Démarrer l’asynchronisation” un changement de politique de restauration plutôt qu’un changement de code unique.
Parce qu’une fois qu’un problème occasionnel est découvert en ligne, le moyen le plus rapide d’arrêter le saignement est généralement de « faire reculer l’asynchronisation ».
Limites applicables : quand l’asynchronisation est-elle rentable et quand est-elle une perte ?
La prémisse selon laquelle l’asynchronisation est rentable est la suivante :
- La dépendance est Soft Ready ou différée
- Le contrat de préparation est clair et il existe une chaîne de preuves d’échec
- La fenêtre prête est petite et stable, et ne couvre pas la première interaction
Scénarios typiques où l’asynchronisation est une perte :
- La dépendance est Hard Ready, mais elle a été déplacée pour des raisons de métriques
- Dissimuler l’échec par la dissimulation, conduisant à une dérive sémantique
- Il n’y a pas de porte prête, donc la condition de concurrence devient un événement probabiliste
Pour résumer en une phrase : pouvez-vous expliquer quand elle n’est pas prête, et si l’entreprise peut maintenir une sémantique cohérente lorsqu’elle n’est pas prête, l’asynchronisation est considérée comme optimisée. **
Résumé
L’optimisation du démarrage à froid est plus facilement pilotée par les KPI vers un problème à objectif unique : rendre le premier écran plus rapide.
Mais ce qu’il faut vraiment garder à l’esprit lors de la phase de démarrage est « quand le système sera-t-il considéré comme disponible ? » Plus l’initialisation est décomposée en morceaux, plus la sémantique de préparation doit être écrite clairement dans le code et dans les observations.
Sinon, nous remplacerons la lenteur déterministe par des erreurs probabilistes.
What to read next
Want more posts about Uncategorized?
Posts in the same category are usually the best next step for reading more on this topic.
View same categoryWant to explore another direction?
If you are not sure what to read next, return to the homepage and start from categories, topics, or latest updates.
Back home