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.

Quelques patterns pour attendre la fin de l’exécution d’une tâche*

*: Le terme “Tâche” est utilisé içi au sens large et non au sens de la Task Parallel Library (TPL).

Lorsque plusieurs threads sont utilisés et qu’il est nécessaire de synchroniser certains de ces threads, on peut être amener à implémenter des mécanismes d’attente. Ces mécanismes permettent ainsi:

  • D’éviter un accès concurrent à une section critique,
  • D’attendre le résultat d’un autre thread (rendre un algorithme synchrone),
  • De ralentir un tempo d’horloge trop rapide pour, par exemple, implémenter des algorithmes de “conflation”.

Plusieurs solutions sont possibles en .NET. Toutefois il peut être utile que ces solutions permettent d’implémenter certaines fonctionnalités comme:

  • Un “timeout”: au délà d’un certain temps d’attente, le thread reprendra son exécution.
  • L’annulation de l’attente: dans le cas où on souhaite terminer prématurément l’exécution d’un thread, il faut pouvoir annuler l’attente.

Mécanisme de signalement

ManualResetEvent

La méthode la plus directe est l’utilisation de l’objet ManualResetEvent ou des autres objets du même type comme AutoResetEvent ou ManualResetEventSlim.

Ces objets permettent de placer directement des threads en attente et de signaler la fin de l’attente.

Les principales différences entre ces objets sont:

  • AutoResetEvent: c’est l’objet le plus simple. Il est possible de mettre un thread en attente en exécutant WaitOne(). Ce thread sera débloqué si un autre thread exécute Set(). Cet objet n’autorise le déblocage que d’un seul thread à la fois. Si plusieurs threads attendent en exécutant Wait
  • ManualResetEvent: par rapport à AutoResetEvent, cet objet nécessite d’exécuter Reset() après avoir exécuté Set() pour qu’un thread puisse attendre à nouveau en exécutant WaitOne(). Si Reset() n’est pas exécuté, les appels suivants à WaitOne() ne seront pas bloquants. Contrairement à AutoResetEvent, si plusieurs threads sont en attente en exécutant WaitOne(), un seul appel à Set() débloque tous les threads.
  • ManualResetEventSlim: le comportement de cet objet est identique à ManualResetEvent à la différence qu’il ne permet pas de synchroniser des threads dans des processus différents. Toutefois il est plus performant que ManualResetEvent et AutoResetEvent.

On peut résumer brievement les performances et l’utilisation entre processus de ces objets dans le tableau suivant:

Objet Utilisation dans des processus différents Ordre de grandeur du temps d’un appel à cet objet *
AutoResetEvent Oui 1000 ns
ManualResetEvent Oui 1000 ns
ManualResetEventSlim Non 40 ns (25 fois plus rapide)

*: Les temps sont indiqués principalement pour montrer les différences en terme d’ordre de grandeur entre les différents objets.

En utilisant ManualResetEvent, on peut implémenter un mécanisme d’attente de la façon suivante:

public class ManualResetEventUsage  
{  
  private readonly ManualResetEventSlim waitingEvent = new ManualResetEventSlim(false); 
 
  public ManualResetEventUsage() 
  { } 
 
  public bool Wait(TimeSpan timeout) 
  { 
    Console.WriteLine("Waiting..."); 
    bool signaled = this.waitingEvent.Wait(timeout); 
    Console.WriteLine("Unlocked"); 
 
    return signaled; 
  } 
 
  public void Signal() 
  {  
    Console.WriteLine("Signaling..."); 
    this.waitingEvent.Set(); 
    Console.WriteLine("Signaled."); 
  } 
} 
 
class Test 
{  
  static void Main(string[] args) 
  {  
    var resetEventUsage = new ManualResetEventUsage(); 
 
    Task t = Task.Factory.StartNew(() => { resetEventUsage.Wait(TimeSpan.FromMilliseconds(1000)); }); 
    Thread.Sleep(200); 
 
    resetEventUsage.Signal(); 
    t.Wait(); 
  } 
}

AutoResetEvent, ManualResetEvent et ManualResetEvent permettent de préciser un timeout quand un appel est fait à WaitOne().

Plus de détails à propos de ces objets sur MSDN: ManualResetEvent, AutoResetEvent et ManualResetEventSlim.

Monitor

Contrairement au "lock" classique, Monitor permet d’indiquer un timeout ce qui permet d’implémenter facilement un mécanisme d’attente.

En exécutant Monitor.Wait(locker), on peut placer un thread en attente. L’objet “locker” est libéré pendant l’attente. Pour débloquer un thread en attente, il faut exécuter Monitor.Pulse(locker). Dans le cas où plusieurs threads sont en attente, on peut les libérer en exécutant Monitor.PulseAll(locker).

L’implémentation d’un mécanisme d’attente avec Monitor n’est pas trivial. On peut distinguer 2 types de files d’attente:

  • La file d’attente des threads prêts à s’exécuter: ce sont des threads qui sont en attente de l’acquisition d’un "lock".
  • La file d’attente des threads attendants: ce sont les threads ayant appelés Monitor.Wait() et qui sont dans l’attente d’une exécution de Monitor.Pulse().

Ces deux files d’attentes peuvent mener à des comportements inattendus. Lorsque Monitor.Pulse() est exécuté, le premier thread dans la file d’attente des threads attendants est placé dans la file d’attente des threads prêts. Toutefois d’autres threads peuvent être déjà présents dans cette file d’attente et faire l’acquisition du "lock" avant que le thread libéré ne puisse en faire l’acquisition. Le thread libéré n’aura donc pas accès au "lock" et ne s’exécutera pas.

La solution consiste à utiliser une boucle "while" pour permettre de replacer le thread libéré dans la file d’attente des threads attendants s’il n’a pas réussi à faire l’acquisition du "lock".

On peut implémenter le mécanisme d’attente de la façon suivante:

public class MonitorUsage  
{  
  private readonly object locker = new object(); 
  private bool block = true; 
 
  public MonitorUsage() 
  { } 
 
  public bool Wait(TimeSpan timeout) 
  { 
    Console.WriteLine("Waiting..."); 
    bool timeoutExpired = false; 
    lock (this.locker) 
    { 
      while (this.block && !timeoutExpired) 
      {  
        timeoutExpired = !Monitor.Wait(this.locker, timeout); 
      } 
         
      this.block = true; 
    } 
    Console.WriteLine("Unlocked"); 
 
    return timeoutExpired; 
  } 
 
  public void Signal() 
  {  
    Console.WriteLine("Signaling..."); 
    lock (this.locker) 
    { 
      this.block = false; 
      Monitor.Pulse(this.locker); 
    } 
    Console.WriteLine("Signaled."); 
  } 
} 
 
class Test 
{  
  static void Main(string[] args) 
  {  
    var monitorUsage = new MonitorUsage(); 
 
    Task t = Task.Factory
      .StartNew(() => { monitorUsage.Wait(TimeSpan.FromMilliseconds(1000)); }); 
    Thread.Sleep(200); 
 
    monitorUsage.Signal(); 
    t.Wait(); 
  } 
}
Attention à l’utilisation de Monitor

Quand on utilise Monitor.Wait() et Monitor.Pulse(), le pattern suivant ne suffit pas:

// thread A
lock (key) Monitor.Wait(key);

// thread B
lock (key) Monitor.Pulse(key);

Il faut plutôt utiliser un pattern semblable à celui utilisé dans l’exemple précédent car il permet de résoudre 2 problèmes:

  • La “race condition” qui peut se produire si on exécute Signal() avant Wait(). Dans ce cas, block sera à “false” et le boucle while ne s’exécutera pas ainsi que l’appel à Monitor.Wait().
  • Le problème du thread libéré qui ne peut pas faire l’acquisition du "lock". Si le thread libéré est ajouté à la file d’attente des threads prêts et qu’un autre thread fait l’acquisition du lock alors block sera à “true”. Ainsi le thread libéré ne sortira pas de la boucle "while" et exécutera à nouveau Monitor.Wait() et donc sera replacé dans la file d’attente des threads attendants.

Plus de détails sur Monitor sur MSDN.

Avec la Task Parallel Library (TPL)

Il existe de multiples implémentations de mécanismes d’attente avec des "Task".

Mécanisme de signalement avec une Task

La solution la plus directe consiste à utiliser Task.Wait() et TaskCompletionSource. Task.Wait() est bloquant et permet de renseigner un timeout. Le gros intérêt de cette méthode est de bénéficier de la gestion de l’annulation et des exceptions avec TPL.

Eviter d’utiliser ce pattern

Cet exemple permet de faire lien avec le mécanisme de signalement mais il n’est pas très adapté quand on utilise des Task.

On peut implémenter un mécanisme d’attente de cette façon:

public class TaskUsage  
{  
  private readonly TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>(); 
  private readonly Task canceledTask; 
  private readonly Task exceptionOccuredTask; 
 
  public TaskUsage() 
  {  
      this.canceledTask = this.taskCompletionSource.Task.ContinueWith( 
          tsk => Console.WriteLine("Canceled"), 
          TaskContinuationOptions.OnlyOnCanceled); 
      this.exceptionOccuredTask = this.taskCompletionSource.Task.ContinueWith( 
          tsk => Console.WriteLine("Exception occured: " + tsk.Exception.ToString()), 
          TaskContinuationOptions.OnlyOnFaulted); 
  } 
 
  public bool Wait(TimeSpan timeout) 
  { 
    Console.WriteLine("Waiting..."); 
    bool executedWithinTimeout = this.taskCompletionSource.Task.Wait(timeout) 
    Console.WriteLine("Unlocked"); 
 
    return executedWithinTimeout; 
  } 
 
  public void Signal() 
  {  
    Console.WriteLine("Signaling..."); 
    this.taskCompletionSource.SetResult(true); 
    Console.WriteLine("Signaled."); 
  } 
} 
 
class Test 
{  
  static void Main(string[] args) 
  {  
    var taskUsage = new TaskUsage(); 
 
    Task t = Task.Factory
      .StartNew(() => { taskUsage.Wait(TimeSpan.FromMilliseconds(1000)); }); 
    Thread.Sleep(200); 
 
    monitorUsage.Signal(); 
    t.Wait(); 
  } 
}

Continuation

Le mécanisme de signalement comme précédemment n’est pas vraiment adapté quand on utilise des “tasks” au sens TPL. Le pattern le plus direct pour attendre la fin de l’exécution d’une tâche est la “continuation”.

Une “continuation” permet de déclencher une tâche lorsqu’une autre tâche est terminée. Si cette autre tâche provoque une exception ou ne se termine pas, la “continuation” peut ne pas s’exécuter suivant les options indiquées à la définition de la “continuation”.

L’intérêt de cette méthode est la simplicité de son implémentation notamment en ce qui concerne la gestion des exceptions et des annulations.

Une “continuation” simple qui s’exécute en récupérant le résultat de la première tâche et qui ne s’exécute que si la première tâche se termine s’implémente de la façon suivante:

Task<bool> t = Task<bool>.Factory.StartNew(() =>  
  { 
    Console.WriteLine("Beginning first task execution..."); 
    // Making other processes 
    // ...
    Console.WriteLine("First ended"); 
  }); 
t.ContinueWith(tsk =>  
  { 
    Console.WriteLine("Result from first task: ", tsk.Result); 
  }, TaskContinuationOptions.OnlyOnRanToCompletion);  
t.Wait();

Il est possible d’implémenter des mécanismes de “continuation” quand la première tâche provoque une exception ou si elle est annulée: Utilisation des “Task” en 5 min.

Continuation + TaskCompletionSource

Dans le cas où la première tâche à exécuter ne contient pas de code particulier, on peut utiliser l’objet TaskCompletionSource et définir une continuation sur la task de la TaskCompletionSource. Pour déclencher la “continuation”, il suffit d’affecter un résultat à la task de la TaskCompletionSource.

L’intérêt de cette méthode est qu’il est possible de définir la “continuation” bien avant son exécution même dans le cas où on a pas d’implémentation particulière pour la première tâche. Le déclenchement de la “continuation” est rapide.

Ce type de mécanisme peut s’implémenter de la façon suivante:

public class TaskCompletionSourceUsage  
{  
  private readonly TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>(); 
 
  public TaskCompletionSourceUsage(Action<Task> actionToExecute) 
  {  
    this.taskCompletionSource.Task.ContinueWith(actionToExecute); 
  } 
 
  public void Signal() 
  {  
    Console.WriteLine("Signaling..."); 
    this.taskCompletionSource.SetResult(true); 
    Console.WriteLine("Signaled."); 
  } 
} 
 
class Test 
{  
  static void Main(string[] args) 
  {  
    var taskCompletionSourceUsage = new TaskCompletionSourceUsage(task =>  
      {  
        Console.WriteLine("Executing task..."); 
        // Making some processes 
        // ... 
        Console.WriteLine("Task ended"); 
      }); 
 
    // Making other processes 
    // ... 

    // Trigger the execution of the task 
    taskCompletionSourceUsage.Signal(); 
  } 
}

Task retardée (ajout d’un “timeout”)

Il est possible d’ajouter un espèce de “timeout” à l’implémentation précédente. Si la “continuation” n’a pas été exécuté dans l’intervalle du “timeout”, on peut déclencher un autre type de traitement.

Task retardée avec un “Timer” (Framework 4.0)

L’intérêt d’utiliser un Timer est de permettre de rendre cette technique compatible avec le Framework .NET 4.0.

Ce type de mécanisme peut s’implémenter de la façon suivante:

using System;
using System.Threading;

public class TimerUsage  
{  
  private readonly TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>(); 
  private bool resultAlreadySet = false; 
  private readonly object resultSync = new object(); 
  private readonly Timer timer; 
 
  public TimerUsage(int timeout, Action<bool> actionToExecute) 
  {  
    this.taskCompletionSource.Task.ContinueWith(completionSourceTask =>  
      {  
        this.ExecuteTask(completionSourceTask, actionToExecute); 
      }, TaskContinuationOptions.OnlyOnRanToCompletion); 
    this.timer = new Timer(state => this.SetTaskCompletionSourceResult(false)); 
    this.timer.Change(timeout, -1); 
  } 
 
  public void Signal() 
  {  
    Console.WriteLine("Signaling..."); 
    this.SetTaskCompletionSourceResult(true); 
    Console.WriteLine("Signaled."); 
  } 
 
  private void ExecuteTask(Task completionSourceTask, Action<bool> actionToExecute) 
  {  
    actionToExecute(completionSourceTask.Result); 
  } 
 
  private void SetTaskCompletionSourceResult(bool result) 
  {  
    if (!this.resultAlreadySet) 
    { 
      lock (this.resultSync) 
      {  
        if (!this.resultAlreadySet) 
        { 
          this.taskCompletionSource.SetResult(result); 
          this.resultAlreadySet = true; 
        } 
      } 
    } 
  } 
} 
 
class Test 
{  
  static void Main(string[] args) 
  {  
    var timerUsage = new TimerUsage(1000, result =>  
      {  
        if (result) 
        {  
          Console.WriteLine("Task executed within timeout"); 
        } 
        else 
        {  
          Console.WriteLine("Timeout");
        } 
      }); 
 
    // Making other processes 
    // ... 
 
    // Trigger the execution of the task 
    timerUsage.Signal(); 
  } 
}

SetTaskCompletionSourceResult permet d’éviter d’affecter le résultat 2 fois à la TaskCompletionSource.

Task retardée en utilisant Task.Delay() (Framework > 4.0)

Il suffit de remplacer le Timer avec une instruction Task.Delay(). A partir de l’exemple précédent la classe TimerUsage devient:

public class TaskDelayUsage  
{  
  private readonly TaskCompletionSource<bool> taskCompletionSource = new TaskCompletionSource<bool>(); 
  private bool resultAlreadySet = false; 
  private readonly object resultSync = new object(); 
  private readonly Task delayedTask; 
 
  public TaskDelayUsage(int timeout, Action<bool> actionToExecute) 
  {  
    this.taskCompletionSource.Task.ContinueWith(completionSourceTask =>  
      {  
        this.ExecuteTask(completionSourceTask, actionToExecute); 
      }, TaskContinuationOptions.OnlyOnRanToCompletion); 
 
    this.delayedTask = Task.Delay(timeout) 
      .ContinueWith(task => this.SetTaskCompletionSourceResult(false)) 
      .Start(); 
  } 
 
  public void Signal() 
  {  
    Console.WriteLine("Signaling..."); 
    this.SetTaskCompletionSourceResult(true); 
    Console.WriteLine("Signaled."); 
  } 
 
  private void ExecuteTask(Task completionSourceTask, Action<bool> actionToExecute) 
  {  
    actionToExecute(completionSourceTask.Result); 
  } 
 
  private void SetTaskCompletionSourceResult(bool result) 
  {  
    if (!this.resultAlreadySet) 
    { 
      lock (this.resultSync) 
      {  
        if (!this.resultAlreadySet) 
        { 
          this.taskCompletionSource.SetResult(result); 
          this.resultAlreadySet = true; 
        } 
      } 
    } 
  } 
}

Liste de patterns non exhaustive

La liste des patterns présentés précédemment est loin d’être exhaustive. Le but est juste de référencer quelques méthodes de façon à donner des idées d’implémentation.
Toutefois, on peut dire que dans la majorité des cas, il est préférable d’utiliser TPL et de privilégier des mécanismes de “continuation”. Les mécanismes de signalement sont à utiliser avec plus de réserve ou pour les tests unitaires.

Plateforme cible en .NET en 5 min

A partir des options du “Configuration Manager” dans Visual, on peut compiler ses projets suivant plusieurs plateformes: “AnyCPU”, “x86”, “x64” et “Itanium”. A chacune de ses valeurs correspond une plateforme cible sur laquelle les assemblies pourront être chargées. Le mauvais choix de plateforme cible peut mener à des exceptions de type BadImageFormatException qu’il n’est pas forcément facile à prévoir.

Ainsi une assembly générée avec pour plateforme cible:

  • “AnyCPU” sera exécutée par le CLR 32 ou 64 bits.
  • “x86” sera exécutée exclusivement par le CLR 32 bits.
  • “x64” sera exécutée exclusivement par le CLR 64 bits.
  • “Itanium” sera exécutée sur une architecture Itanium.

Tout se complique en sachant qu’une machine avec une architecture 64 bits peut exécuter du code compilé pour les plateformes “AnyCPU”, “x86” et “x64”.

Le choix de la plateforme entraîne d’autres implications qu’il est important d’avoir en tête pour éviter des erreurs ou exceptions à l’exécution.

1. Différences de fonctionnement des exécutables

Quel que soit la plateforme cible choisie, s’il n’y a que de code managé, le code IL généré est pratiquement le même. Dans une assembly “AnyCPU”, il n’y a qu’une seule version du code IL (et non une version 32 bits et une version 64 bits). D’ailleurs quelque soit la plateforme, la taille des assemblies est pratiquement la même.

L’indication de la plateforme permet de rajouter des indications dans l’entête de l’assembly. A l’exécution en fonction de ces indications, le compilateur JIT choisira comment transformer le code IL en code machine:

  • Un code 64 bits ne peut être exécuté que sur une architecture 64 bits,
  • Un code 32 bits peut être exécuté sur une architecture 32 bits mais aussi une architecture 64 bits avec WoW64 (Windows 32-bit on Windows 64-bit qui est sous-sytème de Windows capable d’exécuter du code 32 bits).
  • Un code “AnyCPU” peut être exécuté sur une architecture 32 bits et 64 bits, toutefois il sera exécuté par un CLR 32 bits ou un CLR 64 bits (voir plus bas pour plus de détails).

Ainsi suivant la plateforme cible choisie, les assemblies résultants peuvent avoir ou non un comportement différent:

  • Les types primitifs: les types primitifs (integer ou long) sont codés sur 32 bits quel que soit l’architecture, en revanche les pointeurs sont codés sur 32 ou 64 bits suivant les architectures.
  • IntPtr: pour stocker des adresses qui s’adaptent à l’architecture, on peut utiliser le type IntPtr. En 32 bits, IntPtr.Size = 4; en 64 bits, IntPtr.Size = 8.
  • Assemblies système: suivant l’architecture, des assemblies systèmes différentes sont chargées puis utilisées. Par exemple, en 32 bits, C:\Windows\Microsoft.NET\Framework peut être utilisé alors qu’en 64 bits, on utilise plutôt C:\Windows\Microsoft.NET\Framework64.
  • Lecture de la base de registres: si on exécute
    RegistryKey registryKey = Registry.LocalMachine.OpenSubKey(@"SOFTWARE\CustomNode");

    Une application 32 bits exécutée sur une machine 32 bits lira la clé dans: HKEY_LOCAL_MACHINE\SOFTWARE\CustomNode. De même une application 64 bits exécutée sur une machine 64 bits lira la clé dans HKEY_LOCAL_MACHINE\SOFTWARE\CustomNode. En revanche, une application 32 bits exécutée sur une machine 64 bits lira la clé dans: HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\CustomNode.

D’autres différences existent, on peut trouver une liste plus exhaustive de ces différences sur les pages suivantes:

Tests

Si on livre des assemblies séparées en 32 ou 64 bits, il est donc important de tester séparement dans toutes les architectures livrées.

Limite de mémoire occupée par le processus

Par défaut, un processus 32 bits ne peut occuper plus de 2 GB en mémoire. Toutefois en ajoutant
IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly (voir plus bas), on peut augmenter cette limite à:

  • 3 GB sur un Windows 32 bits
  • 4 GB si le processus 32 bits est exécuté sur un Windows 64 bits.

Par défaut, pour un processus 64 bits, IMAGE_FILE_LARGE_ADDRESS_AWARE est ajouté dans l’entête de l’assembly. La limite est donc de:

  • 8 TB sur une architecture 64 bits classique,
  • 7 TB sur une architecture Itanium,
  • 128 TB si le processus s’exécute sur Windows 8.1 ou Windows Server 2012 R2,
  • 2 GB si IMAGE_FILE_LARGE_ADDRESS_AWARE n’est pas rajouté dans l’entête.

Plus de renseignements sont disponibles sur MSDN.

Comment ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête d’une assembly ?

Pour lever la limite de 2 GB qu’un processus 32 bits peut occuper en mémoire, il faut ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.
Pour effectuer cette modification, on peut s’aider de l’outil editbin.exe qui est livré avec Visual Studio C++. Il est accessible avec la ligne de commandes Visual Studio. Toutefois on peut y accéder dans le répertoire:

C:\Program Files\Microsoft Visual Studio [version de VS par exemple 10.0]\VC\bin 

Pour ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête d’une assembly, il suffit d’exécuter la commande:

editbin /LARGEADDRESSAWARE [chemin de l'assembly]

Pour supprimer IMAGE_FILE_LARGE_ADDRESS_AWARE de l’entête:

editbin /LARGEADDRESSAWARE:no [chemin de l'assembly]

On peut vérifier que l’entête a été modifié en utilisant dumpbin (voir plus bas pour plus de détails). Il faut exécuter la commande:

dumpbin /headers [chemin de l'assembly]

Dans le résultat, il faut ensuite vérifier la présence d’une ligne: Application can handle large (>2GB) addresses, par exemple:

FILE HEADER VALUES
             14C machine (x86)
               3 number of sections
               0 file pointer to symbol table
               0 number of symbols
              E0 size of optional header
              22 characteristics
                   Executable
                   Application can handle large (>2GB) addresses

Il est possible d’exécuter la commande editbin.exe dans un évènement “post-build” du projet Visual Studio pour que l’entête soit systématiquement modifié à chaque compilation de l’assembly.

Chargement des assemblies

Une incompatiblité dans le choix de la plateforme cible peut mener à des exceptions de type BadImageFormatException lorsque le CLR tente de charger des assemblies incompatibles.

On peut résumer les différents cas possibles dans le tableau suivant:

Architecture du système Windows Architecture du processus Plateformes cible compatibles
Windows 32 bits 32 bits x86, AnyCPU
Windows 64 bits 32 bits x86, AnyCPU
64 bits x64, AnyCPU

Ainsi, on peut remarquer qu’il est possible d’exécuter un processus 32 bits sur une architecture 64 bits. De plus la plateforme cible “AnyCPU” semble être compatible avec toutes les architectures. Ceci ne veut pas dire que le choix de cette plateforme cible ne ménera jamais à une exception lors du chargement. En effet, sur une architecture 64 bits, à la notion de plateforme cible “AnyCPU”, il faut ajouter la préférence de l’architecture du processus.

Préférence de l’architecture pour la plateforme “AnyCPU”

Avant Visual Studio 2010, “AnyCPU” était la plateforme par défaut pour un projet et l’architecture de préférence du processus était 64 bits. Ainsi si on lance un exécutable compilé suivant la plateforme “AnyCPU” sur une architecture 64 bits, il s’exécute dans un processus 64 bits et s’il doit charger une assembly “x86”, une BadImageFormatException se produit car le CLR 64 bits ne pourra pas charger cette assembly. Seules les assemblies compilées suivant les plateformes “AnyCPU” et “x64” peuvent être chargées.

Pour Visual Studio 2010, la plateforme cible par défaut est “x86”. Il n’y a pas de confusion possible puisqu’une assembly compilé suivant cette plateforme, sera forcément exécutée dans un processus 32 bits. Seules les assemblies compilées suivant les plateformes “AnyCPU” et “x86” peuvent être chargées.

Enfin à partir du framework 4.5 et de Visual Studio 2012, il est possible d’indiquer l’architecture de préférence quand on choisit la plateforme cible “AnyCPU”:

  • “AnyCPU” correspond à l’architecture de préférence 64 bits.
  • “Any CPU 32-bit prefered” correspond à l’architecture de préférence 32 bits.

La valeur par défaut est “Any CPU 32-bit prefered”, ce qui signifie que:

  • Sur une architecture 32 bits, l’exécutable s’exécute dans un processus 32 bits et que les assemblies compatibles ont pour plateforme cible “AnyCPU” et “x86”.
  • De même, sur une architecture 64 bits, l’exécutable s’exécute dans un processus 32 bits et que les assemblies compatibles ont pour plateforme cible “AnyCPU” et “x86”.

2. Outils pour vérifier l’entête d’une assembly

Certains outils permettent de connaître la plateforme cible d’une assembly.

Task Manager

Le gestionnaire de tâches permet de voir si un processus exécuté est 32 ou 64 bits. Il suffit de lancer le gestionnaire de tâches suivant l’une des méthodes suivantes:

  • Appuyer simultanément sur les touches Ctrl + Maj + Echap,
  • Clique droit sur la barre de tâches puis cliquer sur “Start Task Manager”,
  • Appuyer sur Windows + E puis taper “taskmgr”.

Dans l’onglet “Processes”, on peut voir la liste des processus:

  • Si le nom du processus est suffixé avec “*32” alors il s’agit d’un processus 32 bits (Exemple: “firefox.exe *32”)
  • Si le nom du processus n’est pas suffixé avec “*32” alors il s’agit d’un processus 64 bits.

Process Explorer

ProcessExplorer est un outils assez puissant qui permet d’indiquer des informations supplémentaires par rapport au Task Manager.

On peut télécharger ProcessExplorer sur: https://technet.microsoft.com/en-us/sysinternals/processexplorer.aspx.

Pour voir la liste des processus, il faut:

  1. Executer ProcessExplorer avec les droits administrateur,
  2. Faire un clique droit sur l’entête des colonnes pour afficher la colonne “Image Type”,
  3. On peut voir directement quels sont les processus 32 et 64 bits

Corflags.exe

Corflags.exe est avec le SDK Windows et est accessible à partir de la ligne de commande Visual Studio.
Le chemin dépend de la version du système, par exemple sur Windows 7 32 bits:

C:\Program Files\Microsoft SDKs\Windows\v7.1\Bin\CorFlags.exe

Sur une machine Windows 8.1 64 bits avec le framework 4.5.1:

C:\Program Files (x86)\Microsoft SDKs\Windows\v8.1A\bin\NETFX 4.5.1 Tools\CorFlags.exe

En utilisant CorFlags.exe en tapant la ligne suivante, on peut avoir des détails sur l’entête de l’assembly:

CorFlags.exe [chemin de l'assembly]

Framework 4.0

A la version 4.0 du framework, les valeurs qui présentent un intéret sont “PE” et “32BIT”:

Plateforme cible PE 32BIT
AnyCPU PE32 0
x86 PE32 1
x64 PE32+ 0
Itanium PE32+ 0

On peut remarquer que les plateformes cible “x64” et “AnyCPU” donnent le même résultat.

Architecture de préférence par défaut des processus pour la plateforme “AnyCPU”

Pour la version 4.0 du framework, l’architecture de préférence des processus compilé avec la plateforme cible “AnyCPU” est par défaut 64 bits. Ainsi sur une machine 64 bits, l’assembly “AnyCPU” sera exécutée de préférence dans un processus 64 bits.

A partir du framework 4.5

A partir de la version 4.5 du framework et de Visual Studio 2012, dans les options de compilation:

  1. Clique droit sur le projet correspondant à l’assembly
  2. Dans l’onglet “Build”

Le paramètre “Prefer 32-bit” permet d’indiquer l’architecture de préférence des processus dans le cas de la plateforme cible “AnyCPU”. Ainsi sur une machine 64 bits, une assembly avec la plateforme cible “AnyCPU” sera exécutée dans un processus:

  • 32 bits si la case est cochée.
  • 64 bits si la case est décochée.

CorFlags.exe a aussi évolué avec les paramètres “32BITREQ” et “32BITPREF” permettant la préférence de l’architecture:

Plateforme cible Case “Prefer 32-bit” cochée PE 32BITREQ 32BITPREF
x86 N/A PE32 1 0
x64 N/A PE32+ 0 0
AnyCPU Non PE32 0 0
AnyCPU Oui PE32 0 1
Paramètre CLR Header

Attention à ce paramètre qui n’indique pas vraiment la version du framework:

  • Pour les frameworks 1.0 et 1.1, CLR Header = 2.0
  • Pour les frameworks ayant une version supérieure ou égale à 2.0, CLR Header = 2.5.

Plus d’informations à propos de CorFlags.exe sur MSDN.

Dumpbin.exe

Dumpbin est livré avec Visual Studio C++, le chemin de l’exécutable est:

C:\Program Files\Microsoft Visual Studio [version VS]\VC\bin

A partir de la ligne de commandes de Visual Studio, en tapant la ligne suivante, on peut avoir quelques détails sur l’entête de l’assembly:

dumpbin [chemin de l'assembly] /CLRHEADER /HEADERS

Parmi les informations affichées, quelques unes permettent de déterminer l’architecture pour laquelle l’assembly a été compilée.

Par exemple:

FILE HEADER VALUES
             14C machine (x86)
               3 number of sections
        4DFA7751 time date stamp Thu Jun 16 23:36:17 2011
               0 file pointer to symbol table
               0 number of symbols
              E0 size of optional header
            2102 characteristics
                   Executable
                   32 bit word machine
                   DLL

OPTIONAL HEADER VALUES
             10B magic # (PE32)
            8.00 linker version
             800 size of code
[...]

  clr Header:

              48 cb
            2.05 runtime version
            2058 [     5B4] RVA [size] of MetaData Directory
               3 flags
                   IL Only
                   32-Bit Required
               0 entry point token
[...]

La paramètre “machine” permet d’indiquer directement la plateforme cible.

Plus d’informations à propos de Dumpbin sur MSDN.

ILSpy

ILSpy permet d’indiquer directement l’architecture cible d’une assembly ainsi que la version du “runtime”. En cliquant sur le nom de l’assembly, ILSpy affiche des informations contenues dans l’entête de l’assembly.

Plus d’informations à propos d’ILSpy sur: ilspy.net.

3. Résoudre des problèmes de chargement d’assemblies

Dans le cas de BadImageFormatException, parmi toutes les dépendances d’un exécutable, il est parfois difficile d’identifier l’assembly dont l’architecture cible est incompatible avec celle de l’exécutable. Certains outils permettent d’avoir plus d’informations sur les dépendances d’une assembly et de visualiser le détail de chargement des assemblies pour un exécutable donné:
Quelques outils pour résoudre les problèmes de chargement d’assemblies.

Références

Détecter l’architecture d’un exécutable: