Les fonctionnalités C# 13

Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 13.0. Dans un premier temps, on explicitera le contexte de C# 13.0 par rapport aux autres composants (frameworks, IDE, compilateur etc…) qui permettent de l’utiliser. Ensuite, on rentrera dans le détail des fonctionnalités.

Précisions sur les versions de C#

Depuis C# 8.0, les évolutions fonctionnelles de C# se font pour .NET seulement (anciennement appelé .NET Core). Le framework .NET est toujours supporté toutefois les nouvelles fonctionnalités ne sont pas implémentées pour cet environnement.
Comme les environnements du framework .NET et de .NET ne subsistent plus en parallèle, l’approche .NET Standard n’a plus d’intérêt. .NET Standard s’arrête donc à la version 2.1. Les versions 5.0, 6.0, 7.0, 8.0 et 9.0 de .NET implémentent .NET Standard de la version 1.0 à 2.1 toutefois il est désormais conseillé de cibler une version de .NET plutôt que .NET Standard.

Chronologie des releases

Ce tableau permet de résumer les dates de sorties de C# 13.0, de Visual Studio, du compilateur Roslyn et des versions .NET.

Date Version C# Version Visual Studio Version .NET Compilateur Support
Novembre 2021 C# 10.0 VS2022 (17.0) .NET 6.0
(NET Standard 1.0⇒2.1)*
Roslyn 4.0.1 Novembre 2024 (LTS**)
Décembre 2021 Roslyn 4.1.0
Février 2022 VS2022 (17.1)
Avril 2022 Roslyn 4.2.0
Mai 2022 VS2022 (17.2)
Août 2022 VS2022 (17.3) Roslyn 4.3.1
Novembre 2022 C# 11.0 VS2022 (17.4) .NET 7.0
(NET Standard 1.0⇒2.1)*
Roslyn 4.4.0 Mai 2024
Février 2023 VS2022 (17.5) Roslyn 4.5.0
Mai 2023 VS2022 (17.6) Roslyn 4.6.0
Août 2023 VS2022 (17.7) Roslyn 4.7.0
Novembre 2023 C# 12.0 VS2022 (17.8) .NET 8.0
(NET Standard 1.0⇒2.1)*
Roslyn 4.8.0 Novembre 2026 (LTS**)
Février 2024 VS2022 (17.9) Roslyn 4.9.2
Mai 2024 VS2022 (17.10) Roslyn 4.10.0
Août 2024 VS2022 (17.11) Roslyn 4.11.0
Novembre 2024 C# 13.0 VS2022 (17.12) .NET 9.0 Roslyn 4.12.0 Novembre 2026
Février 2025 VS2022 (17.13) Roslyn 4.13.0
Mai 2025 VS2022 (17.14) Roslyn 4.14.0
Novembre 2025 C# 14.0 VS2026 (18.0) .NET 10.0 Roslyn 5.0.0 Novembre 2028 (LTS**)
Décembre 2025 VS2026 (18.1)
Janvier 2026 VS2026 (18.2)
Février 2026 VS2026 (18.3)
Novembre 2026 ? C# 15.0 VS2026 (18.?) .NET 11.0 Roslyn 5.?.? Novembre 2028 ?
  • *: .NET Standard n’est plus nécessaire puisque les 2 environnements framework .NET et .NET Core n’évoluent plus fonctionnellement. Ils ont laissé place à l’environnement uniformisé .NET (voir .NET 5+ and .NET Standard pour plus de détails).
  • **: LTS pour Long Term Support. Ces versions correspondent au support étendu à 2 ans après la 1ère release contrairement aux autres versions qui sont limitées à 1 an.

Lien entre la version C# et le compilateur

Le tableau précédent permet d’indiquer la version de C# dans le contexte des frameworks de façon à avoir une idée des sorties des autres éléments de l’environnement .NET. Toutefois, la version de C# est liée à la version du compilateur C#. Le compilateur est ensuite livré avec Visual Studio (depuis Visual Studio 2017 15.3) ou avec le SDK .NET.

Le chemin du compilateur est lié au composant avec lequel il est livré:

  • Avec Visual Studio: par exemple pour Visual Studio 2022 Professional: C:\Program Files (x86)\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec les Build tools: par exemple pour les Build Tools for Visual Studio 2022: C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec le SDK .NET:
    • Sur Linux: /usr/share/dotnet/sdk/<version>/Roslyn/bincore/csc.dll
    • Sur Windows: C:\Program Files\dotnet\sdk\<version>\Roslyn\bincore\csc.dll

On peut connaître la version du compilateur en tapant:

csc -help

On peut savoir quelles sont les versions de C# que le compilateur peut gérer en exécutant:

csc -langversion:? 

Limiter la version C# à compiler

Par défaut, le compilateur compile dans les versions suivantes de C#:

  • .NET 10.0: C# 14.0
  • .NET 9.0: C# 13.0
  • .NET 8.0: C# 12.0
  • .NET 7.0: C# 11.0
  • .NET 6.0: C# 10.0
  • .NET 5.0: C# 9.0
  • Framework .NET: C# 7.3
  • .NET Core 3.x: C# 8.0
  • .NET Core 2.x: C# 7.3
  • .NET Standard 2.1: C# 8.0
  • .NET Standard 2.0: C# 7.3
  • .NET Standard 1.x: C# 7.3

On peut volontairement limiter la version C# que le compilateur va traiter:

  • Dans Visual Studio: dans les propriétés du projet ⇒ Onglet Build ⇒ Advanced ⇒ Paramètre Language version.
  • En éditant directement le fichier .csproj du projet et en indiquant la version avec le paramètre LangVersion:
    <Project Sdk="Microsoft.NET.Sdk"> 
      <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFramework>net9.0</TargetFramework> 
        <LangVersion>13.0</LangVersion>
      </PropertyGroup> 
    </Project> 
    

Fonctionnalités C# 13

Les fonctionnalités les plus basiques de C# 13.0 sont présentées dans cet article. D’autres fonctionnalités plus avancées sont présentées dans les articles suivants:

Params Collection

Cette fonctionnalité est assez directe. Avant C# 13, le mot-clé params ne pouvait être utilisé qu’avec un tableau:

public void AddItems(params string[] newItems)
{
  items.AddRange(newItems);
}

A partir de C# 13, on peut désormais utiliser d’autres structures comme List<T>, Span<T>, ReadOnlySpan<T>, IEnumerable<T>, IList<T>, ICollection<T>, IReadOnlyList<T>, IReadOnlyList<T>, par exemple:

internal class ParamsCollections
{
  private readonly List<string> items = new List<string>();

  // Before C# 13.0
  public void AddItems(params string[] newItems)
  {
    items.AddRange(newItems);
  }

  // From C# 13.0 
  public void AddItems(params IEnumerable<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params List<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params IList<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params ICollection<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params IReadOnlyList<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params IReadOnlyCollection<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params Span<string> newItems)
  {
    items.AddRange(newItems);
  }

  public void AddItems(params ReadOnlySpan<string> newItems)
  {
    items.AddRange(newItems);
  }
}

Attribut OverloadResolutionPriority

L’utilisation de l’attribut OverloadResolutionPriority permet d’indiquer un ordre de priorité différent de l’ordre par défaut pour la résolution de surcharge de fonctions.

L’attribut prend en paramètre un entier indiquant la priorité: plus le chiffre est élevé et plus la fonction est prioritaire dans l’ordre de résolution. Par défaut, si l’attribut n’est pas présent, l’ordre de priorité est 0.

Par exemple si on considère:

internal class OverloadResolutionPriorityAttributeExample
{
  private string title;

  public void SetTitle(string newTitle)
  {
    title = newTitle;
    Console.WriteLine("Called overload: SetTitle(string newTitle)");
  }

  public void SetTitle(ReadOnlySpan<char> newTitle)
  {
    title = newTitle.ToString();
    Console.WriteLine("Called overload: SetTitle(ReadOnlySpan<char> newTitle)");
  }

  public void ExecuteMe()
  {
    SetTitle("OK") ;
  }
}

Si on exécute ExecuteMe(), la surcharge choisie sera void SetTitle(string newTitle). Si on ajoute des attributs OverloadResolutionPriority à ces fonctions, comme suit:

internal class OverloadResolutionPriorityAttributeExample
{
  private string title;

  [OverloadResolutionPriority(1)]
  public void SetTitle(string newTitle)
  {
    title = newTitle;
    Console.WriteLine("Called overload: SetTitle(string newTitle)");
  }

  [OverloadResolutionPriority(2)]
  public void SetTitle(ReadOnlySpan<char> newTitle)
  {
    title = newTitle.ToString();
    Console.WriteLine("Called overload: SetTitle(ReadOnlySpan<char> newTitle)");
  }

  public void ExecuteMe()
  {
    SetTitle("OK") ;
  }
}

La surcharge exécutée sera SetTitle(ReadOnlySpan<char> newTitle).

Utilisation de partial avec des propriétés (C# 13), indexers (C# 13), des évènements (C# 14) et des constructeurs (C# 14)

Le mot clé partial permet de séparer l’implémentation d’une classe, d’une struct, d’une interface ou d’un record. Lorsque la définition d’un de ces objets est séparée, les données et fonctions membres sont additionnées à la définition finale de l’objet.

Par exemple, la définition de la classe PartialExample peut être séparée dans des fichiers différents:

// Avant C# 13
// Partie 1
internal partial class PartialExample
{
  private readonly Dictionary<string, string> stringData = new Dictionary<string, string>();

  public bool TryGetString(string key, out string value)
  {
    return stringData.TryGetValue(key, out value);
  }
}

// Partie 2
internal partial class PartialExample
{
  private readonly Dictionary<string, int> intData = new Dictionary<string, int>();

  public bool TryGetInt(string key, out int value)
  {
    return intData.TryGetValue(key, out value);
  }
}

Les 2 parties de la classe peuvent se trouver dans des fichiers séparés ou dans le même fichier.

La définition finale de la classe PartialExample contient les membres des 2 parties:

var partialExample = new PartialExample();
partialExample.TryGetString("testKey", out string stringValue);
partialExample.TryGetInt("testKey", out int intValue);

Le mot-clé partial peut s’appliquer aux fonctions et permet d’indiquer la déclaration d’une fonction dans une partie et l’implémentation de cette fonction dans une autre partie:

// Partie 1
internal partial class PartialExample
{
  // [...]

  // Déclaration fonction
  public partial bool TryGetValue(string key, out string value);
}

// Partie 2
internal partial class PartialExample
{
  // [...]

  // Définition fonction
  public partial bool TryGetValue(string key, out string value)
  {
    return stringData.TryGetValue(key, out value);
  }
}

A partir de C# 13, on peut appliquer partial à des propriétés, par exemple:

// Partie 1
internal partial class PartialExample
{
  // [...]

  // Properties (C# 13)
  public partial IEnumerable<string> DataKeys { get; }
}

// Partie 2
internal partial class PartialExample
{
    // [...]

  public partial IEnumerable<string> DataKeys => stringData.Keys;
}

Depuis C# 13, partial peut aussi être appliqué à des indexers:

// Partie 1
internal partial class PartialExample
{
  // [...]

  // Indexers (C# 13)
  public partial string this[string key] { get; set; }
}

// Partie 2
internal partial class PartialExample
{
  // [...]

  public partial string this[string key] 
  { 
    get => stringData[key]; 
    set => stringData[key] = value; 
  }
}

A partir de C# 14, partial peut s’appliquer aux constructeurs. On peut ainsi séparer la déclaration d’un constructeur de sa définition:

// Partie 1
internal partial class PartialExample
{
  // [...]

  // Déclaration d'un constructor (C# 14)
  public partial PartialExample(IDictionary<string, string> data);
}

// Partie 2
internal partial class PartialExample
{
  // [...]

  // Définition du constructeur 
  public partial PartialExample(IDictionary<string, string> data)
  {
    foreach (var kvp in data)
      stringData[kvp.Key] = kvp.Value;
  }
}

Encore à partir de C# 14, on peut désormais utiliser partial pour des évènements:

// Partie 1
internal partial class PartialExample
{
  // [...]

  // Evènement (C# 14)
  public partial event EventHandler DataAdded;
}

// Partie 2
internal partial class PartialExample
{
  // [...]

  public partial event EventHandler DataAdded
  {
    add {  }
    remove {  }
  }
}

Index de fin d’une structure utilisable dans un initializer

Cette fonctionnalité n’est pas très utile car très restrictive. Elle permet d’initialiser une structure de données dans l’initializer d’un objet en utilisant un index de fin. Mais attention, l’initializer est celui d’un objet contenant la structure de données et non pas l’initializer de la structure de données.

Utilisation d’index dans un initializer à partir de C# 6

A partir de C# 6, on pouvait déjà utiliser un index pour initialiser une structure de données membre d’un objet.

Par exemple, si on considère la classe suivante:

public class ContainingObject
{
  public int[] NumberArray = new int[5];

  public List<int> NumberList = new List<int>(5);

  public List<int> NumberListWithElements = new List<int> { 0, 0, 0, 0, 0 };
}

On peut instancier cette classe en utilisant un initializer et affecter directement les structures de données dans l’initializer de la classe:

var containingObject = new ContainingObject
{
  NumberArray = {
    [0] = 1,
    [1] = 2,
    [2] = 3,
    [3] = 4,
    [4] = 5,
  }
};

// Le tableau contient:
// |  Indexes: | 0 | 1 | 2 | 3 | 4 |
// |   Values: | 1 | 2 | 3 | 4 | 5 |

Mais attention, pour que cette notation puisse être utilisée, il faut que le tableau soit déjà instancié à la bonne taille ce qui est fait dans le corps de la classe ContainingObject:

public int[] NumberArray = new int[5];

Si on utilise la même notation pour le membre NumberList ça ne marche pas car les index ne sont pas accessibles:

var containingObject = new ContainingObject
{
  NumberList =
  {
    [0] = 1,
    [1] = 2,				// ATTENTION: OutOfRange exception
    [2] = 3,
    [3] = 4,
    [4] = 5,
  }
};

Cette exécution échoue car les index ne sont pas accessibles, une exception de type OutOfRangeException est lancée.

De même si on essaie d’instancier directement une liste de cette façon, une exception OutOfRange sera aussi lancée:

var integers =
new List<int>(5)
{
  [0] = 1,
  [1] = 2,
  [2] = 3,         // ATTENTION: OutOfRange exception
  [3] = 4,
  [4] = 5,
};

Par contre pour le membre NumberListWithElements, cette instantiation réussit car les index existent:

var containingObject = new ContainingObject
{
  NumberListWithElements =
  {
    [0] = 1,
    [1] = 2,
    [2] = 3,           // OK
    [3] = 4,
    [4] = 5,
  }
};

// Le tableau contient:
// |  Indexes: | 0 | 1 | 2 | 3 | 4 |
// |   Values: | 1 | 2 | 3 | 4 | 5 |

Index de fin à partir de C# 8

A partir de C# 8, est apparu l’index de fin utilisable pour des structures de données [^..], par exemple:

var integers = new List<int> { 1, 2, 3, 4, 5, 6 };
Console.WriteLine(integers[^1]); // 6
Console.WriteLine(integers[^2]); // 5
Console.WriteLine(integers[^6]); // 1

Console.WriteLine(integers[^0]); // ERREUR: out of range exception
Console.WriteLine(integers[^7]); // ERREUR: out of range exception

Pour plus de détails sur cet index de fin: C# 8 – Index et plage de structure de données.

Utilisation d’index de fin dans un initializer à partir de C# 13

C# 13 permet d’utiliser des index de fin pour initialiser un membre dans l’initializer d’un objet. Cependant les restrictions seront les mêmes que pour les index normaux (voir plus haut).

Ainsi, si on peut instancier directement un tableau en tant que membre avec des index de fin:

var containingObject = new ContainingObject
{
  NumberArray = {
    [^1] = 1,
    [^2] = 2,
    [^3] = 3,
    [^4] = 4,
    [^5] = 5,
  }
};

// Le tableau contient:
// |  Indexes: | 0 | 1 | 2 | 3 | 4 |
// |   Values: | 5 | 4 | 3 | 2 | 1 |

De même si une liste est instanciée et que les index existent comme pour NumberListWithElements, on peut écrire:

var containingObject = new ContainingObject
{
  NumberListWithElements = {
    [^1] = 1,
    [^2] = 2,
    [^3] = 3,          // OK
    [^4] = 4,
    [^5] = 5,
  }
};
 
// La liste contient:
// |  Indexes: | 0 | 1 | 2 | 3 | 4 |
// |   Values: | 5 | 4 | 3 | 2 | 1 |

En revanche si la liste ne contient pas d’éléments au préalable, les index n’existent pas et une exception de type OutOfRange est lancée:

var containingObject = new ContainingObject
{
  NumberList =
  {
    [^1] = 1,
    [^2] = 2,
    [^3] = 3,        // ATTENTION: OutOfRange exception
    [^4] = 4,
    [^5] = 5,
  }
};

A cause de cette restriction, cette fonctionnalité paraît peu utile et peut facilement amener à des erreurs lors de l’exécution.

Nouvelle séquence d’échappement \e

Pour représenter le caractère Unicode d’échappement escape, on devait utiliser \u001b ou \x1b:

  • \u001b: dans les chaînes de caractères, \u est utilisé pour permettre d’écrire un caractère Unicode quelconque avec la syntaxe: \u<code du caractère sur 4 octets>.
    Ainsi pour écrire le caractère Unicode escape correspondant au code 1b, on utilise le code 001b sur 4 octets.
  • \x1b: \x est utilisé pour permettre un caractère Unicode quelconque dont le code contient un nombre variable d’octets avec la syntaxe: \x<code du caractère avec un nombre variable d'octets>.
    On peut utiliser \x pour représenter des caractères avec un code sur:

    • 2 octets: par exemple \xe7 correspondant au code e7 (qui est le caractère ç);
    • 3 octets: par exemple \x3b2 correspondant au code 3b2 (qui est le caractère β);
    • 4 octets: par exemple \x24d4 correspondant au code 24d4 (qui est le caractère ⓔ).

Voir Unicode en 5 min pour plus de détails sur les caractères Unicode.

Avant C# 13, pour représenter le caractère Unicode escape avec \x il faudrait utiliser \x1b or cette syntaxe prête à confusion. Par exemple \x1b44 peut être considéré comme:

  • Le caractère avec le code Unicode U+001B suivi de "44" ou
  • Le caractère avec le code Unicode U+1B44.

Pour éviter cette confusion, C# 13 introduit \e correspondant au caractère d’échappement escape.

Par exemple, la séquence [32m est un code d’échappement ANSI utilisé pour changer la couleur du texte dans les terminaux qui prennent en charge les séquences d’échappement ANSI. Plus précisément, 32m définit la couleur du texte en vert. De même, [0m est utilisé pour réinitialiser la mise en forme du texte aux paramètres par défaut.
Si on utilise \u001b pour échapper le caractère escape alors on peut écrire:

string text = "\u001b[32mTexte affiché en vert\u001b[0m";
Console.WriteLine(text);

Avec la nouvelle séquence d’échappement \e, on peut désormais écrire plus simplement:

string text = "\e[32mTexte affiché en vert\e[0m";
Console.WriteLine(text);

L’affichage de ce code dans un terminal qui prend en charge les séquences d’échappement ANSI affichera le texte:

Texte affiché en vert

Autres fonctionnalités

Les autres fonctionnalités sont traitées dans d’autres articles:

Améliorations concernant les variables ref et les ref struct (C# 13)

Cet article fait partie d’une série d’articles sur les nouveautés fonctionnelles de C# 13.

La version C# 13 introduit plusieurs améliorations concernant les ref struct et les variables de référence ref:

  • Les ref struct ont désormais la capacité d’implémenter des interfaces,
  • Une nouvelle contrainte de type générique permet l’utilisation d’objets ref struct,
  • Les variables de référence ref peuvent être implémentées dans les méthodes avec yield return (itérateurs) ainsi que dans les méthodes async,
  • L’utilisation d’unsafe est maintenant autorisée dans les méthodes employant yield et les méthodes async.
Rappels importants sur les ref struct

Le tas managé (managed heap) héberge les objets de type référence (tels que les classes), tandis que la pile (stack) stocke généralement les objets de type valeur (comme les struct). La manipulation d’objets de type référence s’effectue via des références vers ces objets. En revanche, pour les objets de type valeur, leur manipulation peut entraîner des copies par valeur selon la méthode employée.

Bien qu’une structure soit un objet de type valeur habituellement stockée dans la pile (stack), certaines situations particulières modifient ce comportement:

  • Une structure déclarée comme objet statique sera allouée dans un tas spécifique (loader heap ou high frequency heap).
  • Lorsqu’une structure constitue un membre d’un objet de type référence, son stockage s’effectue dans le tas managé (managed heap).
  • Le mécanisme de boxing peut également conduire au stockage d’une structure dans le tas managé.

La déclaration ref struct (disponible depuis C# 7.2) permet de créer une struct exclusivement utilisable dans la pile. Cette déclaration impose des restrictions supplémentaires à une struct afin de garantir son stockage permanent dans la pile. Ces restrictions entraînent toutefois certaines limitations d’utilisation:

  • L’impossibilité d’utiliser une ref struct au sein d’un tableau.
  • L’interdiction pour une ref struct d’être membre d’une classe.
  • L’impossibilité d’utiliser une ref struct comme argument dans une expression lambda.

Depuis C# 13/.NET 9, plusieurs limitations existantes depuis l’apparition des ref struct (C# 7.2) ont été levées:

  • Une ref struct peut désormais satisfaire une interface
  • Une ref struct peut servir d’argument pour un type générique
  • L’utilisation de ref struct dans une méthode async et dans un itérateur est maintenant autorisée.

L’utilisation de ref struct vise l’amélioration des performances en favorisant la manipulation de l’objet dans la pile plutôt que dans le tas managé. De plus, lors de la manipulation d’une ref struct, l’emploi de variables de référence ref est recommandé pour éviter les copies par valeur:

  • Passage en paramètre de méthode:
    Soit la ref struct:

    ref struct RefStruct { }
    

    et la méthode:

    private void PassByRef(ref RefStruct refStruct)
    {
    
    }
    

    Le passage en paramètre s’écrit:

    var refStruct = new RefStruct();
    PassByRef(ref refStruct);
    
  • Retour de fonction:
    Soit la méthode:

    private ref RefStruct ReturnByRef(ref RefStruct refStruct)
    {
      return ref refStruct;
    }
    

    L’appel peut s’effectuer ainsi:

    ref RefStruct returnedRef = ref ReturnByRef(ref refStruct);
    
  • Manipulation de variable locale:
    var refStruct = new RefStruct();
    ref RefStruct otherRefStruct = ref refStruct;
    

Implémentation d’interfaces par les ref struct

Depuis C# 13, les objets ref struct peuvent implémenter des interfaces, mais la conversion (cast) d’une variable de type ref struct vers une interface demeure impossible. Cette restriction persiste pour empêcher le boxing.

Considérons l’interface suivante:

internal interface IRectangle
{
  int Length { get; set; }
  int Width { get; set; }
  int GetArea();
}

L’implémentation dans une ref struct est possible:

internal ref struct Rectangle : IRectangle
{
  public int Length { get; set; }
  public int Width { get; set; }

  public Rectangle(int length, int width)
  {
    Length = length;
    Width = width;
  }

  public int GetArea()
  {
    return Length * Width;
  }
}

Cependant, le cast reste interdit:

Rectangle rect = new Rectangle(10, 20);
IRectangle iRect = rect;   // KO

De manière similaire, le passage d’une ref struct en argument de fonction sous la forme d’une interface n’est pas autorisé, par exemple si on considère une fonction comme celle-ci:

private int CalculateArea(IRectangle rectangle)
{
  return rectangle.GetArea();
}

L’appel avec une ref struct échoue:

var rectangle = new Rectangle(3, 4);
int area = CalculateArea(rectangle); // KO

Comme mentionné précédemment, cette restriction s’explique par le fait que la conversion vers l’interface nécessite du boxing pour stocker l’objet dans le tas managé. Cette opération est impossible puisque les ref struct doivent exclusivement résider dans la pile. Cette limitation concerne uniquement les ref struct et ne s’applique pas aux struct classiques.

Par exemple avec la struct:

internal struct RectangleStruct : IRectangle
{
  // Même contenu que Rectangle
  // ...
}

Le code suivant fonctionne:

var rectangleStruct = new RectangleStruct();
IRectangle rect = rectangleStruct;
int area = rect.GetArea();

L’examen du code MSIL correspondant révèle l’exécution d’une opération de boxing:

IL_0000: nop
// RectangleStruct rectangleStruct = new RectangleStruct(3, 4);
IL_0001: ldloca.s 0
IL_0003: ldc.i4.3
IL_0004: ldc.i4.4
IL_0005: call instance void CS13.RectangleStruct::.ctor(int32, int32)
// IRectangle rectangle = rectangleStruct;
IL_000a: ldloc.0
IL_000b: box CS13.RectangleStruct
IL_0010: stloc.1
// int area = rectangle.GetArea();
IL_0011: ldloc.1
IL_0012: callvirt instance int32 CS13.IRectangle::GetArea()
IL_0017: stloc.2

Nouvelle contrainte de type générique pour les objets ref struct

Une contrainte existante pour les objets génériques permet d’exiger que le paramètre générique soit une struct:

public class ObjectExample<T> where T: struct
{
  // ...
}

Cette contrainte seule n’autorise que l’utilisation de paramètres génériques de type struct, excluant les ref struct. Pour inclure les ref struct, il faut employer la nouvelle contrainte allows ref struct. La documentation (cf. learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13#allows-ref-struct) qualifie cette contrainte d’anti-contrainte, car elle n’ajoute pas de limitation supplémentaire sur le type du paramètre générique mais élargit plutôt les types acceptables.

Si on ajoute allows ref struct à l’objet précédent:

public class ObjectExample<T> where T: struct, allows ref struct
{
  // ...
}

En présence de contraintes multiples :

  • allows ref struct doit impérativement apparaître en dernière position dans la liste des contraintes.
  • allows ref struct est incompatible avec la contrainte class (imposant que le type soit une classe).

Exemple d’utilisation:

internal ref struct ShapeAreaCalculator<T> where T: IRectangle, allows ref struct 
{
  private readonly T innerShape;
  public ShapeAreaCalculator(T shape)
  {
    innerShape = shape;
  }
  public int CalculateArea()
  {
    return innerShape.GetArea();
  }
}

Bien-que la conversion de ref struct vers une interface reste impossible (comme vu précédemment), l’utilisation de la contrainte where T: IRectangle est autorisée:

Rectangle rect = new Rectangle(10, 20);
var shapeAreaCalculator = new ShapeAreaCalculator<Rectangle>(rect);
int area = shapeAreaCalculator.CalculateArea();

L’ajout de cette contrainte a permis à Microsoft d’étendre significativement la compatibilité des différents objets du framework .NET avec les ref struct. Par exemple, avant C# 13, l’utilisation de ref struct avec Func<T> était impossible :

Func<Rectangle, int> getShapeArea = (shape) => shape.GetArea();   // Possible à partir de C# 13

Microsoft a intégré la contrainte allows ref struct dans de nombreux objets du framework, notamment pour les Func<T>: src/libraries/System.Private.CoreLib/src/System/Function.cs#L9.

L’ajout de la contrainte allows ref struct entraîne des restrictions significatives. Par exemple, l’utilisation du paramètre générique comme donnée membre d’un objet impose à cet objet d’être une ref struct. Dans l’exemple précédent, l’objet ShapeAreaCalculator ne peut être ni une struct ni une classe:

internal class ShapeAreaCalculator<T> where T : IRectangle, allows ref struct   //  KO: impossible
{
  private readonly T innerShape;

  // ...
}

Une struct simple n’est également pas autorisée:

internal struct ShapeAreaCalculator<T> where T : IRectangle, allows ref struct   //  KO: impossible
{
  private readonly T innerShape;

  // ...
}

Un avantage majeur de la contrainte allows ref struct est de permettre l’utilisation de Span<T> et ReadOnlySpan<T> comme types de paramètres génériques (Span et ReadOnlySpan étant des ref struct).

Exemple:

internal ref struct ShapeCollectionAreaCalculator<T> where T : allows ref struct
{
  private readonly T innerCollection;
  private readonly int collectionSize;
  private readonly Func<T, int, int> getItemArea;

  public ShapeCollectionAreaCalculator(T collection, int collectionSize, Func<T, int, int> getItemArea)
  {
    this.innerCollection = collection;
    this.getItemArea = getItemArea;
  }

  public int CalculateArea()
  {
    int area = 0;
    for (int i = 0; i < collectionSize; i++)
    {
      area = +getItemArea(innerCollection, i);
    }
    return area;
  }
}

Utilisation de cet objet:

Span<RectangleStruct> shapes = stackalloc RectangleStruct[10];
Func<Span<RectangleStruct>, int, int> getShapeArea = (span, index) => span[index].GetArea();
var shapeCalculator = new ShapeCollectionAreaCalculator<Span<RectangleStruct>>(shapes, 10, getShapeArea);

Variables locales ref dans les méthodes avec yield et async

Le mot-clé ref permet d’effectuer des manipulations par référence sur des objets de type valeur:

  • Pour un objet de type valeur: on manipule une référence vers cet objet (plus précisément, on utilise un objet ref pointant vers l’objet de type valeur).
  • Pour un objet de type référence: on manipule une référence de la référence vers l’objet (on utilise un objet ref pointant vers la référence de l’objet de type référence, cette dernière étant elle-même un objet de type valeur).

Davantage de détails ici : cdiese.fr/csharp7-value-type-object-by-reference-valeur-par-reference/#cs7-value_type_by_ref-sumup.

Avant C# 13.0, l’utilisation de variables locales dans une méthode employant yield ou dans une méthode async était interdite.

Variable locale ref dans une méthode avec yield

Considérons l’itérateur suivant:

internal class CustomIterator : System.Collections.IEnumerable
{
  private readonly CustomEnumerator enumerator;

  public CustomIterator(int max)
  {
    enumerator = new CustomEnumerator(max);
  }

  public System.Collections.IEnumerator GetEnumerator()
  {
    return enumerator;
  }
}

internal class CustomEnumerator : System.Collections.IEnumerator
{
  private readonly int max;
  private int current;

  public CustomEnumerator(int max)
  {
    this.max = max;
    current = -1;
  }

  public object Current => current;

  public bool MoveNext()
  {
    current++;
    return current < max;
  }

  public void Reset()
  {
    current = -1;
  }
}

Son utilisation avec yield return:

public System.Collections.IEnumerable RefLocalVariableInIterator()
{
  var customIterator = new CustomIterator(5);
  foreach (var item in customIterator)
  {
    yield return item;
  }
}

L’emploi de variables locales ref est désormais autorisé dans ce type de méthode :

public System.Collections.IEnumerable RefLocalVariableInIterator()
{
  var customIterator = new CustomIterator(5);
  int simpleSum = 0;

  // C# 13: ref in iterator local variable 
  ref int refSum = ref simpleSum;  // Variable locale déclarée par référence
  foreach (var item in customIterator)
  {
    simpleSum++;
    refSum = ref simpleSum;
    yield return item;
  }
}

Variable locale ref dans une méthode async

L’utilisation d’une variable locale ref dans une méthode async est maintenant possible, par exemple:

internal class AsyncAwaitDemo
{
  private int innerValue = 0;

  public async Task RunAsync()
  {
    int localValue = 0;
    ref int refValue = ref localValue;  // C# 13: ref local variable in async method
    ref int refInnerValue = ref innerValue;
    await Task.Delay(1000); // Asynchronously wait for 1 second
  }
}

Nouveau Lock (C# 13/.NET 9)

Cet article fait partie d’une série d’articles sur les nouveautés fonctionnelles de C# 13.

A partir de C# 13/.NET 9, un nouvel objet System.Threading.Lock est disponible pour simplifier la gestion des sections critiques dans les applications multithread. Cet article présente les différentes syntaxes possibles avec ce nouvel objet, compare ses performances avec le mot-clé lock traditionnel, et explique les raisons qui ont motivé cette nouvelle implémentation.

Pour gérer la synchronisation, éviter les problèmes d’accès concurrent dans les applications multithread, et utiliser un verrou (i.e. lock) de manière simple, on utilise traditionnellement le mot-clé lock en C#.

Syntaxe classique:

private object lockObject = new object();
//
lock (lockObject)
{
  // section critique
  ...
}

Cette solution de synchronisation est la plus simple. Elle permet de protéger une section critique en n’autorisant son exécution que par un seul thread à la fois (lock exclusif) :

  • Le 1er thread arrive au niveau du lock et vérifie si l’objet “verrou” est déjà utilisé par un autre thread.
  • Si le verrou est utilisé, il attend qu’il se libère.
  • Si le verrou n’est pas utilisé, il le verrouille et entre dans la section critique. Tous les autres threads restent bloqués tant que le thread courant n’est pas sorti de la section critique et n’a pas libéré le verrou.

L’utilisation du mot-clé lock implique:

  • Il faut un objet “verrou” dédié (comme lockObject), souvent de type object, mais n’importe quel objet de type référence convient.
  • Les performances de cette solution pour protéger une section critique sont correctes par rapport à d’autres solutions (cf. le célèbre site de Joe Albahari sur la programmation concurrente: www.albahari.com/threading/). Un ordre de grandeur souvent mentionné dans les entretiens d’embauche était 20ns (ce chiffre a probablement évolué depuis).
  • La solution technique derrière cette syntaxe repose sur Monitor.Enter/Monitor.Exit, qui utilise des éléments du système d’exploitation peu interopérables. Maintenant que .NET est multiplateforme, il était nécessaire d’améliorer la solution pour protéger simplement une section critique.

Au fil des versions de .NET, plusieurs objets permettant d’appliquer un verrou fonctionnel ont été ajoutés:

  • .NET 2.0: ReaderWriterLock permet de différencier les threads en lecture seule de ceux en écriture.
  • .NET 3.5: ReaderWriterLockSlim améliore ReaderWriterLock avec de meilleures performances.
  • .NET 4.0: introduction d’autres objets comme SemaphoreSlim (version améliorée de Semaphore, plus rapide mais ne permettant pas les locks entre processus); ManualResetEventSlim (version plus performante de ManualResetEvent) qui offre une solution pour contrôler l’attente d’un thread ; SpinLock qui permet d’appliquer un lock sans changement de contexte, utile dans les cas avec beaucoup de contention entre threads et pour minimiser la durée des locks.
    .NET 4.0 correspond également à l’introduction de TPL (Task Parallel Library) avec les objets Task dans le namespace System.Threading.Tasks, qui ont permis d’implémenter facilement des tâches asynchrones.
  • .NET 4.5: bien qu’il ne s’agisse pas de lock à proprement parler, async/await propose une solution pour attendre la fin d’un traitement de façon asynchrone.
  • .NET 5/.NET Core 1.0: pour faciliter l’utilisation d’objets partagés entre threads sans devoir implémenter explicitement des locks, des objets comme ConcurrentDictionary, ConcurrentQueue, ConcurrentBag et ConcurrentStack ont été introduits.
  • C# 7: introduction de l’objet ValueTask, un objet de type valeur plus léger et équivalent à Task.
  • C# 8: introduction des flux asynchrones (async streams) et des objets dans le namespace System.Threading.Channels pour permettre l’implémentation de cas d’utilisation plus avancés d’accès concurrents.

Avec C# 13 apparaît un nouvel objet System.Threading.Lock pour simplifier certains scénarios de synchronisation tout en conservant la sécurité et en améliorant les performances.

Syntaxe

Mot-clé lock traditionnel

Le mot-clé lock est un sucre syntaxique qui encapsule de manière concise Monitor.Enter/Monitor.Exit. Si on examine le code MSIL correspondant au code ci-dessus, la syntaxe utilisée sera équivalente à:

Monitor.Enter(lockObject);
try
{
  // Section critique
  ...
}
finally
{
  Monitor.Exit(lockObject);
}

Syntaxes possibles avec System.Threading.Lock (C# 13)

Plusieurs syntaxes sont possibles avec le nouveau type System.Threading.Lock. Lorsqu’on utilise le mot-clé lock avec le nouveau type System.Threading.Lock, c’est ce nouveau type qui sera utilisé dans le code MSIL, et non Monitor.

Mot-clé lock + System.Threading.Lock

Exemple de syntaxe avec le lock traditionnel:

private readonly Lock lockObject = new();
//
lock (lockObject)
{
  // section critique
  ...
}

Le code MSIL n’est pas équivalent à celui du mot-clé lock utilisé avec un objet de type référence classique:

// using (lockObject.EnterScope())
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
IL_0006: callvirt instance valuetype [System.Runtime]System.Threading.Lock/Scope [System.Runtime]System.Threading.Lock::EnterScope()
IL_000b: stloc.0
.try
{
  // Section critique
   // ...
  // }
  IL_0019: leave.s IL_0023
} // end .try
finally
{
  // (no C# code)
  IL_001b: ldloca.s 0
  IL_001d: call instance void [System.Runtime]System.Threading.Lock/Scope::Dispose()
  IL_0022: endfinally
} // end handler

IL_0023: ret

Comme on peut le voir, ce n’est pas Monitor.Enter() qui est utilisé mais bien la classe Lock.EnterScope().
Lock.EnterScope() permet d’obtenir un objet de type valeur Lock.Scope correspondant à une portée dans laquelle le lock sera exclusif.

Le code MSIL est équivalent à:

private readonly Lock lockObject = new();
//
var scope = lockObject.EnterScope();
try
{
  // Section critique
}
finally
{
  scope.Dispose();
}

Lock.EnterScope()

Une syntaxe équivalente à la précédente consiste à utiliser directement Lock.EnterScope() avec using :

private readonly Lock lockObject = new();
//
using (lockObject.EnterScope())
{
  // section critique
  ...
}

lockObject.EnterScope() retourne un objet de type Lock.Scope, de type valeur, qui implémente IDisposable.
Le code MSIL généré est identique à celui de lock + System.Threading.Lock (voir ci-dessus).

Lock.Enter()

lock.Enter() doit être utilisé avec try...finally:

private readonly Lock lockObject = new();
//
lockObject.Enter();
try
{
  // Section critique 
  // ...
}
finally
{
  lockObject.Exit();
}

Le code MSIL est identique au code C#:

// lockObject.Enter();
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
IL_0006: callvirt instance void [System.Runtime]System.Threading.Lock::Enter()
.try
{
  // Section critique
   // ...
  // }
  IL_0018: leave.s IL_0026
} // end .try
finally
{
  // lockObject.Exit();
  IL_001a: ldarg.0
  IL_001b: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
  IL_0020: callvirt instance void [System.Runtime]System.Threading.Lock::Exit()
  // }
  IL_0025: endfinally
} // end handler

// (no C# code)
IL_0026: ret

Lock.TryEnter()

Lock.TryEnter() retourne un booléen à true s’il a été possible d’acquérir le lock exclusif, sinon false. Cette fonction ne bloque pas lors de son appel : elle renvoie immédiatement le résultat. D’autres surcharges existent pour différents cas d’utilisation, comme Lock.TryEnter(Int32) et Lock.TryEnter(TimeSpan) pour attendre un temps donné avant d’obtenir l’accès à la section critique.

La syntaxe est:

private readonly Lock lockObject = new();
//
if (lockObject.TryEnter())
{
  try
  {
    // Section critique
    // ...
  }
  finally
  {
    lockObject.Exit();
  }
}

Le MSIL ne révèle pas de surprise, il est proche du code C#:

// if (lockObject.TryEnter())
IL_0000: ldarg.0
IL_0001: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
IL_0006: callvirt instance bool [System.Runtime]System.Threading.Lock::TryEnter()
IL_000b: brfalse.s IL_0028
.try
{
  // Section critique
  //
  // }
  IL_001a: leave.s IL_0028
} // end .try
finally
{
  // lockObject.Exit();
  IL_001c: ldarg.0
  IL_001d: ldfld class [System.Runtime]System.Threading.Lock CS13.LockBenchmark::lockObject
  IL_0022: callvirt instance void [System.Runtime]System.Threading.Lock::Exit()
  // }
  IL_0027: endfinally
} // end handler

// }
IL_0028: ret

Comparaison des performances entre lock et System.Threading.Lock

Pour comparer les performances entre le lock traditionnel et System.Threading.Lock, nous allons exécuter le même code avec différentes méthodes de protection pour accéder à la section critique.

Le code de cette partie est disponible sur github.com/msoft/cs13/blob/master/CS13/NewLockObject/NewLockWithContention.cs.

Le code à exécuter correspond à la suite de Fibonacci, exécutée plusieurs fois:

internal class FibonacciRunner: IRunner
{
  private readonly int runCount;
  private readonly List<long> fibonacciRunResults;
  private readonly Random random = new();
  private int runIndex = 0;
  private readonly ILogger logger;

  public FibonacciRunner(ILogger logger, int runCount)
  {
    this.logger = logger;
    this.runCount = runCount;
    fibonacciRunResults = new List<long>();
  }

  public bool Run()
  {
    if (runIndex >= runCount)
      return false;

    int n = random.Next(1, 100);
    long result = RunFibonacciSequence(n);
    logger.Log($"Fibonacci({n}) = {result} for run #{runIndex}");

    fibonacciRunResults.Add(result);
    runIndex++;
        
    return runIndex < runCount;
  }

  private static long RunFibonacciSequence(int n)
  {
    long n1 = 0;
    long n2 = 1;
    for (int i = 0; i < n; i++)
    {
      long oldN2 = n2;
      n2 = n1 + n2;
      n1 = oldN2;
    }
    return n1;
  }
}

Nous implémentons plusieurs objets de type IRunner correspondant aux différentes méthodes pour protéger la section critique (IRunner.Run() permet d’exécuter la suite de Fibonacci) :

internal interface IRunner
{
  bool Run();
}

Par exemple pour un lock traditionnel, l’implémentation est:

internal class RunnerWithOldLock: IRunner
{
  private readonly FibonacciRunner runner;
  private object oldLock = new object();

  public RunnerWithOldLock(ILogger logger, int runCount)
  {
    runner = new FibonacciRunner(logger, runCount);
  }

  public bool Run()
  {
    lock (oldLock)
    {
      return runner.Run();
    }
  }
}

Pour provoquer de la contention lors de l’exécution du code, nous utilisons un code qui lance plusieurs tasks en parallèle :

internal class TestRunner
{
  private readonly IRunner runner;
  private readonly ILogger logger;

  public TestRunner(ILogger logger, IRunner runner)
  {
    this.logger = logger;
    this.runner = runner;
  }

  public void RunTests(int taskCount)
  {
    var tasks = new Task[taskCount];
    for (int i = 0; i < taskCount; i++)
    {
      logger.Log($"Launching task #{i}");
      Task task = new Task(() => RunJob(i));
      tasks[i] = task;
      task.Start();
    }

    Task.WaitAll(tasks);
  }

  private void RunJob(int taskId)
  {
    bool canRun = true;
    while (canRun)
    {
      logger.Log($"Task #{taskId} job starts...");
      canRun = runner.Run();
      logger.Log($"Task #{taskId} job ends (canRun: {canRun}).");
    }
  }
}

Enfin, nous implémentons les différents cas d’utilisation pour effectuer la comparaison entre:

  • UsingOldLock(): utilisation du lock traditionnel,
  • UsingLockEnterScope(): utilisation de System.Threading.Lock.EnterScope(),
  • UsingLockEnter(): utilisation de System.Threading.Lock.Enter(),
  • UsingLockTryEnter(): utilisation de System.Threading.Lock.TryEnter().

Le code est:

[Benchmark]
public void UsingOldLock()
{
  var runner = new TestRunner(logger, new RunnerWithOldLock(logger, runCount));
  runner.RunTests(threadCount);
}

[Benchmark]
public void UsingLockEnterScope()
{
  var runner = new TestRunner(logger, new RunnerUsingLockEnterScope(logger, runCount));
  runner.RunTests(threadCount);
}

[Benchmark]
public void UsingLockEnter()
{
  var runner = new TestRunner(logger, new RunnerUsingLockEnter(logger, runCount));
  runner.RunTests(threadCount);
}

[Benchmark]
public void UsingLockTryEnter()
{
  var runner = new TestRunner(logger, new RunnerUsingLockTryEnter(logger, runCount));
  runner.RunTests(threadCount);
}

En répétant l’exécution de ce code plusieurs millions de fois, nous obtenons des temps qui permettent de comparer les différentes méthodes.
Résultats :

//| Method              | Mean    | Error    | StdDev   |
//|-------------------- |--------:|---------:|---------:|
//| UsingOldLock        | 1.785 s | 0.0361 s | 0.1041 s |
//| UsingLockEnterScope | 1.540 s | 0.0216 s | 0.0192 s |
//| UsingLockEnter      | 1.576 s | 0.0312 s | 0.0334 s |
//| UsingLockTryEnter   | 1.453 s | 0.0278 s | 0.0352 s |

Ainsi, on peut constater que le lock traditionnel est légèrement moins performant que les autres méthodes utilisant System.Threading.Lock (environ 16% plus lent).

Implémentation des “locks”

Pour comprendre les différences entre le lock traditionnel et System.Threading.Lock(), examinons le code source de ces deux méthodes de protection d’une section critique.

Lock traditionnel

Comme indiqué précédemment, le code généré par l’utilisation du mot-clé lock correspond à Monitor.Enter()/Monitor.Exit().

Le code de Monitor.Enter() se trouve dans Monitor.CoreCLR.cs. Cette méthode renvoie à:

[LibraryImport(RuntimeHelpers.QCall, EntryPoint = "Monitor_Enter_Slowpath")]
private static partial void Enter_Slowpath(ObjectHandleOnStack obj);

Cette méthode fait référence à du code natif:
extern "C" void QCALLTYPE Monitor_Enter_Slowpath(QCall::ObjectHandleOnStack objHandle) dans objectnative.cpp#L191.

En suivant la chaîne d’appels:

On peut remarquer plusieurs choses:

  • Le code correspondant à la gestion du lock est en code natif comme une grande partie du code du CLR,
  • Le lock utilise le SyncBlock.

SyncBlock (bloc de synchronisation)

Le mécanisme de locks en .NET utilise traditionnellement deux concepts: SyncBlock et ThinLock. Le ThinLock est utilisé lorsque l’accès à une section critique n’est autorisé qu’à un seul thread de façon incontestable. Le ThinLock est une protection plus légère et plus performante pour accéder à la section critique. Ce terme ThinLock n’apparaît pas clairement dans le code, mais on peut constater que l’utilisation d’un SyncBlock n’est pas systématique lors de l’utilisation d’un lock.

Pour appliquer un lock, le CLR utilise l’en-tête des objets en .NET pour stocker des informations dans le champ de l’index SyncBlock. Cet en-tête n’est pas fixe et peut changer selon le comportement de l’objet. De manière générale, l’en-tête contient des informations relatives à la gestion des verrous, au hashcode et à d’autres métadonnées.

En cas de lock d’un objet, le CLR stocke dans l’en-tête de l’objet des informations relatives au lock (comme l’ID du thread, l’état du lock, le nombre de boucles récursives et des informations sur les threads en attente). En l’absence de concurrence dans l’application du lock, le mécanisme de ThinLock suffit. Dans le cas contraire, c’est le mécanisme de SyncBlock qui est appliqué. Selon la concurrence entre les threads, le CLR peut décider de convertir un ThinLock en SyncBlock pour gérer la synchronisation.

Justifications à la nouvelle implémentation de locks

Comme nous l’avons vu, même si l’implémentation traditionnelle comporte des optimisations, elle utilise n’importe quel objet de type référence comme objet contenant les informations concernant le thread qui accède à la section critique. Ces informations sont stockées dans la partie SyncBlock de l’objet. Bien que l’objectif initial était de permettre une certaine flexibilité, cette approche rend plus difficile l’implémentation d’optimisations selon les différents cas d’utilisation, car elle empêche d’utiliser un objet précis et spécialisé pour assurer le verrou et stocker les informations du lock.

Dans cette logique, l’utilisation de la classe System.Threading.Lock permet, par exemple, d’appliquer le pattern Dispose pour délimiter une section critique. De la même façon, la classe System.Threading.Lock permet d’utiliser directement des fonctions comme TryEnter() pour éviter d’attendre si le lock est déjà pris par un autre thread. Ce cas d’utilisation existait déjà avant C# 13 avec Monitor.TryEnter(), mais il nécessitait d’utiliser un autre objet.

Dans le code du CLR, la classe System.Threading.Lock existait avant C# 13, à partir de .NET Core 5.0, dans Microsoft.Internal :

internal sealed class Lock: IDisposable
{
  // ...
}

La justification de cette classe reposait sur la volonté de permettre des optimisations pour éviter d’utiliser des mécanismes trop coûteux en performance pour effectuer des locks.
Maintenant, la classe System.Threading.Lock se trouve dans System.Threading. Elle permet de stocker les informations liées au thread ayant pris le lock plutôt que d’utiliser la partie SyncBlock.
Si on examine la méthode Lock.Enter(), on peut voir qu’il existe plusieurs possibilités d’optimisation dans Lock.TryEnter_Inlined() puis Lock.State.TryLock().

L’implémentation à proprement parler du lock se trouve dans Lock.TryEnterSlow().

Conclusion

L’introduction de System.Threading.Lock en C# 13 marque une évolution importante dans la gestion de la synchronisation en .NET. Avec un gain de performance non négligeable par rapport au mot-clé lock traditionnel et une API plus riche offrant notamment TryEnter().

Pour les nouveaux projets, il est recommandé d’utiliser System.Threading.Lock plutôt que le lock traditionnel, tout en gardant à l’esprit que la migration du code existant n’est pas urgente. Le mot-clé lock classique reste parfaitement fonctionnel et continuera d’être maintenu. Cette évolution s’inscrit dans la continuité des améliorations apportées aux mécanismes de synchronisation depuis .NET 2.0, avec une implémentation plus moderne qui facilite les optimisations futures du runtime.

Les fonctionnalités C# 12

Etienne Girardet

Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 12.0. Dans un premier temps, on explicitera le contexte de C# 12.0 par rapport aux autres composants (frameworks, IDE, compilateur etc…) qui permettent de l’utiliser. Ensuite, on rentrera dans le détail des fonctionnalités.

Précisions sur les versions de C#

Depuis C# 8.0, les évolutions fonctionnelles de C# se font pour .NET seulement (anciennement appelé .NET Core). Le framework .NET est toujours supporté toutefois les nouvelles fonctionnalités ne sont pas implémentées pour cet environnement.
Comme les environnements du framework .NET et de .NET ne subsistent plus en parallèle, l’approche .NET Standard n’a plus d’intérêt. .NET Standard s’arrête donc à la version 2.1. Les versions 5.0, 6.0, 7.0, 8.0 et 9.0 de .NET implémentent .NET Standard de la version 1.0 à 2.1 toutefois il est conseillé de cibler une version de .NET plutôt que .NET Standard.

Chronologie des releases

Ce tableau permet de résumer les dates de sorties de C# 12.0, de Visual Studio, du compilateur Roslyn et des versions .NET.

Date Version C# Version Visual Studio Version .NET Compilateur
Novembre 2020 C# 9.0 VS2019 (16.8) .NET 5.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 3.8.0
Février 2021 VS2019 (16.9) Roslyn 3.9.0
Mai 2021 VS2019 (16.10) Roslyn 3.10.0
Août 2021 VS2019 (16.11)
Novembre 2021 C# 10.0 VS2022 (17.0) .NET 6.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 4.0.1
Décembre 2021 Roslyn 4.1.0
Février 2022 VS2022 (17.1)
Avril 2022 Roslyn 4.2.0
Mai 2022 VS2022 (17.2)
Août 2022 VS2022 (17.3) Roslyn 4.3.1
Novembre 2022 C# 11.0 VS2022 (17.4) .NET 7.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 4.4.0
Février 2023 VS2022 (17.5) Roslyn 4.5.0
Mai 2023 VS2022 (17.6) Roslyn 4.6.0
Août 2023 VS2022 (17.7) Roslyn 4.7.0
Novembre 2023 C# 12.0 VS2022 (17.8) .NET 8.0
(NET Standard 1.0⇒2.1)(1)
Roslyn 4.8.0
Février 2024 VS2022 (17.9) Roslyn 4.9.2
Mai 2024 VS2022 (17.10) Roslyn 4.10.0
Août 2024 VS2022 (17.11) Roslyn 4.11.0
Novembre 2024 C# 13.0 VS2022 (17.12) .NET 9.0 Roslyn 4.12.0
Février 2025 VS2022 (17.13) Roslyn 4.13.0
Mai 2025 VS2022 (17.14) Roslyn 4.14.0
Novembre 2025 ? C# 14.0 VS2022 (17.?) .NET 10.0 Roslyn 4.?.?

(1): .NET Standard n’est plus nécessaire puisque les 2 environnements framework .NET et .NET Core n’évoluent plus fonctionnellement. Ils ont laissé place à l’environnement uniformisé .NET (voir .NET 5+ and .NET Standard pour plus de détails).

Lien entre la version C# et le compilateur

Le tableau précédent permet d’indiquer la version de C# dans le contexte des frameworks de façon à avoir une idée des sorties des autres éléments de l’environnement .NET. Toutefois, la version de C# est liée à la version du compilateur C#. Le compilateur est ensuite livré avec Visual Studio (depuis Visual Studio 2017 15.3) ou avec le SDK .NET.

Le chemin du compilateur est lié au composant avec lequel il est livré:

  • Avec Visual Studio: par exemple pour Visual Studio 2022 Professional: C:\Program Files (x86)\Microsoft Visual Studio\2022\Professional\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec les Build tools: par exemple pour les Build Tools for Visual Studio 2022: C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec le SDK .NET:
    • Sur Linux: /usr/share/dotnet/sdk/<version>/Roslyn/bincore/csc.dll
    • Sur Windows: C:\Program Files\dotnet\sdk\<version>\Roslyn\bincore\csc.dll

On peut connaître la version du compilateur en tapant:

csc -help

On peut savoir quelles sont les versions de C# que le compilateur peut gérer en exécutant:

csc -langversion:? 

Limiter la version C# à compiler

Par défaut, le compilateur compile dans les versions suivantes de C#:

  • .NET 9.0: C# 13.0
  • .NET 8.0: C# 12.0
  • .NET 7.0: C# 11.0
  • .NET 6.0: C# 10.0
  • .NET 5.0: C# 9.0
  • Framework .NET: C# 7.3
  • .NET Core 3.x: C# 8.0
  • .NET Core 2.x: C# 7.3
  • .NET Standard 2.1: C# 8.0
  • .NET Standard 2.0: C# 7.3
  • .NET Standard 1.x: C# 7.3

On peut volontairement limiter la version C# que le compilateur va traiter:

  • Dans Visual Studio: dans les propriétés du projet ⇒ Onglet Build ⇒ Advanced ⇒ Paramètre Language version.
  • En éditant directement le fichier .csproj du projet et en indiquant la version avec le paramètre LangVersion:
    <Project Sdk="Microsoft.NET.Sdk"> 
      <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFramework>net8.0</TargetFramework> 
        <LangVersion>12.0</LangVersion>
      </PropertyGroup> 
    </Project> 
    

Fonctionnalités C# 12

Les fonctionnalités les plus basiques de C# 12.0 sont présentées dans cet article. Les inline arrays sont présentés dans un article séparé.

Primary constructors

Cette fonctionnalité permet en une seule ligne de:

  • Créer un constructeur implicite,
  • D’ajouter des données membres à une classe, une struct ou un record,
  • D’initialiser ces données membres.

Ainsi, les 2 extraits de code suivants sont équivalents:

public class Rectangle(int length, int width);

Est équivalent à:

public class Rectangle
{
  private int length;
  private int width;
  
  public Rectangle(int length, int width)
  {
    this.length = length;
    this.width = width;
  }
}

Lorsqu’un constructeur primaire est déclaré, cela entraîne quelques implications suivant le type d’objet:

  • Pour une classe: il n’y a plus de constructeur implicite sans paramètres.
    Ainsi:

    var rectangle = new Rectangle();  // ATTENTION: génère une erreur
    
  • Pour une struct: un constructeur sans paramètre implicite est rajouté à la compilation. Ce constructeur sans paramètre initialise toutes les données membres.
    Par exemple, si on déclare une struct de cette façon:

    public struct Circle(int radius);
    

    Un constructeur sans paramètre implicite est créé:

    var circle = new Circle();  // OK
    
  • Pour un record: un accesseur en lecture est rajouté implicitement pour chaque donnée membre.
    Ainsi:

    public record Car(string Brand, string Model);
    

    Est équivalent à:

    public record Car
    {
      public Car(string brand, string model)
      {
        this.Brand = brand;
        this.Model = model;
      }
    
      public string Brand { get; }
      public string Model { get; }
    }
    

Pour plus de détails sur les records: cdiese.fr/csharp9_records/.

Collection expressions

L’objectif de cette fonctionnalité est de faciliter l’instanciation de collections en permettant 2 nouvelles écritures syntaxiques:

  • L’instanciation d’une collection directement avec les éléments entre crochets.
  • Opérateur spread.

Instancier une collection directement avec les éléments entre crochets

Par exemple, on peut désormais instancier un liste de cette façon:

List<int> items1 = [1, 2, 3, 4];

Cette syntaxe a plusieurs conséquences:

  • La syntaxe [ ] permet de créer des collections de type différent. Ainsi le type de la collection créée ne peut être déterminé que par ce qui est indiqué devant la variable (dans l’exemple List<int>). Cela signifie qu’il n’est pas possible d’utiliser l’opérateur var:
    var items1 = [1, 2, 3, 4]; // NE COMPILE PAS
    
  • Il est possible de créer d’autres types de collection en utilisant la même syntaxe:
    List<int> collection1 = [1, 2, 3, 4];
    int[] collection2 = [1, 2, 3, 4];
    Span<int> collection3 = [1, 2, 3, 4];
    IEnumerable<int> collection4 = [1, 2, 3, 4];
    ICollection<int> collection5 = [1, 2, 3, 4];
    

On peut aussi créer des tableaux en 2 dimensions en indiquant des tableaux entre crochets:

int[] array1 = [1, 2, 3, 4];
int[] array2 = [5, 6, 7, 8];
int[] array3 = [8, 9, 10, 11];
int[][] twoDimArray = [array1, array2, array3];

Le contenu de twoDimArray est:

[[1, 2,  3,  4]
 [5, 6,  7,  8]
 [8, 9, 10, 11]]

Cette syntaxe n’est possible que pour les tableaux, elle ne permet pas de créer d’autres types de collection.
Pour créer une autre collection à 1 dimension à partir d’une collection existante avec la syntaxe [ ], il faut utiliser l’opérateur spread introduit aussi avec C# 12.

Opérateur spread

Cet opérateur permet d’instancier une collection à partir d’un énumérable en utilisant la syntaxe:

[.. <Enumerable>]

Comme précédemment, la syntaxe de la nouvelle collection est déterminée par le type indiqué avant le nom de la variable:
Si on considère:

int[] array1 = [1, 2, 3, 4];

On peut écrire:

List<int> collection1 = [..array1];
int[] collection2 = [..array1];
Span<int> collection3 = [..array1];
IEnumerable<int> collection4 = [..array1];
ICollection<int> collection5 = [..array1];

Si on utilise directement la syntaxe [ ] sans indiquer explicitement le type, il y aura une erreur à la compilation:

foreach (var element in [..array1])   // NE COMPILE PAS
{   }

Il faut indiquer le type explicitement:

foreach (var element in (List<int>)[..array1])
{   }

L’opérateur spread énumère l’enumerable qui se trouve derrière les .., on peut donc écrire:

int[] array1 = [1, 2, 3, 4];
int[] array2 = [5, 6, 7, 8];
List<int> collection1 = [..array1, ..array2];

Le contenu de collection1 sera:

1, 2, 3, 4, 5, 6, 7, 8
Ne pas confondre l’opérateur spread (C# 12) et l’opérateur range (C# 11)

Avant C# 12, il existait déjà une notation .. qui correspond à l’opérateur range (introduit en C# 8) qui permet d’énumérer le contenu d’une collection avec une syntaxe:

<Index> .. <Index>

L’opérateur range permet d’instancier un objet de type System.Range qui servira à effectuer une énumération, par exemple:

List<int> example = new List<int>{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
var sample1 = example[2..5];    // Intervalle de 2 à 5 exclus: 2, 3, 4
var sample2 = example[2..];     // Intervalle de 0 à la fin: 2, 3, 4, 5, 6, 7, 8, 9
var sample3 = example[..5];     // Intervalle de 0 à 5 exclus: 0, 1, 2, 3, 4
var sample4 = example[..^3];    // Intervalle de 0 jusqu'à 3 index avant la fin: 0, 1, 2, 3, 4, 5, 6
var sample5 = example[..];      // Intervalle de 0 à la fin: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9

Il existe une autre notation utilisant aussi .. qui correspond au range pattern dans le cadre du pattern matching (à partir de C# 11). Voir List pattern dans cdiese.fr/cheat-sheet-pattern-matching/.

ref readonly en argument de méthode

Cette fonctionnalité est plus avancée que les précédentes et nécessite la compréhension de plusieurs notions pour être appréhendée. Elle consiste à permettre d’utiliser ref readonly pour les paramètres d’une méthode. Le modificateur ref readonly existait déjà quand on déclarait une variable depuis C# 7.2 mais il n’était pas possible de l’utiliser en paramètre de méthode (on ne pouvait utiliser que ref). Depuis C# 7.2, il existait l’opérateur in en paramètre de méthode qui assurait à peu près la même fonctionnalité.

Pour comprendre cette fonctionnalité, il est nécessaire de rappeler quelques caractéristiques de C#:

  • En C#, on considère les objets de type référence (classe et record) et les objets de type valeur (struct, record struct, enum, type primitif comme int, floatw, double, byte etc…).
  • Les objets de type référence sont manipulés par référence lors des copies de variable ou lors des passages en argument de méthode. Une référence est un objet de type valeur. Ainsi la référence est copiée mais l’objet référencé n’est pas copié.
  • Les objets de type valeur sont manipulés en effectuant des copies lors des copies de variable ou lors des passages en argument de méthode. L’objet est copié entièrement lors de ces manipulations.

Avant C# 12

Le mot-clé ref permet d’effectuer des manipulations d’objets de type valeur par référence:

  • Pour un objet de type valeur, on manipule une référence vers cet objet (plus précisément on utilise un objet ref qui pointe vers l’objet de type valeur).
  • Pour un objet de type référence, on manipule une référence de la référence vers l’objet (on utilise un objet ref qui pointe vers la référence de l’objet de type référence, la référence d’un objet de type référence étant elle-même un objet de type valeur).

Ainsi:

Syntaxe Remarques
Passage en argument
par référence
(ref)
void MethodName(ref <type> argument)
{ ... }
Par exemple:

private static void ChangeRadius(int newRadius, 
    ref Circle circle)
{
  circle.Radius = newRadius;
}
Manipulation
d’une variable locale
(ref)
ref var refVariable = 
  ref <value variable>;
Par exemple:

Circle circle = new Circle();
ref Circle circleRef = ref circle;

Réaffectation d’une référence:

// NE PAS OUBLIER ref
<variable par référence 2> = 
  ref <variable par référence 1> 

Par exemple:

Circle firstCircle = new Circle ...
// ...
// Affectation d’une référence
secondCircleRef = ref firstCircleRef; 
// Affectation par valeur, une copie est effectuée
secondCircleRef = firstCircleRef; 
Manipulation
d’une variable locale
en lecture seule
(ref readonly)
ref readonly var refVariable = 
  ref <value variable>;
Toutes les variables ref peuvent être affectées en readonly.
L’affectation d’un membre sur une variable ref en lecture seule n’est pas possible.

Par exemple:

Circle circle = new Circle { Radius = 2; }
// Référence en lecture seule à partir 
// d’un objet de type valeur
ref readonly readOnlyCircleRef = ref circle; 
readOnlyCircleRef.Radius = 4; // ERREUR

// Référence en lecture/écriture
ref var circleRef = ref circle; 
// Référence en lecture seule à partir 
// d’une autre référence
ref readonly otherReadOnlyCircleRef = ref circleRef; 

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Retour de fonction
par référence
(return ref)
ref <type> FunctionName()
{
  // ...
  return ref <variable>;
}
La variable retournée par référence doit être accessible à l’extérieur de la stack frame correspondant à la fonction, il peut s’agir:

  • D’un membre d’un objet de type référence:
    class CircleWrapper
    {
      private Circle InnerCircle = 
        new Circle { Radius = 2 };
    
      public ref Circle GetCircle()
      {
        return ref this.InnerCircle; // OK l’objet 
        // est membre d’un objet de type référence
      }
    }
  • D’un objet de type valeur statique:
    static Circle staticCircle = new Circle { Radius = 2};
    ref Circle GetCircle()
    {
      return ref staticCircle; // OK l’objet retourné 
      // est statique
    }
  • D’un objet stocké dans un tableau:
    ref Circle FindCircle(Circle[] circles, 
      int circleIndex)
    {
      return ref circles[circleIndex]; // OK l’objet 
      // appartient à un tableau
    }

Par contre, on ne peut pas retourner une variable locale d’une fonction:

ref Circle GetCircle()
{
  Circle localCircle = new Circle { Radius = 4 };
  return ref localCircle; // ERREUR, l’objet est perdu 
  // à la sortie de la stack frame
}
Retour de fonction
par référence
en lecture seule
(return ref)
ref readonly <type> FunctionName()
{
  // ...
  return ref <variable>;
}
Même restriction que pour un retour de fonction par référence simple.
Un membre en readonly doit obligatoirement être retourné en readonly:

class CircleWrapper
{
  private readonly Circle InnerCircle = 
    new Circle { Radius = 2 };

  // OK retour en readonly
  public readonly ref Circle GetReadOnlyCircle() 
  {
    return ref this.InnerCircle;
  }

  // ERREUR le retour doit être en readonly
  public ref Circle GetCircle()
  {
    return ref this.InnerCircle;
  }
}

L’affectation dans une variable d’une fonction en readonly doit obligatoirement être en readonly.

private static Circle staticCircle = 
  new Circle { Radius = 2};
public static ref readonly Circle GetCircle()
{
  return ref staticCircle;
}

// ...
ref readonly var readOnlyCircle = ref GetCircle(); // OK
ref var readOnlyCircle = ref GetCircle(); // ERREUR

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Indiquer qu’un argument
de méthode est en
lecture seule avec in
void MethodName(in <type> argument) 
{ ... }
L’affectation d’un membre d’un argument avec in n’est pas possible.
Par exemple:

struct Circle
{
  public int Radius;

  public void SetRadiusFromInside(int newRadius)
  {
    this.Radius = newRadius;
  }
}

static void ChangeRadiusFromOutside(in Circle circle, 
  int newRadius)
{
  // ERREUR à cause de in
  circle.Radius = newRadius; 

  // Pas d’erreur mais la valeur n’est pas modifiée 
  // à cause de la defensive copy
  circle.SetRadiusFromInside(newRadius); 
  // ATTENTION: il faut éviter de modifier 
  // la valeur de l’argument dans le corps de la méthode
}

ATTENTION: Pour éviter les defensive copies, il est préférable que la structure soit immutable (cf. readonly struct).

Pour plus de détails, voir Type valeur vs type référence.

A partir de C# 12

Il est possible d’utiliser ref readonly en argument de méthode pour éviter les modifications d’un objet à l’intérieur de la méthode.

Par exemple si on considère:

struct Circle
{
  public int Radius;

  public void SetRadiusFromInside(int newRadius)
  {
    this.Radius = newRadius;
  }
}

static void ChangeRadiusFromOutside(ref Circle circle, int newRadius)
{
  circle.Radius = newRadius; 
}

// circle peut être modifié dans la méthode ChangeRadiusFromOutside():
var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadiusFromOutside(ref circle, 2); // circle est passé par référence 
Console.WriteLine(circle.Radius); // 2

Pour éviter de modifier circle dans ChangeRadiusFromOutside(), on peut utiliser ref readonly:

static void ChangeRadiusFromOutside(ref readonly Circle circle, int newRadius) 
{ 
  // circle.Radius = newRadius; // ERREUR car on ne peut pas modifier circle dans le corps de la méthode 
  circle.SetNewRadius(newRadius); // Pas d'erreur mais cette ligne n'aura pas d'effets
  Console.WriteLine(circle.Radius);  
}

Si on effectue l’appel suivant:

var circle = new Circle{ Radius = 4 }; 
Console.WriteLine(circle.Radius); // 4 
ChangeRadiusFromOutside(ref circle, 2); // circle est passé par référence 

// Dans le corps de ChangeRadiusFromOutside(), Console.WriteLine(circle.Radius) affiche 4 

Avec ref readonly, l’argument circle est passé par référence toutefois le compilateur effectue des optimisations en sachant que circle ne peut être modifié dans le corps de la méthode. circle.SetNewRadius() ne modifie pas l’objet circle. Le résultat est toujours 4 quand on essaie de modifier circle à l’intérieur de ChangeRadiusFromOutside().
La valeur de Radius n’est pas modifiée à cause d’une defensive copy effectuée par le compilateur pour éviter que la structure ne soit modifiée dans le corps de ChangeRadiusFromOutside(). Cette copie peut être couteuse en performance, c’est pourquoi il est préférable d’utiliser ref readonly avec des objets immutables comme readonly struct.

Utiliser ref readonly avec des objets de type valeur mutables est plus couteux en performance

Il existe une différence entre utiliser ref et ref readonly avec des structures mutables. Pour garantir que la structure mutable déclarée avec ref readonly n’est pas modifiée, le compilateur effectue une copie par valeur de la structure pour chaque déclaration d’une variable ref readonly (i.e. defensive copy). Cette copie est effectuée si la structure est mutable (c’est-à-dire qu’elle n’est pas déclarée avec readonly struct).
La copie de la structure peut être évitée si la structure est immutable en la déclarant avec readonly struct. Dans ce cas, le compilateur effectue des optimisations en évitant d’effectuer des copies par valeur à chaque déclaration d’une variable ref readonly.

Par exemple, la structure Circle est mutable:

var circle = new Circle();
ref readonly var circleRef = ref circle; // Une copie est effectuée

Si Circle est immutable:

readonly struct ImmutableCircle
{
  public readonly int Radius { get; }

  public ImmutableCircle(int radius)
  {
    Radius = radius;
  }

  void ChangeRadiusFromInside(int radius)
  {
    // this.Radius = radius;  // Provoque une erreur de compilation
  }
}

// ...
var immutableCircle = new ImmutableCircle(4);
ref readonly var circleRef = ref immutableCircle; // OK, le compilateur effectue une optimisation

ref readonly et in ont le même objectif fonctionnel, empêcher qu’un argument ne soit modifié dans le corps d’une méthode. La différence est que des messages de warning sont émis différemment par le compilateur suivant les différentes implémentations possibles.

Par exemple le code MSIL correspondant aux méthodes suivantes est identique:

public static void ChangeRadiusFromOutside(ref readonly Circle circle,
  int newRadius)
{
    circle.SetRadiusFromInside(newRadius);
}

public static void ChangeRadiusFromOutsideWithIn(in Circle circle,
  int newRadius)
{
    circle.SetRadiusFromInside(newRadius);
}

Si on effectue les appels suivants, des messages de warning différents seront émis:

var circle = new CollectionExpressions.Circle(5);

// Appels à un argument déclaré avec ref readonly 	
// L'appel suivant produit le message de warning suivant:
// warning CS9192: Argument 1 should be passed with 'ref' or 'in' keyword
CollectionExpressions.ChangeRadiusFromOutside(circle, 10);     
CollectionExpressions.ChangeRadiusFromOutside(ref circle, 10);     // Pas de warning
CollectionExpressions.ChangeRadiusFromOutside(in circle, 10);       // Pas de warning

// Appels à un argument déclaré avec in
CollectionExpressions.ChangeRadiusFromOutsideWithIn(circle, 10);       // Pas de warning
// L'appel suivant produit le message de warning suivant: 
// warning CS9191: The 'ref' modifier for argument 1 corresponding to 'in' parameter is equivalent to 'in'. 
// Consider using 'in' instead.
CollectionExpressions.ChangeRadiusFromOutsideWithIn(ref circle, 10);
CollectionExpressions.ChangeRadiusFromOutsideWithIn(in circle, 10);        // Pas de warning

Tous ces appels génèrent le même code MSIL.

Attribut “Experimental”

Cette fonctionnalité permet d’indiquer du code qui est expérimental. Si du code expérimental est utilisé, par défaut, une erreur de compilation est générée sauf si ce code est appelé par un autre code qui est aussi expérimental. Pour indiquer que du code est expérimental, il suffit d’utiliser l’attribut: System.Diagnostics.CodeAnalysis.ExperimentalAttribute

Cet attribut peut être appliqué sur une méthode, classe, structure, interface, enum, delegate, propriété ou au niveau d’une assembly entière.

Syntaxe

Le constructeur de l’attribut permet de renseigner un identifiant sous la forme d’une chaîne de caractères qui sera indiqué dans l’éventuelle erreur de compilation, par exemple si on applique cet attribut sur une classe:

[Experimental("ExperimentalClass")]
internal class ExperimentalFeature
{

}

L’identifiant ne doit pas comporter de caractères d’espacement ou des caractères spéciaux. Si l’identifiant excède 8 caractères, il sera tronqué dans l’erreur de compilation.

Si on essaie d’utiliser cette classe à partir de code qui n’est pas expérimental comme par exemple:

internal class FeatureConsumer
{
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();    // ERREUR à la compilation
    }
}

L’erreur de compilation sera:

DoNotUs'CS12.ExperimentalFeature' is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. 

Comme on peut le voir, l’identifiant DoNotUse comportant plus de 8 caractères est tronqué.

Si la classe FeatureConsumer ou la méthode ExecuteMe() comporte l’attribut Experimental, il n’y aura plus d’erreur de compilation:

[Experimental("CanUse")]
internal class FeatureConsumer
{
    //[Experimental("CanUse")]
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();    // OK
    }
}

Il n’est pas nécessaire que l’identifiant de compilation soit le même.

Par défaut, il y a un lien dans l’erreur de compilation qui renvoie la page learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/feature-version-errors?f1url=%3FappId%3Droslyn%26k%3Dk(CS9204).

Si on indique une URL dans l’attribut:

[Experimental("DoNotUse", UrlFormat = "http://microsoft.com")]

Le lien sera "http://microsoft.com" plutôt que celui par défaut.

Supprimer l’erreur de compilation

On peut supprimer l’erreur de compilation en utilisant une directive de préprocesseur:

#pragma warning disable <identifiant>

Par exemple pour l’exemple précédent:

#pragma warning disable DoNotUse
internal class FeatureConsumer
{
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();  // OK
    }
}

Lorsque le préprocesseur parcourt le code ligne par ligne, dès qu’il atteint la ligne #pragma, il ne générera plus l’erreur de compilation. Si du code se trouve avant la directive #pragma, le compilateur générera une erreur, par exemple:

internal class FeatureConsumer
{
    public void ExecuteMe()
    {
        var feature = new ExperimentalFeature();   // ERREUR de compilation
    }

#pragma warning disable DoNotUse
}

On peut restaurer la prise en compte de l’erreur de compilation avec:

#pragma warning restore <identifiant>

Par exemple:

internal class FeatureConsumer
{
    public void ExecuteMe()
    {
#pragma warning disable DoNotUse
        var feature = new ExperimentalFeature();      // OK
#pragma warning restore DoNotUse
        var feature2 = new ExperimentalFeature();     // ERREUR de compilation
    }
}

Indiquer l’attribut “Experimental” au niveau d’une assembly

On peut indiquer l’attribut Experimental à un niveau plus global au niveau d’une assembly entière en indiquant en tête d’un fichier .cs compilé (par exemple, le fichier Properties.cs):

[assembly: ExperimentalAttribute("Id")]

Autre fonctionnalité

Les “tableaux en ligne” (i.e. inline arrays) sont présentés dans un article séparé.

Références

Inline arrays (C# 12)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 12.

Cette fonctionnalité est très avancée. Elle convient à un besoin très précis d’optimisation et très peu nombreux seront les développeurs qui auront le réel besoin de s’en servir. Il est probable que Microsoft a eu un besoin d’optimisation dans le cadre du framework et a été amené à effectuer cette optimisation.

Inline arrays (littéralement “tableaux en ligne”) qu’on appellera par la suite “tableau inline”, permet de créer des tableaux à taille fixe d’un type struct. Ces tableaux permettent, par exemple, de créer un buffer en ligne aux caractéristiques similaires à un buffer non managé à taille fixe. L’intérêt de ce type de buffer est d’améliorer le temps d’accès aux éléments dans le buffer.

Par exemple, si une liste classique List<int> est un objet de type référence stocké dans le tas managé, accessible par l’intermédiaire d’une référence. Les objets stockés sont des entiers Int32 qui sont des objets de type valeur toutefois comme la liste est un objet de type référence, les entiers seront stockés aussi dans le tas managé comme la liste. L’accès aux éléments de la liste est complexe puisqu’il se fait par référence et dans le tas managé ce qui rend cette accès plus lent. D’autre part, le stockage dans le tas managé par l’intermédiaire de références ne permet pas d’avoir des zones continues en mémoire ce qui rend plus complexe de partager ces données avec du code natif.

L’objet Span introduit en C# 7.2 permet de corriger certains de ces problèmes (pour davantage de détails concernant Span: cdiese.fr/csharp7-ref-struct/#cs7-ref_readonly_struct-span). Span est un objet ref struct qui est donc exclusivement stocké sur la pile (voir Structure exclusivement stockée dans la pile: “ref struct” (C# 7, C# 8.0)). De la même façon que les tableaux “inline”, il donne la possibilité d’allouer des espaces de taille fixe et continus en mémoire de façon à améliorer l’accès aux données. Ce type d’objet facilite le partage de données avec du code natif puisque leur représentation en mémoire est simple. Il suffit d’indiquer au code natif l’emplacement du premier objet. Etant donné que l’espace alloué est continu, que les éléments stockés ont une taille fixe, l’accès à ces éléments est facile et prévisible.

Syntaxe

Du point de vue de la syntaxe, un tableau “inline” doit être déclaré de cette façon:

Par exemple pour déclarer un tableau “inline” d’entiers:

[InlineArray(10)]
public struct IntegerArray
{
  private int element;
}

L’attribut [InlineArray(10)] permet d’indiquer que le tableau contient 10 éléments.
Le parcours se fait en itérant sur le tableau directement:

IntegerArray integers = new IntegerArray();
for (int i = 0; i < 10; i++)
{
  integers[i] = i;
}

Pour le contenu, on peut utiliser des structures plus complexes, par exemple:

public struct Point
{
  public int X;
  public int Y;
}

[InlineArray(10)]
public struct PointInlineArray
{
  private Point point;
}

Par exemple pour y accéder:

PointInlineArray points = new PointInlineArray();
for (int i = 0; i < 10; i++)
{
  Point newPoint = new Point();
  newPoint.X = 4;
  newPoint.Y = 6;
  points[i] = newPoint;
}

Pour rendre le tableau “inline” plus générique, on peut aussi utiliser un generic:

[InlineArray(10)]
public struct PointInlineArray<T>
{
  private T element;
}

Allocation sur la pile

Comme indiqué précédemment, un des grands intérêts des tableaux “inline” est d’effectuer des allocations sur la pile ce qui permet d’augmenter les performances concernant les manipulations des éléments de cet objet.
Par exemple, si on effectue un benchmark sur le code suivant:

[MemoryDiagnoser(false)]
public class InlineArrays
{
  [Benchmark]
  public void UseInlineArray()
  {
    PointInlineArray points = new PointInlineArray();
    for (int i = 0; i < 10; i++)
    {
      Point newPoint = new Point();
      newPoint.X = 4; 
      newPoint.Y = 6;
      points[i] = newPoint;
    }
  }
}

En lançant l’exécution avec:

BenchmarkRunner.Run<InlineArrays>();

On obtient:

| Method         | Mean     | Error     | StdDev    | Allocated |
|--------------- |---------:|----------:|----------:|----------:|
| UseInlineArray | 4.362 ns | 0.1174 ns | 0.1040 ns |         - |

On peut voir qu’il n’y a pas eu d’allocations dans le tas managé (car la colonne “Allocated” est vide).

Comparaison des performances

On va juste essayer dans cette partie, de comparer les performances quant à l’accès des éléments d’un tableau “inline”. Pour cet exemple, on va se contenter d’effectuer des écritures dans le tableau à partir de code managé.

On considère 4 méthodes qui effectuent des affectations dans un tableau:

  • Affectation dans un tableau simple: Point[].
  • Affectation dans un objet de type Span<Point> instancié avec new.
  • Affectation dans un objet de type Span<Point> instancié avec stackalloc.
  • Affectation dans un tableau “inline”: PointInlineArray.

Le code avec un tableau simple Point[] est:

[Benchmark]
public void UseSimpleArray()
{
  var points = new Point[10];
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

Le code avec un objet de type Span<Point> instancié avec new est:

[Benchmark]
public void UseSpanWithNew()
{
  Span<Point> points = new Point[10];
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

Comme on l’a évoqué précédemment, l’objet Span<> propose des fonctionnalités semblables aux tableaux “inline”. L’instanciation avec new effectue un allocation dans le tas managé avant de rendre l’objet accessible dans la pile.

Le code avec un objet de type Span<Point> instancié avec stackalloc est:

[Benchmark]
public void UseSpanWithStackalloc()
{
  Span<Point> points = stackalloc Point[10];
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

La différence avec le code précédent est d’utiliser stackalloc qui permet d’effectuer l’allocation directement sur la pile ce qui rend le code plus rapide à l’exécution qu’en utilisant le tas managé.

Le code avec un tableau “inline” PointInlineArray est:

[InlineArray(10)]
public struct PointInlineArray
{
  private Point Point;
}

[Benchmark]
public void UseInlineArray()
{
  var points = new PointInlineArray();
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

En exécutant le code pour effectuer des comparaisons, on obtient:

| Method                | Mean     | Error     | StdDev    | Allocated |
|---------------------- |---------:|----------:|----------:|----------:|
| UseSimpleArray        | 8.807 ns | 0.2067 ns | 0.4268 ns |     104 B |
| UseSpanWithNew        | 8.633 ns | 0.1757 ns | 0.2887 ns |     104 B |
| UseSpanWithStackalloc | 4.030 ns | 0.1090 ns | 0.1564 ns |         - |
| UseInlineArray        | 4.400 ns | 0.1010 ns | 0.0896 ns |         - |

On peut constater que l’utilisation d’un tableau “inline” est plus performant que Span avec new mais moins performant que Span avec stackalloc. Les tableaux “inline” n’utilisent pas le tas managé comme Span avec stackalloc ce qui explique ce gain en performance. Le tableau simple utilise le tas d’où l’accès aux éléments moins bon.

L’inconvénient d’utiliser Span + stackalloc est que l’objet créé doit être utilisé exclusivement dans le corps de la méthode, il ne peut pas être retourné en résultat de fonction ou en donnée membre d’un objet. Ce n’est pas le cas pour un tableau “inline” qui est considéré dans le code comme une structure: on peut donc l’utiliser comme donnée membre ou en retour d’une fonction.

Code MSIL

Si on regarde le code MSIL pour voir les objets qui sont générés à la suite de l’utilisation d’un tableau “inline”, on peut voir ce sont des objets Span qui sont utilisés.
Par exemple pour le code correspondant aux objets précédents, on peut voir que le compilateur rajoute une classe <PrivateImplementationDetails> contenant les fonctions InlineArrayAsSpan() et InlineArrayElementRef():

L’implémentation de ces fonctions est:

  • Pour InlineArrayAsSpan():
    • Code MSIL:
      .method assembly hidebysig static valuetype [System.Runtime]System.Span`1<!!TElement> 
              InlineArrayAsSpan<TBuffer,TElement>(!!TBuffer& buffer, int32 length) cil managed
      {
        // Code size       13 (0xd)
        .maxstack  8
        IL_0000:  ldarg.0
        IL_0001:  call       !!1& [System.Runtime]System.Runtime.CompilerServices.Unsafe::As<!!0,!!1>(!!0&)
        IL_0006:  ldarg.1
        IL_0007:  call       valuetype [System.Runtime]System.Span`1<!!0> [System.Memory]System.Runtime.InteropServices.MemoryMarshal::CreateSpan<!!1>(!!0&, int32)
        IL_000c:  ret
      } 
      
    • L’équivalent en C# est:
      public Span<TElement> InlineArrayAsSpan<TBuffer, TElement>(TBuffer buffer, int length)
      {
        return MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length);
      }
      

      Ce code est appelé pour obtenir un objet Span qui permettra d’accéder aux objets du tableau “inline”. Dans un premier temps, la référence du tableau “inline” est fournie par l’intermédiaire de l’argument buffer. Cette référence est utilisée pour effectuer un cast sous forme de réinterprétation du type du tableau “inline” dans un objet TElement en appelant la fonction Unsafe.As(). Cette étape permet d’obtenir un pointeur vers le premier élément du tableau “inline”.

      Ensuite, un objet Span est créé à partir du cast du tableau “inline” avec MemoryMarshal.CreateSpan(). Bien-qu’un objet Span soit créé quand on appelle MemoryMarshal.CreateSpan(), il n’y a pas de déplacements de données en mémoire. Les objets TElement ou le tableau “inline” sont manipulés par référence. Ces objets étant de type valeur, se trouvent dans la pile. Les casts effectuent une réinterprétation des objets en mémoire dans des types différents sans changer l’emplacement de ces objets. Enfin, MemoryMarshal.CreateSpan() crée un objet Span mais l’objet fourni dans le constructeur de Span est une référence. Ce constructeur n’effectue donc pas de déplacement ou de copie des données se trouvant dans le tableau “inline” (cf. code source de l’objet Span), il donne une possitilité d’accéder rapidement aux objets TElement en mémoire.

      Pour résumer, la ligne: MemoryMarshal.CreateSpan(ref Unsafe.As<TBuffer, TElement>(ref buffer), length) permet d’obtenir un objet Span qui facilitera les manipulations du contenu du tableau “inline” de façon optimisée.

  • Pour InlineArrayElementRef():
    • Code MSIL:
      .method assembly hidebysig static !!TElement& 
              InlineArrayElementRef<TBuffer,TElement>(!!TBuffer& buffer,
                                                      int32 index) cil managed
      {
        // Code size       13 (0xd)
        .maxstack  8
        IL_0000:  ldarg.0
        IL_0001:  call       !!1& [System.Runtime]System.Runtime.CompilerServices.Unsafe::As<!!0,!!1>(!!0&)
        IL_0006:  ldarg.1
        IL_0007:  call       !!0& [System.Runtime]System.Runtime.CompilerServices.Unsafe::Add<!!1>(!!0&, int32)
        IL_000c:  ret
      } 
      
    • L’équivalent en C# est:
      public ref TElement InlineArrayElementRef<TBuffer, TElement>(ref TBuffer buffer, int index)
      {
        return ref Unsafe.Add(ref Unsafe.As<TBuffer, TElement>(ref buffer), index);
      }
      

      Cette méthode permet d’obtenir la référence d’un élément à un index donné du tableau “inline”. La référence du tableau “inline” est fournie par l’intermédiaire de l’argument buffer. Cette référence est utilisée pour effectuer un cast sous forme de réinterprétation du type du tableau “inline” dans un objet TElement en appelant la fonction Unsafe.As(). Cette étape permet d’obtenir un pointeur vers le premier élément du tableau “inline”.

      Ensuite, la fonction Unsafe.Add() trouve la référence d’un objet TElement se trouvant à un index donné dans la tableau “inline”

L’implémentation de la méthode UseInlineArray() est:

public void UseInlineArray()
{
  var points = new PointInlineArray();
  for (int i = 0; i < 10; i++)
  {
    Point newPoint = new Point();
    newPoint.X = i + 4;
    newPoint.Y = i + 6;
    points[i] = newPoint;
  }
}

Le code MSIL généré est:

  .maxstack  3
  .locals init (valuetype CS12.PointInlineArray V_0,
           int32 V_1,
           valuetype CS12.Point V_2,
           valuetype [System.Runtime]System.Span`1<valuetype CS12.Point> V_3)
  IL_0000:  ldloca.s   V_0
  IL_0002:  initobj    CS12.PointInlineArray
  IL_0008:  ldc.i4.0
  IL_0009:  stloc.1
  IL_000a:  br.s       IL_0044
  IL_000c:  ldloca.s   V_2
  IL_000e:  initobj    CS12.Point
  IL_0014:  ldloca.s   V_2
  IL_0016:  ldloc.1
  IL_0017:  ldc.i4.4
  IL_0018:  add
  IL_0019:  stfld      int32 CS12.Point::X
  IL_001e:  ldloca.s   V_2
  IL_0020:  ldloc.1
  IL_0021:  ldc.i4.6
  IL_0022:  add
  IL_0023:  stfld      int32 CS12.Point::Y
  IL_0028:  ldloca.s   V_0
  IL_002a:  ldc.i4.s   10
  IL_002c:  call       valuetype [System.Runtime]System.Span`1<!!1> '<PrivateImplementationDetails>'::InlineArrayAsSpan<valuetype CS12.PointInlineArray,valuetype CS12.Point>(!!0&, int32)
  IL_0031:  stloc.31
  IL_0032:  ldloca.s   V_3
  IL_0034:  ldloc.1
  IL_0035:  call       instance !0& valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::get_Item(int32)
  IL_003a:  ldloc.2
  IL_003b:  stobj      CS12.Point
  IL_0040:  ldloc.1
  IL_0041:  ldc.i4.1
  IL_0042:  add
  IL_0043:  stloc.1
  IL_0044:  ldloc.1
  IL_0045:  ldc.i4.s   10
  IL_0047:  blt.s      IL_000c
  IL_0049:  ret

On peut voir que le compilateur utilise InlineArrayAsSpan() pour obtenir un objet Span qui permettra d’accéder aux éléments du tableau “inline”. On pourrait se demander pourquoi la fonction InlineArrayAsSpan() est exécutée à chaque exécution de la boucle (ligne IL_002c), il suffirait de créer l’objet Span à l’extérieur de la boucle et de l’utiliser par la suite (la boucle est de la ligne IL_000c à IL_0047). Comme on a pu le voir plus haut, les appels à InlineArrayAsSpan() ne sont pas très couteux car cette fonction effectue principalement de la réinterprétation de type en mémoire sans copier ou déplacer des objets.

Cette exécution récurrente de la fonction InlineArrayAsSpan() pourrait expliquer la différence de performance entre les exemples UseInlineArray() et UseSpanWithStackalloc() que l’on a observé plus haut. Le code MSIL de la méthode UseSpanWithStackalloc() semble plus optimisé:

    .maxstack  3
  .locals init (valuetype [System.Runtime]System.Span`1<valuetype CS12.Point> V_0,
           int32 V_1,
           valuetype CS12.Point V_2)
  IL_0000:  ldc.i4.s   10
  IL_0002:  conv.u
  IL_0003:  sizeof     CS12.Point
  IL_0009:  mul.ovf.un
  IL_000a:  localloc
  IL_000c:  ldc.i4.s   10
  IL_000e:  newobj     instance void valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::.ctor(void*,
                                                                                                          int32)
  IL_0013:  stloc.0
  IL_0014:  ldc.i4.0
  IL_0015:  stloc.1
  IL_0016:  br.s       IL_0046
  IL_0018:  ldloca.s   V_2
  IL_001a:  initobj    CS12.Point
  IL_0020:  ldloca.s   V_2
  IL_0022:  ldloc.1
  IL_0023:  ldc.i4.4
  IL_0024:  add
  IL_0025:  stfld      int32 CS12.Point::X
  IL_002a:  ldloca.s   V_2
  IL_002c:  ldloc.1
  IL_002d:  ldc.i4.6
  IL_002e:  add
  IL_002f:  stfld      int32 CS12.Point::Y
  IL_0034:  ldloca.s   V_0
  IL_0036:  ldloc.1
  IL_0037:  call       instance !0& valuetype [System.Runtime]System.Span`1<valuetype CS12.Point>::get_Item(int32)
  IL_003c:  ldloc.2
  IL_003d:  stobj      CS12.Point
  IL_0042:  ldloc.1
  IL_0043:  ldc.i4.1
  IL_0044:  add
  IL_0045:  stloc.1
  IL_0046:  ldloc.1
  IL_0047:  ldc.i4.s   10
  IL_0049:  blt.s      IL_0018
  IL_004b:  ret

Dans cet extrait, l’instanciation de l’objet Span se trouve à la ligne IL_000e et la boucle va de la ligne IL_0018 à la ligne IL_0049.

Même si dans l’exemple présenté, on a pu observer des performances un peu moins bonnes entre les tableaux “inline” et l’utilisation d’un Span avec stackalloc, il faut garder en tête que cette différence est très liée à l’exemple et à la boucle qu’on exécute. L’utilisation de Span + stackalloc ne convient pas à tous les cas d’utilisation puisque:

  • Un objet Span est une ref struct qui ne peut être une donnée membre que d’une autre ref struct. Un objet Span ne peut pas être une donnée membre d’une classe.
  • L’instanciation avec stackalloc contraint à effectuer l’instanciation dans le corps d’une méthode, elle ne peut pas être faite dans une constructeur ou auto-implémentée.

A la différence, il est possible de stocker un tableau “inline” en tant que donnée membre d’un objet de type référence.

Lire un dump mémoire à partir de Visual Studio

Capturer et lire un dump mémoire peut être très utile pour aider à comprendre l’origine d’un crash ou d’une erreur survenue dans un autre environnement qu’une machine de développement. Le but de cet article est de montrer comment on peut facilement capturer un dump mémoire et de le lire directement dans Visual Studio.

Lorsqu’une exception ou une erreur survient dans une application .NET déployée sur une machine de production, il n’est pas toujours facile de comprendre l’origine du problème pour plusieurs raisons:

  • L’environnement de production n’est pas tout à fait le même que la machine sur laquelle le développement a été effectué. Souvent la charge ou les données de l’application en production sont assez différentes pour qu’un cas de figure soit difficilement reproductible dans un environnement de développement ou de test.
  • Le plus souvent, on observe les conséquences d’une erreur et il faut trouver la cause d’un problème à partir de ses conséquences. Par exemple, on constate qu’un comportement est différent de celui attendu, qu’un exécutable a crashé ou on lit les détails d’une exception dans des logs. Ces éléments permettent de donner des indices sur l’erreur survenue sans toutefois indiquer précisément les conditions ayant menées au problème.
  • Il est généralement difficile, voir impossible, de débuguer dans l’environnement de production.

Ainsi une possibilité pour comprendre plus finement l’état d’un exécutable au moment d’un crash ou d’une exception, est de capturer un dump mémoire dans l’environnement de production et de le lire avec Visual Studio sur une machine de développement.

Qu’est-ce qu’un dump ?

Un dump mémoire d’un processus correspond à une copie du contenu de la mémoire virtuelle (pile, tas managé, pile d’appels des différents threads etc…). Un débugueur peut écrire le contenu de la mémoire virtuelle dans un fichier sur le disque de façon à pouvoir le lire plus tard. Avec les sources, on pourra ensuite lire le dump et voir une instance “gelée” du processus de façon à identifier plus précisement la ligne de code qui a menée au crash.

Contenu d’un dump

Un dump peut contenir:

  • La pile en mémoire: contient les objets et variables créés par un processus.
  • Pile d’exécution (i.e. call stack) de tous les threads: on peut savoir précisement les fonctions qui étaient exécutées au moment du dump.
  • Blocs de l’environnement des threads: contient des informations sur les threads en cours d’exécution de façon à en connaître l’état et le thread ID.
  • Code assembleur: dans le pire des cas, on peut avoir à lire le code assembleur. Cette solution est généralement trop fastidieuse et trop couteuse. Toutefois en rapprochant le dump des fichiers de symboles .pdb, on peut avoir les piles d’appels par rapport au code source, ce qui est plus facile pour débuguer.
  • Information sur les modules: le processus charge souvent plusieurs assemblies. Le dump permet d’avoir des informations sur les dépendances qui ont été chargées notamment la version des assemblies.

Différents types de “dumps”

Il existe des types différents de dumps suivant les informations qu’il contient:

  • Full dump: les “full memory dumps” contiennent tout le contenu de la mémoire virtuelle. Ce type de dump est particulièrement utile lorsqu’on a aucune idée de l’origine du problème. L’inconvénient majeur de ce dump est qu’il faut du temps pour le collecter. Si le serveur à partir duquel on récupère le dump est saturé, la collecte pourrait encore ralentir l’exécution des processus.
  • Mini dump: ce type de dump concerne un processus spécifique et est configurable de façon à choisir les informations qu’il contiendra.

Capturer un dump

Plusieurs outils permettent de capturer des dumps d’un processus:

  • Le gestionnaire de tâches (i.e. Task Manager): assez pratique car il est présent sur tous les systèmes Windows.
  • Visual Studio: après s’être attaché à un processus, on peut générer un dump.
  • dotnet-dump: l’intérêt de cet outil est qu’il est possible de l’installer à la ligne de commande avec dotnet. Ce qui le rend disponible facilement sur toutes les plateformes.
  • ProcDump: c’est une espèce de debugger qu’on peut attacher à un processus. Cet outil peut monitorer un processus en scrutant certaines métriques. En cas de dépassement d’une métrique, il peut générer un dump automatiquement.

Gestionnaire de tâches (i.e. Task Manager)

Le gestionnaire de tâches permet de capturer des dumps à la demande. Le plus gros intérêt du gestionnaire de tâche est qu’il est présent directement sur tous les systèmes Windows, il n’est donc pas nécessaire de l’installer.
Pour capturer un dump, il faut:

  1. Ouvrir le gestionnaire de tâches: [Ctrl] + [Maj] + [Echap].
  2. Trouver le processus pour lequel on veut effectuer le dump
  3. Clique droit puis sélectionner “Create dump file”.
  4. Le dump sera écrit dans un répertoire temporaire et le chemin sera indiqué dans une popup.

Visual Studio

On peut capturer un dump avec Visual Studio en effectuant les étapes suivantes:

  1. S’attacher à un processus en cours d’exécution en cliquant sur “Debug” puis “Attach to process…”
  2. Cliquer sur “Pause” pour stopper l’exécution du processus ou cliquer sur “Debug” puis “Break All”:
  3. Cliquer sur “Debug” puis “Save Dump as…”

dotnet-dump

dotnet-dump peut être installé avec la commande dotnet à partir d’un package Nuget:

dotnet tool install --global dotnet-dump

Pour capture un dump, il suffit d’exécuter:

dotnet-dump.exe collect -p <PID du processus>

Plus de précisions concernant dotnet-dump sur: learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-dump?WT.mc_id=DT-MVP-5003978#install

ProcDump

ProcDump est une espèce de débugger qui peut monitorer un processus et générer un dump lorsque certaines conditions sont réunies. Il faut avoir en tête que comme il s’agit d’un débugger, l’utiliser peut avoir certains inconvénients:

  • Il ralentit un peu l’exécution du processus. Le plus souvent, on ne s’en rend pas compte mais pour des processus exigeants qui utilisent beaucoup des capacités de la machine, il peut y avoir un impact.
  • Lorsque ProcDump monitore un processus, il n’est plus possible d’attacher ce processus à Visual Studio pour le débugguer.

On peut aussi utiliser ProcDump simplement pour générer un dump mémoire sans utiliser la fonctionnalité de monitoring.

ProcDump appartient à la suite d’outils Windows Sysinternals (plus de détails sur ProcDump sur: learn.microsoft.com/fr-fr/sysinternals/downloads/procdump).

Pour capturer un dump sans conditions:

procdump -ma <nom processus ou PID>

Pour capturer un dump pour n’importe quelle exception (exception de plus bas niveau):

procdump -e 1 -ma <nom processus ou PID>

Dans le cas d’une exception spécifique, par exemple d’une exception de type System.NullReferenceException:

procdump -e 1 -f "System.NullReferenceException" -ma <nom processus ou PID>

Dans le cas où le processus utilise plus de 500 Mo de mémoire:

procdump -m 500 -ma <nom processus ou PID>

On peut déclencher la capture en fonction de la valeur d’un compteur de performances, par exemple l’argument -p \Process(Name_PID)\<counterName> <threshold> permet d’indiquer un seuil pour une valeur spécifique d’un compteur de performance Windows (Windows Performance Counter).

Par exemple, pour baser le seuil de génération sur le nombre de threads d’un processus avec un seuil de déclenchement à 85 threads, le processus ayant pour nom "w3mp" et pour PID "66666", on peut utiliser les arguments suivants:

procdump -p "\Process(w3wp_66666)\Thread Count" 85 -ma 66666

Il est recommandé d’utiliser le nom et le PID pour désigner le processus pour lequel on veut effectuer la capture. Dans le cas où 2 processus ont le même nom, l’utilisation seule du nom pour désigner le processus peut mener à la capture d’un autre processus.

Lire un dump avec Visual Studio

Visual Studio permet de facilement analyser et lire un dump. Un autre outil plus puissant permet de lire un dump et est plus puissant comme WinDbg mais il est beaucoup plus difficile à utiliser.

Quelques précisions concernant WinDbg

D’autres articles présentent quelques fonctionnalités de WinDbg:

On peut installer WinDbg en suivant: learn.microsoft.com/fr-fr/windows-hardware/drivers/debugger/.
Après installation, il se trouve dans C:\Users\<Windows user>\AppData\Local\Microsoft\WindowsApps\WinDbgX.exe

D’autres infos concernant l’interface sur: learn.microsoft.com/fr-fr/windows-hardware/drivers/debuggercmds/windbg-overview.

Pour illustrer comment on peut lire un fichier dump directement à partir de Visual Studio, on va:

  • Générer un dump d’un exécutable,
  • Ouvrir ce fichier dans Visual Studio
  • Indiquer les manipulations nécessaires pour récupérer quelques informations du dump.

On considère le code suivant permettant d’incrémenter un entier dans une task et de récupérer la valeur de cet entier périodiquement:

internal class BasicCounter
{
  private Task runningTask;
  private long counter;
  private CancellationToken cancellationToken;
  private EventWaitHandle waitingHandle = new AutoResetEvent(false);

  public BasicCounter(CancellationToken cancellationToken)
  {
    this.cancellationToken = cancellationToken;
    this.counter = 0;
  }

  public void Launch()
  {
    this.runningTask = Task.Run(() => {
      while (!this.cancellationToken.IsCancellationRequested)
      {
        Interlocked.Increment(ref counter);
        waitingHandle.Set();
      }
    });
  }

  public long GetCounterValue()
  {
    this.waitingHandle.WaitOne();
    return this.counter;
  }
}

On affiche la valeur de cet entier dans le main:

public static class Program
{
  public static void Main()
  {
    CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();

    var counter = new BasicCounter(cancellationTokenSource.Token);
    counter.Launch();

    while (true)
    {
      long counterValue = counter.GetCounterValue();
      if (counterValue % 10000 == 0)
        Console.WriteLine(counterValue);
    }
  }
}

Comme l’incrémentation du nombre est faite dans une task séparée, cela donnera la possibilité d’observer plusieurs piles d’appels (i.e. call stack).

On exécute ce code, puis on capture un fichier dump à partir du gestionnaire de tâches (i.e. Task Manager):

  1. On appuie simultanément sur les touches [Ctrl] + [Maj] + [Echap].
  2. Il faut chercher le processus par nom dans l’onglet “Details”, faire un clique droit sur “Create a dump file”.
  3. La popup indiquera l’emplacement du fichier dump.

Pour lire le contenu d’un dump avec Visual Studio, il suffit d’ouvrir directement le fichier avec Visual Studio. Une fois ouvert, Visual Studio affichera un onglet “Minidump file summary” contenant les caractéristiques du dump:

Cet onglet présente les caractéristiques du processus, les dépendances de l’exécutable et les actions possibles qu’il est possible d’effectuer pour analyser le dump (dans le coin en haut à droite):

Comme ce dump a été capturé au cours de l’exécution du processus, il n’y a pas d’informations concernant une éventuelle exception.

Définir les chemin des fichiers de symboles

La 1ère étape consiste à paramétrer les chemins des fichiers de symboles pour les assemblies ou DLL système ou du framework (fichiers .pdb). Les fichiers de symboles contiennent des informations concernant le code des fichiers de dépendance comme le nom des objets, leur adresse dans le module, le type des variables, les signatures des fonctions etc… Ces informations permettent de débugguer le processus.

Il suffit de cliquer sur “Set symbol paths” dans l’onglet “Actions”:

Il faut ensuite cocher:

  • Microsoft Symbol Servers: pour que le debugguer récupère les fichiers de symboles pour les assemblies ou DLL système ou du framework.
  • NuGet.org Symbol Server: pour les fichiers des dépendances Nuget.
  • Ajouter les chemins des fichiers de symboles pour le code de l’application.

Le chemin indiqué dans la partie “Cache symbols in this directory” sera le répertoire dans lequel sera téléchargé les fichiers de symboles.

Version des fichiers de symboles de l’application

A la lecture d’un dump mémoire, il faut que les fichiers de symboles soient de la même version que les fichiers dont sont issus le dump. Le risque si les fichiers ne correspondent pas est que les lignes indiquées lors de débuggage ne correspondent pas. L’idéal est d’utiliser les fichiers de symboles issus du même build, des fichiers provenant d’un build en debug ne sont pas les mêmes que des fichiers issues d’un build en release (les optimisations de code n’étant pas complêtement appliquées en mode debug).

Si les fichiers contenant le code source ne correspondent pas au processus dont le dump est issu, une erreur de ce type pourrait être affichée:

Passer en mode debug

Suivant le type de code exécuté par le processus, on peut débugger suivant des modes différents:

  • “Debug with managed only” permet de limiter le debug au code managé uniquement lorsque cela est possible. Tous les dumps ne contiennent pas forcément les informations permettant de débugguer dans ce mode. Dans ce cas on peut avoir l’erreur suivante:

    Dans ce mode, il est possible d’obtenir des informations sur les tasks exécutés et les piles d’appels avec le code source.

  • “Debug with mixed”: ce mode correspond aux assemblies mixtes c’est-à-dire les assemblies contenant du code managé MSIL et du code natif. Dans le code managé, on peut voir les piles d’appels avec le code source et les tasks en cours d’exécution. Pour le code natif, on ne peut voir que le code assembleur où il est plus difficile d’analyser le code en cours d’exécution.
  • “Debug with Native only”: ce mode limite le debug au code natif uniquement. Le code managé n’est pas traité. Lorsque le dump ne contient pas d’information sur le code managé, on ne peut utiliser que ce mode ou le mode mixed.

A ce stade, on peut afficher les threads ou les tasks en cours d’exécution (cliquer sur “Debug” ⇒ “Window” ⇒ “Threads” ou sur “Debug” ⇒ “Window” ⇒ “Tasks” pour afficher le panneau suivant):

On peut voir une vue différente des threads et tasks en affichant le panneau “Parallel Stacks” (cliquer sur “Debug” ⇒ “Window” ⇒ “Parallel Stacks”):

En sélectionnant “Threads” puis en cliquant sur le bon thread par exemple Program.Main, on peut voir la ligne à partir de laquelle le dump a été générée.

Si le dump a été capturé sur une autre machine que la machine sur laquelle on effectue le debug, l’activation du debug dans un mode managé (Managed ou Mixed) n’affichera pas le code source en C#. On ne verra, à ce stade, que le code assembleur.

Afficher le code source en C#

Pour voir le code source en C#, il faut préciser l’emplacement du code source si ce n’est pas fait automatiquement:

  • Afficher les panneaux “Solution Explorer”, “Threads“, “tasks” et “Call Stack“.
    Pour les afficher:

    • “Solution Explorer”: cliquer sur View ⇒ Solution Explorer
    • Threads, tasks et Call Stack: cliquer sur “Debug” ⇒ “Windows”
  • Dans le panneau “Solution Explorer”, il faut sélectionner la solution en cliquant sur son nom puis cliquer sur la clé pour “Properties”:

    Ensuite il faut préciser le chemin des fichiers du code source en cliquant sur “Debug Source Code” puis en indiquant tous les répertoires:

Ensuite si on affiche les panneaux “Parallel Stacks”, “Stacks” ou “Call Stack”, on peut ensuite cliquer sur la ligne du dernier appel de façon à voir le code source correspondant.

Partie “Analyse” en cas d’exception

Cette partie permet d’aider à l’analyse d’un dump mémoire en identifiant le thread dans lequel une exception pourrait s’être produite. Cette fonctionnalité permet d’indiquer le code ayant provoqué l’exception. Avoir la ligne où une exception a été lancée ne veut pas forcément dire qu’on va comprendre immédiatement ce qui a provoqué l’exception.

Si on considère le code suivant:

public static class Program
{
  public static void Main()
  {
    Console.WriteLine("Waiting...");
    Console.ReadLine();

    throw new NullReferenceException();
  }
}

Et si on lance ce code, l’exécution va s’interrompre à la ligne Console.ReadLine(). On s’attache alors au processus avec ProcDump.exe en lançant une ligne de ce type:

procdump.exe  -e 1 -f "System.NullReferenceException" -ma SimpleCounter.exe

Comme expliqué plus haut, ProcDump s’attache au processus en attendant qu’une exception de type System.NullReferenceException soit lancée:

ProcDump v11.0 - Sysinternals process dump utility
Copyright (C) 2009-2022 Mark Russinovich and Andrew Richards
Sysinternals - www.sysinternals.com

Process:         SimpleCounter.exe (26416)
Process image:     C:\MyStuff\Dev\MISC\SimpleCounterExample\bin\Debug\net8.0\SimpleCounter.exe
CPU threshold:     n/a
Performance counter:   n/a
Commit threshold:    n/a
Threshold seconds:   n/a
Hung window check:   Disabled
Log debug strings:   Disabled
Exception monitor:   First Chance+Unhandled
Exception filter:    [Includes]
             *System.NullReferenceException*
             [Excludes]
Terminate monitor:   Disabled
Cloning type:      Disabled
Concurrent limit:    n/a
Avoid outage:      n/a
Number of dumps:     1
Dump folder:       C:\MyStuff\Dev\MISC\SimpleCounterExample\bin\Debug\net8.0\
Dump filename/mask:  PROCESSNAME_YYMMDD_HHMMSS
Queue to WER:      Disabled
Kill after dump:     Disabled

Si on poursuit l’exécution du processus en cliquant sur [Enter], l’exception est lancée et procdump génère le dump:

[12:53:59] Exception: E0434352.CLR
[12:53:59] Unhandled: E0434352.CLR
[12:53:59] Dump 1 initiated: C:\MyStuff\Dev\MISC\SimpleCounterExample\bin\Debug\net8.0\SimpleCounter.exe_250405_125359.dmp
[12:53:59] Dump 1 writing: Estimated dump file size is 122 MB.
[12:53:59] Dump 1 complete: 122 MB written in 0.8 seconds
[12:54:00] Dump count reached.

Si on ouvre le dump avec Visual Studio, on peut analyser le dump directement:

  • Ouvrir l’onglet “Diagnostic Analysis” en allant dans “Debug” ⇒ “Windows” ⇒ “Diagnostic Analysis”:
  • En cliquant sur “Analyze”, VisualStudio va indiquer directement la ligne où l’exception a été lancée. Il faut ajouter le chemin correspondant aux fichiers du code source pour voir la ligne dans le fichiers .cs sinon c’est la code assembleur qui est présenté (voir Afficher le code source en C# pour ajouter les chemins correspondant aux fichiers du code source).
Références

Async/await en bref…

Cet article fait partie d’une série d’articles sur async/await.

Les mot-clés async/await sont apparus avec C# 5.0 et la version 4.5 du framework .NET. Sous l’apparente simplicité des mot-clés se cache une implémentation complexe et beaucoup de mécanismes implicites qu’il est préférable d’avoir en tête car leurs implications peuvent être significatives en terme de performance.

Fonctionnement général

async et await ne sont pas des mot-clés qui permettent la création de thread ou de task à proprement parlé mais ils permettent d’indiquer au compilateur:

  • les méthodes pour lesquelles l’exécution sera asynchrone en utilisant async,
  • les endroits dans le code où on va attendre la fin de l’exécution d’une tâche en utilisant await.

L’utilisation d’async/await entraîne l’exécution d’une partie du code de façon asynchrone. Comme on a pu l’indiquer précédemment, effectuer des traitements de façon asynchrone n’est pas tout à fait la même chose que d’exécuter du code en parallèle:

  • L’asynchronisme consiste à exécuter du code de façon non bloquante et éventuellement d’attendre le résultat de l’exécution de code. Exécuter de façon asynchrone peut entraîner l’exécution d’une partie du code en parallèle mais ce n’est pas indispensable. Le but recherché avec l’exécution asynchrone est l’aspect non bloquant.
  • Exécuter du code en parallèle implique de tirer partie des ressources matérielles pour effectuer plus de traitements pour une période de temps donnée en les exécutant en parallèle (sur plusieurs machines, plusieurs processeurs, plusieurs threads etc…).

Async

Async s’utilise dans la signature d’une fonction pour indiquer qu’elle contient du code qui peut être exécuté de façon asynchrone. Il s’applique sur des méthodes qui renvoient un objet de type Task<TResult>, Task, ValueTask ou ValueTask<TResult> (depuis C# 7.0). Utiliser async implique dans la majorité des cas d’utiliser le mot-clé await (mais ce n’est pas indispensable) pour attendre la fin de l’exécution.

Si le corps d’une méthode avec async ne contient pas le mot clé await, il y aura un message d’avertissement du compilateur car cela signifie que le code sera exécuté de façon synchrone.

Par exemple, le code suivant est exécuté de façon synchrone i.e. le thread principal est bloqué jusqu’à la fin du “Sleep” même avec la présence du mot-clé async:

public string WaitSynchronously()
{
  Thread.Sleep(10000);
  return "Finished";
}

En revanche le code suivant sera exécuté de façon asynchrone i.e. le thread principal ne sera pas bloqué:

public async Task<string> WaitAsynchronouslyAsync()
{
  await Task.Delay(10000);

  return "Finished";
}

Tout ce qui se trouve après l’instruction await dans la fonction async sera considéré comme une continuation. Ainsi le code ne sera pas bloquant et ce qui se trouve après le await dans la fonction sera exécuté quand la tâche sera terminée.

Si on utilise une boucle:

Task GetWebPageAsync(string uri) 
{ 
  ... 
}

async void Test() 
{ 
  for (int i = 0; i < 5; i++) 
  { 
    string html = await GetWebPageAsync("..."); 
    Console.WriteLine(html): 
  } 
}

L’exécution des boucles se fera sans blocages. Ce qui se trouve après la ligne avec le await sera exécuté comme une continuation quand le GetWebPageAsync() aura terminé son exécution.

Pour arriver à exécuter ce traitement, le compilateur transforme le code en une machine à états dont le but est de capturer le contexte d’exécution à chaque état c’est-à-dire à chaque fois qu’une instruction est lancée avec un await. Ainsi les valeurs des variables à ce moment sont celles au moment du lancement de l’instruction await.

Await

Ce mot-clé permet d’indiquer l’emplacement du code où il faut attendre la fin de l’exécution pour continuer. Ainsi, lorsque l’exécution arrive au mot-clé await, elle est stoppée pour attendre la fin de l’exécution du code qui suit. Ce code étant exécuté en parallèle de façon à ne pas être bloquant. Ainsi la partie située après la ligne await est considérée comme une continuation et sera exécutée lorsque la ligne await aura terminé son exécution.

Par exemple, si on reprend l’exemple précédent:

public async Task<string> WaitAsynchronouslyAsync()  
{
  // Exécution synchrone
  Console.WriteLine("Before await...");

  // L'exécution est stoppée jusqu'à la fin du Task.Delay(10000)
  await Task.Delay(10000);  

  // Continuation exécutée quand Task.Delay(10000) est terminé
  Console.WriteLine("Before await...");

  return "Finished";  
}

Si on exécute ce code:

string result = WaitAsynchronouslyAsync().Result;
Console.WriteLine(result);  

On obtient:

Before await...     ⇐ on attends 10 sec 
Continuation...     ⇐ la continuation est lancée après attente des 10 sec
Finished

On modifie légèrement le code pour que l’intérêt de await soit plus compréhensible:

public static async Task<string> WaitAsynchronouslyAsyncModified()
{
  // Exécution synchrone
  Console.WriteLine("Before await...");

  // L'exécution est stoppée jusqu'à la fin du Task.Delay(10000)
  Task<int> delayTask = Task.Run<int>(() =>
  {
    int taskThreadId = Environment.CurrentManagedThreadId;
    Task.Delay(10000);
    return taskThreadId;
  });
  
  Console.WriteLine("Code exécuté par le thread principal (thread ID: {0})...", Environment.CurrentManagedThreadId);

  int taskThreadId = await delayTask;

  // Continuation exécutée quand Task.Delay(10000) est terminé
  Console.WriteLine("Continuation...");
  Console.WriteLine("Thread ID du code exécuté en parallèle: {0}", taskThreadId);

  return "Finished";
}

On lance l’exécution:

string result = WaitAsynchronouslyAsyncModified().Result;
Console.WriteLine(result);

On obtient:

Before await...                                         ⇐ L'exécution en parallèle est lancée mais on n'attend pas à ce stade
Code exécuté par le thread principal (thread ID: 1)...  ⇐ Comme le lancement de la Task est non bloquant, ce code est exécuté 
                                                        ⇐ tout de suite par le thread principal
                                                        ⇐ On attends 10 sec au niveau du await
Continuation...                                         ⇐ la continuation est lancée après attente des 10 sec
Thread ID du code exécuté en parallèle: 9               ⇐ On affiche l'ID du thread utilisé pour l'exécution du code en parallèle
Finished

Dans cet exemple, on peut voir qu’une partie du code est exécutée en parallèle c’est-à-dire dans un thread différent du thread principal (mais ce n’est pas obligatoire):

Task<int> delayTask = Task.Run<int>(() =>
{  ...  });

Le thread principal continue de s’exécuter. Lorsque l’exécution atteint le await, elle est stoppée pour attendre la fin de l’exécution du code en parallèle.

Exemple WPF

Le grand intérêt d’async/await n’est pas d’implémenter ce modèle complètement mais de l’utiliser avec les bibliothèques du framework .NET qui fournissent des méthodes et fonctions compatibles. Par exemple, si on considère un exemple WPF consistant à utiliser 3 boutons:

  • Un bouton “Start/Stop counter” permettant de lancer et stopper un compteur exécuté par le thread principal.
  • Un bouton “Launch sync process” qui va lancer un traitement bloquant pendant 20 sec.
  • Un bouton “Launch async process” pour lancer un traitement asynchrone non bloquant pendant 20 sec.

Le code de l’application est disponible sur: github.com/msoft/asyncAwaitExamples.

Le traitement consiste à attendre pendant 20 sec et à renvoyer un nombre aléatoire. L’implémentation synchrone est:

public class UselessProcessSync
{
  private readonly int processExecutionTimeMs;
  private readonly Random randomGenerator;

  public UselessProcessSync(int processExecutionTimeMs)
  {
    this.processExecutionTimeMs = processExecutionTimeMs;   
    randomGenerator = new Random();
  }

  public int ProceedAndWait()
  {
    Thread.Sleep(processExecutionTimeMs);
    return randomGenerator.Next();
  }
}

Le traitement asynchrone est le même mais utilise async/await:

public class UselessProcess
{
  private readonly UselessProcessSync uselessProcessSync;

  public UselessProcess(int processExecutionTimeMs)
  {
    uselessProcessSync = new UselessProcessSync(processExecutionTimeMs);
  }

  public async ValueTask<int> Proceed()
  {
    return await Task<int>.Run(() => uselessProcessSync.ProceedAndWait());
  }
}

Le traitement synchrone est lancé sans async/await:

private void LaunchSyncProcess(object sender, RoutedEventArgs e)
{
  // ...
  int result = synchronousBackEndProcess.ProceedAndWait();
  this.Dispatcher.Invoke(() =>
  {
    RandomValue.Content = result;
    // ...
  });
}

Le traitement asynchrone est lancé avec async/await:

private async void LunchAsyncProcess(object sender, RoutedEventArgs e)
{
  // ...
  int result = await asynchronousBackEndProcess.Proceed();
  this.Dispatcher.Invoke(() =>
  {
    RandomValue.Content = result;
    // ...
  });
}

Le lancement des méthodes LaunchSyncProcess() et LaunchAsyncProcess() se fait de la même façon car WPF supporte async/await et prend en compte cette implémentation avec LaunchAsyncProcess():

<Button Name="SyncProcessLaunchButton" Content="Launch sync process" Click="LaunchSyncProcess"/>
<Button Name="AsyncProcessLaunchButton" Content="Launch async process" Click="LunchAsyncProcess"/>

A l’exécution, on peut se rendre compte que le traitement synchrone gèle l’application, le compteur s’arrête pendant le traitement. L’exécution du traitement synchrone étant effectuée par le thread principal, aucun autre traitement n’est possible. A la différence, le traitement asynchrone ne gèle pas l’interface car le thread principal est disponible. Il n’exécute pas le traitement asynchrone.

Modèle awaitable

Les mots clés async/await permettent de simplifier par des éléments de syntaxe l’implémentation du modèle awaitable. Ce modèle permet d’implémenter une action à exécuter de façon asynchrone et une continuation à appeler quand l’action est exécutée. On peut ensuite récupérer le résultat de l’opération asynchrone. Le modèle awaitable est proche du modèle basé sur des tasks (i.e. Task-based Asynchronous Pattern) vu précédemment.

La condition pour utiliser async/await est que l’objet qui suit await possède une fonction GetAwaiter():

TaskAwaiter GetAwaiter();

Appeler la fonction GetAwaiter() permet de retourner un objet de type TaskAwaiter contenant des propriétés et fonctions permettant d’implémenter le modèle awaitable:

  • IsCompleted() pour savoir si l’opération est exécutée
  • GetResult() pour récupérer le résultat
  • OnCompleted() cette méthode sera exécutée en tant que continuation.

L’objet awaitable doit aussi satisfaire la classe System.Runtime.CompilerServices.INotifyCompletion.

Les objets Task et ValueTask (disponibles à partir de C# 7.0) contiennent une fonction GetAwaiter(). L’utilisation des objets Task et ValueTask n’est pas indispensable après await, il suffit d’utiliser un objet fournissant la méthode GetAwaiter().

L’évaluation d’une expression await peut se résumer avec le diagramme de séquence suivant:

Pour résumer, await doit être suivi d’un objet awaitable ou d’une action fournissant un objet awaitable. Par exemple, les objets Task et ValueTask sont des objets awaitables. Les objets awaitable permettent de fournir un autre objet qui pourra être utilisé pour attendre la fin de l’exécution de la tâche asynchrone avec awaitable.GetAwaiter(). L’objet awaiter permet de fournir des propriétés et des méthodes pour vérifier que la tâche asynchrone a terminé son exécution.

Lors de l’instanciation de l’objet awaitable et plus généralement lors de l’exécution de la tâche asynchrone, des informations liées au contexte transitent entre le corps de la méthode async et l’objet awaitable. Ces informations correspondent au contexte d’exécution. Le contexte d’exécution est un ensemble de données d’état du thread dans lequel le code est exécuté. Ces données d’état peuvent être capturées et restaurées dans un autre thread. L’objet ExecutionContext permet de gérer les données de contexte du thread courant.

En plus du contexte d’exécution, il existe une autre notion appelée SynchronizationContext. Cette notion correspond à l’environnement dans lequel le code est exécuté. L’objet SynchronizationContext va fournir une abstraction permettant d’interagir avec cet environnement. Plus concrètement, on peut récupérer l’instance d’un objet correspondant au contexte de synchronisation (avec SynchronizationContext.Current). L’instance permet d’interagir avec le contexte en y lançant l’exécution de code de façon synchrone (avec Send()) ou asynchrone (avec Post()). Ces méthodes peuvent s’appliquer pour des technologies différentes comme WPF ou en Windows Forms etc…

Avec aync/await, lors d’un appel à await, un objet awaiter est instancié. Il sera utilisé pour attendre la fin de l’exécution grâce à un autre objet awaitable. Lorsque l’exécution dans la méthode async est suspendue, le contexte d’exécution est capturé. La continuation correspondant au code exécuté lorsque l’exécution asynchrone est terminée, utilisera aussi ce contexte d’exécution. Lors de l’utilisation d’async, les objets awaitables sont créés par l’intermédiaire d’objets System.Runtime.CompilerServices.AsyncTaskMethodBuilder qui assurent que le contexte d’exécution transitera de l’appelant vers le delegate.

Exemple d’implémentation d’un objet awaitable

Comme indiqué plus haut, on peut implémenter un objet awaitable personnalisé plutôt que d’utiliser les classes Task ou ValueTask. Pour illustrer les différentes étapes du diagramme plus haut, on implémente un objet awaitable qui satisfait INotifyCompletion et contient des propriétés et fonctions:

  • IsCompleted()
  • GetResult()
  • OnCompleted()

Cette classe ne fait qu’attendre un certain temps avant de renvoyer un entier aléatoire:

internal class CustomAwaitable: INotifyCompletion
{
    private Task<int> waitingTask;
    private int result;

    public CustomAwaitable(TimeSpan waitingTime)
    {
        result = (new Random()).Next();
        if (waitingTime == TimeSpan.Zero)
            this.waitingTask = Task.FromResult<int>(result);
        else
            this.waitingTask = Task.Run(() =>
            {
              // Attente avant de renvoyer un résultat
              Task.Delay(waitingTime).Wait();
              return result;
            });
    }

    public bool IsCompleted 
    { 
        get 
        {
            bool isCompleted = this.waitingTask.IsCompleted;
            Log.LogConsole($"Calling \"IsCompleted\": {isCompleted}");
            return isCompleted;
        }
    }

    public void OnCompleted(Action continuation)
    {
        Log.LogConsole("Calling \"OnCompleted()\"");
        continuation();
    }

    public int GetResult() // Can also be void
    {
        Log.LogConsole("Calling \"GetResult()\"");
        return this.waitingTask.Result;
    }

}

L’objet se trouvant après await doit implémenter une fonction GetAwaiter() qui doit instancier l’objet awaitable. Une possibilité est de créer une méthode d’extension sur TimeSpan correspondant au temps d’attente:

internal static class CustomAwaitableBuilder
{
  public static CustomAwaitable GetAwaiter(this TimeSpan timeSpan)
  {
    Log.LogConsole("GetAwaiter()");
    return new CustomAwaitable(timeSpan);
  }
}

On peut lancer l’exécution en utilisant await suivi d’un objet TimeSpan qui grâce à la méthode d’extension implémente la fonction GetAwaiter():

Log.LogConsole("Starting...");
//TimeSpan timeSpan = TimeSpan.FromSeconds(0);  // Temps d'attente nul
TimeSpan timeSpan = TimeSpan.FromSeconds(10);

int result = await timeSpan;

Log.LogConsole($"Ending with result: {result}");

A l’exécution, on obtient:

14:13:36.278: Starting...
14:13:36.318: GetAwaiter()
14:13:36.319: Calling "IsCompleted": False
14:13:36.320: Calling "OnCompleted()"
14:13:36.320: Calling "GetResult()"
14:13:46.329: Ending with result: 706177357

Avec un temps d’attente nul:

14:14:47.755: Starting...
14:14:47.774: GetAwaiter()
14:14:47.775: Calling "IsCompleted": True
14:14:47.775: Calling "GetResult()"
14:14:47.775: Ending with result: 1427412265

Le code de cet exemple est disponible sur: github.com/msoft/asyncAwaitExamples/blob/master/ExampleWithTestLauncher/CustomAwaitable.cs.

Références

Modèles de programmation asynchrone (async/await)

Cet article fait partie d’une série d’articles sur async/await.

Quelque soit le type d’application, il peut être nécessaire de vouloir exécuter des traitements de façon asynchrone, en particulier pour permettre l’exécution de traitements longs sans bloquer l’interface graphique; ou de pouvoir lancer plusieurs traitements simultanément. La programmation asynchrone implique de pouvoir lancer un ou plusieurs traitements sans bloquer le thread appelant et de pouvoir récupérer les résultats éventuels quand les traitements sont terminés.

En .NET, une implémentation de ce type de programmation a abouti à l’utilisation des mot-clés async/await. Le but de cet article est d’expliquer l’utilisation de async/await et ce qu’implique l’utilisation de ces mot-clés. Dans un premier temps, on va expliquer les différentes approches en .NET pour implémenter des traitements asynchrones. Ensuite, on va expliquer le fonctionnement d’ async/await et enfin, indiquer la façon dont ce pattern est implémenté.

Programmer une exécution de façon asynchrone impose quelques mécanismes qui ne sont pas triviaux:

  • Il faut lancer l’exécution éventuellement dans un thread séparé pour que l’exécution dans le thread appelant puisse se poursuivre.
  • Récupérer le résultat de l’exécution effectuée dans un thread séparé du thread appelant.
  • Le cas échéant attendre la fin d’un traitement en cours d’exécution dans un thread séparé.

Avant d’aborder async/await, plusieurs approches ont été implémentées en .NET pour permettre une programmation asynchrone:

Modèle de programmation asynchrone (Asynchronous Programming Model)

Ce modèle est basé sur l’utilisation de 2 méthodes pour lancer le traitement asynchrone:

  • BeginXXX pour commencer à effectuer le traitement,
  • EndXXX pour éventuellement attendre la fin du traitement asynchrone et récupérer le résultat.

Dans sa version la plus simple, ce modèle permet d’implémenter un algorithme qui lance le traitement asynchrone, permet d’effectuer un autre traitement et attend le résultat de façon bloquante. Un exemple de ce modèle est la lecture d’un fichier avec FileStream.BeginRead() et FileStream.EndRead():

FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
  FileAccess.Read, FileShare.Read, 1024,
  FileOptions.Asynchronous);

Byte[] buffer = new Byte[100];

// Lecture asynchrone d'une quantité de données dans le fichier
IAsyncResult result = fs.BeginRead(buffer, 0, buffer.Length, null, null);

// On peut effectuer un autre traitement 
// ...

// On attend le résultat du traitement asynchrone. 
Int32 bytesRead = fs.EndRead(result);

// Fermeture du flux de lecture
fs.Close();

Un modèle plus complexe comprend une fonctionnalité de “polling” pour vérifier périodiquement si le traitement est terminé. Ce modèle permet d’exécuter un autre traitement tant que le traitement asynchrone n’est pas terminé.

Par exemple si la fonction effectuant un traitement synchrone s’appelle MakeProcess(string arg) avec un argument en entrée alors les fonctions permettant de lancer le traitement asynchrone seront:

public IAsyncResult BeginMakeProcess(string arg, AsyncCallback? callback, object? state);
public int EndMakeProcess(IAsyncResult asyncResult);

La fonction BeginMakeProcess() a pour argument:

  • arg qui est l’argument fonctionnel de la fonction originale
  • AsyncCallback qui est un delegate:
    public delegate void AsyncCallback(IAsyncResult ar);
    
  • IAsyncResult permet d’indiquer le statut de l’opération asynchrone avec les propriétés IsCompleted et CompletedSynchronously.

  • state permet de transmettre la référence d’un objet qui sera transmis dans le résultat de la fonction BeginMakeProcess().

La fonction EndMakeProcess() prend pour argument l’objet de type IAsyncResult renvoyé par BeginMakeProcess().

Avec FileStream, pour vérifier le statut on peut, par exemple, utiliser IAsyncResult.IsCompleted():

FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
    FileAccess.Read, FileShare.Read, 1024,
    FileOptions.Asynchronous);

Byte[] buffer = new Byte[100];

// Lecture asynchrone d'une quantité de données dans le fichier
IAsyncResult result = fs.BeginRead(buffer, 0, buffer.Length, null, null);

while (!result.IsCompleted)
{
  // On peut effectuer un autre traitement 
  // ...

  // On attend avant une nouvelle tentative
  Thread.Sleep(100);
}

// On attend le résultat du traitement asynchrone. 
Int32 bytesRead = fs.EndRead(result);

// Fermeture du flux de lecture
fs.Close();

Ce modèle était valable à un époque où il n’y avait pas les Tasks (Framework .NET 1.0), il est maintenant obsolète.

Modèle asynchrone basé sur des événements (Event-based asynchronous pattern)

A partir du Framework .NET 2.0, est apparu un modèle basé sur des évènements avec des callbacks qui sont exécutées lorsque le traitement est terminé. Ce modèle de programmation introduit la notion de continuation comme dans le cas des Tasks. L’exécution de la continuation est lancée lorsque le tâche asynchrone est terminée. L’implémentation de la callback est fournie au moment du lancement de la tâche asynchrone sous la forme d’une méthode. L’exécution de cette implémentation doit se faire dans un contexte d’exécution précis. Ce contexte va permettre d’organiser l’exécution de la callback.

L’objet SynchronizationContext est apparu aussi avec le framework .NET 2.0. Suivant la technologie utilisée, SynchronizationContext se décline différemment, par exemple: WindowsFormsSynchronizationContext pour les Windows Forms; DispatcherSynchronizationContext pour WPF.

Par exemple, pour lancer la lecture d’un fichier de façon asynchrone, on peut utiliser la surcharge de FileStream:

IAsyncResult BeginRead(Byte[] array, Int32 offset, Int32 numBytes, AsyncCallback userCallback, Object stateObject)

Comme pour le modèle Asynchronous Programming Model:

  • AsyncCallback qui est un delegate:
    public delegate void AsyncCallback(IAsyncResult ar);
    
  • IAsyncResult permet d’indiquer le statut de l’opération asynchrone avec les propriétés IsCompleted et CompletedSynchronously.

En déclinant la lecture d’un fichier avec la modèle Event-based asynchronous pattern, on obtient:

private static Byte[] buffer = new Byte[100];

public static void ReadAsynchronouslyWithEap() 
{
  // On affiche l'ID du thread avec lequel la méthode est exécutée
  Console.WriteLine("Main thread ID={0}", Thread.CurrentThread.ManagedThreadId);

  FileStream fs = new FileStream(<chemin du fichier à lire>, FileMode.Open,
  FileAccess.Read, FileShare.Read, 1024, FileOptions.Asynchronous);

  // Lecture asynchrone d'une quantité de données dans le fichier
  // On passe le paramètre fs (FileStream) à la callback (méthode ReadIsDone)
  fs.BeginRead(buffer, 0, buffer.Length, WhenReadIsCompleted, fs);

  // On peut effectuer un autre traitement 
  // ...

  // On stoppe le thread principal pour éviter de sortir de la méthode
  Console.ReadLine();
}

private static void WhenReadIsCompleted(IAsyncResult result) 
{
  // On affiche l'ID du thread avec lequel la méthode est exécutée
  Console.WriteLine("ReadIsDone thread ID={0}", Thread.CurrentThread.ManagedThreadId);

  // On récupère l'objet FileStream (correspondant à l'état) fournit en argument
  FileStream fs = (FileStream) result.AsyncState;

  // On récupère le résultat
  Int32 bytesRead = fs.EndRead(result);

  // Fermeture du fichier
  fs.Close();
}

L’intérêt de ce modèle par rapport à Asynchronous Programming Model est la possibilité d’utiliser le contexte de synchronisation SynchronizationContext plus spécifique au contexte dans lequel l’exécution asynchrone est effectuée.
On peut obtenir une instance de SynchronizationContext en utilisant la propriété statique SynchronizationContext.Current. L’ajout de tâches à exécuter dans le scheduler peut se faire avec la méthode SynchronizationContext.Post(), par exemple:

static void ExecuteAction(Action<string> actionToBeExecuted)
{
  SynchronizationContext? sc = SynchronizationContext.Current;
  ThreadPool.QueueUserWorkItem(_ =>
  {
    string message = "Message exemple";
    if (sc is not null)
    {
	    // Exécution par le "scheduler"
      sc.Post(_ => actionToBeExecuted(message), null);
    }
    else
    {
      // Exécution directe
      update(message);
    }
  });
}

Modèle asynchrone basé sur les Tasks (Task-based asynchronous pattern)

Les tasks sont apparues avec le framework .NET 4.0, permettant ainsi d’ajouter une abstraction au dessus des threads. Le gros intérêt des tasks est qu’elles facilitent considérablement l’implémentation d’algorithmes asynchrones. Un objet de type Task contient, en particulier, plusieurs propriétés:

Ces propriétés sont utiles pour savoir si la Task a terminé son exécution, gérer les erreurs, implémenter un mécanisme de continuation (correspondant à une autre Task qui s’exécutera quand la Task précédente sera terminée).

Contexte d’exécution

Le contexte d’exécution permet de capturer des données qui pourront être restituées d’un thread à l’autre. Ainsi dans le cadre de l’exécution d’une continuation, le contexte sera transmis d’une Task à l’autre. De même quand on appelle des méthodes asynchrones, ce contexte est transmis implicitement. Des méthodes comme Task.Run() et ThreadPool.QueueUserWorkItem() transmettent le contexte d’exécution automatiquement. Le contexte est capturé à partir du thread appelant et il est stocké dans l’instance de Task. Si le TaskScheduler exécute un delegate, il le fait avec ExecutionContext.Run() en utilisant le contexte stocké.

Par exemple si on crée une continuation en utilisant Task.ContinueWith() le contexte d’exécution est transmis d’une Task à l’autre. Avec TaskAwaiter.GetAwaiter().UnsafeOnComplete() le contexte n’est pas transmis.

Si on rapproche le modèle Task-based asynchronous pattern aux modèles précédents, la continuation s’apparente à la callback exécutée quand le code asynchrone a terminé son exécution. De plus tout le code implémenté explicitement dans le cadre des autres modèles pour la gestion d’erreurs se trouve dans l’objet Task.

Dans le cadre de l’exécution d’une série de traitements asynchrones, on peut imaginer une série de continuations ce qui amène à l’implémentation d’ async/await.

Dans l’article suivant, on rentrera davantage dans les détails d’async/await.