En .NET, chaque objet alloué sur le tas managé finit par coûter du travail au Garbage Collector, qui doit le parcourir puis libérer sa mémoire (voir la pile et le tas pour un rappel). Sur un chemin de code très sollicité (parsing, sérialisation, traitement de flux), ces petites allocations s’accumulent et deviennent visibles en latence comme en CPU. Span<T>, ReadOnlySpan<T> et Memory<T> permettent de manipuler des portions de mémoire sans copier ni allouer, tout en restant dans du code C# sûr.
1. Pourquoi chercher à réduire les allocations ?
Une allocation sur le tas managé est très rapide : le runtime se contente d’avancer un pointeur. Le coût arrive plus tard :
- chaque allocation consomme le budget de la génération 0 ; quand il est épuisé, une collection se déclenche ;
- une collection suspend les threads managés (au moins brièvement) ;
- les objets qui survivent sont promus et coûtent de plus en plus cher à collecter.
Le code suivant paraît anodin :
// ❌ Chaque appel alloue : un tableau string[] + une string par morceau
public static int SumWithSplit(string line)
{
int sum = 0;
foreach (string part in line.Split(','))
{
sum += int.Parse(part, CultureInfo.InvariantCulture);
}
return sum;
}
Pour une ligne "12,45,7,300", on alloue un tableau et quatre chaînes, simplement pour les lire une fois puis les jeter. Multiplié par des millions de lignes, cela fait autant d’objets à ramasser.
L’idée de Span<T> : au lieu de copier les morceaux dans de nouvelles chaînes, on regarde directement la portion concernée de la chaîne d’origine.
2. Span<T> et ReadOnlySpan<T>
Une vue sur de la mémoire contiguë
Un Span<T> est une structure qui contient une référence vers le début d’une zone mémoire et une longueur. Il ne possède pas la mémoire : il la décrit. Cette mémoire peut provenir de trois endroits différents.
graph LR
S1["Span#lt;int#gt;<br/>(ref + longueur)"] --> A["int[] sur le tas<br/>(tableau managé)"]
S2["Span#lt;byte#gt;<br/>(ref + longueur)"] --> B["stackalloc<br/>(pile du thread)"]
S3["Span#lt;byte#gt;<br/>(ref + longueur)"] --> C["Mémoire native<br/>(NativeMemory, interop)"]
S4["ReadOnlySpan#lt;char#gt;"] --> D["string<br/>(immuable)"]
style S1 fill:#4A90E2
style S2 fill:#4A90E2
style S3 fill:#4A90E2
style S4 fill:#4A90E2
style A fill:#F5A623
style B fill:#7ED321
style C fill:#F5A623
style D fill:#F5A623
Quelle que soit l’origine, l’API est la même : indexeur, Length, Slice, CopyTo, IndexOf, etc. ReadOnlySpan<T> est la variante en lecture seule, utilisée notamment pour les chaînes (une string est immuable).
int[] numbers = [1, 2, 3, 4, 5, 6];
Span<int> all = numbers; // conversion implicite, aucune copie
Span<int> middle = all.Slice(2, 3); // [3, 4, 5] : même mémoire
middle[0] = 42; // modifie numbers[2]
Console.WriteLine(numbers[2]); // 42
ReadOnlySpan<char> text = "Bonjour le monde";
ReadOnlySpan<char> word = text[8..10]; // "le" : opérateur de plage, sans allocation
Le slicing ne copie rien
C’est le point clé : Slice (ou l’opérateur [a..b]) renvoie une nouvelle vue sur la même mémoire, pas une copie. À comparer avec Substring, qui alloue une nouvelle chaîne :
string date = "2026-05-09";
// ❌ Trois allocations de chaînes
int y1 = int.Parse(date.Substring(0, 4));
int m1 = int.Parse(date.Substring(5, 2));
int d1 = int.Parse(date.Substring(8, 2));
// ✅ Zéro allocation : int.Parse accepte un ReadOnlySpan<char>
ReadOnlySpan<char> span = date;
int y2 = int.Parse(span[..4]);
int m2 = int.Parse(span[5..7]);
int d2 = int.Parse(span[8..]);
La plupart des API de la BCL ont aujourd’hui une surcharge acceptant un span : int.Parse, Guid.TryParse, Encoding.GetBytes, Stream.Read, TryFormat, etc.
stackalloc : un buffer sur la pile
Pour un buffer temporaire de petite taille, on peut l’allouer directement sur la pile avec stackalloc. Il disparaît automatiquement à la sortie de la méthode, sans aucun travail pour le GC.
using System.Security.Cryptography;
// ✅ 32 octets sur la pile, aucune allocation sur le tas
Span<byte> hash = stackalloc byte[32];
SHA256.HashData("payload"u8, hash);
Console.WriteLine(Convert.ToHexString(hash));
La pile est limitée (1 Mo par thread par défaut sous Windows). On ne fait donc jamais de stackalloc avec une taille arbitraire venant de l’extérieur. Le motif classique combine pile et pool (voir plus bas) :
using System.Buffers;
public static string Normalize(ReadOnlySpan<char> input)
{
const int StackLimit = 256;
char[]? rented = null;
Span<char> buffer = input.Length <= StackLimit
? stackalloc char[StackLimit] // petit : pile
: (rented = ArrayPool<char>.Shared.Rent(input.Length)); // gros : pool
try
{
int written = input.ToUpperInvariant(buffer);
return new string(buffer[..written]); // seule allocation : le résultat
}
finally
{
if (rented is not null)
{
ArrayPool<char>.Shared.Return(rented);
}
}
}
ref struct : les contraintes à connaître
Pour garantir qu’un span ne pointe jamais vers une zone de pile déjà libérée, Span<T> est déclaré comme une ref struct. Le compilateur impose alors qu’il reste sur la pile :
| Interdit | Pourquoi |
|---|---|
Champ d’une class ou d’une struct classique |
L’objet pourrait vivre sur le tas, plus longtemps que la mémoire pointée |
Boxing (object o = span;) |
Le boxing place la valeur sur le tas |
| Capture dans une lambda ou une fonction locale capturante | La closure est un objet sur le tas |
Variable vivante de part et d’autre d’un await ou d’un yield |
L’état de la méthode est stocké dans un objet sur le tas |
Argument générique, sauf si le paramètre déclare allows ref struct (C# 13) |
Le code générique pourrait le boxer |
// ❌ Ne compile pas
public class Parser
{
private Span<byte> _buffer; // erreur : un ref struct ne peut pas être champ d'une classe
}
// ❌ Ne compile pas
public async Task ProcessAsync(byte[] data)
{
Span<byte> span = data;
await Task.Delay(10);
span[0] = 1; // erreur : span utilisé après un await
}
Depuis C# 13, une variable ref struct est autorisée dans une méthode async, tant qu’elle n’est pas utilisée après un await. Pour conserver une référence à un buffer au-delà d’un await, il faut passer à Memory<T>.
3. Memory<T> : la version « tas » pour l’asynchrone
Memory<T> (et ReadOnlyMemory<T>) décrit la même chose qu’un span (une zone contiguë avec un début et une longueur), mais c’est une struct classique : on peut la stocker dans un champ, la capturer dans une lambda ou la garder de part et d’autre d’un await. Elle ne peut en revanche pas pointer vers la pile.
On travaille donc avec Memory<T> pour transporter le buffer, et on récupère un Span<T> via .Span au moment de le traiter de façon synchrone.
using System.Buffers;
public static async Task<int> CountLinesAsync(Stream stream, CancellationToken ct)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(16 * 1024);
try
{
Memory<byte> memory = buffer;
int count = 0;
int read;
// Stream.ReadAsync(Memory<byte>) renvoie un ValueTask<int>
while ((read = await stream.ReadAsync(memory, ct)) > 0)
{
// Traitement synchrone : on passe par le Span
count += memory.Span[..read].Count((byte)'\n');
}
return count;
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
Règle simple pour les signatures :
- méthode synchrone → paramètre
Span<T>/ReadOnlySpan<T>; - méthode asynchrone ou stockage dans un champ →
Memory<T>/ReadOnlyMemory<T>.
Pour les rappels sur ValueTask et le fonctionnement de la machine à états, voir l’article sur async/await.
4. ArrayPool<T> : réutiliser les tableaux
Quand un buffer est trop gros pour la pile, ou qu’il doit traverser un await, ArrayPool<T> évite d’allouer un nouveau tableau à chaque appel :
using System.Buffers;
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096); // peut renvoyer un tableau PLUS GRAND
try
{
Span<byte> useful = buffer.AsSpan(0, 4096); // on ne travaille que sur la taille demandée
// ... utilisation ...
}
finally
{
ArrayPool<byte>.Shared.Return(buffer, clearArray: true); // clearArray si données sensibles
}
Les pièges classiques :
- ❌ utiliser
buffer.Lengthau lieu de la taille demandée : le tableau loué peut être plus grand ; - ❌ continuer à utiliser le tableau après
Return: un autre appelant peut déjà l’avoir récupéré ; - ❌ rendre deux fois le même tableau ;
- ❌ oublier le
try/finally: ce n’est pas une fuite mémoire (le GC finira par le collecter), mais le pool perd son intérêt.
5. string.Create : construire une chaîne sans intermédiaire
Construire une chaîne par concaténations ou via un StringBuilder produit des objets intermédiaires. Quand la longueur finale est connue, string.Create alloue directement la chaîne finale et donne accès à son contenu sous forme de Span<char> pendant l’initialisation :
using System.Globalization;
public static class OrderFormatter
{
// "ORD-000123" : 4 caractères de préfixe + 6 chiffres
public static string FormatOrderId(int id) =>
string.Create(10, id, static (span, value) =>
{
"ORD-".CopyTo(span);
value.TryFormat(span[4..], out _, "D6", CultureInfo.InvariantCulture);
});
}
Points à noter :
- la lambda est
staticet reçoit l’état (id) en paramètre : aucune closure n’est allouée ; - l’exemple suppose un identifiant positif d’au plus 6 chiffres (sinon
TryFormatrenvoiefalse) ; - la seule allocation est la chaîne résultat.
Pour des cas plus simples, l’interpolation de chaînes moderne ($"ORD-{id:D6}") est déjà très optimisée par le compilateur via DefaultInterpolatedStringHandler ; string.Create se réserve aux chemins vraiment critiques.
6. Parsing sans allocation
Reprenons l’exemple du début. Depuis .NET 9, MemoryExtensions.Split sur un ReadOnlySpan<char> renvoie un énumérateur de Range : on découpe sans créer la moindre chaîne.
using System.Globalization;
public static class Parsers
{
// ❌ Version classique : 1 tableau + N chaînes par appel
public static int SumWithSplit(string line)
{
int sum = 0;
foreach (string part in line.Split(','))
{
sum += int.Parse(part, CultureInfo.InvariantCulture);
}
return sum;
}
// ✅ Version span : aucune allocation
public static int SumWithSpan(ReadOnlySpan<char> line)
{
int sum = 0;
foreach (Range range in line.Split(','))
{
sum += int.Parse(line[range], CultureInfo.InvariantCulture);
}
return sum;
}
}
Le même principe s’applique à EnumerateLines() pour parcourir les lignes d’un texte, ou à IndexOf / IndexOfAny pour chercher un séparateur. Pour du parsing binaire ou de très gros flux, System.IO.Pipelines et SequenceReader<T> prolongent cette approche.
7. C# 14 : les spans deviennent des types de première classe
Les conversions T[] → Span<T> existaient déjà, mais sous forme d’opérateurs définis par l’utilisateur, que le compilateur ne prenait pas en compte partout. C# 14 introduit des conversions implicites de span au niveau du langage entre T[], Span<T>, ReadOnlySpan<T> (et string → ReadOnlySpan<char>). Conséquence pratique : un span peut maintenant servir de receveur de méthode d’extension et participer à l’inférence de type générique.
public static class SpanExtensions
{
public static int SumFast(this ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int v in values)
{
sum += v;
}
return sum;
}
}
int[] data = [1, 2, 3];
// C# 13 : il fallait écrire data.AsSpan().SumFast() ou ((ReadOnlySpan<int>)data).SumFast()
// ✅ C# 14 : conversion implicite tableau → ReadOnlySpan<int> sur le receveur
int total = data.SumFast();
Cette évolution a aussi un effet de bord : certains appels sur des tableaux peuvent désormais résoudre vers une surcharge de MemoryExtensions plutôt que vers Enumerable. C’est en général plus rapide, mais c’est un changement de comportement à garder en tête lors d’une montée de version (il est listé dans les breaking changes du compilateur).
8. Mesurer avant et après
Optimiser sans mesurer, c’est deviner. Avec BenchmarkDotNet, l’attribut [MemoryDiagnoser] ajoute aux résultats les octets alloués par opération, ce qui est exactement l’indicateur visé ici.
dotnet new console -n SpanBench
cd SpanBench
dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
// Parsers : la classe de la section 6, dans le même projet
BenchmarkRunner.Run<ParsingBenchmarks>();
[MemoryDiagnoser]
public class ParsingBenchmarks
{
private const string Line = "12,45,7,300,8,1024,42,9,77,15";
[Benchmark(Baseline = true)]
public int Split() => Parsers.SumWithSplit(Line);
[Benchmark]
public int Span() => Parsers.SumWithSpan(Line);
}
# Toujours en Release, sans debugger attaché
dotnet run -c Release
Résultats indicatifs (l’ordre de grandeur compte plus que les valeurs exactes, qui dépendent de la machine) :
| Method | Mean | Ratio | Gen0 | Allocated |
|---|---|---|---|---|
| Split | ~190 ns | 1.00 | 0.07 | ~400 B |
| Span | ~110 ns | 0.58 | - | 0 B |
Le gain en temps est appréciable, mais le chiffre important est la colonne Allocated : zéro octet, donc zéro pression sur le GC, quel que soit le volume traité.
9. Quand ne pas en faire trop
- Les spans complexifient les signatures : on les réserve aux chemins chauds identifiés par un profilage ou un benchmark.
stackallocetArrayPooldemandent de la rigueur (tailles bornées,try/finally).- Pour du code métier ordinaire, la lisibilité prime : un
Splitsur une chaîne lue une fois au démarrage ne pose aucun problème.
Résumé
| Outil | Usage | Contrainte principale |
|---|---|---|
Span<T> |
Vue modifiable sur tableau, pile ou mémoire native | ref struct : reste sur la pile, pas de await |
ReadOnlySpan<T> |
Vue en lecture seule (chaînes, littéraux u8) |
Idem |
Slicing [a..b] |
Sous-portion sans copie | Ne survit pas à la mémoire d’origine |
stackalloc |
Petit buffer temporaire sur la pile | Taille bornée (quelques centaines d’octets) |
Memory<T> |
Buffer transportable : champs, async, lambdas | Accès au contenu via .Span |
ArrayPool<T> |
Réutilisation de gros tableaux | Rent peut renvoyer plus grand, Return obligatoire |
string.Create |
Construire une chaîne en une seule allocation | Longueur connue à l’avance |
MemoryExtensions.Split (.NET 9) |
Découper sans créer de chaînes | Renvoie des Range |
| Conversions C# 14 | Tableau → span implicite, extensions sur spans | Résolution de surcharges parfois différente |
Span<T> et Memory<T> ne rendent pas le GC inutile, ils lui évitent du travail. Concrètement, chaque allocation évitée sur un chemin critique se traduit par moins de collections, donc moins de pauses et une latence plus stable.