IL2CPP interne : Intégration du collecteur de déchets

Comme tous les articles de cette série, cet article traite de détails de mise en œuvre qui peuvent changer et changeront probablement à l'avenir. Dans ce billet, nous nous intéresserons plus particulièrement à certaines API internes utilisées par le code d'exécution pour communiquer avec le garbage collector. Ces API ne sont pas prises en charge publiquement et vous ne devez pas essayer de les appeler à partir d'un code dans un projet réel. Mais comme il s'agit d'un article sur les systèmes internes, nous allons nous y plonger.
Je n'aborderai pas les techniques générales de ramassage des ordures dans ce billet, car il s'agit d'un sujet vaste et varié, qui fait l'objet de nombreuses recherches et publications. Pour continuer, considérez un GC comme un algorithme qui développe un graphe orienté de références d'objets. Si un objet Enfant est utilisé par un objet Parent (via un pointeur dans le code natif), le graphique se présente comme suit :

Lorsque le GC parcourt la mémoire d'un processus, il recherche les objets qui n'ont pas de parent. S'il en trouve un, il peut réutiliser la mémoire de cet objet pour quelque chose d'autre.
Bien entendu, la plupart des objets ont un parent d'une manière ou d'une autre, de sorte que le GC a vraiment besoin de savoir quels objets sont les parents importants. J'aime à considérer qu'il s'agit des objets qui sont réellement utilisés par votre programme. Dans la terminologie du GC, on les appelle les "racines". Voici un exemple de parent sans racine.

Dans ce cas, Parent 2 n'a pas de racine, le GC peut donc réutiliser la mémoire de Parent 2 et d'Enfant 2. Parent 1 et Enfant 1 ont toutefois une racine, de sorte que le GC ne peut pas réutiliser leur mémoire. Le programme les utilise toujours pour quelque chose.
Pour .NET, il existe trois types de racines :
- Variables locales sur la pile de tout thread exécutant du code géré.
- Variables statiques
- Objets GCHandle
Nous verrons comment IL2CPP communique avec le ramasse-miettes à propos de ces trois types de racines.
Pour ce billet, j'utilise Unity 5.1.0p1 sur OSX, et je construis pour la plateforme iOS. Cela nous permettra d'utiliser Xcode pour voir comment IL2CPP interagit avec le garbage collector. Comme pour les autres articles de cette série, j'utiliserai un projet d'exemple avec un seul script :
using System;
using System.Runtime.InteropServices;
using System.Threading;
using UnityEngine;
public class AnyClass {}
public class HelloWorld : MonoBehaviour {
private static AnyClass staticAnyClass = new AnyClass();
void Start () {
var thread = new Thread(AnotherThread);
thread.Start();
thread.Join();
var anyClassForGCHandle = new AnyClass();
var gcHandle = GCHandle.Alloc(anyClassForGCHandle);
}
private static void AnotherThread() {
var anyClassLocal = new AnyClass();
}
}J'ai activé l'option "Development Build" dans la boîte de dialogue Build Settings, et j'ai défini l'option "Run in Xcode as" à une valeur de "Debug". Dans le projet Xcode généré, recherchez d'abord la chaîne "Start_m". Vous devriez voir le code généré pour la méthode Start dans la classe HelloWorld nommée HelloWorld_Start_m3.
Ajoutez un point d'arrêt dans la fonction HelloWorld_Start_m3 sur la ligne où Thread_Start_m9 est appelé. Cette méthode va créer un nouveau thread géré, et nous nous attendons donc à ce que ce thread soit ajouté au GC en tant que racine. Nous pouvons voir où cela se produit en explorant les fichiers d'en-tête libil2cpp livrés avec Unity. Dans l'installation d'Unity, ouvrez le fichier Contents/Frameworks/il2cpp/libil2cpp/gc/gc-internal.h. Ce fichier contient un certain nombre de méthodes préfixées par il2cpp_gc_. Il fait partie de l'API entre le moteur d'exécution libil2cpp et le ramasse-miettes. Notez qu'il ne s'agit pas d'une API publique, donc n'appelez pas ces méthodes à partir du code d'un projet réel. Elles peuvent être modifiées ou supprimées sans préavis.
Créons un point d'arrêt dans Xcode sur la fonction il2cpp_gc_register_thread, en utilisant Debug > Breakpoints > Create Symbolic Breakpoint.

Si vous exécutez ensuite le projet dans Xcode, vous remarquerez que le point d'arrêt est atteint presque immédiatement. Nous ne pouvons pas voir le code source ici, car il est construit dans la bibliothèque statique d'exécution libil2cpp, mais nous pouvons voir dans la pile d'appels que ce thread est créé dans la méthode InitializeScriptingBackend, qui s'exécute lorsque le lecteur démarre.

Nous verrons ce point d'arrêt frappé un certain nombre de fois, car le lecteur crée chaque thread géré utilisé en interne. Pour l'instant, vous pouvez désactiver ce point d'arrêt dans Xcode et permettre au projet de continuer. Nous devrions atteindre le point d'arrêt que nous avons défini plus tôt dans la méthode HelloWorld_Start_m3.
Nous sommes maintenant sur le point de démarrer le thread géré créé par notre code de script, donc activez à nouveau le point d'arrêt sur il2cpp_gc_register_thread. Lorsque nous atteignons ce point d'arrêt, le premier thread attend de rejoindre notre thread créé, mais la pile d'appels du thread créé montre que nous ne faisons que le démarrer :

Lorsqu'un thread est enregistré auprès du garbage collector, le GC traite tous les objets de la pile locale de ce thread comme des racines. Examinons le code généré pour la méthode que nous exécutons sur ce thread (HelloWorld_AnotherThread_m4) :
AnyClass_t1 * L_0 = (AnyClass_t1 *)il2cpp_codegen_object_new (AnyClass_t1_il2cpp_TypeInfo_var);
AnyClass__ctor_m0(L_0, /*hidden argument*/NULL);
V_0 = L_0;Nous pouvons voir une variable locale, L_0, que le GC doit traiter comme une racine. Pendant la durée de vie (courte) de ce thread, cette instance de l'objet AnyClass et tous les autres objets qu'elle référence ne peuvent pas être réutilisés par le ramasse-miettes. Les variables définies sur la pile sont le type le plus courant de racines GC, car la plupart des objets d'un programme partent d'une variable locale dans une méthode s'exécutant sur un thread géré.
Lorsqu'un thread se termine, la fonction il2cpp_gc_unregister_thread est appelée pour indiquer au GC de ne plus traiter les objets de la pile du thread comme des racines. Le GC peut alors travailler à la réutilisation de la mémoire pour l'objet AnyClass représenté en code natif par L_0.
Certaines variables ne vivent pas sur les piles d'appels des threads. Il s'agit de variables statiques, qui doivent également être traitées comme des racines par le ramasse-miettes.
Lorsque IL2CPP établit la représentation native d'une classe, il regroupe tous les champs statiques dans une structure C++ différente de celle des champs d'instance de la classe. Dans Xcode, nous pouvons passer à la définition de la classe HelloWorld_t2 :
struct HelloWorld_t2 : public MonoBehaviour_t3
{
};
struct HelloWorld_t2_StaticFields{
// AnyClass HelloWorld::staticAnyClass
AnyClass_t1 * ___staticAnyClass_2;
};Notez que IL2CPP n'utilise pas le mot-clé C++ static, car il doit contrôler la disposition et l'allocation des champs statiques pour communiquer correctement avec le GC. Lorsqu'un type est utilisé pour la première fois à l'exécution, le code de libil2cpp l'initialise. Une partie de cette initialisation consiste à allouer de la mémoire à la structure HelloWorld_t2_StaticFields. Cette mémoire est allouée par un appel spécial au GC : il2cpp_gc_alloc_fixed (également dans le fichier gc-internal.h).
Cet appel informe le ramasse-miettes de traiter la mémoire allouée comme une racine, ce que le GC fait consciencieusement pendant toute la durée de vie du processus. Il est possible de mettre un point d'arrêt sur la fonction il2cpp_gc_alloc_fixed dans Xcode, mais elle est appelée assez souvent (même pour ce projet simple), donc le point d'arrêt n'est pas très utile.
Supposons que vous ne souhaitiez pas utiliser de variable statique, mais que vous vouliez tout de même avoir un peu plus de contrôle sur le moment où le ramasse-miettes est autorisé à réutiliser la mémoire d'un objet. Ceci est généralement utile lorsque vous devez passer un pointeur sur un objet géré du code géré au code natif. Si le code natif prend possession de cet objet, nous devons indiquer au ramasse-miettes que le code natif est désormais une racine dans son graphe d'objets. Cela fonctionne en utilisant un objet géré spécial appelé GCHandle.
La création d'un GCHandle informe le code d'exécution qu'un objet géré donné doit être traité comme une racine dans le GC, de sorte que cet objet et tous les objets qu'il référence ne seront pas réutilisés. Dans IL2CPP, nous pouvons voir l'API de bas niveau pour accomplir cela dans le fichier Contents/Frameworks/il2cpp/libil2cpp/gc/GCHandle.h. Encore une fois, il ne s'agit pas d'une API publique, mais il est amusant de l'étudier. Plaçons un point d'arrêt sur la fonction GCHandle::New. Si nous laissons le projet se poursuivre, nous devrions voir cette pile d'appels :

Remarquez que le code généré pour notre méthode Start appelle GCHandle_Alloc_m11, qui crée finalement un GCHandle et informe le garbage collector que nous avons un nouvel objet racine.
Nous avons examiné certaines méthodes internes de l'API pour voir comment le runtime IL2CPP interagit avec le garbage collector, en lui indiquant quels objets sont les racines qu'il doit préserver. Notez que nous n' avons pas du tout parlé du ramasse-miettes utilisé par IL2CPP. Il utilise actuellement le GC de Boehm-Demers-Weiser, mais nous avons travaillé dur pour isoler le ramasse-miettes derrière une interface propre. Nous prévoyons actuellement d'étudier l'intégration du ramasse-miettes open-source CoreCLR. Nous n'avons pas encore de date de livraison ferme pour cette intégration, mais surveillez notre feuille de route publique pour les mises à jour.
Comme d'habitude, nous n'avons fait qu'effleurer l'intégration du GC dans IL2CPP. Je vous encourage à en savoir plus sur l'interaction entre l'IL2CPP et le GC. N'hésitez pas à nous faire part de vos commentaires.
La prochaine fois, nous conclurons la série sur les éléments internes d'IL2CPP en examinant comment nous testons le code IL2CPP.
