C# · Le langage

Mémoire et performance

GC, struct contre class, boxing, Span<T>, IDisposable.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 18 min

En .NET, la mémoire se libère seule, et c'est pour cela qu'elle se gaspille sans bruit : une allocation ne coûte presque rien au moment où elle se fait, elle se paie plus tard, en collectes et en pauses. Ce cours sert le jour où un service tient la charge mais consomme trop, où un profil montre des mégaoctets alloués par requête, où un fichier reste verrouillé faute d'un Dispose. Il suppose connus les types valeur et référence du cours Bases du langage ; le coût d'une exception est traité dans Exceptions et gestion d'erreur, et ValueTask dans Programmation asynchrone.

Le ramasse-miettes et ses générations

Allouer sur le tas géré revient à avancer un pointeur : c'est presque aussi rapide qu'une allocation sur la pile. Le prix vient ensuite. Pour libérer, le GC part des racines — variables locales, champs statiques, handles — marque tout ce qui est encore joignable, puis compacte les survivants et corrige les références qui les désignent. Ce travail dépend de ce qui survit : un objet mort ne coûte rien à parcourir.

D'où les générations. Un objet naît en génération 0 ; s'il survit à une collecte, il passe en 1, puis en 2, où il reste. Le pari est que la plupart des objets meurent jeunes : une collecte de génération 0 ne parcourt qu'une petite zone et récupère l'essentiel. Collecter une génération, c'est collecter aussi les plus jeunes ; une collecte de génération 2 est dite complète et parcourt tout le tas.

Les objets de 85 000 octets et plus, en-tête compris — des tableaux presque toujours — vont sur le tas des gros objets (LOH). Il est collecté avec la génération 2 et n'est pas compacté par défaut, parce que déplacer de gros blocs coûte cher : il se fragmente. Un tampon de 100 Ko alloué à chaque requête naît donc directement vieux, et ne se récupère que par des collectes complètes.

using System;
using System.Runtime;

static class Generations
{
    public static void Executer()
    {
        var survivant = new object();
        Console.WriteLine(GC.GetGeneration(survivant)); // 0 : tout objet nait en generation 0

        // Des dechets ranges dans un tableau : ils s'echappent de l'iteration, et
        // le JIT ne peut ni supprimer ces allocations ni les placer sur la pile.
        var tampons = new byte[16][];
        var collectes = GC.CollectionCount(0);
        for (var i = 0; i < 1_000_000; i++)
        {
            tampons[i % 16] = new byte[64];
        }

        // Le budget de la generation 0 s'est epuise : des collectes ont eu lieu
        // sans que rien ne les demande, et survivant a ete promu.
        Console.WriteLine(GC.CollectionCount(0) > collectes); // True
        Console.WriteLine(GC.GetGeneration(survivant) > 0);   // True

        // 85 000 octets et plus, en-tete compris : le tas des gros objets,
        // collecte avec la generation 2.
        Console.WriteLine(GC.GetGeneration(new byte[84_000])); // 0
        Console.WriteLine(GC.GetGeneration(new byte[85_000])); // 2

        Console.WriteLine(GCSettings.IsServerGC); // False : une console tourne en mode station
    }
}

Une collecte se déclenche dans trois cas : les allocations dépassent un seuil que le runtime réajuste en continu (le budget de la génération 0, le cas ordinaire), le système ou l'hôte signale un manque de mémoire, ou le code appelle GC.Collect. Pendant une collecte, les threads gérés sont suspendus ; seule la collecte de génération 2 peut se faire en arrière-plan.

Station ou serveur

Le mode station, celui d'une application console, exécute la collecte sur le thread qui l'a déclenchée. Le mode serveur peut aller jusqu'à un tas et un thread de collecte par cœur logique : plus de débit, au prix de davantage de mémoire. Le SDK Web l'active pour tout projet qui s'appuie sur lui, et depuis .NET 9 il s'accompagne par défaut de DATAS : le serveur part alors d'un seul tas et en ajoute ou en retire selon la charge. Quand de nombreux processus se partagent une machine, leurs collectes se disputent les mêmes cœurs, et le mode station redevient préférable.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <!-- Mode serveur : jusqu'a un tas et un thread de collecte par coeur
         logique ; avec DATAS, actif par defaut depuis .NET 9, il part d'un seul
         tas. Le SDK Web (Microsoft.NET.Sdk.Web) pose deja cette valeur a true. -->
    <ServerGarbageCollection>true</ServerGarbageCollection>
  </PropertyGroup>

</Project>

Pourquoi on n'appelle pas GC.Collect

Le réflexe de « rendre la mémoire » après un traitement lourd libère bien de la mémoire, mais au pire prix :

using System;

static class Import
{
    static int Traiter(int lot)
    {
        var lignes = new string[1_000];
        for (var i = 0; i < lignes.Length; i++)
        {
            lignes[i] = $"{lot};{i}";
        }

        return lignes.Length;
    }

    public static void Executer()
    {
        var completes = GC.CollectionCount(2);
        for (var lot = 0; lot < 100; lot++)
        {
            Traiter(lot);

            // « Rendre la memoire » apres chaque lot : chaque appel declenche une
            // collecte complete et bloquante, qui parcourt aussi tout ce qui vit
            // depuis longtemps.
            GC.Collect();
        }

        Console.WriteLine(GC.CollectionCount(2) - completes); // 100
    }
}
using System;

static class Import
{
    static int Traiter(int lot)
    {
        var lignes = new string[1_000];
        for (var i = 0; i < lignes.Length; i++)
        {
            lignes[i] = $"{lot};{i}";
        }

        return lignes.Length;
    }

    public static void Executer()
    {
        var completes = GC.CollectionCount(2);
        for (var lot = 0; lot < 100; lot++)
        {
            // Les lignes d'un lot meurent jeunes : elles attendent la prochaine
            // collecte de generation 0, declenchee quand son budget s'epuise, qui
            // les recuperera sans parcourir le reste du tas.
            Traiter(lot);
        }

        Console.WriteLine(GC.CollectionCount(2) - completes); // 0
    }
}

GC.Collect() sans argument est une collecte complète et bloquante. Elle parcourt tout le tas pour récupérer des objets qu'une collecte de génération 0, au moment où son budget s'épuiserait, récupérerait pour presque rien, promeut au passage tout ce qui vit encore — qui coûtera plus cher à collecter ensuite — et fausse les seuils que le GC ajuste d'après ce qu'il observe. L'appel reste légitime dans une mesure ou un test de fuite, qui veulent un tas dans un état connu.

Struct contre class

Le cours Bases du langage montre ce qu'une affectation copie selon la famille du type. Côté performance, la struct n'est pas allouée à part : elle vit dans sa variable, dans son tableau ou dans l'objet qui la contient, et le GC n'a rien à suivre. En contrepartie, elle se recopie en entier à chaque affectation, passage et retour. Une struct mutable ajoute un piège que rien ne signale :

using System;

// Une struct mutable : ses methodes ecrivent dans ses propres champs.
struct Compteur
{
    public int Valeur;

    public void Incrementer() => Valeur++;
}

sealed class Guichet
{
    // readonly sur un champ de type struct : pour appeler Incrementer, le
    // compilateur copie le champ et appelle la methode sur la copie.
    private readonly Compteur _passages;

    public void Accueillir() => _passages.Incrementer();

    public int Passages => _passages.Valeur;
}

static class Accueil
{
    public static void Executer()
    {
        var guichet = new Guichet();
        guichet.Accueillir();
        guichet.Accueillir();

        // Aucune erreur, aucun avertissement : les deux increments sont tombes
        // dans des copies jetees aussitot.
        Console.WriteLine(guichet.Passages); // 0
    }
}
using System;

// readonly : le compilateur refuse toute ecriture dans un membre d'instance, et
// une modification ne peut plus se perdre dans une copie.
readonly record struct Compteur(int Valeur)
{
    public Compteur Incrementer() => this with { Valeur = Valeur + 1 };
}

sealed class Guichet
{
    private Compteur _passages;

    // La nouvelle valeur remplace l'ancienne : l'ecriture est visible.
    public void Accueillir() => _passages = _passages.Incrementer();

    public Compteur Passages => _passages;
}

static class Accueil
{
    public static void Executer()
    {
        var guichet = new Guichet();
        guichet.Accueillir();
        guichet.Accueillir();

        Console.WriteLine(guichet.Passages.Valeur); // 2
        Console.WriteLine(guichet.Passages);        // Compteur { Valeur = 2 }
    }
}

Un champ readonly ne peut pas être modifié ; pour y appeler une méthode qui pourrait écrire, le compilateur appelle donc la méthode sur une copie. Un readonly struct supprime la question : aucune méthode ne peut écrire, la copie défensive disparaît, et une « modification » rend une nouvelle valeur. Le record struct ajoute l'égalité par valeur et with, et in évite la copie d'une grosse struct passée en paramètre — à condition qu'elle soit readonly, comme le détaille le cours Bases du langage.

La page « Choosing Between Class and Struct » de la documentation .NET, reprise de la deuxième édition des Framework Design Guidelines, ne retient la struct que pour un type qui représente une seule valeur, fait moins de 16 octets, est immuable et n'est pas boxé souvent. Elle est donc un mauvais choix :

  • quand elle est grosse et circule beaucoup : chaque copie coûte plus qu'un pointeur ;
  • quand elle est mutable : les modifications se perdent dans des copies ;
  • quand elle est souvent vue comme object ou comme interface ;
  • quand le type a une identité, ou quand default, qui met tous les champs à zéro sans passer par un constructeur, n'est pas une valeur valide.

Le boxing, mesuré

Boxer, c'est copier une valeur dans un objet neuf sur le tas pour la voir comme object ou comme interface. Le cours Generics, delegates et events montre le cas d'ArrayList ; les boxings qui restent aujourd'hui sont cachés. Ils se mesurent avec GC.GetAllocatedBytesForCurrentThread, en Release : depuis .NET 9 le JIT 64 bits place sur la pile une boîte qui ne s'échappe pas de la méthode, et .NET 10 fait de même pour certains petits tableaux, si bien qu'un boxing écrit n'est pas forcément un boxing exécuté.

Le plus répandu est une struct servant de clé sans implémenter IEquatable<T> : chaque recherche boxe.

using System;
using System.Collections.Generic;

// Une cle composee, sans IEquatable<T> : le dictionnaire se rabat sur
// ValueType.GetHashCode et ValueType.Equals(object), qui boxent.
struct Emplacement
{
    public int Allee;
    public int Rayon;
}

static class Entrepot
{
    static int Rechercher(Dictionary<Emplacement, int> stock)
    {
        var total = 0;
        for (var i = 0; i < 1_000; i++)
        {
            total += stock[new Emplacement { Allee = i % 100, Rayon = i % 100 }];
        }

        return total;
    }

    public static void Executer()
    {
        var stock = new Dictionary<Emplacement, int>();
        for (var i = 0; i < 100; i++)
        {
            stock[new Emplacement { Allee = i, Rayon = i }] = i;
        }

        // Un premier passage hors mesure : sa premiere execution alloue 88 octets
        // que la version sans boxing alloue aussi. La mesure porte sur le second.
        Rechercher(stock);

        var avant = GC.GetAllocatedBytesForCurrentThread();
        Rechercher(stock);

        // Release, .NET 10, x64 : 72 octets par recherche, soit trois boites de
        // 24 octets.
        Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - avant); // 72000
    }
}
using System;
using System.Collections.Generic;

// Un record struct implemente IEquatable<Emplacement> et redefinit GetHashCode :
// le comparateur par defaut appelle ces methodes typees, sans boite.
readonly record struct Emplacement(int Allee, int Rayon);

static class Entrepot
{
    static int Rechercher(Dictionary<Emplacement, int> stock)
    {
        var total = 0;
        for (var i = 0; i < 1_000; i++)
        {
            total += stock[new Emplacement(i % 100, i % 100)];
        }

        return total;
    }

    public static void Executer()
    {
        var stock = new Dictionary<Emplacement, int>();
        for (var i = 0; i < 100; i++)
        {
            stock[new Emplacement(i, i)] = i;
        }

        Rechercher(stock);

        var avant = GC.GetAllocatedBytesForCurrentThread();
        Rechercher(stock);
        Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - avant); // 0
    }
}

EqualityComparer<T>.Default, qu'emploient Dictionary, HashSet, Contains ou IndexOf, choisit une comparaison typée quand T implémente IEquatable<T>. Sinon, il passe par Equals(object), qui reçoit l'autre valeur boxée, et faute de redéfinition, ValueType.Equals et ValueType.GetHashCode boxent en plus la valeur elle-même. Le record struct écrit l'interface et le hachage à votre place.

L'autre boxing courant est un paramètre typé par une interface : l'IL boxe à chaque appel. C'est là que joue la réserve du début de section : selon la forme de l'appel, une méthode que le JIT a pleinement optimisée peut voir la boîte disparaître, alors que dans la boucle ci-dessous elle reste sur le tas. Seule une contrainte générique, qui garde le même contrat, la supprime à coup sûr :

using System;

interface IForme
{
    double Aire();
}

readonly struct Carre : IForme
{
    private readonly double _cote;

    public Carre(double cote) => _cote = cote;

    public double Aire() => _cote * _cote;
}

static class Surfaces
{
    // Le parametre est une interface : l'IL de chaque appel boxe le Carre recu.
    static double ParInterface(IForme forme) => forme.Aire();

    // Contrainte generique : le JIT compile une version propre a Carre, et
    // l'appel se fait sur la valeur elle-meme.
    static double ParContrainte<T>(T forme) where T : IForme => forme.Aire();

    public static void Executer()
    {
        var carre = new Carre(2);
        var total = 0.0;

        var avant = GC.GetAllocatedBytesForCurrentThread();
        for (var i = 0; i < 1_000; i++)
        {
            total += ParInterface(carre);
        }

        var milieu = GC.GetAllocatedBytesForCurrentThread();
        for (var i = 0; i < 1_000; i++)
        {
            total += ParContrainte(carre);
        }

        var apres = GC.GetAllocatedBytesForCurrentThread();

        // Release, .NET 10, x64. Dans cette boucle, la boite reste sur le tas :
        // 24 octets par appel. Une methode que le JIT a pleinement optimisee peut
        // la faire disparaitre ; la contrainte generique, elle, n'en cree aucune.
        Console.WriteLine(milieu - avant); // 24000
        Console.WriteLine(apres - milieu); // 0
    }
}

Span<T>, ReadOnlySpan<T> et stackalloc

Span<T> est une référence et une longueur : une vue sur une zone contiguë, qu'elle appartienne à un tableau, à une chaîne, à la pile ou à de la mémoire native. Découper une tranche ne copie rien, et l'accès reste vérifié contre les bornes. ReadOnlySpan<T> interdit l'écriture ; c'est la forme que prend une string, immuable. L'analyse de texte en tire le plus : au lieu de fabriquer une chaîne par champ pour la jeter aussitôt, on lit les champs en place.

using System;
using System.Diagnostics;
using System.Threading;

static class Lecture
{
    const string Ligne = "12;7;30;5;18;3;41;9";

    // Split alloue un tableau et une chaine par champ, que int.Parse jette aussitot.
    static int SommeParSplit(string ligne)
    {
        var total = 0;
        foreach (var champ in ligne.Split(';'))
        {
            total += int.Parse(champ);
        }

        return total;
    }

    // Split sur un ReadOnlySpan<char> rend des Range : chaque champ est une
    // tranche de la chaine d'origine, sans copie.
    static int SommeParSpan(ReadOnlySpan<char> ligne)
    {
        var total = 0;
        foreach (var plage in ligne.Split(';'))
        {
            total += int.Parse(ligne[plage]);
        }

        return total;
    }

    static (long Octets, long Millisecondes, int Controle) Tour(Func<string, int> calcul)
    {
        var octets = GC.GetAllocatedBytesForCurrentThread();
        var chrono = Stopwatch.StartNew();
        var controle = 0;
        for (var i = 0; i < 1_000_000; i++)
        {
            controle += calcul(Ligne);
        }

        chrono.Stop();
        return (GC.GetAllocatedBytesForCurrentThread() - octets, chrono.ElapsedMilliseconds, controle);
    }

    static void Mesurer(string nom, Func<string, int> calcul)
    {
        // Echauffement. Le JIT compile d'abord vite et peu optimise ; il ne
        // commence a compter les appels qu'apres une breve periode sans nouvelle
        // compilation (100 ms par defaut), puis recompile les methodes chaudes en
        // arriere-plan. Un premier tour, une pause, puis un second tour lui en
        // laissent le temps : le premier tour, lui, dure bien plus longtemps.
        Tour(calcul);
        Thread.Sleep(500);
        Tour(calcul);

        // Seul le troisieme tour est affiche.
        var (octets, millisecondes, controle) = Tour(calcul);
        Console.WriteLine(
            $"{nom} : {octets / 1_000_000} octets par ligne, {millisecondes} ms ({controle})");
    }

    public static void Executer()
    {
        Mesurer("Split", SommeParSplit);
        Mesurer("Span", ligne => SommeParSpan(ligne));
        // Une execution en Release sur la machine de redaction, partagee avec
        // d'autres processus. Les octets ne bougent pas. Sur douze executions,
        // Split a pris de 1,2 a 2,5 fois le temps de Span :
        // Split : 312 octets par ligne, 159 ms (125000000)
        // Span : 0 octets par ligne, 68 ms (125000000)
    }
}

Les octets par ligne ne dépendent que du code et du runtime. Les millisecondes, elles, varient avec le processeur, la charge et l'exécution : elles ne valent que comparées entre elles, sur la même machine. La mesure se fait en Release et après un vrai échauffement : le JIT commence par une compilation rapide et peu optimisée, n'en compte les appels qu'après une pause de compilation, et ne la remplace qu'ensuite ; un tour mesuré trop tôt tourne en partie sur l'ancien code. Pour un chiffre qu'on publie, BenchmarkDotNet répète, isole et fait les statistiques ; un Stopwatch suffit à comparer deux écritures.

stackalloc réserve un tampon dans le cadre de pile de la méthode : il disparaît au retour, sans rien coûter au GC. Réservé aux petites tailles fixées ou bornées : la pile d'un thread se compte en mégaoctets, et son débordement termine le processus sans qu'aucun catch puisse l'intercepter.

using System;

static class Reference
{
    public static string Formater(int numero)
    {
        // Huit caracteres sur la pile : liberes au retour de la methode, sans que
        // le ramasse-miettes les voie jamais.
        Span<char> tampon = stackalloc char[8];
        tampon[0] = 'C';
        tampon[1] = '-';

        if (!numero.TryFormat(tampon[2..], out var ecrits, "D6"))
        {
            throw new ArgumentOutOfRangeException(nameof(numero));
        }

        // La seule allocation : la chaine rendue.
        return new string(tampon[..(2 + ecrits)]);
    }

    public static void Executer()
    {
        Console.WriteLine(Formater(42)); // C-000042
    }
}

Memory<T> et les limites des ref struct

Span<T> est une ref struct : pour qu'il ne survive jamais à la zone qu'il désigne, le compilateur lui interdit d'aller sur le tas. D'où ses limites : il ne peut être ni élément de tableau, ni champ d'une classe ou d'une struct ordinaire, ni boxé, ni capturé par un lambda, ni utilisé après un await ou un yield return.

Cette méthode asynchrone ne compile pas, parce que le span sert après l'attente :

using System;
using System.IO;
using System.Threading.Tasks;

static class Journal
{
    public static async Task<int> CompterLignesAsync(Stream flux)
    {
        var tableau = new byte[4096];
        Span<byte> tampon = tableau;
        var lignes = 0;
        int lus;

        while ((lus = await flux.ReadAsync(tableau)) > 0)
        {
            // CS4007 : un Span<byte> ne peut pas etre conserve au-dela d'un await.
            // Les variables qui traversent un await vivent dans la machine a
            // etats, sur le tas, ou un ref struct n'a pas le droit d'aller.
            lignes += tampon[..lus].Count((byte)'\n');
        }

        return lignes;
    }
}
using System;
using System.IO;
using System.Threading.Tasks;

static class Journal
{
    public static async Task<int> CompterLignesAsync(Stream flux)
    {
        // Memory<T> est une struct ordinaire : elle traverse l'await.
        Memory<byte> tampon = new byte[4096];
        var lignes = 0;
        int lus;

        while ((lus = await flux.ReadAsync(tampon)) > 0)
        {
            // Depuis C# 13, un Span local est admis dans une methode async tant
            // qu'il ne sert plus apres un await.
            var octets = tampon.Span[..lus];
            lignes += octets.Count((byte)'\n');
        }

        return lignes;
    }

    public static async Task ExecuterAsync()
    {
        using var flux = new MemoryStream("a\nb\nc\n"u8.ToArray());
        Console.WriteLine(await CompterLignesAsync(flux)); // 3
    }
}

Memory<T> et ReadOnlyMemory<T> désignent la même zone sans être des ref struct : ils se stockent dans un champ et traversent un await, et leur propriété Span donne la vue rapide au moment de s'en servir. La règle usuelle : Span dans les méthodes synchrones, Memory dans les signatures asynchrones et dans les champs.

C# 13 a desserré l'étau. Un ref struct local est admis dans une méthode async ou un itérateur tant qu'il ne sert pas au-delà d'un await ou d'un yield return ; l'implémentation d'interface et allows ref struct sont présentés dans le cours Generics, delegates et events. C# 14 fait de Span<T> et ReadOnlySpan<T> des types de première classe : les conversions depuis un tableau ou une chaîne entrent dans le langage, et une méthode d'extension écrite une fois sur ReadOnlySpan<T> sert désormais aux tableaux.

using System;
using System.Numerics;

static class Tranches
{
    // Une seule methode, sur ReadOnlySpan<T> : tableaux, Span<T> et tranches
    // l'atteignent tous.
    public static T Maximum<T>(this ReadOnlySpan<T> valeurs) where T : INumber<T>
    {
        var maximum = valeurs[0];
        foreach (var valeur in valeurs[1..])
        {
            maximum = T.Max(maximum, valeur);
        }

        return maximum;
    }
}

static class Notes
{
    public static void Executer()
    {
        int[] notes = [12, 7, 30, 5];

        // C# 14 : la conversion d'un tableau ou d'un Span<T> en ReadOnlySpan<T>
        // vaut pour le receveur d'une extension et pour l'inference de T.
        // En C# 13, ces deux lignes echouent avec CS0411.
        Console.WriteLine(notes.Maximum());              // 30
        Console.WriteLine(notes.AsSpan(1, 2).Maximum()); // 30
    }
}

ArrayPool<T>

Quand un tampon est trop grand pour la pile et sert à chaque appel, ArrayPool<T>.Shared le prête au lieu de l'allouer. Rent rend un tableau d'au moins la taille demandée — 128 éléments pour 100 demandés —, qui n'est pas remis à zéro et peut contenir les données de l'emprunteur précédent. Return le rend au pool, qui le prêtera au suivant ; s'en servir après, c'est écrire dans le tampon d'un autre.

Ce calcul d'empreinte commet les deux erreurs classiques : il lit le tableau entier au lieu de la partie écrite, et ne le rend pas si le hachage lève.

using System;
using System.Buffers;
using System.Security.Cryptography;
using System.Text;

static class Empreinte
{
    public static string Calculer(string texte)
    {
        var tampon = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(texte.Length));
        Encoding.UTF8.GetBytes(texte, tampon);

        // Le tableau loue fait au moins la taille demandee, souvent davantage, et
        // garde le contenu de son dernier usage : le hachage porte sur des octets
        // etrangers au texte.
        var empreinte = Convert.ToHexString(SHA256.HashData(tampon));

        // Jamais atteint si HashData leve : le tableau n'est pas rendu, le pool
        // en allouera un autre, et l'economie disparait.
        ArrayPool<byte>.Shared.Return(tampon);
        return empreinte;
    }

    public static void Executer()
    {
        var attendu = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("commande 42")));
        Console.WriteLine(Calculer("commande 42") == attendu); // False
    }
}
using System;
using System.Buffers;
using System.Security.Cryptography;
using System.Text;

static class Empreinte
{
    public static string Calculer(string texte)
    {
        var tampon = ArrayPool<byte>.Shared.Rent(Encoding.UTF8.GetMaxByteCount(texte.Length));
        try
        {
            var ecrits = Encoding.UTF8.GetBytes(texte, tampon);

            // Trente-deux octets de resultat : la pile suffit.
            Span<byte> hachage = stackalloc byte[SHA256.HashSizeInBytes];
            SHA256.HashData(tampon.AsSpan(0, ecrits), hachage);
            return Convert.ToHexString(hachage);
        }
        finally
        {
            // Rendu meme si le hachage leve, et plus touche ensuite.
            ArrayPool<byte>.Shared.Return(tampon);
        }
    }

    public static void Executer()
    {
        var attendu = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes("commande 42")));
        Console.WriteLine(Calculer("commande 42") == attendu); // True

        var avant = GC.GetAllocatedBytesForCurrentThread();
        Calculer("commande 43");

        // Tampon et hachage ne coutent plus rien : reste la chaine de 64
        // caracteres rendue a l'appelant.
        Console.WriteLine(GC.GetAllocatedBytesForCurrentThread() - avant); // 152
    }
}

La longueur utile se porte à part, et une tranche AsSpan(0, ecrits) la fait respecter à tout le code qui suit. Return(tampon, clearArray: true) efface le contenu avant de le rendre, pour un tampon qui a porté un secret. Le pool ne vaut que pour des tampons gros ou fréquents : pour quelques dizaines d'octets, stackalloc coûte moins, et un tableau gardé longtemps n'a rien à faire dans un pool.

IDisposable, using et finaliseurs

Le GC libère la mémoire, pas un fichier, une connexion ou une socket. Ces ressources ont un propriétaire, qui les rend par Dispose au moment voulu, sans attendre une collecte. Qui possède un service injecté, et donc qui le libère, relève du cours Injection de dépendances.

L'instruction using est un try/finally écrit par le compilateur : Dispose s'exécute en sortie de bloc, exception ou non. La déclaration using var étend la portée au reste du bloc englobant, et plusieurs déclarations se libèrent dans l'ordre inverse. IAsyncDisposable sert quand la fermeture fait des entrées-sorties : await using attend DisposeAsync.

using System;
using System.Threading.Tasks;

sealed class Ressource(string nom) : IDisposable
{
    public void Dispose() => Console.WriteLine($"{nom} liberee");
}

// Une ressource dont la fermeture fait des entrees-sorties : un flux a vider.
sealed class Flux(string nom) : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await Task.Yield();
        Console.WriteLine($"{nom} vide puis ferme");
    }
}

static class Portees
{
    public static async Task ExecuterAsync()
    {
        // Instruction using : la portee est le bloc.
        using (var a = new Ressource("a"))
        {
            Console.WriteLine("dans le bloc");
        }

        // Declaration using : la portee est le reste du bloc englobant.
        using var b = new Ressource("b");
        using var c = new Ressource("c");
        await using var d = new Flux("d");
        Console.WriteLine("fin de methode");

        // Sortie :
        // dans le bloc
        // a liberee
        // fin de methode
        // d vide puis ferme
        // c liberee
        // b liberee
    }
}

Un finaliseur (~Type()) est le filet que le GC appelle quand il récupère un objet que personne n'a libéré. En écrire un « par sécurité » est le contresens classique :

using System;
using System.IO;

sealed class Journal : IDisposable
{
    private readonly FileStream _fichier = new(
        Path.GetTempFileName(), FileMode.Create, FileAccess.Write, FileShare.None,
        bufferSize: 4096, FileOptions.DeleteOnClose);

    public void Ecrire(byte octet) => _fichier.WriteByte(octet);

    public void Dispose() => _fichier.Dispose();

    // Un finaliseur « par securite » sur une classe qui ne tient que des objets
    // geres : chaque instance passe par la file de finalisation et survit a une
    // collecte de plus. Et quand il s'execute, _fichier a pu etre finalise avant
    // lui : l'ordre entre finaliseurs n'est pas garanti.
    ~Journal() => _fichier.Dispose();
}
using System;
using System.IO;

sealed class Journal : IDisposable
{
    private readonly FileStream _fichier = new(
        Path.GetTempFileName(), FileMode.Create, FileAccess.Write, FileShare.None,
        bufferSize: 4096, FileOptions.DeleteOnClose);

    private bool _libere;

    public void Ecrire(byte octet)
    {
        ObjectDisposedException.ThrowIf(_libere, this);
        _fichier.WriteByte(octet);
    }

    // Aucun finaliseur : le handle du fichier est deja enveloppe par la
    // SafeFileHandle du FileStream, qui porte le sien. Dispose peut etre appele
    // plusieurs fois sans effet.
    public void Dispose()
    {
        if (_libere)
        {
            return;
        }

        _libere = true;
        _fichier.Dispose();
    }
}

static class Traces
{
    public static void Executer()
    {
        using var journal = new Journal();
        journal.Ecrire(42);
        journal.Dispose();
        journal.Dispose(); // sans effet
    }
}

Un objet finalisable coûte cher : il est inscrit dans une file à sa création, survit à la collecte qui le trouve mort, attend que son finaliseur s'exécute sur un thread que le runtime choisit, et n'est récupéré qu'à une collecte suivante. L'heure d'exécution n'est pas définie, l'ordre entre finaliseurs non plus, et depuis .NET Core aucun n'est exécuté à l'arrêt du processus. Un finaliseur ne se justifie que pour une classe qui détient directement un handle natif — et même là, SafeHandle l'écrit à sa place.

Le patron Dispose complet, avec Dispose(bool) protégé et virtuel, sert à une classe ouverte à l'héritage : une dérivée y ajoute sa propre libération. disposing vaut true pour un appel de Dispose et false depuis un finaliseur, où les autres objets gérés ne doivent plus être touchés. Une classe scellée s'en passe : un Dispose idempotent suffit.

using System;
using System.Runtime.InteropServices;

// La seule classe qui touche au natif. SafeHandle porte le finaliseur a sa place.
sealed class MemoireNative : SafeHandle
{
    public MemoireNative(int taille) : base(IntPtr.Zero, ownsHandle: true)
    {
        SetHandle(Marshal.AllocHGlobal(taille));
    }

    public override bool IsInvalid => handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        Marshal.FreeHGlobal(handle);
        return true;
    }
}

// Classe ouverte a l'heritage : Dispose(bool) est le point d'extension des
// derivees, qui ne redefinissent jamais Dispose().
class Tampon : IDisposable
{
    private readonly MemoireNative _memoire = new(4096);
    private bool _libere;

    public void Dispose()
    {
        Dispose(disposing: true);

        // Pour une derivee qui declarerait un finaliseur : il n'a plus rien a faire.
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_libere)
        {
            return;
        }

        if (disposing)
        {
            _memoire.Dispose();
        }

        _libere = true;
    }
}

sealed class TamponTrace : Tampon
{
    // Un drapeau propre a la derivee : celui de la base est prive, et la base
    // n'est appelee qu'apres le travail de la derivee.
    private bool _libere;

    protected override void Dispose(bool disposing)
    {
        if (_libere)
        {
            return;
        }

        if (disposing)
        {
            Console.WriteLine("tampon trace libere");
        }

        _libere = true;
        base.Dispose(disposing);
    }
}

static class Natif
{
    public static void Executer()
    {
        using var tampon = new TamponTrace();
        tampon.Dispose(); // tampon trace libere

        // Le second appel explicite, puis celui du using en fin de methode, ne
        // font plus rien : la ligne ne s'affiche qu'une fois.
        tampon.Dispose();
    }
}
Ce cours vous a servi ? Offrir un café Signaler une erreur