Nous avons publié récemment un témoignage de la Société Générale qui expliquait qu’elle avait réussi à ramener certains temps de traitement de 20 minutes à 20 secondes en utilisant notre Cockpit Qualité sur l’un de ses projets.
Trop gros pour être crédible ? On va pourtant montrer ici que l’analyse statique de code permet bien de détecter au plus tôt des problèmes de performances, qu’ils soient liés au CPU ou à la RAM.
Un premier exemple
Voici un premier exemple, très simple à comprendre et qu’on retrouve souvent :
User findUser(UserManager userManager, String id)
{
...
if (userManager.retrieveUser(id) != null)
{
User user = userManager.retrieveUser(id);
...
}
...
}
On a là un cas typique de l’antipattern DRY : un traitement est répété 2 fois dans le code. Le développeur a peut-être pensé que déclarer la variable user systématiquement avant le if était plus coûteux, ou il a simplement effectué un copier-coller paresseux, voire il a considéré que l’appel à la méthode retrieveUser engendrait un coût négligeable. Or ce code pose plusieurs problèmes :
- le développeur peut consulter le code de la méthode retrieveUser à un instant T, mais il ne sait pas ce qui adviendra plus tard de l’implémentation de cette méthode. Par exemple, cette implémentation peut exploiter un cache mémoire ultra-performant dans un premier temps, puis évoluer vers une récupération en base de données. Les appels inutiles qui passaient inaperçus avant deviendront alors impactants pour les performances.
- Suivant le même principe, on ne maîtrise pas où sera appelé notre traitement. Si la méthode findUser est peu performante, ce n’est pas forcément grave si elle n’est appelée qu’une fois, par exemple à la connexion de l’utilisateur. Si cette méthode devient appelée régulièrement, par exemple à chaque ouverture d’une page web d’un site très actif, alors elle devient très critique.
Cette problématique est à la base de la programmation objet, c’est le principe d’encapsulation : on ne doit pas se baser sur la manière dont un service est implémenté, seulement sur son contrat (e.g. : signature d’une méthode). C’est d’autant plus vrai avec les mécanisme d’injection par dépendances (IOC).
C’est là un des écueils les plus courants dans les problèmes de performances ; un vrai problème peut être caché dans une partie du code peu visible pendant un certain temps avant de devenir vraiment critique. D’où l’intérêt de corriger ces problèmes au plus tôt.
Quelques causes récurrentes
Dans les violations liées aux performances que nous remonte notre plateforme, nous pouvons identifier quelques catégories de problèmes récurrents :
Accès concurrents mal gérés
Sans même aborder la problèmatique des deadlocks, on trouve régulièrement des synchronisations inutiles. Par exemple, au lieu de synchroniser sur un champ, toute la méthode est synchronisée, ce qui peut ralentir les traitements. C’est d’ailleurs pourquoi nous proposons une règle interdisant les synchronisations au niveau des méthodes afin d’encourager les développeurs à mieux cibler et maîtriser leurs synchronisations.
De même, on utilise parfois des classes thread-safe alors qu’on pourrait utiliser leur version non synchronisée, donc moins coûteuse (e.g. : en Java, java.util.ArrayList pour un java.util.Vector).
Mauvaise gestion de la mémoire
Ce n’est pas parce qu’un langage dispose d’un ramasse-miettes pour nettoyer automatiquement les objets en mémoire qu’il ne faut pas prêter attention à la gestion mémoire.
D’une part, on trouve parfois des appels directs au ramasse-miettes. C’est une pratique déconseillée car elle peut fausser ses algorithmes internes et diminue donc les performances des prochains nettoyages.
D’autre part, il faut penser à libérer des ressources. Ceci passe par supprimer les références vers des objets qui ne seront plus utilisés, et, pour C#, bien gérer les objets Disposable : par exemple libérer les attributs Disposable dans le finaliseur, ou déclarer Disposable les classes avec des attributs de type natif.
On trouve aussi diverses mauvaises pratiques liées à des instanciations inutiles :
- déclaration de loggers non statique. Généralement, un objet logger est rattaché à une classe, il n’y a pas lieu de créer une instance spécifique pour chaque objet de cette classe, le logger doit donc être déclaré en statique.
- instanciations d’objets pour n’utiliser que des méthodes statiques
- instanciations redondantes dans des boucles
- …
Code inutile
Un moyen simple pour améliorer ses performances consiste également à faire la chasse au code inutile :
- transtypages (casts) redondants
if (o is User) handleUser(o as User);
Car l’opérateur C# is effectue déjà un cast implicite.
- variables instanciées mais inutilisées (code mort)
- écriture de logs sans vérifier le niveau de trace
User findUser(UserManager userManager, String id) { User user = ... List<Project> projects = projectManager.findProjects(user); LOGGER.debug("User found: " + user + ",available projects:" + projects); return user; }Ici, on récupère une liste de projets seulement pour les afficher dans un log de debug. Il ne faut réaliser cette opération que si l’application tourne en mode debug, donc englober le traitement par un
if (LOGGER.isDebug())) - tests inutiles
if (true) { ... }
Connaissance insuffisante du langage
Certains problèmes de performances sont tout simplement dûs au manque de connaissances du langage et des classes de base. Quelques exemples :
- en C# :
if (someString == ""). Le test d’une chaîne de caractères vide doit être réalisé avec System.String.IsNullOrEmpty(System.String), qui génére un code IL plus léger. - en C# :
public static readonly Int32 someConstant=128. La déclaration d’une constante doit être réalisée avec le mot-clé const :public const Int32 someConstant=128. Le code généré IL utilisera alors la valeur en dur et sera donc plus performant. - en Java :
String s = new String("kalistick"). Ceci provoque systématiquement l’instanciation d’un nouvel objet alors qu’en écrivantString s = "kalistick"la JVM gère un cache des chaînes de caractères. - en Java :
Integer i = new Integer(args[0]). Même chose : à partir de Java 5, la JVM gère un cache des valeurs numériques. Ce cache est ici invoqué en écrivantInteger i = Integer.valueOf(args[0]). - en Java :
String s = "value = " + args[0]. Erreur classique qui ressort souvent en phase de profiling : la concaténation de chaîne de caractères devrait toujours être réalisée à l’aide de la classeStringBufferou mieuxStringBuilder(sauf si les termes concaténés sont constants, auquel cas le compilateur optimisera la concaténation).
Comment prévenir les problèmes de performances
Une fois qu’on a évoqué quelques uns de ces problèmes facilement identifiables, la question est de savoir comment les éviter au plus tôt. La première réponse est de recourir à des développeurs formés ou expérimentés ! Tout développeur a le droit de commettre des fautes de jeunesse, mais on peut espérer qu’il ne les commettra qu’une seule fois
La formation est un point clé dans notre approche : le développeur peut constater ses erreurs, se documenter sur les bonnes pratiques, et donc éviter de reproduire les mêmes erreurs à l’avenir.
La deuxième solution consiste à utiliser un outil spécialisé dans l’analyse de performances : un outil de profiling. Ces outils visent à tracer l’exécution d’une application pour fournir une vision détaillée des performances, en temps réel ou à postériori : utilisation du CPU, occupation mémoire, threads, activité du ramasse-miettes, … Ils proposent généralement un mécanisme de drill-down pour cibler les traitements fautifs. Leur apprentissage est plus ou moins simple mais la difficulté consiste surtout a faire tourner l’application avec des scénarios de tests assez exhaustifs pour bien couvrir tout le code à tester.
Quelques références : JProfiler (Java), YourKit (Java et C#), dotTrace (C#). A noter que Java dispose depuis sa version 6 d’un outil de profiling intégré : VisualVM.
Conclusion
L’analyse statique est certes moins riche et moins précise qu’une session de profiling, car elle ne connaît pas le contexte d’exécution, mais permet d’identifier des défauts évidents de manière simple et rapide et surtout en amont des problèmes. Et c’est un des points clés de notre approche : plus vous corrigez tôt les problèmes, moins ils vous coûteront cher !
