PerformanceCounter en 5 min

Les compteurs de performance Windows permettent de mesurer l’évolution de certaines données de performance liées à une machine (charge CPU, mémoire utilisée, les entrées/sorties réseau) ou à un ou plusieurs processus (taille des piles, quantité de mémoire managée utilisée etc…).

Cet outil peut être utile pour monitorer des données de performance d’un point de vue général dans des données liées à une machine mais aussi plus spécifiquement à un processus si on souhaite, par exemple, révéler une fuite mémoire ou évaluer les performances de l’exécution d’un processus.

Il existe un certain nombre de compteurs de performance livrés avec Windows mais il est possible de créer ses propres compteurs et de les alimenter avec un processus .NET classique. Sur MSDN, on peut avoir la liste complête des compteurs de performance: Performance Counters in the .NET Framework.

Si on dispose des droits administrateur, on peut créer des catégories de compteurs de performance. Il n’est pas nécessaire d’avoir ces droits pour alimenter les compteurs.

1. Types de compteurs de performance

Les principaux types de compteurs sont:

  • NumberOfItems32: permet d’indiquer une grandeur totale comme le nombre d’éléments ou d’opérations. Ce type permet d’indiquer un nombre entier 32 bits, toutefois l’équivalent NumberOfItems64 existe pour des entiers 64 bits. Une grandeur de ce type est utilisée sans transformation.
  • RateOfCountsPerSecond32: permet d’indiquer une grandeur par seconde. Par exemple le nombre d’opérations par seconde. De même, ce type permet d’indiquer un nombre entier 32 bits, l’équivalent pour un entier 64 bits est RateOfCountsPerSecond64. Une grandeur de ce type est utilisée en prenant en compte le temps en ticks de l’horloge système.
  • AverageTimer32: permet d’indiquer le temps moyen en seconde pour effectuer un processus. Ce type permet d’indiquer un entier 32 bits, l’équivalent pour un entier 64 bits est AverageTimer64. Une grandeur de ce type est utilisée en prenant en compte le temps en ticks de l’horloge système.

D’autres types sont disponibles, des exemples sont précisés sur MSDN.

Précisions sur le type RateOfCountsPerSecond32

Les grandeurs de type RateOfCountsPerSecond32 indiquent un nombre par seconde.
Donc si on considère une valeur V0 lue au temps T0 et une autre valeur V1 lue au temps T1.
La formule: (V1 - V0) / (T1 - T0) permet de calculer le nombre d’éléments traités V1 – V0 pour la durée T1 – T0 en ticks.
Pour ramener ce nombre d’éléments à la durée d’une seconde, il faut utiliser F c’est-à-dire le fréquence de ticks par seconde (1/F étant le nombre de ticks par seconde).
La formule devient pour une seconde: (V1 - V0) / ((T1 - T0) / F).

Dans la pratique, on indique seulement les valeurs Vx et en fonction des temps pendant lesquels on a indiqué chaque valeur, le compteur connait les différentes valeurs Tx. De même F étant connu, le compteur applique la formule précédente pour obtenir le taux par seconde.

Précisions sur le type AverageTimer32

Les grandeurs de type AverageTimer32 indique un nombre moyen par seconde.
Donc si on considère une valeur V0 lue au temps T0 et une autre valeur V1 lue au temps T1.
La formule est: (V1 - V0) / (T1 - T0).

De même que précédemment, la formule (V1 - V0) / (T1 - T0) permet de calculer le nombre d’éléments traités V1 – V0 pour la durée T1 – T0 en ticks.
Si on veut calculer la moyenne d’éléments traités en un seconde, il faut utiliser le nombre de ticks par seconde 1/F (F étant la fréquence de ticks par seconde).
La formule devient pour une seconde: ((V1 - V0) / F) / (T1 - T0).

Dans la pratique, on indique les valeurs Vx mais aussi les valeurs Tx. Les valeurs Vx sont indiquées en utilisant le compteur dont le nom est AverageTimer32. Les valeurs Tx sont indiquées en utilisant le compteur correspondant pour le dénominateur de la formule c’est-à-dire AverageBase.
Les équivalences sont les mêmes pour AverageCount32 et AverageBase.

Utiliser les compteurs de performance

3 méthodes sont possibles:

  • Performance Monitor: outil de monitoring qui permet d’afficher les compteurs de performance sous forme de graphique.
  • Avec Visual Studio: on peut directement créer des catégories avec Visual Studio à condition d’avoir les droits administrateur.
  • Par programmation: on peut créer des catégories mais aussi alimenter des compteurs de performance par code.

2. Performance Monitor

Le performance monitor permet de visualiser les compteurs de performance configurés sur la machine locale ou une machine distante. On peut afficher les compteurs livrés avec Windows ou des compteurs personnalisés.

Pour ouvrir Performance Monitor, il faut taper à la ligne de commandes perfmon.

Visualiser un compteur de performance

Avec Performance Monitor, il suffit d’ajouter un compteur en cliquant sur “Add Counters”.
Il faut généralement affiner l’échelle en se référant à la dernière valeur courante car la courbe est souvent en dehors du graphique.

Profiler une fuite mémoire

Performance monitor permet de facilement visualiser une fuite mémoire en affichant les différentes mémoires utilisées par un processus. La mémoire d’un processus .NET est partagées entre mémoire managée et mémoire non managée.

Grâce à Performance Monitor, il est possible d’afficher la mémoire totale consommée par le processus et la mémoire managée. On peut en déduire la quantité de mémoire non managée utilisée:

  • La mémoire totale du processur .NET: on peut afficher cette grandeur en allant dans la catégorie de compteur “Process” puis en sélectionnant le compteur “Private Bytes”.
  • La mémoire managée: cette grandeur est accessible dans la catégorie “.NET CLR Memory” et en sélectionnant le compteur “# Bytes en all Heaps”.

L’écart entre les 2 grandeurs précédentes permettent de déterminer la quantité de mémoire non-managée utilisée. Ainsi si la mémoire managée reste constante mais que la mémoire totale du processus ne cesse d’augmenter, on peut en déduire qu’il y a bien une fuite mémoire dans la mémoire non managée.

3. Visual Studio

Visual Studio permet de créer des catégories de compteurs de performance et des compteurs de performance.

Création d’une catégorie de compteur de performance

Il faut démarrer Visual Studio avec les droits administrateur:

  1. Cliquer sur le menu “View”,
  2. Cliquer sur “Server-Explorer”.
  3. Dépliant le noeud correspondant à un serveur et déplier “Performance Counters”.
  4. Clique droit sur “Performance Counters”
  5. Cliquer sur “Create New Category”.

Création d’un compteur de performance

Un compteur doit appartenir à une catégorie. Sur le panneau d’une catégorie, on peut ajouter ou supprimer des compteurs de performance. La création d’un compteur de performance se fait en indiquant son type, son nom et une description.

4. Utiliser les compteurs de performance pour programmation

On peut ajouter une nouvelle catégorie par si le processus est exécuté avec les droits administrateur. De même, on peut aussi créer et alimenter les compteurs de performance.

Les classes nécessaires à la manipulation des compteurs de performance se trouvent dans le namespace System.Diagnostics.

Création d’une catégorie de compteur de performance

Un compteur de performance doit obligatoirement se trouver dans une catégorie. Si la catégorie dans laquelle on veut ranger le compteur n’existe pas, il faut la créer. Une fois créée, il n’est pas nécessaire de la recréer pour l’utiliser.

Pour exécuter le code de création d’une catégorie, il faut que le processus soit exécuté avec les droits administrateur sinon une exception survient.

La classe PerformanceCounterCategory permet de vérifier que la catégorie existe et de créer la catégorie ainsi que les compteurs de performance qui s’y trouvent:

if (!PerformanceCounterCategory.Exists("Counter custom category")) 
{ 
    CounterCreationDataCollection counters = new CounterCreationDataCollection(); 
 
    // Creating counter with type NumberOfItems32 
    CounterCreationData totalOperations = new CounterCreationData(); 
    totalOperations.CounterName = "# operations executed"; 
    totalOperations.CounterHelp = "Total number of operations executed"; 
    totalOperations.CounterType = PerformanceCounterType.NumberOfItems32; 
    counters.Add(totalOperations); 
 
    PerformanceCounterCategory.Create("Counter custom category",  
        "Category help message", counters); 
}

Ce code permet de créer un compteur de performance dans le type est NumberOfItems32 et de le ranger dans la catégorie “Counter custom category”. Le nom du compteur “# operations executed” est le seul moyen d’identifier le compteur.
A ce moment le compteur n’est pas alimenté toutefois la catégorie et le compteur sont visibles dans Performance Monitor ou dans Visual Studio.

Alimenter un compteur de performance

Après avoir créé le compteur avec l’étapé précédente, on peut l’alimenter en valeurs avec le code suivant:

var customPerformanceCounter = new PerformanceCounter("Counter custom category", "# operations executed"); 
customPerformanceCounter.ReadOnly = false; 
CustomPerformanceCounter.RawValue = 0;

Cette surcharge permet d’utiliser le compteur de performance “# operations executed” sur la machine locale. On indique ensuite qu’on souhaite l’alimenter avec

customPerformanceCounter.ReadOnly = false;

On peut indiquer qu’on souhaite lire ou écrire les valeurs d’un compteur distant en précisant le nom de la machine:

customPerformanceCounter.MachineName = "FRXXXXXX";

On peut indiquer de nouvelles valeurs en exécutant:

var customPerformanceCounter = new PerformanceCounter("Counter custom category", "# operations executed");  
customPerformanceCounter.ReadOnly = false;  
customPerformanceCounter.RawValue = 0; 
Random random = new Random(); 
while (true) 
{ 
    int newValue = random.Next(0, 5); 
    customPerformanceCounter.IncrementBy(newValue); 
    Thread.Sleep(1000); 
}

Dans cette exemple, on ne précise qu’une seule valeur car avec un compteur de type NumberOfItems32, la valeur est utilisée directement (voir plus haut).

Les méthodes de la classe System.Diagnostics.PerformanceCounter permettant d’ajouter des valeurs sont:

  • Increment(): incrémente la valeur d’une unité.
  • IncrementBy(): incrémente ou décrémente la valeur d’une certaine valeur.
  • Decrement(): décrémente la valeur d’une unité.
  • RawValue: indique une valeur brute.
Utilisation des PerformanceCounters en terme de performances

L’utilisation de PerformanceCounter n’est pas anodin sur les performances du processus. Les méthodes Increment(), IncrementBy() et Decrement() utilisent des “locks” pour éviter les accès concurrents, ce qui ralentit l’exécution.
L’utilisation de PerformanceCounter.RawValue est plus rapide car l’affectation n’utilise pas de “lock”.

Enfin si plusieurs processus alimentent des valeurs dans le compteur de performance en utilisant PerformanceCounter.Increment() ou PerformanceCounter.Decrement(), les différentes contributions de valeurs se cumulent.

RatesOfCountsPerSecond32

Comme indiqué précédemment (voir plus haut), ce type de compteur permet de préciser un nombre d’éléments par seconde toutefois on ne précise qu’une seule valeur.

Si on crée le compteur de cette façon:

CounterCreationData operationsPerSec = new CounterCreationData(); 
operationsPerSec.CounterName = "# operations per second executed"; 
operationsPerSec.CounterHelp = "Number of operations per second executed"; 
operationsPerSec.CounterType = PerformanceCounterType.RatesOfCountsPerSecond32; 
counters.Add(operationsPerSec);

On peut l’utiliser de cette façon:

var operationsPerSecCounter = new PerformanceCounter("Counter custom category",  
    "# operations per second executed");  
operationsPerSecCounter.ReadOnly = false;  
operationsPerSecCounter.RawValue = 0; 
Random random = new Random(); 
while (true) 
{ 
    int newValue = random.Next(0, 5); 
    operationsPerSecCounter.IncrementBy(newValue); 
    Thread.Sleep(50); 
}

AverageTimer32

Comme indiqué précédemment (voir plus haut), les compteurs de type AverageTimer32 sont couplés avec un compteur de type AverageBase contenant les valeurs du dénominateur dans la formule de calcul des valeurs affichées.
On doit donc préciser 2 valeurs en utilisant le compteur de type AverageTimer32 et le compteur de type AverageBase.

Si on crée les compteurs de cette façon:

CounterCreationData averageOperationsPerSec = new CounterCreationData(); 
averageOperationsPerSec.CounterName = "Average operations per second executed"; 
averageOperationsPerSec.CounterHelp = "Average of operations per second executed"; 
averageOperationsPerSec.CounterType = PerformanceCounterType.AverageTimer32; 
counters.Add(averageOperationsPerSec); 

CounterCreationData averageOperationsPerSecBase = new CounterCreationData(); 
averageOperationsPerSecBase.CounterName = "Base average operations per second executed"; 
averageOperationsPerSecBase.CounterHelp = "Base average of operations per second executed"; 
averageOperationsPerSecBase.CounterType = PerformanceCounterType.AverageBase; 
counters.Add(averageOperationsPerSecBase);

On peut les utiliser de cette façon:

var averageOperationsPerSec = new PerformanceCounter("Counter custom category",  
    "Average operations per second executed", false);  
var averageOperationsPerSecBase = new PerformanceCounter("Counter custom category",  
    "Base average operations per second executed", false);  
 
Random random = new Random(); 
while (true) 
{ 
    int newValue = random.Next(0, 5); 
    averageOperationsPerSec.RawValue = newValue; 
    averageOperationsPerSecBase.IncrementBy(10); 
    Thread.Sleep(1000); 
}

Lire un compteur de performance

Pour lire les mesures d’un compteur de performance, il faut définir le compteur comme précédemment et utiliser les fonctions PerformanceCounter.NextSample() ou PerformanceCounter.NextValue():

var customPerformanceCounter = new PerformanceCounter("Counter custom category", "# operations executed");   
while (true)  
{  
    CounterSample sample = customPerformanceCounter.NextSample();  
    Thread.Sleep(1000);  
}

PerformanceCounter.NextSample() permet d’obtenir un objet de type CounterSample contenant des propriétés contenant les valeurs comme CounterSample.RawValue pour obtenir la valeur brute.
On peut aussi obtenir cette valeur directement avec:

Float nextValue = CounterSampler.NextValue();

QueryPerformanceCounter

Utiliser QueryPerformanceCounter plutôt que System.DateTime.Now est plus précis pour mésurer des temps d’exécution. QueryPerformanceCounter est une fonction de l’API Win32.

Pour l’utiliser, il faut faire un appel en utilisant Platform Invoke:

[DllImport("Kernel32.dll")] 
public static extern void QueryPerformanceCounter(ref long ticks); 
 
// ...
 
long startTime = 0; 
long endTime = 0; 
 
// Getting start time 
QueryPerformanceCounter(ref startTime); 
 
// Simulating processing time 
Thread.Sleep(40); 
 
// Getting end time 
QueryPerformanceCounter(ref endTime); 
 
long executionTime = endTime - startTime; 

Synthèse des fonctionnalités du langage C# par version

Pour avoir plus de détails sur la version du langage par rapport aux versions de framework, se reporter à Versions des composants .NET.

C# 6.0

    Visual Studio 2015

  • Version implémentée avec Roslyn.
  • Initialiseurs pour implémenter automatiquement les propriétés:
    public class ItemId
    { 
        public Guid Id { get; } = Guid.NewGuid();
    }
  • Utiliser des directives using static pour importer des membres statiques:
    using static System.Console;
    
    public class Driver
    {
        public static void Main()
        {
            WriteLine("Hello, World!");
        }
    }
  • Filtres d’exceptions:
    try
    {
        ...
    }
    catch (Exception ex) if (ex.Message.Contains("Invalid Operation Exception"))
    {
        ...
    }
    
    try
    {
        ...
    }
    catch (Exception ex) when (ex.Message.Contains("Invalid Operation Exception"))
    {
        ...
    }
  • Interpolation de chaines de caractères:
    En plus de la syntaxe utilisant string.Format() comme:

    class dto
    {
      public const string StringMember = "des mots";
      public const int IntMember = 34;
      public const bool BoolMember = false;
    }
    
    string s = string.Format("Chaine de caractères contenant {0} puis {1} et {2}.", 
      dto.StringMember, dto.IntMember, dto.BoolMember);

    On peut dorénavant utiliser une syntaxe plus claire:

    string s = $"Chaine de caractères contenant {dto.StringMember} puis {dto.IntMember} et {dto.BoolMember}.";
  • Initialisation indexée de membres:
    var indexedMemberDict = new Dictionary<string, string>()
    {
      ["apple"] = "pomme",
      ["tomato"] = "tomate",
      ["water"] = "eau"
    };
  • Possibilité d’utiliser await dans un bloc catch et finally,
  • Opérateur nameof: il permet de retourner le nom d’une variable ou d’une méthode sous forme de chaîne de caractères:
    Si on considère la classe:

    class SimpleClass
    {
      public static string SimpleMember { get; set; }
      public static int SimpleMethod { return -1; }
    }

    On peut obtenir directement le nom du membre SimpleMember en faisant:

    string memberName = nameof(SimpleClass.SimpleMember); // Retourne "SimpleMember"
    string methodName = nameof(SimpleClass.SimpleMethod); // Retourne "SimpleMethod"
  • Opérateur ?.:
    Cet opérateur est un équivalent de:

    string value = person != null ? person.Name : null;

    On peut dorénavant utiliser la syntaxe:

    string value = person?.person.Name;
C# 5.0

    Visual Studio 2012

  • Programmation asynchrone plus facile avec les mot-clés async et await,
  • Attribut permettant de connaître l’appelant:
    public void DoProcessing()
    {
        TraceMessage("Something happened.");
    }
    
    public void TraceMessage(string message, 
        [CallerMemberName] string memberName = "",
        [CallerFilePath] string sourceFilePath = "", 
        [CallerLineNumber] int sourceLineNumber = 0)
    { ... }
C# 4.0

    Visual Studio 2010

  • Typage dynamique avec dynamic,
  • Arguments nommés et facultatifs:
    static void Main()
    {
      MethodExample(status: "KO", size: 5);
      MethodExample(5);
    }
    
    static void MethodExample(int size, string status = "OK")
    { ... }
  • Covariance et contravariance pour les interfaces et les délégués génériques.
C# 3.0

    Visual Studio 2008

  • Ajout des mot-clés select, from et where pour la fonctionnalité LinQ,
  • Expressions Lambda:
    Action<int> deletePerson = (id) => { ... };
    Func<int, int, int> addNumbers = (a, b) => a + b;
  • Méthodes d’extension:
    public static class ListExtensionMethods
    {
        public static string GetString(this List<int> items)
        { ... }
    }
    
    // Utilisation de la méthode d'extension:
    List<int> items = new <int>();
    string listAsString = items.GetString();
  • Arbres d’expressions ("expression trees"):
    // Création d'un arbre d'expressions
    Expression<Func<int, bool>> exprTree = num => num < 5;
  • Type implicite avec var: var idList = new List();.
C# 2.0

    Visual Studio 2005

  • Types génériques:
    public class CustomList<TItem> 
    {...}
  • Méthodes anonymes:
    // Création d'un délégué
    delegate void AddNumbers(int x, int y);
    // Instantiation du délégué en utilisant une méthode anonyme
    AddNumbers d = delegate(int i, int j) { ... };
  • Types nullables: int? = null;
  • Nouvel itérateur avec le mot-clé yield.

PLINQ en 5 min

L’intérêt de Parallel LINQ (PLINQ) est de proposer plusieurs méthodes pour regrouper (“merger”) le résultat d’un traitement parallèle.

Simplement en lançant un traitement parallèle en utilisant AsParallel(), on bénéficiera de méthodes pour, par exemple:

  • Ranger le résultat de chaque traitement dans une structure de données,
  • Agréger les résultats sous forme d’un entier ou d’un double pour obtenir une moyenne,
  • Agréger tous les résultats en utilisant une opération commutative et associative.

Certaines opérations sont similaires à celles de Linq:

  • Select pour effectuer une projection,
  • Where pour filtrer,
  • OrderBy pour ordonner.

1. Comparaison des performances

L’exécution d’un traitement en parallèle n’est pas forcément plus performant qu’un traitement séquentiel (cf. Parallel.For et Parallel.ForEach en 5 min). Un traitement parallèle occasionne:

  • Des changements de contexte entre threads,
  • Une synchronisation des threads pour accéder à des objets partagés.

Il faut comparer des exécutions avec une implémentation séquentielle et son équivalent en parallèle de façon à être sûr du gain apporté par un processeur multi-coeur.

Certaines opérateurs dégradent les performances de PLINQ

Certaines instructions Linq autorisent la parallélisation mais sont généralement couteuses en performance:

Join, GroupBy, GroupJoin, Distinct, Union, Intersect et Except

Pour ces instructions, l’exécution séquentielle s’avère plus rapide que l’exécution en parallèle. Il convient donc de comparer le 2 types d’implémentation pour privilégier celle qui est la plus efficace.

Dans certains cas, suivant les opérateurs qui sont utilisés, une instruction PLINQ ne sera pas forcément exécutée en parallèle. Elle peut être exécutée séquentiellement. On peut forcer l’exécution parallèle en ajoutant:

.WithExecutionMode(ParallelExecutionMode.ForceParallelism)

Performances différentes entre IEnumerable<T> et IList<T>

Pour utiliser PLINQ, il suffit que la structure satisfait au moins IEnumerable<T>. Toutefois, comme pour Parallel.For() et Parallel.ForEach(), les performances de PLINQ sont différentes suivant si la structure d’entrée satisfait IList<T> ou seulement IEnumerable<T>.

En effet:

  • Si la structure satisfait IList<T>: généralement ces structures permettent de prévoir plus facilement le partitionement des threads utilisés pour le traitement car, la liste est indexée et le nombre d’éléments de cette structure est connu. Le partitionnement des threads pour cette structure se fait par intervalle d’index (“range partitioning”) c’est-à-dire qu’un intervalle d’élements est attribué à chaque thread.
  • Pour les structures ne satisfaisant que IEnumerable<T>: il est plus difficile de prévoir le partitionnement des threads et de fait, le traitement est généralement moins performant. Le partitionnement des threads pour cette structure se fait par bloc (“chunk partitioning”) c’est-à-dire que plusieurs éléments sont traités pour chaque threads.

Par défaut, si la structure satisfait IList<T>, un partitionnement par intervalle d’index sera appliqué. Même si dans la plupart des cas, ce type de partitionnement est plus performant, il peut s’avérer plus lent dans certain cas. On peut donc imposer un partitionnement par bloc en utilisant un Partitioner.

2. Exemple simple

L’instruction de base pour utiliser PLINQ est AsParallel(). Comme pour linq, 2 notations sont possibles.

Notation “Query”

On peut utiliser les mots clés select, from, where, etc… (notation similaire à Linq):

using System; 
using System.Linq; 
//... 
 
var source = Enumerable.Range(100, 20000); 
 
var parallelQuery = from num in source.AsParallel() 
                    where num % 10 == 0 
                    select num; 
 
int[] result = parallelQuery.ToArray();

Notation “fluent”

On peut obtenir l’équivalent avec la notation “fluent”:

int[] result = Enumerable.Range(100, 20000) 
  .AsParallel() 
  .Where(n => n % 10 == 0) 
  .Select(n => n) 
  .ToArray();

3. D’autres alternatives possibles

Comme pour la parallélisation, regrouper le résultat de traitement parallèle induit un coût en performance. Il est possible d’éviter d’utiliser PLINQ.

Rangement dans une structure classique indexée

Si on effectue une exécution parallèle en utilisant Parallel.For(), on connaît précisemment le nombre d’itérations. On peut donc prévoir de ranger le résultat dans une structure dont la taille est fixe:

var source = Enumerable.Range(1, 10000); 
var result = new int?[10000]; 
 
Parallel.For(0, 10000, i => 
{ 
  if (source[i] % 2 == 0) 
  { 
    result[i] = i; 
  } 
  else 
  { 
    result[i] = null; 
  } 
}); 
 
result = result.Where(n => n.HasValue()).ToArray(); 

Rangement dans un structure de données autorisant les appels concurrents

Pour ranger le résultat d’une boucle parallèle, on peut utiliser une structure du namespace System.Collections.Concurrent comme ConcurrentBag, ConcurrentStack, ConcurrentQueue ou ConcurrentDictionary. Ces structures permettent de minimiser les contentions entre threads. Certaines d’entre elles dans certaines conditions n’utilisent pas de “lock”.

Par exemple, si on utilise ConcurrentBag pour ranger le résultat:

var source = Enumerable.Range(1, 10000); 
ConcurrentBag result = new ConcurrentBag<int>(); 
 
Parallel.For(0, 10000, i => 
{ 
  if (source[i] % 2 == 0) 
  { 
    result.Add(i); 
  } 
});

4. Maintenir l’ordre des éléments

Lorsqu’un traitement est parallélisé, pour améliorer le partitionnement des threads qui effectuent le traitement (cf. partionnement dans Parallel.For et Parallel.ForEach en 5 min), on ne garantit pas de maintenir le même ordre que la structure de données en entrée.

Pour préserver l’ordre des éléments, on peut utiliser AsOrdered().

Par exemple:

int[] result = Enumerable.Range(100, 20000) 
  .AsParallel() 
  .AsOrdered() 
  .Where(n => n % 10 == 0) 
  .Select(n => n) 
  .ToArray();

Il est possible de supprimer la contrainte de l’ordre à un endroit du pipeline après l’avoir imposé. Par exemple, on peut vouloir garantir l’ordre pour certains traitements et supprimer cette contrainte pour d’autres traitements.

Pour supprimer la contrainte d’ordre après avoir utilisé AsOrdered(), on peut utiliser AsUnordered(), par exemple:

var result = Enumerable.Range(100, 20000)  
  .AsParallel()  
  .AsOrdered() // Ordre garanti 
  .Traitement1()  
  .AsUnordered() // Ordre non garanti 
  .Traitement2();

5. Limiter le degré de parallélisme

Pour éviter de paralléliser le traitement sur tous les coeurs du processeur, on peut imposer le degré de parallélisme. Par défaut, ce nombre est égal au nombre de coeurs du processeur. Ce nombre ne peut dépasser le nombre de coeurs du processeur.

Pour imposer un degré de parallélisme, on peut utiliser l’instruction WithDegreeOfParallelism().

Par exemple:

int[] result = Enumerable.Range(100, 20000)  
  .AsParallel()  
  .WithDegreeOfParallelism(2) 
  .Where(n => n % 10 == 0)  
  .Select(n => n)  
  .ToArray();

6. Agréger le résultat d’une exécution parallèle en utilisant PLINQ

Lorsqu’on utilise AsParallel() pour paralléliser le traitement, le type de retour est ParallelQuery<T>. Les fonctions disponibles pour agréger le résultat sont celles de cette classe. Par défaut, les éléments en sortie de AsParallel() peuvent être dans un ordre différent des éléments en entrée.

Plus de détails sur MSDN.

Agréger le résultat dans une structure de données

ParallelQuery<T> permet de regrouper les résultats directement dans une structure de données en utilisant:

  • ToArray() pour regrouper dans un tableau,
  • ToList() pour une liste,
  • ToDictionary() pour un dictionaire ou
  • ToLookUp() pour regrouper dans une structure LookUp<TKey, TElement>. Cette structure permet de stocker une collection de clés. Chaque valeur de clé permet d’atteindre une ou plusieurs valeurs.

Par exemple, pour regrouper le résultat directement dans une liste:

IEnumerable<T> input = ... : 
 
var result = input.AsParallel() 
  .Select(item => Compute(item)) 
  .ToList(); 

Agréger le résultat dans une valeur unique

ParallelQuery<T> permet d’utiliser quelques méthodes pour regrouper les résultats dans une valeur unique avec:

  • Average() pour calculer la moyenne d’entiers, de décimaux ou de doubles obtenus à partir des résultats du traitement parallèle.
  • Min() ou Max() pour obtenir respectivement le minimum ou le maximum des nombres entiers, décimaux ou doubles obtenus à partir des résultats du traitement parallèle.
  • Aggregate() pour effectuer un traitement d’agrégation plus personnalisé.

Par exemple, en utilisant Average():

IEnumerable<int> input = Enumerable.Range(3, 100000-3); 
 
double average = input.AsParallel() 
  .Where(n => n % 2 == 0) 
  .Average(n => Math.Sqrt(Convert.ToDouble(n)); 

Agréger les résultats en utilisant ForAll()

ForAll() permet d’appliquer une Action<T> à tous les résultats du traitement parallélisé.

Par exemple:

List<InputData> input = ...; 
input.AsParallel() 
  .Select(i => new OutputData(i)) 
  .ForAll(o => ProcessOutput(o));

7. Sortie du traitement parallèle

La sortie d’une instruction PLINQ peut se faire en utilisant une CancellationToken dans le cas où elle est prévue. L’exécution peut aussi être interrompue si une exception survient.

CancellationToken

Pour interrompre une instruction PLINQ volontairement, on peut utiliser une CancellationToken en entourant l’instruction d’un try...catch:

var cancellationSource = new CancellationTokenSource();  
 
try  
{ 
  int[] result = Enumerable.Range(100, 20000) 
    .AsParallel() 
    .WithCancellation (cancelSource.Token) 
    .Where(...) 
} 
catch (OperationCanceledException) 
{ 
  Console.WriteLine ("Query canceled"); 
}

On peut annuler l’exécution de l’instruction PLINQ à partir d’un autre thread si on exécute:

cancellationSource.Cancel()

Sortie par exception

Une exception interrompt l’exécution, toutefois dans certains cas elle ne sera pas propagée tel quel à l’extérieur de l’instruction PLINQ:

  • Si l’exécution de l’instruction PLINQ est séquentielle: l’exception sera propagée avec le même type. Par attraper l’exception, on peut utiliser une clause try...catch avec le type de l’exception.
  • Si l’exécution est parallélisée: l’exception sera encapsulée dans une exception de type AggregateException. Pour attraper l’exception à l’extérieur de l’instruction PLINQ, il faut utiliser une clause try...catch avec le type AggregateException.

Plus de détails sur MSDN.

8. ParallelMergeOptions

Par défaut, le regroupement des itérations du traitement parallèle se fait en utilisant un buffer. L’objectif de ce buffer est d’améliorer les performances du traitement parallèle en regroupant les itérations avec un équilibre entre:

  • Une faible latence entre le début du traitement et la génération du résultat et
  • De bonne performance globale en terme de consommation des ressources.

On peut, toutefois, changer l’option par défaut de regroupement en utilisant WithMergeOptions et l’énumérable ParallelMergeOptions:

  • AutoBuffered: valeur par défaut permettant un équilibre entre faible latence et bonne performance.
  • NotBuffered: pas de buffer. Le résultat est généré dès qu’il est calculé.
  • FullyBuffered: les résultats sont rangés systématiquement dans un buffer pour qu’ils soient délivrés d’un coup en fin de traitement.

Par exemple, pour utiliser WithMergeOptions:

int[] result = Enumerable.Range(100, 20000) 
  .AsParallel() 
  .WithMergeOptions(ParallelMergeOptions.NotBuffered) 
  .Where(...)
Les opérateurs OrderBy et Reverse imposent FullyBuffered

Lorsqu’on utilise les fonctions OrderBy et Reverse, l’option ParallelMergeOptions est FullyBuffered.

Plus de détails sur MSDN.

9. Partitioner

Comme indiqué plus haut, la classe System.Concurrent.Collections.Partitioner permet de forcer le partitionnement par bloc d’une structure satisfaisant IList<T>.

Par exemple:

IList<int> input = ... ; 
var parallelQuery = Partitioner.Create (input, true) 
  .AsParallel() 
  .Where (...)

La surcharge Partitioner.Create(input, true) permet de renseigner avec le booléen la façon dont la partitionnement des threads est effectué suivant la charge:

  • true: le partitionnement est dynamique donc il convient bien pour un partitionnement par bloc.
  • false: le partitionnement est statique donc pour appliquer un partitionnement par intervalle d’index.

Plus de détails sur la classe Partitioner sur MSDN.

Références

Exécution asynchrone avec "await" et "async" en 5 min

"Await" et "async" ne sont pas des mot-clé qui permettent la création de thread 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’asynchronisme avec Async/Await permet, par exemple, d’éviter d’avoir un thread qui tourne en plus du thread principal pour attendre la réponse d’une tâche ou d’un traitement. L’asynchronisme va permettre d’exécuter du code quand la tâche ou le traitement ont fini de s’exécuter (équivalent d’une continuation). Il n’est donc pas nécessaire d’avoir un thread d’attente.

Plus de détails sur MSDN.

Async

Ce mot-clé va indiquer que la méthode contient du code qui peut être exécuté de façon asynchrone. Il s’applique sur des méthodes qui renvoient une tâche Task<TResult> ou Task.

Le type Task permet de récupérer des exceptions, d’avoir des fonctions du type Task.Wait(), Task.WaitAny() etc…

On a indiqué précedemment que pour utiliser ce mot-clé sur une méthode, le corps de cette méthode doit contenir du code à exécuter de façon asynchrone. Dans la majorité des cas, on aura à utiliser le mot-clé "await" 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 async Task WaitSynchronously()  
{  
  // Add a using directive for System.Threading.  
  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 WaitAsynchronouslyAsync()  
{  
  await Task.Delay(10000);  

  return "Finished";  
}

Plus de détails sur MSDN.

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.

De même 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): 
  } 
}

Ainsi l’exécution des boucles se feront 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 à faire ce traitement, .NET utilise des machines à états c’est-à-dire qu’il sauvegarde l’état à 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 que l’exécution sera arrêtée jusqu’à ce que la méthode retourne son résultat. En fait on peut considérer que tout ce qui se trouve après le "await" dans le code pourrait être une "continuation" (voir plus haut).

Plus de détails sur MSDN.

Exemple simple

public static async Task DownloadContent() 
{ 
  using (HttpClient client - new HttpClient()) 
  { 
    string result = await client.GetStringAsync("http://www.microsoft.com"); 
    return result; 
  } 
} 

static void Main(string[] args) 
{ 
  string result = DownloadContent().Result; 
}

La méthode "GetStringAsync" renvoie une tâche et donc il est possible d’utiliser "await" avec cette méthode.

Utilisation de async et await

La plupart du temps ces mot-clé sont utilisés avec des méthodes asynchrones de l’API .NET:

  • leur nom se terminent par "Async": HttpClient.GetStringAsync(), StreamWriter.WriteAsync().
  • des fonctions d’un service WCF qui permettent les exécutions asynchrones (avec l’attribut "OperationContractAttribute(AsyncPattern=true)" (MSDN).

Plus de détails sur MSDN.

Remarques importantes

Défauts

L’utilisation de async/await sous-entend que du code est généré pour permettre l’asynchronisme. Ce code est lourd et est plus compliqué que pour du code synchrone. Donc si le temps de traitement de la tâche est inférieur à 500ms, il n’y a pas vraiment d’intérêt à utiliser async/Await.

SynchronizationContext

Le contexte de synchronisation est ce qui permet à des threads d’être synchronisé avec le thread principal (i.e. celui de l’interface graphique).

Certaines fonctions dans la classe Task permettent de ne pas utiliser du contexte de synchronisation:
Task.ConfigureAwait(bool continueOnCapturedContext):

  • true (par défaut): on poste une continuation vers le contexte actuel.
  • false: on ne poste pas de continuation, l’exécution de la tâche s’effectue dans le pool de thread.

On peut éviter d’effectuer une continuation pour des besoins de performances (puisqu’on ne fait pas une copie du contexte de synchronization).

Deadlock

Ne pas utiliser de continuation peut entraîner des deadlocks.
Par exemple:

async void button1_Click() 
{ 
  await DoWorkAsync().Wait(); 
} 

async Task DoWorkAsync() 
{ 
  await Task.Run(); 
  Console.WriteLine("Done Task"); 
}

Dans l’appel à DoWorkAsync(), on attends la fin de l’exécution de la tâche donc le thread bloqué jusqu’à la fin de la tâche. Dans la tâche avec le Console.WriteLine(), on veut faire appel au thread principal pour écrire une ligne or le thread principal est bloqué d’où le deadlock.

Recommandations dans l’utilisation d’async/await

Tout ce qui se trouve après le "await" est considéré comme une continuation donc toutes les variables locales sont converties en champs dans la machine à état. Donc plus il y a de variables et plus il y a de champs dans la machine à état.

Il faut éviter d’utiliser trop de variables locales après le "await" en particulier lorsque le "await" est dans une boucle.

Références

Programmation asynchrone avec Async et Await: https://msdn.microsoft.com/fr-fr/library/hh191443.aspx

Gestion des “Corrupted State Exceptions” par le CLR

Toutes les exceptions n’ont pas une importance égale lors de l’exécution d’un processus. Certaines exceptions peuvent être simplement gérer au niveau d’une fonction ou d’une classe lorsqu’elles surviennent. D’autres exceptions sont plus graves parce qu’elles surviennent, par exemple, lorsque le processus a tenté de corrompre la mémoire du système d’exploitation.

Ainsi le CLR traite les exceptions lancées par le système d’exploitation de façon différente des exceptions survenues dans le processus.

Types d’exception

Les exceptions sont utilisées pour signaler qu’une opération non prévue est survenue lors de l’exécution d’une instruction. Elles peuvent être lancées par du code utilisateur, par du code du framework ou par des instructions de l’API Windows.

Exceptions survenues dans le processus

Les exceptions les plus classiques sont lancées directement par le code avec, par exemple, un throw new NotImplementedException().

Elles peuvent aussi survenir lors d’un appel à une fonction du framework. Par exemple une exception ArgumentException est lancée, si on appelle la fonction File.Open() avec un argument nul.

Généralement, ces exceptions doivent être traitées dans le scope des fonctions exécutant les instructions qui peuvent éventuellement les provoquées. On peut prévenir du lancement de ces exceptions en utilisant try...catch:

try
{  
  FileStream fs = new FileStream(name, FileMode.Create);  
}  
   catch (ArgumentException e) 
{  
  throw new System.IO.IOException("File Open Error!");  
}

D’une façon générale:

  • Lorsqu’une exception est attrapée dans un catch, il faut la traiter en la signalant avec un message de log par exemple. Si une exception n’est pas signalée, elle reste inaperçue et le comportement de la fonction et plus généralement du processus peut être différent du comportement voulu.
  • Il faut traiter des exceptions précises plutôt que de traiter une exception générale du type Exception. Par exemple, il vaut mieux utiliser catch (ArgumentException) plutôt que catch (Exception).
  • Il faut avoir en tête que le traitement des exceptions est couteux pour le CLR et les performances du processus seront affectées si beaucoup d’exceptions surviennent.

Traitement des exceptions survenues dans le processus par le CLR

Les exceptions survenues dans le processus sont traitées par le CLR suivant un système à double-passage (“two-pass exception system”).

Lorsque l’exception survient, le CLR cherche dans la pile des appels précédant l’appel de la fonction où l’exception est survenue, du code qui permet de gérer l’exception. Ainsi, il cherche le “catch” qui va permettre de gérer l’exception. Le premier “catch” trouvé sera exécuté.

Ensuite, il parcourt le code pour trouver et exécuter d’éventuelles clauses finally.

Dans le cas où il n’y a pas de code permettant de gérer une exception, elle sera lancée au niveau du thread, domaine d’applications puis plus généralement au niveau du processus.

Exceptions survenues à l’extérieur du processus

Certaines exceptions sont provoquées par le système d’exploitation parce qu’il tente de se protéger à la suite d’une exécution par le processus d’une instruction qui peut éventuellement le déstabiliser ou le corrompre.

Ces exceptions sont provoquées à l’extérieur du processus parce qu’elles sont provoquées par le système d’exploitation et que ce dernier ne connaît pas l’instruction qui a provoquée l’exception. Il sait juste le processus qui l’a provoqué.

Exceptions Win32 SEH

Windows notifie les exceptions au processus en utilisant l’API Win32 par l’intermédiaire d’une exception SEH (pour “System Exception Handling”). Le CLR convertit ensuite ces exceptions et comme il connaît les instructions qu’il exécute, il peut retrouver l’instruction qui l’a provoquée. Il présente cette exception au code managé après l’avoir encapsulé dans un objet de type Exception.

Toutes les exceptions SEH ne se valent pas en gravité. Par exemple, une exception provoquée par une division par zéro (i.e. DivideByZeroException) n’a pas la même gravité d’une exception provoquée par l’accès à une zone mémoire en dehors de celle du processus (i.e. AccessViolationException).

Ainsi certaines exceptions SEH mettent le processus dans un état catastrophique. Il est, ensuite, préférable d’arrêter le processus plutôt que de le laisser s’exécuter.

Jusqu’au framework .NET 3.5, toutes les exceptions SEH pouvaient être gérées dans une clause “catch”. A partir du framework .NET 4.0, certaines exceptions SEH ne sont pas gérées dans une simple clause “catch”.

Exceptions “Corrupted State Exception” (CSE)

A partir du framework .NET 4.0, certaines exceptions SEH sont considérées assez graves pour nécessiter l’arrêt du processus. A partir du framework 4.0, ces exceptions ne sont plus gérées, par défaut, dans une simple clause “catch”.

Par défaut, lorsque des exceptions de ce type surviennent, le CLR va stopper le processus directement sans essayer de chercher une clause “catch”.

Les exceptions SEH de ce type sont appelées “Corrupted State Exception” (CSE) parce qu’elles mettent le processus dans un état corrompu. Les exceptions SEH de Win32 qui sont considérées comme des CSE sont:

  • EXCEPTION_ACCESS_VIOLATION
  • EXCEPTION_STACK_OVERFLOW
  • EXCEPTION_ILLEGAL_INSTRUCTION
  • EXCEPTION_IN_PAGE_ERROR
  • EXCEPTION_INVALID_DISPOSITION
  • EXCEPTION_NONCONTINUABLE_EXCEPTION
  • EXCEPTION_PRIV_INSTRUCTION
  • STATUS_UNWIND_CONSOLIDATE

Même si ces exceptions ne sont plus traitées, par défaut, dans une clause “catch” comme pour les autres exceptions, il existe des méthodes pour que le code managé puisse les traiter.

Le CLR convertit la plupart de ces exceptions CSE en exception de type System.Runtime.InteropServices.SEH. Toutefois, les exceptions EXCEPTION_ACCESS_VIOLATION sont convertis en System.AccessViolationException. De même, les exceptions EXCEPTION_STACK_OVERFLOW sont convertis en exceptions de type System.StackOverflowException.

Traitement des exceptions CSE à partir du framework 4.0

Traitement des CSE par code

Pour traiter les exceptions CSE (“Corrupted State Exception”) dans du code managé, il faut utiliser l’attribut System.Runtime.ExceptionServices.HandleProcessCorruptedStateExceptionsAttribute.

Ainsi quand une exception CSE survient, le CLR va chercher du code managé permettant de gérer cette exception seulement dans les fonctions possédant l’attribut HandleProcessCorruptedStateExceptions.

Même s’il est possible de traiter les exceptions CSE avec du code, il faut garder à l’esprit que si une exception survient, le processus est dans un état instable et qu’il faut qu’il s’arrête. La seule chose qu’on doit faire dans le corps de la clause “catch” est de logger une erreur.

Gestion des SEH:

Toutes les exceptions SEH graves ne sont pas gérées de la même façon:

  • Les exceptions StackOverflowException ne sont pas considérées comme des CSE à partir du framework .NET 2.0 ne peuvent pas être traitées dans du code même avec la présence de l’attribut HandleProcessCorruptedStateExceptions.
  • Les exceptions OutOfMemoryException ne nécessite pas la présence de l’attribut pour être gérées dans du code, une clause catch (OutOfMemoryException) suffit.

Par exemple, pour utiliser l’attribut HandleProcessCorruptedStateExceptions:

using System.Runtime.ExceptionServices;  
using System.Runtime.InteropServices; 
     
[HandleProcessCorruptedStateExceptions] 
public static void HandleCorruptedState() 
{ 
  try 
  { 
    IntPtr ptr = new IntPtr(1000); 
    Marshal.StructureToPtr(1000, ptr, true); 
  } 
  catch (AccessViolationException e) 
  { 
    System.Console.WriteLine(e.Message);  
  } 
} 

Traitement des CSE dans le fichier de configuration

Pour assurer la compatibilité ascendante entre le framework 3.5 et 4.0 dans la gestion des exceptions CSE, on peut utiliser l’élément de configuration legacyCorruptedStateExceptionsPolicy dans le fichier de configuration de l’application. Cet élément permet d’indiquer que les exceptions CSE seront gérées par le code de la même façon que les exceptions normales comme sur les frameworks antérieurs au 4.0.

On peut l’utiliser en le rajoutant dans le fichier de configuration:

<configuration> 
  <runtime> 
    <legacyCorruptedStateExceptionsPolicy enabled="true"/> 
  </runtime> 
</configuration> 
HandleProcessCorruptedStateExceptions est plus fin que legacyCorruptedStateExceptionsPolicy

L’utilisation de legacyCorruptedStateExceptionsPolicy doit être limitée au cas de compatibilité ascendante d’une application du framework 3.5 au framework 4.0. Dans les autres cas, il est préférable d’utiliser l’attribut HandleProcessCorruptedStateExceptions.
En effet, l’attribut HandleProcessCorruptedStateExceptions permet une gestion beaucoup plus fine des exceptions CSE par fonction plutôt qu’une gestion globale pour toute l’application avec l’élément de configuration legacyCorruptedStateExceptionsPolicy.

Parallel.For et Parallel.ForEach en 5 min

Parallel.For() et Parallel.ForEach() permettent de paralléliser l’exécution d’une boucle. La syntaxe de ces instructions est très proche de celles des boucles for et foreach, toutefois leur utilisation est loin d’être anodine. Il faut observer certaines précautions et avoir toujours en tête que l’exécution du corps des boucles se fait en parallèle et non séquentiellement.

Parallélisation de boucles

Dans un contexte de processeurs multi-coeur, la parallélisation permet de tirer plus avantageusement les capacités du processeur. Une exécution séquentielle d’une boucle s’effectue sur un seul thread à la fois (par forcément le même). Avec un processeur multi-cœur, le thread s’exécutera sur un seul cœur et les autres cœurs resteront inactifs.

Comparaison des performances entre une exécution séquentielle et en parallèle

L’exécution en parallèle d’une boucle n’est pas forcément plus performant qu’une exécution séquentielle principalement pour plusieurs raisons:

Le changement de contexte entre threads

Même avec des architectures multi-cœur, tous les threads ne sont pas exécutés au même moment par le processeur. Pour des threads ayant la même priorité et pour que les threads avancent de la même façon, le processeur alloue un certain temps-processeur à chaque thread. Pendant ce laps de temps, le thread est exécuté, en dehors de ce laps le thread ne s’exécute pas et c’est un autre thread qui sera exécuté.

Pour passer d’un thread à l’autre, le processeur doit stocker les ressources liées au thread à désactiver et charger les ressources liées au thread à activer. Cette étape permet de charger tout le contexte lié à l’exécution du thread. Ce changement de contexte est assez couteux en temps par rapport à l’exécution d’instructions par le processeur car il nécessite d’effectuer des lectures et écritures en mémoire.

L’exécution séquentielle des boucles limite le nombre de changement de contexte par rapport à une exécution en parallèle. Si les changements de contexte sont trop nombreux par rapport à une exécution séquentielle, une exécution en parallèle peut s’avérer moins performante.

Création de threads

Pour exécuter des instructions en parallèle, une solution consiste à créer plusieurs threads, de les exécuter en parallèle puis de les détruire. La création de threads prends du temps et peut rendre l’exécution peu performante si beaucoup de threads sont créés et qu’ils exécutent peu d’instructions.

Pour palier à ce problème et permettre une exécution en parallèle d’instructions, une possibilité est d’utiliser un “pool de threads”. Ce “pool” permet de créer un certain nombre de threads et d’exécuter les instructions sur ces threads sans les détruire.

En .Net, le “pool” de threads est disponible avec la classe statique System.Threading.ThreadPool. Plus de détails à propos de cette classe sur MSDN.

Synchronisation des objets partagés par plusieurs threads

Lorsque des threads partagent des objets communs, et pour éviter les accès concurrents à ces objets, il faut synchroniser les threads pour qu’ils n’effectuent pas de lectures et des écritures en même temps dans ces objets.

Beaucoup de mécanismes existent pour synchroniser l’exécution des threads. On peut trouver une liste exhaustive de ces mécanismes dans Threading en C# de Joe Albahari.

D’une façon générale, les mécanismes de synchronisation ralentissent l’exécution puisque des threads doivent attendre que d’autres threads terminent leur exécution. Une bonne implémentation des mécanismes de synchronisation minimise le temps d’attente des threads de façon à ce qu’ils exécutent le plus d’instructions possibles dans un laps de temps.

Une exécution séquentielle ne nécessite pas de mécanisme de synchronisation par rapport à une exécution en parallèle.

Préparer l’implémentation pour utiliser Parallel.For() ou Parallel.ForEach()

Partitionnement

Le partitionnement permet d’affecter une tâche à exécuter à un thread. Comme indiqué plus haut, dans le cas où on a plusieurs tâches à exécuter, une solution consiste à utiliser un “pool” de threads. Ce “pool” comprend un nombre limité de threads qui se partiront l’exécution de toutes les tâches.

Le partionnement va donc consister à répartir ces tâches sur chaque thread. Un partionnement efficace repartira équitablement les tâches pour que la charge processeur soit plus ou moins identique pour chaque thread. Dans les faits, la répartition n’est jamais équitable parce qu’il est difficile de prévoir la charge que va engendrer l’exécution d’une tâche.

A l’opposé, un mauvais partionnement peut mener à une charge processeur déséquilibrée qui peut entraîner une charge importante sur quelques threads et une charge faible sur les autres. Un déséquilibre fait baisser l’efficacité d’une exécution parallèle en particulier dans un contexte multi-cœur.

La synchronisation entre threads tend à faire baisser l’efficacité d’une exécution parallèle puisqu’elle augmente l’attente de threads par rapport à d’autres. Plus il y a de synchronisation entre thread et moins efficace sera le partionnement.

Dans le cas où les tâches à exécuter sont équivalentes, le partionnement le plus efficace consiste à affecter exactement le même nombre de tâches à chaque thread. Sans avoir systématiquement ce cas de figure, une bonne implémentation est un compromis entre le moins de synchronisation possible et un partionnement permettent une répartition dynamique pour favoriser la charge processeur équitable entre threads.

Eviter un grand nombre de changements de contexte

Dans le corps d’une boucle, l’appel à un délégué a un coût. Un grand nombre d’appels à un délégué à partir de threads différents peut mener à des changements de contexte fréquents entre threads. Le gain provenant de l’utilisation d’une boucle Parallel.For() ou Parallel.ForEach() peut être perdu dans le cas où ces changements de contexte dégraderaient trop les performances.

D’une façon générale, éviter d’avoir un corps de boucle trop petit permet de limiter des changements de contexte fréquents. Toutefois il faut tester différentes implémentations de façon à assurer que celle qui est choisie est la plus optimale.

Implémentations possibles de boucles parallèles avec Parallel

Contrairement aux apparences, les différentes implémentations de boucles avec Parallel ne sont pas équivalentes. Ainsi:

  • Parallel.For() n’utilise pas “lock”: les accès aux éléments de la liste se font avec un index, il n’y a donc pas besoin de “locks”. Le partionnement est de plus prévisible puisqu’on connait la taille de la liste. Cette implémentation est particulièrement efficace pour répartir la charge processeur équitablement.
  • Parallel.ForEach(): dans le cas où la structure parcourue satisfait IList<T> cette implémentation est aussi efficace que Parallel.For() et n’utilise pas de “lock”. En revanche si la structure satisfait seulement IEnumerable<T>, Parallel.ForEach() utilise des “locks” pour accéder aux éléments de la structure. De plus le partitionnement est moins efficace car le nombre d’éléments de la structure est inconnu.
  • Parallel.Invoke(): cette implémentation est la moins efficace car elle ne permet pas de prévoir un partitionnement optimal.

Parallel.For()

Par exemple:

ParallelLoopResult result = Parallel.For(0, 100, cpt =>  
{  
  // ...
});

Cette implémentation est la plus efficace, elle permet d’exploiter toutes les optimisations possibles:

  • Partionnement efficace: elle permet de partitionner efficacement la charge processeur.
  • Paramétrage dynamique du nombre de threads: elle permet de gérer dynamiquement le nombre de threads pendant l’exécution pour optimiser la répartition de la charge.
  • Gestion de long: une surcharge permet d’utiliser des objets de type long.
  • Boucles imbriquées: il est possible d’implémenter des boucles Parallel.For() dans une autre boucle Parallel.For() sans se préoccuper de nombre de threads utilisés. Une boucle Parallel.For() imbriqué prend en compte le nombre de threads déjà exécuté par une boucle de plus haut niveau.
  • Pas d’utilisation de “lock”: il n’est pas nécessaire d’utiliser un “lock” pour exécuter des itérations parallèlement.
  • Gestion des exceptions

Accès couteux à une liste indexée

Dans la plupart des cas, il faut privilégier cette implémentation. Toutefois elle peut ne pas convenir dans certaines conditions notamment si l’accès indexé à la structure de données parcourue est très couteux. Dans ce cas il vaut mieux parcourir la structure sous forme de IEnumerable<T> plutôt qu’en l’utilisant en structure indexée avec IList<T>.
On peut forcer à utiliser la structure en IEnumerable<T>.

Parallel.ForEach()

Par exemple:

List items = new List{ ... }; 
Parallel.ForEach(items, item =>  
{  
  // ...
});
Toutes les surcharges de Parallel.ForEach() ne sont pas équivalentes

L’exécution de cette boucle est différente suivant le type de l’objet énumérable passé en paramètre:

  • Si l’objet satisfait IList<T>: le fonctionnement est similaire à celui d’une boucle Parallel.For() et l’exécution parallèle des itérations se fait sans utiliser de “lock”. Cette surcharge bénéficie des mêmes optimisations que les boucles Parallel.For() concernant la répartition efficace de la charge.
  • Si l’objet satisfait IEnumerable<T>: l’exécution est moins efficace que le cas précédent puisque le nombre d’itérations n’est pas connu en avance. De plus un “lock” est utilisé pour éviter des appels concurrents aux fonctions de l’interface IEnumerable<T> en particulier si la structure n’est pas “thread-safe”.

Cette implémentation est la plus flexible puisque la plupart des structures de données satisfont IEnumerable<T>. Elle bénéficie de certaines fonctionnalités communes avec Parallel.For():

  • La gestion des exceptions,
  • Paramétrage dynamique du nombre de threads,
  • Partitionnement efficace: même s’il n’est pas aussi précis que pour Parallel.For().

Présence d’un “lock” pour accéder aux éléments de la structure IEnumerable<T>

L’inconvénient majeur de cette implémentation est la présence de “lock” pour accéder aux éléments de la structure IEnumerable<T> (si la structure satisfait IList<T>, Parallel.ForEach() n’utilise pas de “lock”).

Il faut avoir en tête que l’acquisition de “lock” se fait à chaque accès à un élément de la structure parcourue, ensuite un thread exécute une ou plusieurs itérations de la boucle donc plus il y a des accès au corps de la boucle Parallel.ForEach(), plus il y aura des changements de contexte entre threads. Un grand nombre de changements de contexte peut ralentir l’exécution de la boucle.

Dans le cas où on se rends compte du manque d’efficacité de la boucle Parallel.ForEach(), il faut augmenter la taille du code exécuté dans le corps de la boucle pour minimiser les changements de contexte.

Par exemple, si on doit traiter une structure IEnumerable<T> avec:
Action<T> treatment = item => { ... }.

Au lieu d’utiliser la boucle directement:

Parallel.ForEach(items, treatment);

On peut trouver un élément discriminant dans la structure IEnumerable<T> pour organiser les éléments, par exemple, dans une structure comme un Dictionary<TKey, IEnumerable<T>> (les accès en lecture seule d’un dictionnaire sont “thread-safe” s’il n’y a pas d’écritures).

On peut alors utiliser le dictionnaire:

Dictionary<Key, IEnumerable<T>> dictionary = new Dictionary<Key, IEnumerable<T>> { ... }; 
Parallel.ForEach(dictionary.Keys, key =>  
{ 
  foreach (var item in dictionary[key]) 
  { 
    treatment(item); 
  } 
});

Forcer l’utilisation de Parallel.ForEach() avec Partitioner

Dans certains cas, on peut se rendre compte que l’utilisation de Parallel.For() est moins performant que Parallel.ForEach(), on peut utiliser la classe System.Concurrent.Collections.Partitioner pour forcer l’utilisation de Parallel.ForEach() avec une structure satisfaisant IList<T>.

La fonction Partitioner.Create() permet d’obtenir un OrderablePartitioner<Tuple<Int32, Int32>> à partir d’un intervalle from...to.

Si on utilise Parallel.For() de cette façon:

Parallel.For(from, to, i =>  
{ 
  // ...  
});

On peut forcer l’utilisation de Parallel.ForEach() en écrivant:

Parallel.ForEach(Partitioner.Create(from, to), range =>  
{ 
  for (int i = range.Item1; i < range.Item2; i++)  
  { 
    // ...  
  }  
});

Forcer l’utilisation de Parallel.ForEach() avec LinQ

On peut aussi utiliser LinQ pour forcer la surcharge de Parallel.ForEach() avec pour argument une structure IEnumerable<T>:

IList<T> source = ...; 
Parallel.ForEach(source.Select(i => i), item =>  
{  
  // ...  
});

Paralle.Invoke()

Par exemple:

Parallel.Invoke( 
  () => action1(), 
  () => action2(), 
  () => action3() 
);

Cette surcharge est la moins performante des 3. Elle permet d’appeler directement plusieurs délégués. Contrairement aux autres surcharges, il n’y a pas forcément un parcourt d’une structure de données ni de boucles. Les délégués sont exécutés parallèlement suivant le degré de parallélisme possible sur la machine.

Cette implémentation est un équivalent à l’utilisation de Task avec un Task.WaitAll():

var t1 = Task.Factory.StartNew(() => action1()); 
var t2 = Task.Factory.StartNew(() => action2()); 
var t3 = Task.Factory.StartNew(() => action3()); 
Task.WaitAll(t1, t2, t3);

Parallel.Invoke() convient bien s’il n’y a pas beaucoup de délégués. Dans le cas contraire, il faut privilégier l’utilisation de Parallel.For() ou Parallel.ForEach().

Sortie d’une boucle

Sortie prévue en utilisant Stop() ou Break()

Il est possible d’arrêter une boucle Parallel.For() et Parallel.ForEach() en utilisant Stop() ou Break(). Toutefois l’utilisation de ces méthodes doit respecter certaines précautions.

Stop() et Break() peuvent s’utiliser de cette façon:

ParallelLoopResult loopResult = Parallel.For(0, N,  
(int i, ParallelLoopState loop) => 
{ 
  // ... 
  loop.Stop(); 
  // ... 
});

loop.Break() peut s’utiliser de la même façon.

Stop()

Lorsque Stop() est exécuté, la boucle Parallel.For() ou Parallel.ForEach() n’exécute plus de nouvelles itérations. Les itérations commencées sont terminées. La boucle se terminera normalement s’il n’y a pas d’exception.

Le résultat de la boucle ParallelLoopResult sera false.

loop.IsStopped() permet de savoir si loop.Stop() a été exécuté dans une des itérations. L’intérêt de cette méthode est de permettre de stopper une itération longue dans le cas où un arrêt est requis.

Stop() convient bien pour les structure non ordonnées puisque toute itération avant ou après l’itération où Stop() est exécuté, ne sera pas exécutée.

Break()

Lorsque Break() est exécuté, la boucle Parallel.For() ou Parallel.ForEach() tente d’arrêter les itérations après celle où Break() a été exécuté. Les itérations précédent l’itération où Break() est exécuté seront exécutées.

Toutefois il n’y a pas de garantie absolue que les itérations suivant l’itération où Break() a été exécuté, ne soient pas exécutées. Leur exécution sera juste évitée.

loop.LowestBreakIteration() retourne l’itération à partir de la laquelle on a exécuté Break().

Break() convient bien pour les structures ordonnées puisqu’il y a une notion d’itérations précédents et suivants l’itération où Break() a été exécuté.

L’exécution de Stop() et Break() dans la même boucle provoque une exception

Il faut prendre des précautions si on utilise Stop() et Break() dans le même corps d’une boucle Parallel.For() ou Parallel.ForEach(). En effet si on exécute Stop() puis Break(), une exception survient.

On peut résumer certaines propriétés lorsque Stop() et Break() sont utilisés de cette façon:

Instruction exécutée ParallelLoopResult
.IsCompleted
ParallelLoopResult
.LowestBreakIteration
.HasValue
ParallelLoopState
.IsStopped
Pas d’exécution de Stop() ou Break() true (si pas d’exception) false false
Stop() false true true
Break() false true false

Sortie par exception

L’utilisation d’un bloc try...catch dans une boucle Parallel.For() et Parallel.ForEach() peut s’utiliser de cette façon:

var exceptions = new ConcurrentQueue();  
Parallel.For(0, N, i => 
{ 
  try 
  { 
    // ... 
  } 
  catch (Exception e)  
  { 
    exceptions.Enqueue(e);  
  } 
}); 
if (!exceptions.IsEmpty) 
{ 
  throw new AggregateException(exceptions); 
}

Il faut avoir en tête qu’une exception qui survient à l’intérieur de la boucle dégrade les performances. Si une exception survient pour beaucoup d’itérations, l’exécution peut être considérablement ralentie.

CancellationToken

Les boucles Parallel.For() et Parallel.ForEach() supportent l’utilisation de CancellationToken. A chaque nouvelle itération, Parallel.For() et Parallel.ForEach() vérifient si CancellationToken.IsCanceled est vraie, si c’est le cas, une OperationCancelledException est lancée. Il faut donc protéger la boucle dans le cas où on utilise un CancellationToken:

CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); 
// ... 
var options = new ParallelOptions { CancellationToken = cancellationTokenSource.Token }; 
try 
{ 
  Parallel.For(0, N, options, i => 
  { 
    // ... 
  }); 
} 
catch(OperationCanceledException operationCanceledException) 
{ 
  // Traitement de l'exception 
}

IsExceptional

Dans le cas d’itérations longues, si une exception survient, la sortie de la boucle Parallel.For() ou Parallel.ForEach() peut prendre du temps. Pour éviter des comportements imprévus, on peut utiliser ParallelLoopState.IsExceptional pour vérifier si une exception est survenue dans une autre itération.

Pour résumer…

  • Pour utiliser des boucles Parallel.For() et Parallel.ForEach(), il faut implémenter le code des boucles pour que chaque itération soit indépendante.
  • Le corps des boucles ne doit pas être de trop petite taille pour éviter des changements de contexte trop nombreux lors de l’exécution des itérations. Il faut tester différentes implémentations pour comparer les performances.
  • Parallel.For() est généralement plus performant que Parallel.ForEach() car l’accès à une structure indexée permet de prévoir un partionnement plus optimal des threads lors de l’exécution.
  • Parallel.For() n’utilise pas de “locks” pour accéder aux différents éléments de la structure.
  • Parallel.ForEach() fonctionne comme Parallel.For() pour les structures satisfaisant IList<T>. En revanche, pour les structures satisfaisant seulement IEnumerable<T>, Parallel.ForEach() utilise des “locks” pour accéder aux différents éléments de la structure.
  • Parallel.Invoke() est moins performant que Parallel.For() et Parallel.ForEach(), toutefois la différence peut être minime suivant l’implémentation du corps des boucles. Il convient donc de tester pour comparer les performances.
  • Dans certains cas, on peut forcer l’utilisation d’une structure avec IEnumerable<T> plutôt que IList<T> pour Parallel.ForEach() en utilisant Partionner ou LinQ.
  • L’exécution successive de ParallelLoopState.Stop() et ParallelLoopState.Break() dans le même corps d’une boucle Parallel.For() et Parallel.ForEach() provoque une exception.
  • Lorsque ParallelLoopState.Stop() est exécuté, la boucle évitera d’exécuter toute nouvelle itération. ParallelLoopState.Stop() convient bien pour les structures non indexées.
  • Lorsque ParallelLoopState.Break() est exécuté, la boucle évitera d’exécuter toute nouvelle itération se trouvant après l’itération durant laquelle ParallelLoopState.Break() a été exécutée. ParallelLoopState.Break() convient bien pour les structures indexées.
Références

MSBuild en 5 min

MSBuild est un moteur de compilation utilisé par Visual Studio pour effectuer des opérations de compilation ou de nettoyage. Lorsque ces opérations sont lancées au moyen de l’interface, c’est l’exécutable msbuild.exe qui sera, en fait, exécuté.

Ainsi, à partir du fichier de la solution ou des fichiers de projets, MSBuild va ordonner et lancer la compilation en utilisant le compilateur csc.exe de façon à générer une application exécutable ou une assembly. Pour connaître la façon d’ordonner la compilation et faire appel à csc.exe avec les bons arguments, MSBuild utilise les informations contenues dans le fichier solution ou dans les fichiers de projet. De ce fait, ces fichiers constituent des espèces de scripts lisibles par MSBuild.

MSBuild est découplé de Visual Studio ce qui permet de:

  • L’utiliser à partir de la ligne de commandes sur des serveurs sans lancer Visual Studio,
  • Personnaliser le mécanisme de compilation pour effectuer des opérations particulières, par exemple, avant la compilation ou pour copier le résultat de compilation dans un répertoire particulier etc…

Le point d’entrée de msbuild.exe est un fichier solution (i.e. un fichier de type .sln) ou un fichier projet (i.e. un fichier de type .csproj, .vbproj, .vcxproj etc…).

Comment accéder à MSBuild ?

Avant Visual Studio 2013, MsBuild était livré avec le Framework .NET et sa version était couplée avec celle du Framework .NET. Depuis Visual Studio 2013 et le Framework .NET 4.5.1, MSBuild est livré avec Visual Studio et sa version est désormais couplée avec celle de Visual Studio. Toutefois l’exécutable msbuild.exe reste découplé de Visual Studio et il est possible de l’exécuter sans que Visual Studio ne soit lancé.

Pour plus d’informations sur les versions de MSBuild: Versions des composants .NET.

Chemin de msbuild.exe

Pour connaître les versions de msbuild.exe sur le disque, il faut aller dans la base de registres et consulter la valeur MSBuildToolsPath des clés se trouvant dans:

HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\MSBuild\ToolsVersions\

Il y a autant de clés que de versions du Framework .NET installées. Jusqu’à la version 4.0 (et jusqu’au Framework .NET 4.0 inclus), msbuild.exe se trouve dans le répertoire Windows:

[répertoire de windows]\Microsoft.NET\Framework64\[version du framework .NET]\

Par exemple:

C:\Windows\Microsoft.NET\Framework64\v4.0.30319\

Ou

C:\Windows\Microsoft.NET\Framework64\v2.0.50727\

A partir de Visual Studio 2013, msbuild.exe se trouve dans:

[répertoire Program Files (x86)]\MSBuild\[version Visual Studio]\bin\amd64\

Par exemple:

C:\Program Files (x86)\MSBuild\12.0\bin\amd64\

Ou

C:\Program Files (x86)\MSBuild\14.0\bin\amd64\

Comment appeler MSBuild ?

Pour lancer la compilation d’une solution

Pour compiler une solution à partir du fichier .sln et en précisant la configuration de compilation et la plateforme cible (la configuration et la plateforme doivent être définis dans le fichier “.sln”), il faut exécuter:

msbuild.exe "solutionName.sln" /p:configuration=Debug /p:platform="Any CPU"

Les propriétés "/p" sont facultatives.

Appeler une “target” particulière

Les actions de génération (build), de nettoyage (clean) ou de regénération (rebuild) correspondent à des “targets” prédéfininies. Il est possible d’exécuter une “target” sur projet en tapant:

msbuild.exe "[chemin du fichier projet]" /t:[nom des "targets" séparées par ";"]

Par exemple:

msbuild.exe "projectName.csproj" /t:build

Personnaliser une chaine de build avec MsBuild

Le gros intérêt de s’intéresser à MSBuild est de pouvoir personnaliser des comportements de la chaine de build. MSBuild utilise en entrée:

  • des fichiers projet de type .csproj, .vbproj, .vcproj, .vcxproj etc…: se sont des projets typiques de Visual Studio.
  • des fichiers de type .proj ou .targets: ce sont des fichiers qui ne sont pas des projets Visual Studio mais respectant la syntaxe attendue par MSBuild.

Ces fichiers définissent des éléments qui seront lus par MSBuild pour effectuer ces différentes actions. Les actions ne sont pas forcement des actions de génération ou nettoyage, l’intérêt est de pouvoir définir et exécuter d’autres actions comme faire appel à un script powershell, effectuer des copies de fichiers, afficher un message etc…

Les éléments principaux d’un fichier projet exécutable par MSBuild sont:

  • target: cet élément est désigné par un nom qui pourra être appelé en argument de MSBuild de façon à indiquer l’action qui doit être réalisée. Les “targets” prédéfinies sont "build", "clean" et "rebuild" mais il est possible d’en définir d’autres. Les targets exécutent des “tasks”.
  • task: ce sont des actions simples à exécuter, comme par exemple, faire une copie de fichiers ou lancer une compilation etc…
  • item: généralement MSBuild traite une liste d’éléments, le plus souvent une liste de fichiers.
  • property: il s’agit d’une variable dont on définit la valeur et qui est réutilisable dans le reste du script.

Fichier projet

Ce fichier contient la définition des éléments définis plus haut de façon à ce que MSBuild exécute des actions.
Un fichier projet doit contenir un noeud Project, par exemple:

<?xml version="1.0" encoding="utf-8" ?> 
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
  <Target Name="Build"> 
    <Message Text="Hello world" /> 
  </Target> 
</Project>

Pour exécuter MSBuild avec ce fichier, il faut taper:

msbuild.exe helloWorld.proj /t:Build

DefaultTargets

Si on précise une “target” par défaut:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"  
   DefaultTargets="Build"> 

On peut exécuter directement la “target” en tapant:

msbuild.exe helloWorld.proj

Property

On peut définir des variables pour lesquelles on affecte des valeurs dans des noeuds PropertyGroup:

<PropertyGroup> 
  <[nom de la propriété]>[valeur de la propriété]</[nom de la propriété]> 
</PropertyGroup> 

On peut ensuite réutiliser une propriété en utilisant la syntaxe:

$([nom de la propriété])

Par exemple:

<PropertyGroup> 
    <OutputDirectory>bin\debug</OutputDirectory> 
    <OutputAssembly>$(OutputDirectory)\HelloWorld.exe</OutputAssembly> 
    <Optimize>false</Optimize> 
</PropertyGroup> 

Portée des propriétés

La portée des propriétés n’est pas la même si elles sont définies directement dans le noeud Project du fichier projet ou dans un noeud target.

Propriétés réservées

Certaines propriétés sont réservées et reconnues directement par MSBuild sans nécessiter d’en définir la valeur au préalable.
Par exemple:

Nom Valeur
MSBuildProjectDirectory Répertoire où se trouve le fichier projet
MSBuildProjectFile Nom du fichier projet
MSBuildProjectExtension Extension fichier projet
MSBuildProjectPath Chemin du fichier projet
MSBuildProjectDefaultTargets Liste des “targets” par défaut du fichier projet
MSBuildBinPath Répertoire où se trouve msbuild.exe

On peut trouver une liste plus exhaustive de ces propriétés sur MSDN.

Variables d’environnement

Elles sont accessible dans un fichier projet en utilisant directement son nom. Par exemple pour la variable d’environnement BIN_PATH:

<FinalOutput>$(BIN_PATH)\MyAssembly.dll</FinalOutput>

Valeur de la base de registre

Une valeur est accessible avec la syntaxe:

$(registry:[chemin de la clé]@[nom de la valeur])

Par exemple:

<PropertyGroup> 
  <VisualStudioWebBrowserHomePage> 
    $(registry:HKEY_CURRENT_USER\Software\Microsoft\VisualStudio\12.0\WebBrowser@HomePage) 
  </VisualStudioWebBrowserHomePage> 
<PropertyGroup> 

Propriétés dont la valeur est définie à l’appel de MSBuild

On peut utiliser directement des propriétés dans un fichier projet si leur valeur a été définie dans l’appel à MSBuild. Pour définir la valeur d’une propriété dans l’appel, on peut taper:

msbuild.exe [nom fichier projet] /p:[nom de la propriété]=[valeur de la propriété]

Par exemple:

msbuild.exe helloWorld.proj /p:platform=x64

Condition d’affectation

On peut préciser une condition d’affectation pour la valeur d’une propriété en utilisant l’attribut Condition dans le noeud de la propriété:

<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>

Plus de détails sur les propriétés sur MSDN.

Item

Ce sont des listes dynamiques d’éléments, le plus souvent, des listes de fichiers. Ces listes ont une portée similaire à celle des propriétés en fonction du noeud dans lequel elles sont définies.

Les valeurs des “items” sont calculées avant exécution des “targets”

Si ces listes sont fournies en entrée des “targets”, leur valeur est calculée avant d’exécuter les “targets”.

Un “item” est défini dans un noeud ItemGroup.

On parle d’un “item” mais il s’agit bien d’une liste d’éléments.

Définition d’items sans wildcards

Pour définir une liste simple d’items, on peut indiquer directement leur valeur dans un sous-noeud d’un noeud ItemGroup:

<ItemGroup> 
  <Compile Include = "file1.cs"/> 
  <Compile Include = "file2.cs"/> 
</ItemGroup>

Ou

<ItemGroup> 
  <Compile Include = "file1.cs;file2.cs"/> 
</ItemGroup>

Dans cet exemple “l’item” Compile contient une liste de fichiers.

Définition dynamique d’items avec wildcards

Les valeurs de l’item peuvent être calculées dynamiquement en fonction de la valeur des attributs Include et Exclude:

  • Include: permet d’indiquer les fichiers qui seront inclus dans la liste.
  • Exclude: permet d’indiquer les fichiers qui seront exclus de la liste.

Par exemple:

<ItemGroup> 
  <MyReleaseFiles Include=".\bin\debug\*.*" Exclude=".\bin\debug\*vshost.exe" /> 
</ItemGroup>

On peut utiliser les caractères de remplacement (i.e. wildcards) suivants pour les attributs Include et Exclude:

  • ?: permet d’indiquer la présence d’un caractère.
  • *: permet d’indiquer la présence de zéro ou plusieurs caractères.
  • **: permet d’indiquer une suite partielle de chemin, par exemple, "D:/**/*.cs" permet de désigner des fichiers .cs dans des répertoires différents sur le disque “D”.

Utiliser un “item”

Pour utiliser un “item”, il faut utiliser la syntaxe:

@([nom de la liste])

Ainsi la liste “MyReleaseFiles”:

<ItemGroup> 
  <MyReleaseFiles Include=".\bin\debug\*.*" /> 
</ItemGroup>

Peut être utilisée dans une “target”:

<Target Name="Release" > 
  <Copy SourceFiles="@(MyReleaseFiles)" DestinationFolder="$(MyReleaseOutput)" /> 
</Target>

Metadata

Il est possible de définir des metadatas par éléments de la liste de cette façon:

<ItemGroup> 
  <InputFile Include="One.cs" > 
    <Culture>false</Culture> 
  </InputFile> 
  <InputFile Include="Two.cs"> 
     <Culture>true</Culture> 
  </InputFile> 
</ItemGroup>

Plus de détails sur les metadatas sur MSDN.

Task

Une “task” est une action exécutée au sein d’une “target”. Une “target” peut comporter plusieurs “tasks”.
Pour appeler une “task”, il faut l’ajouter dans un noeud Target, par exemple la “task” MakeDir consistant à créer un répertoire, peut être appelée de la façon suivante:

<Target Name="MakeBuildDirectory"> 
  <MakeDir Directories="$(BuildDir)" /> 
</Target>

Les “tasks” font référence à une implémentation qui se trouve une classe .NET. Il existe plusieurs “tasks” usuelles prédéfinies dans Microsoft.Build.Framework. Toutefois il est possible de définir une “task” personnalisée avec une implémentation C#.

Utilisation d’un “item” en entrée de la “task”

Lorsqu’une “task” utilise un “item” défini en dehors de la “target” dans laquelle se trouve la “task”, “l’item” est calculé avant l’exécution de la “target”.
“L’item” qui est calculé à l’intérieur d’une “task” correspond au résultat de la “task”. Ce résultat doit être indiqué dans un élément Output.

Résultat d’une “task”

Une “task” peut éventuellement calculer un résultat. Si c’est le cas, ce résultat doit être rangé dans un élément Output. Cet élément doit se trouver à l’intérieur du noeud de la “task”:

<Target Name="CopyFiles"> 
  <Copy 
    SourceFiles="@(MySourceFiles)" 
    DestinationFolder="@(MyDestFolder)"> 
    <Output 
        TaskParameter="CopiedFiles" 
        ItemName="SuccessfullyCopiedFiles"/> 
  </Copy> 
</Target>

Dans cet exemple, le résultat de la “task” Copy qui est une liste de fichiers copiés, est calculée dans “l’item” SuccessfullyCopiedFiles. “CopiedFiles” est le paramètre de la “task” Copy qu’on souhaite affecter à “l’item” SuccessfullyCopiedFiles.
Sachant que le résultat de la “task” est rangé dans un “item”, on peut y accéder en utilisant la syntaxe:

@(SuccessfullyCopiedFiles)

Lorsque le résultat doit être rangé dans une propriété, dans le noeud Output, il faut utiliser l’attribut PropertyName, par exemple:

<Csc ... 
    <Output TaskParameter="BuildSucceeded" 
              PropertyName="BuildWorked" /> 
</Csc>

Comme une propriété normal, le résultat de la “task” rangé dans une propriété, peut être utilisée en utilisant la syntaxe:

$(BuildWorked)

Plus de détails sur l”élément Output sur MSDN.

“Tasks” courantes

Quelques “tasks” les plus courantes:

Nom Fonction
Message Affiche un message sur la console.
Copy Permet de copier un ou plusieurs fichiers d’un répertoire à un autre
Delete Supprimer un ou plusieurs fichiers
MakeDir Crée un ou plusieurs répertoires
RemoveDir Supprime un ou plusieurs répertoires
Exec Permet d’exécuter un processus externe
MSBuild Permet d’exécuter un ou plusieurs “targets” dans un fichier MSBuild externe
Csc Permet d’exécuter le compilateur C# pour produire un exécutable ou une assembly

Target

Une “target” correspond à une liste d’actions qu’il est possible d’exécuter en indiquant le nom de la “target” lors de l’appel à MSBuild. Une “target” est composée de plusieurs “tasks” qui correspondent à des actions à exécuter.

Le plus souvent le point d’entrée d’une “target” est un ou plusieurs “items”. Toutefois il faut avoir en tête que lorsque qu’un “item” défini en dehors de la “target”, est calculé avant l’exécution de la “target”.

Une “target” se définit à l’intérieur d’un noeud Target et est identifiable en utilisant l’attribut Name:

<Target Name="MakeBuildDirectory"> 
  <MakeDir Directories="$(BuildDir)" /> 
</Target>

Le résultat d’une “target” correspond au résultat qui pourrait éventuellement être défini dans une “task” de la “target” (voir Résultat d’une “task”).

Plus de détails sur les “targets” MSBuild sur MSDN.

Appeler une “target” lors de l’appel à msdbuild.exe

Il faut utiliser l’option "/t":

msbuild.exe /t:MakeBuildDirectory

Définir une dépendance entre les “targets”

On peut définir une dépendance entre “targets” en utilisant l’attribut DependsOnTarget dans le noeud Target:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
  <Target Name="Build"> 
    <!-- ... --> 
  </Target> 
  <Target Name="Release" DependsOnTargets="Build"> 
    <!-- ... --> 
  </Target> 
</Project>

Dans cet exemple, la “target” Release dépend de la “target” Build. Cette dépendance permet d’exécuter les “targets” dans un ordre qui permet de respecter les dépendances. Ainsi la “target” Build sera exécutée avant la target Release.

Appel d’une “target” dans un autre fichier projet

Pour appeler une “target” qui se trouve dans un autre fichier projet, il faut utiliser la “task” MSBuild:

<?xml version="1.0" encoding="utf-8" ?> 
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
  <Target Name="Build"> 
    <MSBuild Projects="msbuildintro.csproj" Targets="Build" /> 
  </Target> 
</Project>

Dans cet exemple, la “task” va exécuter la “target” Build dans le fichier projet msbuildintro.csproj.

Importer des “targets” d’un autre fichier MSBuild

On peut importer des “targets” définis dans un autre fichier MSBuild. Ce fichier peut être un fichier projet, toutefois ces “targets” sont généralement définis dans un fichier spécialisé avec une extension “.targets”.
L’import se fait en utilisant le noeud Import à l’intérieur du noeud Project du fichier projet:

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
    <Import Project="$(CommonLocation)\General.targets" /> 
</Project>

Dans cet exemple, l’import se fait à partir du fichier se trouvant dans $(CommonLocation)\General.targets.

Fichiers “.targets” livrés avec MSBuild

Les projets Visual Studio comportent des “targets” prédéfinis: build, clean et rebuild. Ces “targets” sont définis dans des fichiers “.targets” qui sont livrés avec MSBuild. Ces fichiers sont importés dans le fichier projet de Visual Studio.
Les fichiers “target” livrés avec MSBuild sont:

  • Microsoft.Common.targets: pour l’importer il faut indiquer: <Import Project="Microsoft.Common.targets" />.
  • Microsoft.CSharp.targets: pour l’importer il faut indiquer: <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />.
  • Microsoft.VisualBasic.targets: pour l’importer il faut indiquer: <Import Project="$(MSBuildToolsPath)\Microsoft.VisualBasic.targets" />.

Ces fichiers se trouvent dans le répertoire de MSBuild. Jusqu’au Framework .NET 4.0, ce répertoire est:

[répertoire de windows]\Microsoft.NET\Framework64\[version du framework .NET]\

Par exemple:

C:\Windows\Microsoft.NET\Framework64\v4.0.30319\

A partir de Visual Studio 2013, le chemin est:

[répertoire Program Files (x86)]\MSBuild\[version Visual Studio]\bin\amd64\

Par exemple:

C:\Program Files (x86)\MSBuild\12.0\bin\amd64\

“Task” personnalisée

Des “tasks” personnalisées peuvent être implémentées en utilisant directement du code C#.

Par exemple, le code C# de la “task” peut être implémenté dans une assembly. Il faut dériver la classe “task” à définir de Microsoft.Build.Framework.Task:

using System; 
using Microsoft.Build.Utilities; 
using Microsoft.Build.Framework; 
 
namespace MyTasks 
{ 
  public class AddTask : Task 
  { 
    private int argument1; 
    private int argument2; 
    private int sum; 
 
    [Required] 
    public int Argument1 
    { 
      get { return argument1; } 
      set { argument1 = value; } 
    } 
  
    [Required] 
    public int Argument2 
    { 
      get { return argument2; } 
      set { argument2 = value; } 
    } 
  
    [Output] 
    public int Sum 
    { 
      get { return sum; } 
      set { sum = value; } 
    } 
  
    public override bool Execute() 
    { 
      try 
      { 
        sum = argument1 + argument2; 
      } 
      catch (ArithmeticException e) 
      { 
        Console.WriteLine("Error occured during addition: {0}", e.Message); 
        return false; 
      } 
      return true; 
    } 
  } 
}

On utilise les attributs:

  • Required: pour indiquer un argument en entrée de la “task” qui est obligatoire,
  • Output: pour indiquer la propriété en sortie de la “task”.

On peut appeler la “task” à partir d’un fichier projet en utilisant l’élément UsingTask dans le noeud Project pour indiquer l’assembly dans laquelle se trouve la classe. On peut appeler la “task” en utilisant directement le nom de la classe AddTask:

<?xml version="1.0" encoding="utf-8" ?> 
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
  <UsingTask TaskName="MyTasks.AddTask" AssemblyFile=".\AddTask.dll"/> 
  <Target Name="Addition"> 
    <AddTask Argument1="10" Argument2="12"> 
      <Output TaskParameter="Sum" PropertyName="CalculatedSum" /> 
    </AddTask> 
  </Target> 
</Project>

BindingRedirect en 5 min

Le “BindingRedirect” permet d’indiquer comment charger des assemblies à la compilation d’un exécutable. L’intérêt de cette technique est:

  • de pouvoir choisir une version différente de celle utilisée à la compilation,
  • de faire ces indications sans avoir à recompiler l’application.

Chargement des assemblies

Par défaut, les dépendances sont chargées par Fusion exécuté par le compilateur JIT au moment où une méthode nécessite ce chargement. Ce chargement se fait en cherchant les assemblies à différents endroits dans un ordre déterminé et suivant certaines conditions. Les assemblies sont chargées en fonction de leur identité “AssemblyIdentify”.

L’identité “AssemblyIdentify” d’une assembly se définit par:

  • Le nom de l’assembly
  • Sa clé publique: la PublicKeyToken est présente si l’assembly est signée c’est-à-dire qu’elle possède un nom fort. La PublicKeyToken n’est pas indispensable si l’application n’est pas elle-même signée.
  • Sa version: la version recherchée par défaut est la version définie à la compilation.
  • Sa culture
  • Les indications d’architecture cible: l’architecture doit être compatible avec l’architecture de l’application (cf. Plateforme cible en .NET en 5 min).

Le chargement de l’assembly se fait dans un ordre déterminé en cherchant le fichier qui satisfait tous les critères qui se trouvent dans “l’AssemblyIdentity”:

  1. Répertoire de l’application: la recherche se fait d’abord dans le répertoire de l’application, y compris les répertoires enfants de ce répertoire. Si l’application n’est pas signée, l’assembly qui se trouve dans le même répertoire ne doit pas forcément être signée.
  2. GAC: l’assembly est recherché dans le Global Assembly Cache. Si elle se trouve dans le GAC, elle est forcément signée.

Si l’assembly n’a pas été trouvée, une exception de type FileNotFoundException est lancée. Pour éviter cette erreur il est possible d’indiquer une version différente de celle recherchée ou en indiquant un répertoire différent du répertoire de l’application. Le mécanisme de BindingRedirect permet de donner des informations à fusion pour qu’il puisse charger une dépendance différemment qu’avec la méthode par défaut.

Redirection par fichier de configuration

Redirection de la version de l’assembly

Le “BindingRedirect” peut se faire à des niveaux différents et les paramètres d’un niveau peuvent être surchargé par ceux d’un autre niveau.

Les niveaux de paramétrage de la redirection de la version d’une assembly sont:

  1. Par la politique de l’éditeur: ce niveau permet aux éditeurs d’effectuer une redirection d’une assembly vers une version supérieure de façon à ce qu’elle s’applique dès qu’une application consomme l’assembly lorsqu’elle se trouve dans le GAC. Les paramêtres de redirection se trouvent aussi dans le GAC. Plus de détails sur la redirection par la politique de l’éditeur sur MSDN.
  2. Par l’intermédiaire du fichier de configuration de l’application: ce niveau peut surcharger la redirection définie au niveau de la politique de l’éditeur.
  3. Au niveau de la machine: ce niveau peut surcharger les redirections définies dans les 2 niveaux précédents. Les paramètres de redirection à ce niveau peuvent être définies localement sur la machine dans un fichier machine.config, user.config ou system.config de façon à modifier le comportement de Fusion respectivement pour la machine, l’utilisateur ou le système.

Redirection de la version d’une assembly par fichier de configuration

On peut indiquer une version supérieure ou inférieure à la version définie à la compilation. Toutefois il faut avoir en tête que ce sont juste des indications à Fusion pour qu’il modifie son comportement lors du chargement d’une dépendance. Il faut que l’assembly vers laquelle on effectue la redirection soit compatible avec celle définie à la compilation.

Cette redirection se fait en complêtant le fichier de configuration de l’application en ajoutant le noeud assemblyBinding:

<configuration> 
  <runtime> 
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> 
      <dependentAssembly> 
        <assemblyIdentity  
          name="AssemblyToRedirect"  
          publicKeyToken="bcabfaff346163aa" 
          culture="neutral" /> 
        <bindingRedirect oldVersion="1.0.0.0-2.0.0.0" 
          newVersion="3.0.0.0" />
      </dependentAssembly>
    </assemblyBinding> 
  </runtime> 
</configuration>

Cette configuration permet de rediriger la version de l’assembly “AssemblyToRedirect” avec la clé publique “bcabfaff346163aa” et dont la version est entre 1.0.0.0 et 2.0.0.0 vers la version 3.0.0.0.

ATTENTION

Pour que la redirection fonctionne il faut absolument renseigner l’attribut xmlns="urn:schemas-microsoft-com:asm.v1" dans le noeud assemblyBinding.

Surcharger la redirection définie par la politique de l’éditeur

Pour une assembly donnée, si on veut appliquer une redirection différente de celle définie par la politique de l’éditeur, il faut ajouter le noeud publisherPolicy dans le fichier de configuration de l’application de la façon suivante:

<configuration> 
  <runtime> 
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> 
      <dependentAssembly> 
        <assemblyIdentity  
          name="AssemblyToRedirect"  
          publicKeyToken="bcabfaff346163aa" 
          culture="neutral" /> 
        <publisherPolicy apply="no" />
      </dependentAssembly>
    </assemblyBinding> 
  </runtime> 
</configuration>

Dans ce cas, il n’y aura pas de redirection appliquée et le chargement de l’assembly se fera suivant le comportement par défaut.

Limiter la redirection à une version spécifique du framework .NET

Dans le cas où on redéfinit la redirection d’assembly du framework .NET, il est possible de préciser une condition d’application pour qu’elle s’applique à une version spécifique du framework.

Pour indiquer cette condition d’application, il faut ajouter l’attribut “appliesTo” dans le noeud “assemblyBinding” en précisant la version du framework à laquelle s’applique la redirection:

<configuration> 
  <runtime> 
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1" 
       appliesTo="v3.5"> 
      <dependentAssembly>  
        <assemblyIdentity name="AssemblyToRedirect" 
           publicKeyToken="b03f5f7f11d50a3a" culture=""/> 
        <bindingRedirect oldVersion="0.0.0.0-65535.65535.65535.65535" 
           newVersion="3.5.1.17000"/> 
      </dependentAssembly> 
    </assemblyBinding> 
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1" 
        appliesTo="v4.0.30319">
      <dependentAssembly>  
          <assemblyIdentity name="AssemblyToRedirect" 
             publicKeyToken="b03f5f7f11d50a3a" culture=""/> 
          <bindingRedirect oldVersion="0.0.0.0-65535.65535.65535.65535" 
             newVersion="4.5.30319.17000"/> 
      </dependentAssembly> 
    </assemblyBinding> 
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> 
      <dependentAssembly>  
        <assemblyIdentity name="AssemblyToRedirect" 
             publicKeyToken="b03f5f7f11d50a3a" culture=""/> 
        <bindingRedirect oldVersion="0.0.0.0-65535.65535.65535.65535" 
             newVersion="4.6.30319.17000"/> 
      </dependentAssembly> 
    </assemblyBinding> 
  </runtime> 
</configuration>

Indiquer des chemins de recherche des assemblies

Indiquer des sous-répertoires de recherche des assemblies

Il est possible de préciser des chemins de répertoires enfant du répertoire de l’application que Fusion devra parcourir pour chercher une dépendance.

Ces chemins peuvent être préciser en utilisant l’attribut “privatePath” du noeud “probing”:

<configuration> 
   <runtime> 
      <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> 
         <probing privatePath="subFolder1;subFolder2\subSubFolder" /> 
      </assemblyBinding> 
   </runtime> 
</configuration>

Indiquer le chemin d’une assembly

On peut préciser à Fusion directement le chemin d’une assembly. Toutefois ce paramètre s’applique suivant certaines conditions:

  • Si l’assembly est signée: le chemin peut rediriger vers une adresse sur un intranet local, internet ou sur la machine.
  • Si l’assembly n’est pas signée: le chemin doit être un sous-répertoire du répertoire de l’application. Le chemin indiqué doit être relatif à ce répertoire.

Ce paramètre peut être définit en utilisant le noeud codeBase:

<configuration> 
  <runtime> 
    <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1"> 
      <dependentAssembly> 
        <assemblyIdentity name="AssemblyToRedirect"  
            publicKeyToken="b03f5f7f11d50a3a"  
            culture="neutral" /> 
        <codeBase version="2.0.0.0" 
            href="http://assemblies.com/AssemblyToRedirect.dll" /> 
      </dependentAssembly> 
    </assemblyBinding> 
  </runtime> 
</configuration>

Le chemin sur la machine peut être préciser de la façon suivante:

<codeBase version="2.0.0.0" 
     href="file:///C:\folder\AssemblyToRedirect.dll"/>

Redirection par programmation

Il est possible d’indiquer des informations de redirection à Fusion en utilisant l’évènement AppDomain.AssemblyEvent.

Cet évènement ne se déclenche que si Fusion n’a pas réussi à trouver l’assembly suivant le comportement par défaut (c’est-à-dire en cherchant dans le répertoire de l’application puis dans le GAC).

L’évènement AppDomain.AssemblyEvent peut s’utiliser de la façon suivante:

using System; 
using System.Reflection; 
//... 
 
AppDomain.CurrentDomain.AssemblyResolve += 
   new ResolveEventHandler(ResolveEventHandler); 

Avec:

private static Assembly ResolveEventHandler(object sender, ResolveEventArgs args) 
{ 
  string longName = "CustomAssembly, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"; 
  return Assembly.Load(longName); 
}

Pour utiliser AppDomain.AssemblyEvent, il faut prendre certaines précautions:

  • S’abonner à l’évènement avant l’exécution du compilateur JIT: l’abonnement à l’évènement doit se faire avant l’exécution de Fusion et donc avant toute méthode dont la compilation par la compilateur JIT exécutera le chargement d’une dépendance.
  • Assemblies chargées dans l’ordre des dépendances: si une assembly dépend d’une assembly tierce, l’assembly tierce doit être chargée avant.
  • StackoverflowException et Assembly.Load(): si le chargement de l’assembly avec Assembly.Load() échoue, l’évènement sera redéclenché, ce qui peut mener à une boucle infinie puis une exception de type StackOverflowException.

Pour définir les paramètres de chargement de l’assembly

On peut utiliser l’objet AssemblyName:

private static Assembly ResolveEventHandler(object sender, ResolveEventArgs args)  
{  
  AssemblyName requestedAssembly = new AssemblyName(args.Name); 
  if (requestedAssembly.Name != shortName) 
        return null; 
 
  requestedAssembly.Version = new Version(4, 0, 0, 0); 
  requestedAssembly.SetPublicKeyToken( 
    new AssemblyName("CustomAssembly, PublicKeyToken=" 
       + publicKeyToken).GetPublicKeyToken()); 
  requestedAssembly.CultureInfo = CultureInfo.InvariantCulture; 
  return Assembly.Load(requestedAssembly);  
}

Pour éviter la boucle infinie

Si le chargement échoue et pour éviter une StackOverflowException, on peut se désabonner de l’évènement dans la callback de l’évènement:

public static void AddResolveEventHandler(string longName)  
{ 
    ResolveEventHandler handler = (sender, args) => { 
      AppDomain.CurrentDomain.AssemblyResolve -= handler; 
 
      return Assembly.Load(longName); 
    }; 
 
    AppDomain.CurrentDomain.AssemblyResolve += handler; 
}

Plus d’information sur l’évènement AppDomain.AssemblyResolve sur MSDN.

Redirection automatique avec Visual Studio

A partir de Visual Studio 2013, si on utilise des assemblies de version différente dans une même solution, on aura une erreur du type:

Found conflicts between different versions of the same dependent assembly.  
Please set the "AutoGenerateBindingRedirects" property to true in the project file.  
For more information, see http://go.microsoft.com/fwlink/?LinkId=294190.

La fonction “AutoGenerateBindingRedirects” permet d’ajouter dans le fichier de configuration de l’application des indications de redirection “BindingRedirect”. Ces indications permettront à l’exécutable d’utiliser une seule version de l’assembly dupliquée dans les projets de la solution. Le fichier de configuration source n’est pas modifié, seul le fichier de configuration résultat (i.e. dont le nom est [nom de l’assembly].exe.config) dans le répertoire de l’exécutable est modifié.

Activer la redirection automatique

Il faut ajouter le noeud AutoGenerateBindingRedirects directement en éditant le fichier .csproj:

<?xml version="1.0" encoding="utf-8"?> 
<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> 
  <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props"  
     Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> 
  <PropertyGroup> 
    ... 
    <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
  </PropertyGroup> 
... 
</Project>

Désactiver la redirection automatique

La redirection automatique du fichier de configuration peut mener à des conflits si on souhaite indiquer manuellement les indications de redirection “BindingRedirect”.

Pour désactiver la fonctionnalité, il faut paramétrer AutoGenerateBindingRedirects à “false” en éditant le fichier .csproj:

<AutoGenerateBindingRedirects>false</AutoGenerateBindingRedirects>

Plus de détails sur la redirection automatique sur MSDN.