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.

L'image

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érationDurée typiqueConséquence pratique
Appel de méthode, allocation courte~1 nanosecondeNégligeable, sauf en boucle serrée sur des millions d'éléments
Collecte Gen 0quelques microsecondesInvisible
Lever une exception~5–50 microsecondesÀ éviter dans le flux normal (ch. 9)
Requête SQL simple sur index~1 milliseconde1 000 × plus lent que du code : c'est presque toujours là que ça se joue
Requête SQL sans index (balayage)10–5 000 msLe premier endroit à regarder
Appel HTTP interne5–50 msÀ paralléliser quand les appels sont indépendants
Appel HTTP externe50–2 000 msÀ mettre en cache, avec délai d'expiration et repli
Ce que ça implique

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-requests

Une 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émoire

Allocations : 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); }
N'écris pas ce code partout

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);
}
Cache de sortie HTTP : la réponse entière, sans exécuter l'endpoint
builder.Services.AddOutputCache();
app.UseOutputCache();

app.MapGet("/catalogue", Catalogue)
   .CacheOutput(p => p.Expire(TimeSpan.FromMinutes(5)).SetVaryByQuery("categorie", "page"));
Les quatre pièges du cache
  1. 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.
  2. 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.
  3. Tempête de cache : à l'expiration, mille requêtes simultanées recalculent la même chose. HybridCache gère ce cas ; IMemoryCache nu, non.
  4. Cache mémoire sans limite : le processus grossit jusqu'au redémarrage forcé. Fixe SizeLimit et 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());
Ce que ça change concrètement

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érifierComment
1Index manquantsPlan d'exécution SQL, requêtes lentes du serveur
2N+1Compter les requêtes SQL pour une requête HTTP
3Trop de données ramenéesSELECT * déguisé : projeter vers un DTO
4Appels bloquantsChercher .Result, .Wait(), GetAwaiter().GetResult()
5Appels réseau en sérieTask.WhenAll quand ils sont indépendants
6Absence de paginationToute liste doit être plafonnée
7Réponses non compressées / non cachéesUseResponseCompression, CacheOutput, ETag
8Sérialisation coûteuseRéduire la charge utile, JsonSerializerContext généré
9Démarrage à froidReadyToRun, Native AOT, instances toujours actives
10Seulement maintenant : le code C#BenchmarkDotNet sur le point chaud identifié
Réglages avancés qui valent la peine

Vérifie que c'est passé

🎯 Quiz — 4 questions

1. Une page met 3 secondes. Par quoi commences-tu ?

Une requête SQL coûte mille fois plus cher qu'un appel de méthode. Dans une application web, la base et le réseau expliquent la quasi-totalité des lenteurs.

2. Processeur à 15 %, temps de réponse qui explosent, file du pool de threads qui grimpe. Diagnostic ?

Signature classique de .Result / .Wait(). Le processeur ne fait rien : les threads attendent (chapitre 8).

3. Pourquoi ne pas mesurer avec un simple Stopwatch autour du code ?

Le premier appel inclut la compilation JIT ; le deuxième bénéficie d'un cache chaud. BenchmarkDotNet gère échauffement, itérations, variance et allocations.

4. Ta clé de cache est $"profil" pour la page de profil. Problème ?

C'est une fuite de données, pas un problème de performance. Toute clé de cache doit inclure l'identifiant de l'utilisateur ou du locataire quand la donnée est personnelle.

Fiches de révision

La règle absolue ?
Mesurer avant d'optimiser. Et mesurer après, pour vérifier le gain.
Ordre d'examen d'une appli web lente ?
Base (index, N+1) → réseau → sérialisation → code C#.
Latence : quelle statistique ?
p95 et p99, jamais la moyenne : ce sont les queues qui font les utilisateurs mécontents.
Comparer deux implémentations ?
BenchmarkDotNet, en Release, avec [MemoryDiagnoser].
Fuite mémoire ?
Deux dotnet-gcdump espacés, comparaison, puis gcroot.
Cache : la partie difficile ?
L'invalidation, et la clé (qui doit inclure l'utilisateur ou le locataire).
À retenir