
This is the seventh post in the IL2CPP Internals series. In this post, we will explore a bit about how the IL2CPP runtime integrates with a garbage collector. Specifically, we’ll see how the GC roots in managed code are communicated to the native garbage collector.
As with all of the posts in this series, this post deals with implementation details that can and likely will change in the future. In this post we will specifically look at some internal APIs used by the runtime code to communicate with the garbage collector. These APIs are not publicly supported, and you should not attempt to call them from any code in a real project. But, this is a post about internals, so let’s dig in.
Garbage collection
I won’t discuss general garbage collection techniques in this post, as it is a wide and varied subject, with plenty of existing research and published information. To follow along, just think of a GC as an algorithm that develops a directed graph of object references. If an object Child is used by an object Parent (via a pointer in native code), then the graph looks like this:
As the GC scans through the memory for a process, it looks for objects which don’t have parent. If it finds one, then it can reuse the memory for that object on something else.
Of course, most object will have a parent of some sort, so the GC really needs to know which objects are the important parents. I like to think of these as the objects that are actually in use by your program. In GC terminology, these are called the “roots”. Here is an example of a parent without a root.
In this case, Parent 2 does not have a root, so the GC can reuse the memory from Parent 2 and Child 2. Parent 1 and Child 1, however, do have a root, so the GC cannot reuse their memory. The program is still using them for something.
For .NET, there are three kinds of roots:
- Local variables on the stack of any thread executing managed code
- Static variables
GCHandle
objects
We’ll see how IL2CPP communicates with the garbage collector about all three of these kinds of roots.
The setup
For this post, I’m using Unity 5.1.0p1 on OSX, and I’m building for the iOS platform. This will allow us to use Xcode to have a look at how IL2CPP interacts with the garbage collector. As with the other posts in this series, I’ll use an example project with a single script:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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(); } } |
I have enabled the “Development Build” in the Build Settings dialog, and I set the “Run in Xcode as” option to a value of “Debug”. In the generated Xcode project, first search for the string “Start_m”. You should see the generated code for the Start
method in the the HelloWorld
class named HelloWorld_Start_m3
.
Adding thread local variables as roots
Add a breakpoint in the HelloWorld_Start_m3
function on the line where Thread_Start_m9
is called. This method will create a new managed thread, so we expect that thread to be added to the GC as a root. We can see where this happens by exploring the libil2cpp header files that ship with Unity. In the Unity installation open the Contents/Frameworks/il2cpp/libil2cpp/gc/gc-internal.h file. This file has a number of methods prefixed with il2cpp_gc_ it serves as part of the API between the libil2cpp runtime and the garbage collector. Note that this is not a public API, so please don’t call these methods from any real project code. They are subject to change or removal without notice.
Let’s create a breakpoint in Xcode on the il2cpp_gc_register_thread
function, using Debug > Breakpoints > Create Symbolic Breakpoint.
If you then run the project in Xcode, you’ll notice that the breakpoint is hit almost immediately. We can’t see the source code here, as it is built in the libil2cpp runtime static library, but we can see from the call stack that this thread is created in the InitializeScriptingBackend
method, which executes when the player starts.
We will actually see this breakpoint hit a number of times, as the player creates each managed thread used internally. For now, you can disable this breakpoint in Xcode and allow the project to continue. We should hit the breakpoint we set earlier in the HelloWorld_Start_m3
method.
Now we are just about to start the managed thread created by our script code, so enable the breakpoint on il2cpp_gc_register_thread
again. When we hit that breakpoint, the first thread is waiting to join our created thread, but the call stack for the created thread shows that we are just starting it:
When a thread is registered with the garbage collector, the GC treats all objects on the local stack for that thread as roots. Let’s look at the generated code for the method we run on that thread (HelloWorld_AnotherThread_m4
) :
1 2 3 |
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; |
We can see one local variable, L_0
, which the GC must treat as a root. During the (short) lifetime of this thread, this instance of the AnyClass
object and any other objects it references cannot be reused by the garbage collector. Variables defined on the stack are the most common kind of GC roots, as most objects in a program start off from a local variable in a method executing on a managed thread.
When a thread exits, the il2cpp_gc_unregister_thread
function is called to tell the GC to stop treating the objects on the thread stack as roots. The GC can then work on reusing the memory for the AnyClass
object represented in native code by L_0
.
Static variables
Some variables don’t live on thread call stacks though. These are static variables, and they also need to be handled as roots by the garbage collector.
When IL2CPP lays out the native representation of a class, it groups all of the static fields together in a different C++ structure from the instance fields in the class. In Xcode, we can jump to the definition of the HelloWorld_t2
class:
1 2 3 4 5 6 7 8 |
struct HelloWorld_t2 : public MonoBehaviour_t3 { }; struct HelloWorld_t2_StaticFields{ // AnyClass HelloWorld::staticAnyClass AnyClass_t1 * ___staticAnyClass_2; }; |
Note that IL2CPP does not use the C++ static
keyword, as it needs to be in control of the layout and allocation of the static fields to properly communicate with the GC. When a type is first used at runtime, the libil2cpp code will initialize the type. Part of this initialization involves allocating memory for the HelloWorld_t2_StaticFields
structure. This memory is allocated with a special call into the GC: il2cpp_gc_alloc_fixed
(also in the gc-internal.h file).
This call informs the garbage collector to treat the allocated memory as a root, and the GC dutifully does this for the lifetime of the process. It is possible to set a breakpoint on the il2cpp_gc_alloc_fixed
function in Xcode, but it is called rather often (even for this simple project), so the breakpoint is not too useful.
GCHandle objects
Suppose that you don’t want to use a static variable, but you still want a bit more control over when the garbage collector is allowed to reuse the memory for an object. This is usually helpful when you need to pass a pointer to a managed object from managed to native code. If the native code will take ownership of that object, we need to tell the garbage collector that the native code is now a root in its object graph. This works by using a special managed object called a GCHandle
.
The creation of a GCHandle
informs the runtime code that a given managed object should be treated as a root in the GC so that it and any objects it references will not be reused. In IL2CPP, we can see the low-level API to accomplish this in the Contents/Frameworks/il2cpp/libil2cpp/gc/GCHandle.h file. Again, this is not a public API, but it is fun to investigate. Let’s put a breakpoint on the GCHandle::New
function. If we let the project continue then, we should see this call stack:
Notice that the generated code for our Start
method is calling GCHandle_Alloc_m11
, which eventually creates a GCHandle
and informs the garbage collector that we have a new root object.
Conclusion
We’ve looked at some internal API methods to see how the IL2CPP runtime interacts with the garbage collector, letting it know which objects are the roots it should preserve. Note that we have not talked at all about which garbage collector IL2CPP uses. It is currently using the Boehm-Demers-Weiser GC, but we have worked hard to isolate the garbage collector behind a clean interface. We currently have plans to research integration of the open-source CoreCLR garbage collector. We don’t have a firm ship date yet for this integration, but watch our public roadmap for updates.
As usual, we’ve just scratched the surface of the GC integration in IL2CPP. I encourage you to explore more about how IL2CPP and the GC interact. Please share your insights as well.
Next time, we will wrap up the IL2CPP internals series by looking at how we test the IL2CPP code.
Oddur Magnusson
July 23, 2015 at 2:53 pmAny thoughts or ideas about migrating to a incremental garbage collection ? Maybe the CLR does not allow such things, but being able to amortize your GC over multiple frames would be an awesome feature.
Say you have the classic 16ms budget and you are running at 12ms, you could use the remaining 4ms to do GC and avoid the “stop the world” frame spikes. I know this has been a huge advantage for engines that use LUA as their scripting language.
Andrew
July 22, 2015 at 12:37 amAwesome! This helps me with my hobby project of transpiling C# to C++.
Have my own GC so I can run C# code on super low memory devices but was confused about how .NET considered variables within a method as roots. This answered a question I was having about that.
Eric
July 15, 2015 at 8:37 pmThis was a great post!
Christian
July 11, 2015 at 9:47 amSo, the GC of il2cpp is not the same as mono?
How is the performance of this GC compared to mono one? Better or same?
Josh Peterson
July 13, 2015 at 12:25 pmThis version of Mono that ships with Unity also uses BDWGC, although the version of BDWGC used for IL2CPP is a bit newer. So it is the same codebase for the GC as Mono. The performance is similar between the two, based on our tests.
Dave Voyles
July 10, 2015 at 7:33 pmLove this series. Keep up the great work!
Josh Peterson
July 10, 2015 at 7:37 pmThanks Dave. We only have one more post scheduled, but I’m open to suggestions for additional topics!
koblavi
July 9, 2015 at 9:35 pmGreat to know you’re researching into some core CLR components. For one thing, I personally think moving closer to .NetCore and farther from Mono dependence will augur well for Unity. The .Net framework is generally the more stable, production-ready, bigger sibling :-D. Most devs still think of Mono as an experimental project and I dare say it is one of the reasons why some devs don’t take Unity too seriously. Although I suspect that a move like this will only motivate Microsoft to start making billion-dollar offers to acquire Unity, which will ultimately be a bad thing.
Gabe
July 9, 2015 at 6:10 pmHi!
I’d like to ask, does the GC have any knowledge of the internal object layouts, so it knows where the pointers are?
How do you generate this information now that physical layout of fields is basically up to the C++ compiler?
Josh Peterson
July 10, 2015 at 12:54 pmYes, the GC does know about which fields have pointers. The type metadata for each type has a GC descriptor which is a bit field indicating whether or not there is a pointer at each field offset in a given type.
For a few reasons, including GC descriptors, the IL2CPP runtime code knows about the field layout of each type. We have some compiler specific code related to packing and field layout, but for the most part it is pretty straight-forward to understand how the C++ compiler will layout a given type.