Accueil › Qualité · perf · production › Chapitre 19
Performance et diagnostic
La règle est brutale et sans exception : on ne devine pas, on mesure. L'intuition d'un développeur sur la lenteur d'une application est fausse la plupart du temps — et le temps passé à optimiser le mauvais endroit est du temps perdu deux fois.
Trois choses différentes appelées « performance »
⏱️ Latence
Le temps d'une requête. Ce que ressent l'utilisateur. Se mesure en centiles : p50, p95, p99 — la moyenne ne veut rien dire.
📈 Débit
Le nombre de requêtes par seconde. Ce que dimensionne async/await et le nombre
d'instances.
💾 Empreinte
Mémoire et processeur consommés. Ce que facture ton hébergeur et ce qui déclenche les redémarrages.
Vitesse de pointe, nombre de passagers, consommation. Trois questions différentes, trois réponses différentes. Un bus est lent mais transporte 60 personnes ; une moto est rapide mais n'en transporte qu'une. « Mon application est lente » n'est pas un diagnostic : il faut savoir laquelle des trois grandeurs est en cause.
Les ordres de grandeur à connaître par cœur
| Opération | Durée typique | Conséquence pratique |
|---|---|---|
| Appel de méthode, allocation courte | ~1 nanoseconde | Négligeable, sauf en boucle serrée sur des millions d'éléments |
| Collecte Gen 0 | quelques microsecondes | Invisible |
| Lever une exception | ~5–50 microsecondes | À éviter dans le flux normal (ch. 9) |
| Requête SQL simple sur index | ~1 milliseconde | 1 000 × plus lent que du code : c'est presque toujours là que ça se joue |
| Requête SQL sans index (balayage) | 10–5 000 ms | Le premier endroit à regarder |
| Appel HTTP interne | 5–50 ms | À paralléliser quand les appels sont indépendants |
| Appel HTTP externe | 50–2 000 ms | À mettre en cache, avec délai d'expiration et repli |
Remplacer une boucle LINQ par un for gagne des microsecondes. Supprimer un
N+1 gagne des secondes. Ajouter un index gagne des
secondes. Dans une application web, l'ordre d'examen est donc immuable : base de données, appels
réseau, sérialisation, puis — éventuellement — le code C#.
Mesurer : les outils, du plus simple au plus précis
// ASP.NET Core émet déjà la durée de chaque requête. Ajoute tes propres mesures métier :
public class ServiceCommande(ILogger<ServiceCommande> log)
{
private static readonly Meter Metrique = new("Boutique.Commandes");
private static readonly Histogram<double> Duree =
Metrique.CreateHistogram<double>("commandes.duree", "ms", "Durée de validation");
private static readonly Counter<long> Validees =
Metrique.CreateCounter<long>("commandes.validees");
public async Task ValiderAsync(int id)
{
var chrono = Stopwatch.StartNew();
try { /* ... */ Validees.Add(1); }
finally { Duree.Record(chrono.Elapsed.TotalMilliseconds); }
}
}Le SQL généré par EF Core en développement (LogTo) est souvent suffisant pour trouver
90 % des problèmes de performance d'une application web.
dotnet tool install --global dotnet-counters
dotnet-counters ps # lister les processus .NET
dotnet-counters monitor -p 1234 \
--counters System.Runtime,Microsoft.AspNetCore.Hosting
# Ce qu'il faut regarder :
# cpu-usage → saturation processeur ?
# gc-heap-size / gen-2-gc-count → fuite mémoire ? (croissance continue)
# threadpool-thread-count → famine de threads (voir chapitre 8)
# threadpool-queue-length → travail en attente : mauvais signe
# requests-per-second, current-requestsUne file d'attente du pool de threads qui grimpe pendant que le processeur reste bas est la
signature d'un appel bloquant (.Result) : voir chapitre 8.
// dotnet add package BenchmarkDotNet — pour comparer deux implémentations
[MemoryDiagnoser] // affiche aussi les allocations : essentiel
public class ConcatBenchmarks
{
private readonly string[] _mots = Enumerable.Range(0, 1000).Select(i => $"mot{i}").ToArray();
[Benchmark(Baseline = true)]
public string Concatenation()
{
var s = "";
foreach (var m in _mots) s += m; // O(n²) allocations
return s;
}
[Benchmark]
public string Builder()
{
var sb = new StringBuilder();
foreach (var m in _mots) sb.Append(m);
return sb.ToString();
}
[Benchmark]
public string Join() => string.Join("", _mots);
}
// dotnet run -c Release
// | Method | Mean | Ratio | Allocated |
// | Concatenation | 3 812.4 us | 1.00 | 3.85 MB |
// | Builder | 18.7 us | 0.005| 36.19 KB |
// | Join | 12.1 us | 0.003| 19.55 KB |⚠️ Toujours en Release, jamais sous débogueur. C'est ce qui rend inutiles les mesures
maison au Stopwatch : elles ignorent l'échauffement du JIT et la variance.
dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
# Profil processeur pendant 30 s (analysable dans Visual Studio ou PerfView)
dotnet-trace collect -p 1234 --duration 00:00:30 --profile cpu-sampling
# Suspicion de fuite mémoire : deux clichés espacés, puis comparaison
dotnet-gcdump collect -p 1234 -o avant.gcdump
# ... attendre, faire tourner l'application ...
dotnet-gcdump collect -p 1234 -o apres.gcdump
# Analyse d'un vidage complet
dotnet-dump collect -p 1234
dotnet-dump analyze core_dump
> dumpheap -stat # objets par type et taille cumulée
> gcroot <adresse> # QUI retient cet objet en mémoireAllocations : le coût invisible
Chaque objet créé sur le tas donne du travail au ramasse-miettes. Ce n'est pas grave à l'unité ; ça le devient à un million par seconde.
// ❌ 10 000 chaînes intermédiaires
var resultat = "";
foreach (var l in lignes) resultat += l + ";";
// ✅ un seul tampon
var sb = new StringBuilder();
foreach (var l in lignes) sb.Append(l).Append(';');
// ❌ découpe et alloue trois sous-chaînes
var parties = "2026-07-30".Split('-');
var annee = int.Parse(parties[0]);
// ✅ aucune allocation : on regarde la mémoire existante
ReadOnlySpan<char> d = "2026-07-30";
var annee2 = int.Parse(d[..4]);
// ❌ un tableau neuf à chaque appel
byte[] tampon = new byte[8192];
// ✅ emprunté puis rendu (chemins très chauds seulement)
var loue = ArrayPool<byte>.Shared.Rent(8192);
try { /* ... */ } finally { ArrayPool<byte>.Shared.Return(loue); }
Span, ArrayPool, stackalloc rendent le code plus difficile à lire et
à modifier. Ils se justifient dans un analyseur syntaxique, un pilote réseau, une bibliothèque appelée des
millions de fois par seconde — pas dans un gestionnaire d'endpoint qui attend ensuite 40 ms de base de
données. Ce serait optimiser 0,1 % du temps total.
Le cache : le plus grand gain, et le plus grand risque
// HybridCache (.NET 9+) : mémoire locale + cache distribué, protection contre les tempêtes de cache
builder.Services.AddHybridCache();
public class ServiceCatalogue(HybridCache cache, AppDb db)
{
public async Task<List<ProduitDto>> ObtenirAsync(string categorie, CancellationToken ct) =>
await cache.GetOrCreateAsync(
$"catalogue:{categorie}",
async jeton => await db.Produits
.Where(p => p.Categorie == categorie)
.Select(p => new ProduitDto(p.Id, p.Nom, p.Prix))
.ToListAsync(jeton),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(10) },
cancellationToken: ct);
// Invalider explicitement à chaque écriture : c'est TOUJOURS la partie difficile
public async Task InvaliderAsync(string categorie, CancellationToken ct) =>
await cache.RemoveAsync($"catalogue:{categorie}", ct);
}
builder.Services.AddOutputCache();
app.UseOutputCache();
app.MapGet("/catalogue", Catalogue)
.CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)).SetVaryByQuery("categorie", "page"));
- Données périmées : l'utilisateur modifie son profil et voit encore l'ancien pendant 10 minutes. Invalide à l'écriture, ne compte pas seulement sur l'expiration.
- Fuite entre utilisateurs : une clé qui n'inclut pas l'identifiant de l'utilisateur ou du locataire sert les données de l'un à l'autre. C'est une faille de sécurité, pas un bug de performance.
- Tempête de cache : à l'expiration, mille requêtes simultanées recalculent la même chose.
HybridCachegère ce cas ;IMemoryCachenu, non. - Cache mémoire sans limite : le processus grossit jusqu'au redémarrage forcé. Fixe
SizeLimitet déclare une taille par entrée.
Observer en production : OpenTelemetry
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("boutique-api"))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Boutique.Commandes") // tes métriques métier
.AddOtlpExporter())
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddEntityFrameworkCoreInstrumentation() // chaque requête SQL dans la trace
.AddOtlpExporter());
Un utilisateur signale « la page commandes est lente ». Sans télémétrie, tu devines. Avec une trace distribuée, tu vois : requête totale 2 400 ms, dont 1 900 ms dans une seule requête SQL répétée 47 fois. Le diagnostic prend trente secondes au lieu d'une demi-journée. ASP.NET Core 11 émet ces attributs nativement, sans instrumentation supplémentaire.
En développement, le tableau de bord .NET Aspire affiche traces, métriques et logs corrélés sans rien configurer — même sans utiliser Aspire pour l'orchestration.
Liste de contrôle d'une application web lente
| # | À vérifier | Comment |
|---|---|---|
| 1 | Index manquants | Plan d'exécution SQL, requêtes lentes du serveur |
| 2 | N+1 | Compter les requêtes SQL pour une requête HTTP |
| 3 | Trop de données ramenées | SELECT * déguisé : projeter vers un DTO |
| 4 | Appels bloquants | Chercher .Result, .Wait(), GetAwaiter().GetResult() |
| 5 | Appels réseau en série | Task.WhenAll quand ils sont indépendants |
| 6 | Absence de pagination | Toute liste doit être plafonnée |
| 7 | Réponses non compressées / non cachées | UseResponseCompression, CacheOutput, ETag |
| 8 | Sérialisation coûteuse | Réduire la charge utile, JsonSerializerContext généré |
| 9 | Démarrage à froid | ReadyToRun, Native AOT, instances toujours actives |
| 10 | Seulement maintenant : le code C# | BenchmarkDotNet sur le point chaud identifié |
- GC serveur (activé par défaut pour ASP.NET Core) : plus de débit, plus de mémoire. En conteneur
contraint,
<ConcurrentGarbageCollection>false</ConcurrentGarbageCollection>ou le mode DATAS (par défaut depuis .NET 9) ajustent la mémoire à la charge réelle. - Limites de conteneur : sans
--memoryni requêtes/limites Kubernetes, le GC croit disposer de toute la machine et le processus est tué par l'orchestrateur. - Sérialisation JSON générée :
[JsonSerializable]+JsonSerializerContextsupprime la réflexion — obligatoire en AOT, et plus rapide partout. - Requêtes EF compilées (
EF.CompileAsyncQuery) : quelques pourcents sur un chemin très chaud, à mesurer avant d'adopter. - Objectif chiffré : définis un budget (« p99 < 300 ms sur /produits ») et mets-le sous surveillance. Sans cible, « optimiser » n'a pas de fin.
Vérifie que c'est passé
🎯 Quiz — 4 questions
1. Une page met 3 secondes. Par quoi commences-tu ?
2. Processeur à 15 %, temps de réponse qui explosent, file du pool de threads qui grimpe. Diagnostic ?
.Result / .Wait(). Le processeur ne
fait rien : les threads attendent (chapitre 8).3. Pourquoi ne pas mesurer avec un simple Stopwatch autour du code ?
4. Ta clé de cache est $"profil" pour la page de profil. Problème ?
Fiches de révision
[MemoryDiagnoser].dotnet-gcdump espacés, comparaison, puis gcroot.- Mesure :
dotnet-countersen direct, BenchmarkDotNet pour comparer, traces en production. - Une requête SQL coûte 1 000 fois un appel de méthode : commence toujours par la base.
- Les micro-optimisations (
Span,ArrayPool) sont pour les bibliothèques chaudes, pas pour un endpoint. - Le cache est le plus gros gain et le plus gros risque : clé complète, invalidation explicite, taille limitée.
- Instrumente avec OpenTelemetry avant d'avoir un incident, pas pendant.