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) :
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.
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:
struct HelloWorld_t2 : public MonoBehaviour_t3
// 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.
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
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.
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.