Analyse des problèmes CPU et retour d'expérience sur l'optimisation pour les programmes C/C++ sous Linux

Introduction

Les problèmes liés au CPU représentent une catégorie typique de problèmes de performance logicielle, et de nombreux développeurs sont confrontés à des situations où leurs programmes consomment trop de ressources processeur. Cet article explore d'abord les méthodes d'investigation pour identifier les pics d'utilisation du CPU, puis analyse certains problèmes typiques, et总结了一些实践心得.

Méthodes d'investigation de l'utilisation CPU

Pour les programmes C/C++, les outils les plus utilisés dans l'industrie pour la localisation des points chauds CPU sont : le composant callgrind de valgrind, gprof (GNU Profiler), le profileur CPU de google perf tools et Oprofiler.

  • callgrind (valgrind) : Cet outil utilise une approche basée sur une machine virtuelle, transformant les instructions du programme analysé en code Ucode propre à valgrind, permettant ainsi une analyse complète (CPU, mémoire).
  • gprof : Outil fourni par GNU, existant depuis environ 30 ans. Il fonctionne en insérant du code à l'entrée des fonctions pour établir des statistiques sur les appels de fonctions, leur fréquence et l'utilisation du CPU.
  • google perf tools (CPU Profiler) : Cet outil effectue un échantillonnage des piles d'appels du programme, permettant de déduire le nombre d'appels, les relations entre fonctions et le temps de consommation CPU.
  • Oprofile : Utilise les compteurs de performance matérielle du CPU pour analyser les problèmes de performance à différents niveaux (processus, fonctions, code). Plus adapté à l'analyse au niveau système.

Sous Linux, la méthode la plus simple consiste à exécuter la commande top pour visualiser l'utilisation globale du CPU (le processus 32230 dans l'image ci-dessous occupe le plus de ressources) :

Exécuter top -p 32230 -H permet de voir l'utilisation du CPU par chaque thread du processus courant.

Exécuter pstack 32230 permet d'examiner les informations de pile du thread courant et de localiser la fonction spécifique.

#!/bin/sh

if test $# -ne 1; then
    echo "Usage: `basename $0 .sh` <process-id>" 1>&2
    exit 1
fi

if test ! -r /proc/$1; then
    echo "Process $1 not found." 1>&2
    exit 1
fi

trace="bt"
if test -d /proc/$1/task ; then
    if test `/bin/ls /proc/$1/task | /usr/bin/wc -l` -gt 1 2>/dev/null ; then
    trace="thread apply all bt"
    fi
elif test -f /proc/$1/maps ; then
    if /bin/grep -e libpthread /proc/$1/maps > /dev/null 2>&1 ; then
    trace="thread apply all bt"
    fi
fi

DBG=${DBG:-gdb}

$DBG --quiet -nx $DBGARGS /proc/$1/exe $1 <<eof>&1 |
set width 0
set height 0
set pagination no
$trace
EOF
/bin/sed -n \
    -e 's/^\((dbg) \)*//' \
    -e '/^#/p' \
    -e '/^Thread/p'
</eof></process-id>

Analyse des problèmes CPU

En général, la principale cause d'une forte consommation CPU réside dans une conception logicielle inadaptée. La majorité des problèmes CPU proviennent de décisions de conception. Par conséquent, améliorer la qualité de la conception est le moyen principal d'éviter ces problèmes. Ci-dessous des exemples de problèmes CPU courants :

Problèmes causés par des opérations inefficaces

Dans la conception logicielle, certaines écritures de code sont inefficaces, et les programmeurs inexpérimentés utilisent facilement des fonctions ou méthodes peu performantes.

L'utilisation excessive de memset est un piège courant. Si un programme utilise memset de manière excessive, cela peut entraîner une consommation CPU importante. Le problème avec memset est souvent subtil, mais peut dégrader considérablement les performances. Bien que certains problèmes liés à memset soient détectables lors de revues de code, il faut noter que des opérations memset implicites peuvent également se produire.

char buffer[1024] = {0};

Ce code appelle en réalité memset lors de l'exécution : un tampon est alloué sur la pile, puis l'initialisation appelle implicitement memset pour mettre la mémoire à zéro.

De plus, lors de l'utilisation de fonctions système ou de bibliothèque, il est nécessaire de lire attentivement la documentation pour éviter des opérations inutiles d'allocation, de libération et de réinitialisation de mémoire.

La fonction strncpy pour les opérations sur les chaînes de caractères est relativement coûteuse en performances. Des alternatives comme snprintf et memcpy+strlen existent. En examinant le code source de ces fonctions, on constate que memcpy utilise une combinaison de copie par page et par mot, offrant de meilleures performances, et que strlen utilise un pas de boucle de 4 octets. strncpy copie simplement octet par octet et remplit également tous les espaces restants du tampon de destination avec des zéros, ce qui est très coûteux en performances.

Solution recommandée : Identifier les fonctions qui consomment beaucoup de CPU et tenter de réduire leur utilisation. Par exemple, certaines initialisations de structures de données sont inutiles car elles seront naturellement remplies par les données de la requête suivante. On peut également adopter des méthodes d'initialisation plus efficaces. Par exemple, pour l'initialisation d'un tableau de chaînes, il suffit de mettre le premier caractère à zéro.

Problèmes liés à une mauvaise utilisation des conteneurs

L'utilisation de conteneurs est indispensable dans la conception logicielle. Différents types de conteneurs ont des objectifs de conception différents, et certains ont naturellement des performances plus faibles dans certains aspects. Lors de la conception, il faut pouvoir identifier correctement les performances des différentes utilisations des conteneurs.

std::list<int> liste;
// logique métier
for(int i=0; i < liste.size(); i++){
    // logique métier
}

Ce code place le calcul de la taille de la liste dans la boucle. Pour le type list, la fonction de calcul de longueur a une complexité O(n). En plaçant cette opération dans une boucle, la complexité du code passe à O(n²), ce qui peut avoir un impact considérable sur les performances lorsque la liste contient de nombreux éléments.

Solution recommandée : Pour les programmes en boucle, il faut éviter d'effectuer des opérations coûteuses en CPU à l'intérieur du corps de la boucle. Même si la consommation CPU est faible à chaque itération, l'effet cumulatif dû à la boucle peut augmenter la complexité algorithmique d'un ordre de grandeur.

Problèmes liés à un nombre excessif de verrous et de changements de contexte

L'existence d'un nombre excessif d'opérations de verrouillage/déverrouillage dans le programme est une autre cause majeure de dégradation des performances CPU. Le symptôme typique est un CPU en mode système trop élevé, dépassant même le CPU en mode utilisateur.

Les spinlocks (verrous rotatifs), comme les mutex, constituent une méthode courants pour résoudre l'exclusion mutuelle des ressources système. Contrairement aux mutex, les spinlocks ne mettent pas l'appelant en sommeil. Si le spinlock est déjà maintenu par une autre unité d'exécution, l'appelant continue simplement à vérifier en boucle si le détenteur du verrou a libéré ce dernier. En général, les ressources verrouillées par un spinlock sont libérées rapidement. Dans ce cas, comme l'appelant n'a pas besoin de dormir, cela réduit les commutations système et peut améliorer les performances. Cependant, selon l'évolution de la capacité de traitement, du flux et de la taille des données du programme, les spinlocks peuvent parfois dégrader les performances. Dans un cas documenté, lorsqu'un programme accède au cache avec un spinlock pour le contrôle de synchronisation, aucun problème n'a été observé au début. Mais avec l'augmentation du flux, lorsque les requêtes par seconde (QPS) ont atteint 1700, le CPU système a atteint 73%, causant un goulot d'étranglement sévère. La solution principale pour ce cas consiste à réduire les opérations de verrouillage.

Un autre exemple concerne également un nombre excessif de verrouillages. Dans un module de recherche, le traitement d'une requête peut obtenir jusqu'à 4096 mots. Le programme doit obtenir les informations de ces mots, qui sont principalement stockées dans le cache. Si un mot n'existe pas dans le cache, il faut le recalculer et mettre à jour le cache. Un problème existant était que la logique de conception du programme interrogeait les informations d'un seul mot à chaque fois dans le cache, avec une opération de verrouillage/déverrouillage à chaque requête. Ainsi, pour une seule requête, jusqu'à 4096 opérations étaient effectuées. Pour un programme avec un QPS de 1000, le nombre d'opérations de verrouillage par seconde atteignait le million, dégrasant gravement les performances.

Dans les cours de systèmes d'exploitation, lorsqu'un thread doit attendre une condition, il est placé par le système d'exploitation dans une file d'attente de sommeil jusqu'à son réveil. Un nombre excessif de commutations de contexte peut également dégrader les performances du programme.

Dans un module, un phénomène a été observé : le CPU système de la machine augmentait périodiquement. Après investigation, la cause była ligne de code shell dans un fragment de code. Cette ligne de code cherche les 100 dernières lignes de logs contenant un mot-clé et les réécrit. Ce code est exécuté périodiquement. Pendant le processus d'exécution, grep écrit dans la sortie standard en passant par la bibliothèque C standard. La taille du tampon par défaut est de 4K. Donc, grep appelle d'abord fwrite pour écrire dans le tampon de la bibliothèque C standard. Une fois les 4K remplis, la bibliothèque C appelle le système write pour vider le tampon dans le tampon de pipe du noyau. Ensuite, le processus tail lit 4K d'un coup depuis le tampon de pipe du noyau. Clairement, une fois que grep a rempli le tampon de pipe du noyau, il doit attendre que tail ait terminé la lecture pour continuer, donc il est basculé vers une file d'attente d'attente. Le processus tail est basculé, lit 4K, puis réveille grep, tail est basculé, grep est basculé... Plus le fichier à traiter par grep devient grand, plus le nombre de commutations de processus augmente, et l'utilisation du CPU système augmente en conséquence.

Solution recommandée : Réduire les opérations de verrouillage et les commutations de contexte inutiles.

Autres analyses de problèmes

De nombreuses autres situations peuvent entraîner une consommation excessive de CPU par le programme, comme un nombre excessif d'opérations E/S. L'un des problèmes les plus courants dans les opérations E/S excessives est la génération excessive de logs. Dans un système, un exemple s'est produit où, suite à la mise à niveau des logs du format binaire vers le format texte, le volume global d'E/S a augmenté de 30%, faisant chuter le débit du programme de 38 000 à 21 000 unités, soit une diminution de près de moitié. Un autre problème type d'E/S concerne les nombreux logs de débogage dans le programme. Bien que les logs de débogage ne soient pas activés en production, le programme exécute toujours la logique associée aux logs de débogage pendant son exécution, mais sans afficher les logs. Si la logique de sortie des logs implique des calculs complexes, les performances du programme diminuent également.

Fast JSON est un outil JSON open source fourni par Alibaba, supportant la sérialisation et la désérialisation JSON, considéré comme l'outil d'analyse JSON le plus rapide. La version 1.2.2 de Fast JSON a un problème : lors de l'appel de java.lang.System.getProperty, le multithreading nécessite un verrouillage, ce qui peut bloquer les threads et dégrader les performances système. Ce problème a causé des problèmes graves en production.

Conclusion et retour d'expérience

Cet article a examiné les méthodes d'investigation de l'utilisation CPU, puis a analysé certains problèmes CPU typiques. En analysant ces problèmes, nous constatons que de nombreux problèmes de performance liés au CPU sont causés par des problèmes de conception logicielle. Réduire les appels inefficaces et libérer pleinement les capacités du CPU sont la clé pour améliorer les performances CPU des programmes. À un niveau plus large, les performances CPU du programme nécessitent également une meilleure conception architecturale pour mobiliser pleinement les ressources et accomplir les tâches efficacement. Le profileur CPU de google perf tools est un excellent outil pour localiser les points chauds CPU, et nous espérons que chacun l'utilisera davantage pour optimiser les performances CPU de ses programmes.

Publié le 22 juillet à 07h43