Les “Application Domains” en 5 min

Le code d’une application .NET est déployé sous la forme de code IL (i.e. Intermediate Language) dans des unités déployables appelées assemblies. Ces assemblies sont des fichiers avec une extension .exe pour un exécutable ou .dll pour une bibliothèque de classes. L’intérêt de pouvoir organiser le code dans des assemblies différentes est, par exemple, de partager du code semblable entre plusieurs applications ou de rendre une application modulable en permettant de charger du code sous la forme de plug-in.

Durant l’exécution d’une application, par défaut, les assemblies sont chargées en mémoire en mode “lazy-loading” c’est-à-dire qu’elles ne sont chargées que si le code qui s’y trouve est appelé.

Dans le cadre du framework .NET, les assemblies sont chargées en mémoire dans une couche d’isolation appelée application domain. Cette couche peut être unique pour tout le processus ou suivant les besoins il peut en exister plusieurs. L’implémentation plus récente de .NET (anciennement appelée .NET Core) n’utilise pas les application domains mais une notion améliorée équivalente appelée assembly load context.

Le but de cet article est de passer en revue les caractéristiques des application domains. Dans un prochain article, on explicitera les fonctionnalités principales des assembly load contexts.

Dans un 1er temps, on va passer en revue les caractéristiques principales des application domains puis dans un 2e temps, on va illustrer quelques cas d’utilisation avec des exemples.

Caractéristiques principales

Les application domains sont des couches d’isolation dans un processus qui permettent:

  • D’isoler le code: il est possible de charger des assemblies dans des application domains différents. L’isolation des application domains permet de charger, le cas échéant, de même assemblies dans des versions différentes.
  • D’implémenter des plug-ins: les assemblies correspondant au plug-in peuvent être chargées dans un application domain différent de l’application domain principal. Par la suite, ces assemblies ne peuvent pas être déchargées mais l’application domain dans lequel elles se trouvent peut, en revanche, être déchargés.
  • D’apporter une isolation en terme de sécurité: des portions de code dans des assemblies particulières peuvent être exécutées avec un niveau de sécurité différent.

Espace mémoire isolé

Les application domains permettent d’isoler des espaces contigués de mémoire virtuelle et d’y placer du code et des ressources auxquels on pourra accéder et les référencer sans, toutefois, partager ces espaces. Par exemple, d’un application domain à l’autre:

  • Il n’est pas possible de référencer directement un contenu dans un application domain différent,
  • Les données ne peuvent pas être passées d’un application domain à un autre directement, elles sont copiées par valeur en utilisant la sérialisation. Dans le cadre des objets de type référence qui sont accessibles avec des références (les références sont des objets de type valeur), les références pourraient être copiées par valeur d’un application domain à l’autre. Toutefois pour que les méthodes de l’objet soit réellement “visible” dans un autre application domain, il faut utiliser le mécanisme de marshalling: un objet proxy sert d’intermédiaire entre l’application domain où l’objet a été créé et l’application domain où l’objet est utilisé.

Enfin, les application domains partagent le même tas managé toutefois ils sont assez isolés pour qu’un application domain ne puisse interférer directement les objets d’un autre application domain.

Partage des threads

Dans le système d’exploitation, les processus sont isolés en terme de mémoire et en terme de thread.
Ainsi:

  • Pour exécuter du code de façon isolée, par exemple dans un autre processus, il faudrait créer ce processus et le détruire à la fin de son utilisation;
  • Le partage de données entre ces processus ne se fait pas directement, il faudrait prévoir des mécanismes de type Named Pipes, Memory mapped file ou le système de fichiers.
  • Enfin le lancement de code dans un processus séparé puis la récupération du résultat le cas échéant nécessite des mécanismes de synchronisation et d’appels comme le remoting, WCF, des communications réseaux ou RPC (Remote Procédure Call).

Tous ces mécanismes sont couteux en ressource, en temps d’exécution et en complexité d’implémentation. Les application domains proposent une solution pour s’affranchir de la difficulté de devoir faire des appels à du code dans des processus différents en partageant les mêmes threads. Le gros intérêt est d’éviter de devoir implémenter des mécanismes de synchronisation lorsqu’on appelle du code dans un application domain différent.

Marshalling et sérialisation

Le passage d’objets entre des application domains se fait par des copies par valeur, les objets sont sérialisés dans leur application domain d’origine puis désérialisés dans l’application domain où ils seront utilisés. Même si entre des application domains, les mêmes threads sont partagés, la frontière est assez importante pour qu’un application domain ne puisse pas accéder et exécuter du code se trouvant dans un autre application domain. Les appels se font par un mécanisme appelé marshalling qui consiste à créer un objet proxy dans l’application domain dans lequel on veut exécuter une méthode, l’appel à travers la frontière des application domains consistera à appeler une méthode dans l’objet proxy.

Le marshalling utilise la sérialisation lors du passage des objets.

Manipulation des application domains

La plupart des manipulations concernant les application domains se font par l’intermédiaire de la classe statique System.AppDomain:

  • Accéder à l’application domain principal: AppDomain.CurrentDomain.
  • Créer un nouvel application domain: AppDomain.CreateDomain()
  • Instancier une classe dans un application domain particulier: <instance AppDomain>.CreateInstanceAndUnwrap(<nom assembly>, <nom complet de la classe à instancier>).
    On peut voir dans la fonction CreationInstanceAndUnwrap() qu’on manipule les noms des objets sous la forme d’une chaîne de caractères. Par exemple pour l’argument correspondant au nom du type de la classe à instancier, on n’utilise pas un objet Type car le code qui exécute la fonction CreateInstanceAndUnwrap() se trouve dans l’application domain courant. Ainsi si on manipule un objet Type, cela signifie que l’assembly contenant ce type est chargé dans l’application domain courant ce qui n’est pas le but recherché.
  • Lister les assemblies chargées dans un application domain: <AppDomain>.GetAssemblies().
  • Décharger un application domain (et toutes les assemblies qu’il contient): AppDomain.Unload(<AppDomain à décharger>)

Exemples d’utilisation des application domains

Les cas d’utilisation les plus fréquents où on souhaite manipuler les application domains sont pour:

  • Plug-in: avoir du code qu’il est possible de charger sous la forme d’un plug-in. Le plug-in est ainsi modulaire, on peut le charger et le décharger à sa guise suivant les besoins.
  • Charger plusieurs versions d’une même assembly: normalement une seule version d’une assembly est chargée en mémoire toutefois pour satisfaire des dépendances indirectes, il peut être nécessaire de charger des versions différentes d’une même assembly.
Seulement disponible pour le framework .NET

Les application domains ne sont disponibles que si on cible le framework .NET (<= 4.8). Cette fonctionnalité n’est pas disponible avec .NET (>= 5.0).

Au travers de quelques exemples, on va montrer comment on peut manipuler les application domains. Ces exemples comportent 3 assemblies:

  • FxDotNetExamples.exe: c’est le “main” qui permet de lancer l’exécution de l’exemple.
  • SimplePlugIn.dll: c’est l’assembly qui sera chargée dans un application domain séparé. Cette assembly contient la classe SimpleClassMarshalByRef.
  • FxDotNetCommonInterfaces.dll: cette assembly est référencée dans FxDotNetExamples.exe et SimplePlugIn.dll. Elle contient l’interface ISimpleClass.

Charger et décharger du code sous la forme d’un plug-in avec une interface

On se propose de:

  • Charger du code se trouvant dans une assembly nommée SimplePlugIn dans un application domain différent,
  • Exécuter du code dans SimplePlugIn puis
  • Décharger cet application domain.

Tout au long des étapes, on affiche les assemblies chargées dans l’application domain courant en exécutant:

static void PrintLoadedAssemblies()
{
  Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
  foreach (Assembly assembly in assemblies)
    Console.WriteLine(assembly.FullName);
}

On crée une assembly nommée FxDotCommonInterfaces contenant une interface définissant la méthode à exécuter dans un autre application domain:

public interface ISimpleClass
{
  void HelloWorldExample();
}

FxDotCommonInterfaces est ajoutée en référence du programme principal et de SimplePlugIn.

Le classe concrète à exécuter qui satisfait ISimpleClass est:

public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  public void HelloWorldExample()
  {
    Console.WriteLine($"{nameof(SimpleClassMarshalByRef.HelloWorldExample)} executed");
  }
} 
System.MarshalByRefObject

Cet objet permet de mettre en œuvre le marshalling en . NET (voir plus haut). En dérivant de MarshalByRefObject, il sera possible d’appeler des fonctions dans la classe SimpleClassMarshalByRef par l’intermédiaire d’un objet proxy. SimpleClassMarshalByRef et son proxy satisfont la même interface. SimpleClassMarshalByRef reste dans l’application domain supplémentaire, le proxy est utilisé dans l’application domain courant. Quand la fonction HelloWorldExample() est appelée, l’appel se fait dans l’objet proxy et cet appel est répercuté par référence sur l’objet réel dans l’application domain supplémentaire.

Comme on peut le voir dans le code de cet objet mscorlib/system/marshalbyrefobject.cs#L46, les appels se font par référence. Ainsi s’il n’y a pas d’arguments lors de l’appel de fonction, l’appel d’une méthode par l’intermédiaire du proxy n’est pas significativement plus couteux. Malheureusement des appels de fonction se font rarement sans arguments impliquant des mécanismes de sérialisation couteux en performance.

Le code permettant d’exécuter les différentes étapes est:

Console.WriteLine("Before loading FxDotNetDependency");
PrintLoadedAssemblies();
Console.ReadLine();

// 1. Création de l'app domain différent
Console.WriteLine("Creating app domain");
AppDomain ad = AppDomain.CreateDomain(appDomainName);
PrintLoadedAssemblies();

// 2. Instanciation d'une classe dans cet app domain
var o = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);

Console.WriteLine("Before executing SimpleClass");
PrintLoadedAssemblies();
Console.ReadLine();

// 3. Appel d'une fonction dans cette classe 
this.CallFunctionWithInterface(o);

Console.WriteLine("Before app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

// 4. Déchargement de l'app domain différent
AppDomain.Unload(ad);

Console.WriteLine("After app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

Le code de CallFunctionWithInterface() est:

private void CallFunctionWithInterface(object instance)
{
  var simpleClass = instance as ISimpleClass;
  simpleClass.HelloWorldExample();
}

A l’exécution, on peut voir que l’assembly SimplePlugIn n’est jamais chargée dans l’application domain courant:

Before loading FxDotNetDependency
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Creating app domain
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
Before executing SimpleClass
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

HelloWorldExample executed
Before app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

After app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------  

L’interface permet de manipuler le type SimpleClassMarshalByRef sans avoir à charger explicitement son type (dans le méthode CallFunctionWithInterface()).

Exemple sans marshalling

Sans marshalling il n’est pas possible d’exécuter une méthode dans la classe SimpleClassMarshalByRef dans l’application domain supplémentaire sans charger le type SimpleClassMarshalByRef dans l’application domain courant.

Par exemple si on modifie la classe SimpleClassMarshalByRef pour qu’elle ne dérive pas de MarshalByRefObject:

[Serializable]
public class SimpleClassMarshalByRef : ISimpleClass
{
  // ...
}

En exécutant le même code permettant d’exécuter la méthode SimpleClass.HelloWorldExample(), on peut voir que l’assembly SimplePlugIn est chargée dans l’application domain courant:

Before loading FxDotNetDependency
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Creating app domain
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
Before executing SimpleClass
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

HelloWorldExample executed
Before app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

After app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Charger et décharger du code sous la forme d’un plug-in avec la reflection

On se propose d’exécuter le même code que précédemment à la différence qu’on n’utilise pas une interface mais on essaie d’exécuter la méthode SimpleClassMarshalByRef.HelloWorldExample() avec la reflection. On remplace l’appel à CallFunctionWithInterface() par:

private void CallFunctionWithReflection(object instance)
{
  Type type = instance.GetType();
  MethodInfo methodInfo = type.GetMethod("HelloWorldExample");
  methodInfo.Invoke(instance, Array.Empty<object>());
}

Si on exécute ce code (très similaire au code précédemment à l’exception de l’appel à CallFunctionWithReflection()):

Console.WriteLine("Before loading FxDotNetDependency");
PrintLoadedAssemblies();
Console.ReadLine();

// 1. Création de l'app domain différent
Console.WriteLine("Creating app domain");
AppDomain ad = AppDomain.CreateDomain(appDomainName);
PrintLoadedAssemblies();

// 2. Instanciation d'une classe dans cet app domain
var o = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);

Console.WriteLine("Before executing SimpleClass");
PrintLoadedAssemblies();
Console.ReadLine();

// 3. Appel d'une fonction avec la reflection
this.CallFunctionWithReflection(o);
    
Console.WriteLine("Before app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

// 4. Déchargement de l'app domain différent
AppDomain.Unload(ad);

Console.WriteLine("After app domain unload");
PrintLoadedAssemblies();
Console.ReadLine();

On peut voir que le comportement est différent de précédemment, l’assembly contenant la classe SimpleClassMarshalByRef est bien chargée dans l’application domain courant:

Before loading FxDotNetDependency
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

Creating app domain
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------
Before executing SimpleClass
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

HelloWorldExample executed
Before app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

After app domain unload
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
SimplePlugIn, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

La reflection ne permet pas d’isoler la manipulation du code à l’application domain supplémentaire. La mise en œuvre de la reflection entraîne le chargement du type SimpleClassMarshalByRef et donc le chargement de l’assembly SimplePlugIn dans l’application domain courant. Ceci s’explique par le fait que la reflection doit charger le type SimpleClassMarshalByRef pour l’instancier, il n’y a pas d’utilisation du marshalling.

Passage d’argument par valeur

Dans cet exemple, on se propose d’effectuer un passage d’un argument sérialisable par valeur. Volontairement le type de l’objet passé en argument ne permet d’effectuer du marshalling (i.e. il ne dérive pas de MarshalByRefObject) toutefois il est sérialisable.

On rajoute une fonction dans l’interface ISimpleClass permettant le passage d’une liste générique en argument et la modification de cette liste:

public interface ISimpleClass
{
  void HelloWorldExample();
     void ChangeArgumentPassedByValue(int newValue, List<int> intValues);
}

On rajoute l’implémentation correspondante dans la classe SimpleClassMarshalByRef:

[Serializable]
public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  // ...

  public void ChangeArgumentPassedByValue(int newValue, List<int> intValues)
  {
    Console.WriteLine($"Hash code: {intValues.GetHashCode()}");
    intValues.Add(newValue);
  }
}

La liste générique est un objet sérialisable:

[Serializable]
[DebuggerTypeProxy(typeof(Mscorlib_CollectionDebugView<>))]
[DebuggerDisplay("Count = {Count}")]
[__DynamicallyInvokable]
public class List<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T>
{
  // ...
}

Si on exécute le code suivant:

AppDomain ad = AppDomain.CreateDomain(appDomainName);
var instance = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);
var simpleClass = instance as ISimpleClass;      

var integerList = new List<int> { 1, 2, 3 };
Console.WriteLine($"Hash code: {integerList.GetHashCode()}");
simpleClass.ChangeArgumentPassedByValue(4, integerList);
Console.WriteLine(string.Join(", ", integerList.Select(l => l.ToString())));

PrintLoadedAssemblies();
Console.ReadLine();

On obtient le résultat:

Hash code: 19575591
Hash code: 47096010
1, 2, 3
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
System.Core, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
----------------------------------

On peut voir que le hash code n’est pas le même car lors du passage en argument, il s’est produit une copie par valeur de la liste. Même si on rajoute un élément dans la liste dans la fonction ChangeArgumentPassedByValue(), on peut voir que la liste d’origine n’est pas modifiée car il ne s’agit du même objet.

Passage d’argument par référence

Il est possible de passer un objet en argument d’un application domain à un autre à condition qu’il soit possible d’effectuer du marshalling. Par exemple, si on considère l’objet suivant autorisant le marshalling:

public class MarshalByRefList<T> : MarshalByRefObject
{
  private List<T> values;

  public MarshalByRefList(params T[] values)
  {
    this.values = new List<T>(values);
  }
  
  public void AddValue(T value)
  {
    this.values.Add(value);
  }
  
  public void DisplayValues()
  {
    foreach (T value in this.values)
      Console.WriteLine(value);
  }

  public T this[int key]
  {
    get => this.values[key];
    set => this.values[key] = value;
  }
}

En reprenant l’exemple précédent permettant:

  • De passer un objet en argument d’une méthode d’un application domain à l’autre et
  • De modifier dans un application domain pour vérifier si les modifications sont visibles dans l’autre application domain.

On rajoute la méthode ChangeArgumentPassedByRef<T>() dans l’interface ISimpleClass et la classe SimpleClassMarshalByRef:

public interface ISimpleClass
{
  // ...

  void ChangeArgumentPassedByRef<T>(T newValue, MarshalByRefList<T> intValues);
}

public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  // ...

  public void ChangeArgumentPassedByRef<T>(T newValue, MarshalByRefList<T> values)
  {
    Console.WriteLine($"Hash code: {values.GetHashCode()}");
    values.AddValue(newValue);
  }
}

Si on exécute le code suivant:

AppDomain ad = AppDomain.CreateDomain(appDomainName);
var instance = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);
var simpleClass = instance as ISimpleClass;

var marshalByRefList = new MarshalByRefList<int>(1, 2, 3);
Console.WriteLine($"Hash code: {marshalByRefList.GetHashCode()}");
marshalByRefList.DisplayValues();
simpleClass.ChangeArgumentPassedByRef(4, marshalByRefList);
marshalByRefList.DisplayValues();

PrintLoadedAssemblies();
Console.ReadLine();

On obtient le résultat suivant:

Hash code: 19575591
1
2
3
Hash code: 19575591
1
2
3
4
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

La structure modifiée est la même entre les 2 application domains. Le marshalling permet de faire passer l’objet par référence, il n’y a pas de copie par valeur.

Serialization vs marshalling

Cet exemple permet d’illustrer la différence entre la sérialisation et le marshalling. Il consiste à observer la différence de comportement lorsqu’on passe une liste d’objets d’un application domain à un autre suivant si le marshalling est appliqué ou non à ces objets.

On considère l’objet sérialisable suivant sur lequel on peut appliquer le marshalling (grâce à MarshalByRefObject):

[Serializable]
public class CustomMarshalByRefObject: MarshalByRefObject
{
  public int InnerValue { get; set; }

  public override string ToString()
  {
    return this.InnerValue.ToString();
  }
}

On modifie le classe SimpleClassMarshalByRef et l’interface ISimpleClass pour permettre le passage d’une liste de type CustomMarshalByRefObject d’un application domain à un autre:

public interface ISimpleClass
{
  // ...
  void PassArgumentByRef(List<CustomMarshalByRefObject> values);
  
}
[Serializable]
public class SimpleClassMarshalByRef : MarshalByRefObject, ISimpleClass
{
  private List<CustomMarshalByRefObject> marshalByRefObjectList;

  public void DisplayInnerValues()
  {
    if (marshalByRefObjectList != null)
    {
      foreach (var value in this.marshalByRefObjectList)
      {
        Console.WriteLine(value);
      }
    }
  }

  // ...

  public void PassArgumentByRef(List<CustomMarshalByRefObject> values)
  {
    this.marshalByRefObjectList = values;
  }
}

Le code suivant permet de passer d’un application domain à un autre une liste de CustomMarshalByRefObject. On modifie ensuite le contenu d’un objet de la liste dans l’application domain principal et on vérifie si cette modification s’est répercutée dans l’application domain supplémentaire:

public void ChangeMarshalByRefObjectWithList()
{
  AppDomain ad = AppDomain.CreateDomain(appDomainName);
  var instance = ad.CreateInstanceAndUnwrap(dependencyAssemblyName, simpleClassTypeName);
  var simpleClass = instance as ISimpleClass;

  var thirdObject = new CustomMarshalByRefObject { InnerValue = 3 };
  var marshalByRefList = new List<CustomMarshalByRefObject>{
    new CustomMarshalByRefObject { InnerValue = 1 },
    new CustomMarshalByRefObject { InnerValue = 2 },
    thirdObject };
  simpleClass.PassArgumentByRef(marshalByRefList);
  simpleClass.DisplayInnerValues();
  thirdObject.InnerValue = 5;
  simpleClass.DisplayInnerValues();

  PrintLoadedAssemblies();
  Console.ReadLine();
}

En exécutant ce code, on obtient:

1
2
3
1
2
5
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

On constate que la valeur modifiée dans l’application domain principal a bien été répercutée dans l’application domain principal. Plusieurs éléments expliquent ce comportement:

  • Une liste générique est sérialisable mais il n’est pas possible d’y appliquer du marshalling. Ainsi lors du passage en argument dans la fonction SimpleClassMarshalByRef.PassArgumentByRef(), la liste est dupliquée dans l’application domain supplémentaire.
  • Etant donné que la liste ne contient que des références vers des instances d’objets de type CustomMarshalByRefObject, les objets CustomMarshalByRefObject vers lesquelles pointent les références ne sont pas dupliquées.
  • Enfin, le marshalling est appliquée sur les instances d’objets CustomMarshalByRefObject, ainsi lorsqu’on modifie le contenu de ces objets dans un application domain, cette modification est répercutée via le proxy dans la seule instance de cet objet. La modification est alors visible à partir des 2 application domains.

Si on modifie la classe CustomMarshalByRefObject pour que le marshalling ne puisse plus s’appliquer:

[Serializable]
public class CustomMarshalByRefObject //: MarshalByRefObject
{
  public int InnerValue { get; set; }

  public override string ToString()
  {
    return this.InnerValue.ToString();
  }
}

En exécutant le même code que précédemment, on obtient:

1
2
3
1
2
3
----------------------------------
mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
AssemblyLoadContextExamples_FxDotNet, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
FxDotCommonInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
----------------------------------

On peut voir que la modification dans l’application domain principal ne s’est pas répercutée dans l’application domain supplémentaire. Cela s’explique par le fait qu’en supprimant le marshalling, le passage en argument de la liste d’objets CustomMarshalByRefObject a conduit à la duplication de ces objets en utilisant la sérialisation. 2 instances de chaque objet existent dans les 2 application domains. Quand on modifie une instance de CustomMarshalByRefObject, la modification n’est pas répercutée dans l’autre instance.

En conclusion…

Les application domains sont une solution pour permettre d’isoler des portions de code dans un même processus dans le but de rendre ces portions modulables. Cette fonctionnalité permet, par exemple:

  • De mettre en place des mécanismes de plug-in: on peut ainsi charger des assemblies dans des application domains différents et décharger ces application domains par la suite.
  • De charger des versions différentes d’une même assembly.

La mise en œuvre des application domains n’est, toutefois, pas tout à fait directe car elle implique que les objets transitants d’un application domain à l’autre soient sérialisables. D’autre part, pour appeler du code d’un application domain à l’autre, il faut utiliser des mécanismes de marshalling. Le marshalling et surtout la sérialisation entraînent un coût en performance non négligeable par rapport à des appels dans un même application domain.

Les application domains ne sont utilisables que dans le cadre du framework .NET car une fonctionnalité plus performante a été implémentée à partir de .NET 5: les assembly load contexts. Les assembly load contexts permettent une isolation moins franche entre des portions de code en permettant de maîtriser le chargement des assemblies. Malgré des contraintes moins fortes que pour les application domains, les assembly load contexts permettent d’implémenter tous les cas d’utilisation adressés par les application domains sans dégrader les performances.

Leave a Reply