Nous avons récemment vu l'un de nos projets Rust , un service axum
, présenter un comportement étrange en ce qui concerne l'utilisation de la mémoire. Un profil de mémoire étrange est la dernière chose que j'attendrais d'un programme Rust, mais nous y sommes.
Le service fonctionnerait avec une mémoire "plate" pendant un certain temps, puis sauterait soudainement vers un nouveau plateau. Ce schéma se répétait pendant des heures, parfois sous charge, mais pas toujours. La partie inquiétante était qu'une fois que nous avons vu une forte augmentation, il était rare que la mémoire redescende. C'était comme si la mémoire était perdue, ou autrement "fuite" de temps en temps.
Dans des circonstances normales, ce profil "en escalier" était tout simplement étrange, mais à un moment donné, l'utilisation de la mémoire a augmenté de manière disproportionnée. Une croissance illimitée de la mémoire peut obliger les services à se fermer. Lorsque les services s'arrêtent brusquement, cela peut réduire la disponibilité... ce qui est mauvais pour les affaires . Je voulais creuser et comprendre ce qui se passait.
Normalement, quand je pense à une croissance inattendue de la mémoire dans un programme, je pense à des fuites. Pourtant, cela semblait différent. Avec une fuite, vous avez tendance à voir un schéma de croissance plus stable et régulier.
Souvent, cela ressemble à une ligne en pente vers la droite. Donc, si notre service ne fuyait pas, que faisait-il ?
Si je pouvais identifier les conditions qui ont provoqué le saut dans l'utilisation de la mémoire, je pourrais peut-être atténuer ce qui se passait.
J'avais deux questions brûlantes :
En regardant les mesures historiques, je pouvais voir des schémas similaires de fortes augmentations entre de longues périodes plates, mais nous n'avions jamais eu ce type de croissance auparavant. Pour savoir si la croissance elle-même était nouvelle (bien que le modèle "en escalier" soit normal pour nous), j'aurais besoin d'un moyen fiable de reproduire ce comportement.
Si je pouvais forcer "l'étape" à se montrer, alors j'aurais un moyen de vérifier un changement de comportement lorsque je prends des mesures pour freiner la croissance de la mémoire. Je pourrais également revenir en arrière dans notre histoire de git et rechercher un moment où le service n'a pas affiché une croissance apparemment illimitée.
Les dimensions que j'ai utilisées lors de l'exécution de mes tests de charge étaient :
Taille des corps POST envoyés au service.
Le taux de requêtes (c'est-à-dire les requêtes par seconde).
Le nombre de connexions client simultanées.
La combinaison magique pour moi était : des corps de requête plus grands et une concurrence plus élevée .
Lors de l'exécution de tests de charge sur un système local, il existe toutes sortes de facteurs limitants, notamment le nombre fini de processeurs disponibles pour exécuter à la fois les clients et le serveur lui-même. Pourtant, j'ai pu voir "l'escalier" dans la mémoire de ma machine locale dans les bonnes circonstances, même à un taux de demande global inférieur.
En utilisant une charge utile de taille fixe et en envoyant des requêtes par lots, avec un bref repos entre elles, j'ai pu augmenter la mémoire du service à plusieurs reprises, une étape à la fois.
J'ai trouvé intéressant que même si je pouvais développer la mémoire au fil du temps, je finirais par atteindre un point de rendements décroissants. Finalement, il y aurait un certain plafond (toujours beaucoup plus élevé que prévu) à la croissance. En jouant un peu plus, j'ai découvert que je pouvais atteindre un plafond encore plus élevé en envoyant des requêtes avec différentes tailles de charge utile.
Une fois que j'ai identifié ma contribution, j'ai pu remonter dans notre histoire de git, apprenant finalement que notre peur de la production n'était probablement pas le résultat de changements récents de notre côté.
Les détails de la charge de travail pour déclencher cette "marche d'escalier" sont spécifiques à l'application elle-même, même si j'ai pu forcer un graphique similaire à se produire avec un projet de jouet .
#[derive(serde::Deserialize, Clone)] struct Widget { payload: serde_json::Value, } #[derive(serde::Serialize)] struct WidgetCreateResponse { id: String, size: usize, } async fn create_widget(Json(widget): Json<Widget>) -> Response { ( StatusCode::CREATED, Json(process_widget(widget.clone()).await), ) .into_response() } async fn process_widget(widget: Widget) -> WidgetCreateResponse { let widget_id = uuid::Uuid::new_v4(); let bytes = serde_json::to_vec(&widget.payload).unwrap_or_default(); // An arbitrary sleep to pad the handler latency as a stand-in for a more // complex code path. // Tweak the duration by setting the `SLEEP_MS` env var. tokio::time::sleep(std::time::Duration::from_millis( std::env::var("SLEEP_MS") .as_deref() .unwrap_or("150") .parse() .expect("invalid SLEEP_MS"), )) .await; WidgetCreateResponse { id: widget_id.to_string(), size: bytes.len(), } }
Il s'est avéré que vous n'aviez pas besoin de grand-chose pour y arriver. J'ai réussi à voir une augmentation similaire (mais dans ce cas beaucoup plus petite) d'une application axum
avec un seul gestionnaire recevant un corps JSON.
Alors que les augmentations de mémoire dans mon projet de jouet étaient loin d'être aussi dramatiques que nous l'avons vu dans le service de production, c'était suffisant pour m'aider à comparer et à contraster au cours de la phase suivante de mon enquête. Cela m'a également aidé à avoir la boucle d'itération plus étroite d'une base de code plus petite pendant que j'expérimentais différentes charges de travail. Voir le README pour plus de détails sur la façon dont j'ai exécuté mes tests de charge.
J'ai passé du temps à chercher sur le Web des rapports de bogues ou des discussions qui pourraient décrire un comportement similaire. Un terme qui est revenu à plusieurs reprises était Heap Fragmentation et après avoir lu un peu plus sur le sujet, il semblait que cela pouvait correspondre à ce que je voyais.
Les personnes d'un certain âge peuvent avoir l'expérience de regarder un utilitaire de défragmentation sous DOS ou Windows déplacer des blocs sur un disque dur pour consolider les zones "utilisées" et "libres".
Dans le cas de cet ancien disque dur de PC, des fichiers de différentes tailles ont été écrits sur le disque puis déplacés ou supprimés ultérieurement, laissant un "trou" d'espace disponible entre les autres régions utilisées. Au fur et à mesure que le disque commence à se remplir, vous pouvez essayer de créer un nouveau fichier qui ne tient pas tout à fait dans l'une de ces zones plus petites. Dans le scénario de fragmentation de tas, cela conduira à un échec d'allocation, bien que le mode d'échec de la fragmentation de disque soit légèrement moins drastique. Sur le disque, le fichier devra alors être divisé en morceaux plus petits, ce qui rend l'accès beaucoup moins efficace (merci wongarsu
pour la correction ). La solution pour le lecteur de disque consiste à "défragmenter" (défragmenter) le lecteur afin de réorganiser ces blocs ouverts en espaces continus.
Quelque chose de similaire peut se produire lorsque l'allocateur (l'élément responsable de la gestion de l'allocation de mémoire dans votre programme) ajoute et supprime des valeurs de tailles variables sur une période de temps. Des lacunes trop petites et dispersées dans le tas peuvent conduire à l'allocation de nouveaux blocs de mémoire « frais » pour accueillir une nouvelle valeur qui ne conviendra pas autrement. Malheureusement, à cause du fonctionnement de la gestion de la mémoire, une "défragmentation" n'est pas possible.
La cause spécifique de la fragmentation peut être un certain nombre de choses : analyse JSON avec serde
, quelque chose au niveau du framework dans axum
, quelque chose de plus profond dans tokio
, ou même juste une bizarrerie de l'implémentation spécifique de l'allocateur pour le système donné. Même sans connaître la cause première (s'il y a une telle chose), le comportement est observable dans notre environnement et quelque peu reproductible dans une application simple. (Mise à jour : une enquête plus approfondie est nécessaire, mais nous sommes presque sûrs qu'il s'agit de l'analyse JSON, voir notre commentaire sur HackerNews )
Si c'est ce qui arrivait à la mémoire de processus, que peut-on faire à ce sujet ? Il semble qu'il serait difficile de modifier la charge de travail pour éviter la fragmentation. Il semble également qu'il serait difficile de dénouer toutes les dépendances de mon projet pour éventuellement trouver une cause première dans le code de la façon dont les événements de fragmentation se produisent. Alors qu'est ce qui peut être fait?
Jemalloc
à la rescousse jemalloc
se décrit lui-même comme visant à "[souligner] l'évitement de la fragmentation et la prise en charge de la concurrence évolutive". La simultanéité était en effet une partie du problème de mon programme, et éviter la fragmentation est le nom du jeu. jemalloc
semble être exactement ce dont j'ai besoin.
Étant donné que jemalloc
est un répartiteur qui fait tout son possible pour éviter la fragmentation en premier lieu, l'espoir était que notre service puisse fonctionner plus longtemps sans augmenter progressivement la mémoire.
Ce n'est pas si trivial de changer les entrées de mon programme, ou la pile de dépendances d'applications. Il est cependant trivial d'échanger l'allocateur.
En suivant les exemples du https://github.com/tikv/jemallocator readme, très peu de travail a été nécessaire pour le tester.
Pour mon projet de jouet , j'ai ajouté une fonctionnalité de chargement pour éventuellement remplacer l'allocateur par défaut par jemalloc
et réexécuter mes tests de charge.
L'enregistrement de la mémoire résidente pendant ma charge simulée montre les deux profils de mémoire distincts.
Sans jemalloc
, nous voyons le profil de marche d'escalier familier. Avec jemalloc
, nous voyons la mémoire monter et descendre à plusieurs reprises au fur et à mesure que le test s'exécute. Plus important encore, bien qu'il existe une différence considérable entre l'utilisation de la mémoire avec jemalloc
pendant les périodes de charge et d'inactivité, nous ne "perdons pas du terrain" comme nous le faisions auparavant puisque la mémoire revient toujours à la ligne de base.
S'il vous arrive de voir un profil "escalier" sur un service Rust, envisagez de prendre jemalloc
pour un essai routier. Si vous avez une charge de travail qui favorise la fragmentation du tas, jemalloc
peut donner un meilleur résultat global.
Séparément, inclus dans le dépôt du projet de jouet est un benchmark.yml
à utiliser avec l'outil de test de charge https://github.com/fcsonline/drill . Essayez de modifier la simultanéité, la taille du corps (et la durée de veille du gestionnaire arbitraire dans le service lui-même), etc. pour voir comment le changement d'allocateur affecte le profil de mémoire.
En ce qui concerne l'impact dans le monde réel, vous pouvez clairement voir le changement de profil lorsque nous avons déployé le passage à jemalloc
.
Là où le service affichait des lignes plates et de grandes étapes, souvent indépendamment de la charge, nous voyons maintenant une ligne plus irrégulière qui suit de plus près la charge de travail active. Outre l'avantage d'aider le service à éviter une croissance inutile de la mémoire, ce changement nous a donné un meilleur aperçu de la façon dont notre service réagit à la charge, donc dans l'ensemble, ce fut un résultat positif.
Si vous souhaitez créer un service robuste et évolutif à l'aide de Rust, nous recrutons ! Consultez notre page carrières pour plus d'informations.
Pour plus de contenu comme celui-ci, assurez-vous de nous suivre sur Twitter , Github ou RSS pour les dernières mises à jour du service de webhook Svix , ou rejoignez la discussion sur notre communauté Slack .
Également publié ici.