IL2CPP 내부: 가비지 컬렉터 통합

이 시리즈의 모든 게시물과 마찬가지로 이 게시물은 향후 변경될 수 있고 변경될 가능성이 있는 구현 세부 사항을 다루고 있습니다. 이 글에서는 런타임 코드가 가비지 수집기와 통신하는 데 사용하는 몇 가지 내부 API를 구체적으로 살펴보겠습니다. 이러한 API는 공개적으로 지원되지 않으므로 실제 프로젝트의 어떤 코드에서도 호출을 시도해서는 안 됩니다. 하지만 이 글은 내부에 관한 글이니 자세히 살펴보겠습니다.
이 글에서는 일반적인 가비지 수집 기술에 대해서는 기존의 많은 연구와 출판된 정보가 있는 광범위하고 다양한 주제이므로 다루지 않겠습니다. 따라 하려면 GC를 객체 참조의 방향성 그래프를 개발하는 알고리즘이라고 생각하면 됩니다. 자식 객체가 부모 객체에서 사용되는 경우(네이티브 코드의 포인터를 통해) 그래프는 다음과 같이 표시됩니다:

GC는 메모리를 스캔하여 프로세스를 찾을 때 부모가 없는 객체를 찾습니다. 객체를 찾으면 해당 객체에 대한 메모리를 다른 객체에 재사용할 수 있습니다.
물론 대부분의 객체에는 어떤 종류의 부모가 있기 때문에 GC는 어떤 객체가 중요한 부모인지 알아야 합니다. 저는 이것을 프로그램에서 실제로 사용하는 객체라고 생각하고 싶습니다. GC 용어로는 이를 '루트'라고 합니다. 다음은 루트가 없는 부모의 예입니다.

이 경우 부모 2에는 루트가 없으므로 GC는 부모 2와 자식 2의 메모리를 재사용할 수 있습니다. 그러나 부모 1과 자식 1은 루트를 가지고 있으므로 GC가 메모리를 재사용할 수 없습니다. 이 프로그램은 여전히 무언가에 사용하고 있습니다.
.NET의 경우 세 가지 종류의 루트가 있습니다:
- 관리되는 코드를 실행하는 스레드의 스택에 있는 로컬 변수
- 정적 변수
- GCHandle 개체
이 세 가지 종류의 루트에 대해 IL2CPP가 가비지 컬렉터와 통신하는 방법을 살펴보겠습니다.
이 포스팅에서는 OSX에서 Unity 5.1.0p1을 사용하고 있으며, iOS 플랫폼용으로 빌드하고 있습니다. 이렇게 하면 Xcode를 사용하여 IL2CPP가 가비지 컬렉터와 상호 작용하는 방식을 살펴볼 수 있습니다. 이 시리즈의 다른 게시물과 마찬가지로 단일 스크립트가 포함된 예제 프로젝트를 사용하겠습니다:
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();
}
}빌드 설정 대화 상자에서 "개발 빌드"를 활성화하고 "Xcode에서 다른 이름으로 실행" 옵션을 "디버그" 값으로 설정했습니다. 생성된 Xcode 프로젝트에서 먼저 "Start_m" 문자열을 검색합니다. HelloWorld_Start_m3라는 HelloWorld 클래스에서 Start 메서드에 대해 생성된 코드를 확인할 수 있습니다.
HelloWorld_Start_m3 함수에서 Thread_Start_m9가 호출되는 줄에 중단점을 추가합니다. 이 메서드는 새 관리 스레드를 생성하므로 해당 스레드가 GC에 루트로 추가될 것으로 예상합니다. Unity와 함께 제공되는 libil2cpp 헤더 파일을 살펴보면 이러한 문제가 발생하는 위치를 확인할 수 있습니다. Unity 설치에서 콘텐츠/프레임워크/il2cpp/libil2cpp/gc/gc-internal.h 파일을 엽니다. 이 파일에는 il2cpp_gc_라는 접두사가 붙은 여러 메서드가 있으며, 이는 libil2cpp 런타임과 가비지 컬렉터 사이의 API의 일부로 사용됩니다. 이 메서드는 공개 API가 아니므로 실제 프로젝트 코드에서 이 메서드를 호출하지 마세요. 사전 통지 없이 변경되거나 삭제될 수 있습니다.
Xcode에서 디버그 > 중단점 > 심볼릭 중단점 생성을 사용하여 il2cpp_gc_register_thread 함수에서 중단점을 생성해 보겠습니다.

그런 다음 Xcode에서 프로젝트를 실행하면 중단점에 거의 즉시 도달하는 것을 확인할 수 있습니다. 이 스레드는 libil2cpp 런타임 정적 라이브러리에 빌드되어 있으므로 소스 코드를 볼 수는 없지만, 호출 스택을 통해 플레이어가 시작될 때 실행되는 InitializeScriptingBackend 메서드에서 이 스레드가 생성되었음을 알 수 있습니다.

플레이어가 내부적으로 사용되는 각 관리 스레드를 생성하기 때문에 실제로 이 중단점에 여러 번 도달하는 것을 볼 수 있습니다. 현재로서는 Xcode에서 이 중단점을 비활성화하고 프로젝트를 계속 진행할 수 있습니다. 앞서 HelloWorld_Start_m3 메서드에서 설정한 중단점에 도달해야 합니다.
이제 스크립트 코드에서 생성한 관리 스레드를 막 시작하려고 하므로 il2cpp_gc_register_thread에서 중단점을 다시 활성화합니다. 이 중단점에 도달하면 첫 번째 스레드가 생성된 스레드에 합류하기 위해 대기 중이지만 생성된 스레드의 호출 스택에는 이제 막 시작했음을 보여줍니다:

가비지 컬렉터에 스레드가 등록되면 GC는 해당 스레드의 로컬 스택에 있는 모든 객체를 루트로 취급합니다. 해당 스레드에서 실행하는 메서드(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;GC가 루트로 처리해야 하는 L_0이라는 로컬 변수가 하나 보입니다. 이 스레드의 (짧은) 수명 동안에는 가비지 컬렉터가 이 AnyClass 객체 인스턴스와 이 객체가 참조하는 다른 객체를 재사용할 수 없습니다. 스택에 정의된 변수는 가장 일반적인 종류의 GC 루트로, 프로그램의 대부분의 객체는 관리형 스레드에서 실행되는 메서드의 로컬 변수에서 시작하기 때문입니다.
스레드가 종료되면 il2cpp_gc_unregister_thread 함수가 호출되어 GC에 스레드 스택의 오브젝트를 루트로 취급하지 않도록 지시합니다. 그러면 GC는 네이티브 코드에서 L_0으로 표현되는 AnyClass 오브젝트의 메모리를 재사용할 수 있습니다.
하지만 일부 변수는 스레드 호출 스택에 존재하지 않습니다. 이러한 변수는 정적 변수이며 가비지 컬렉터에서 루트로 처리해야 합니다.
IL2CPP가 클래스의 네이티브 표현을 배치할 때 모든 정적 필드를 클래스의 인스턴스 필드와 다른 C++ 구조로 그룹화합니다. Xcode에서 HelloWorld_t2 클래스의 정의로 이동할 수 있습니다:
struct HelloWorld_t2 : public MonoBehaviour_t3
{
};
struct HelloWorld_t2_StaticFields{
// AnyClass HelloWorld::staticAnyClass
AnyClass_t1 * ___staticAnyClass_2;
};IL2CPP는 GC와 제대로 통신하기 위해 정적 필드의 레이아웃과 할당을 제어해야 하므로 C++ 정적 키워드를 사용하지 않습니다. 런타임에 타입이 처음 사용되면 libil2cpp 코드가 타입을 초기화합니다. 이 초기화의 일부에는 HelloWorld_t2_StaticFields 구조에 메모리를 할당하는 작업이 포함됩니다. 이 메모리는 GC에 대한 특수 호출인 il2cpp_gc_alloc_fixed(gc-internal.h 파일에도 있음)를 통해 할당됩니다.
이 호출은 가비지 컬렉터에게 할당된 메모리를 루트로 취급하도록 알려주며, GC는 프로세스의 수명 동안 이 작업을 충실히 수행합니다. Xcode의 il2cpp_gc_alloc_fixed 함수에 중단점을 설정할 수 있지만, 이 간단한 프로젝트에서도 자주 호출되므로 중단점이 그다지 유용하지 않습니다.
정적 변수를 사용하고 싶지는 않지만 가비지 컬렉터가 객체에 대한 메모리를 재사용할 수 있는 시점을 좀 더 제어하고 싶다고 가정해 보겠습니다. 이는 일반적으로 관리되는 객체에 대한 포인터를 관리형 코드에서 네이티브 코드로 전달해야 할 때 유용합니다. 네이티브 코드가 해당 객체의 소유권을 가져가려면 가비지 컬렉터에 네이티브 코드가 이제 객체 그래프에서 루트라고 알려야 합니다. 이는 GCHandle이라는 특수 관리 객체를 사용하여 작동합니다.
GCHandle을 생성하면 런타임 코드에 특정 관리 객체가 GC에서 루트로 처리되어 해당 객체와 참조하는 모든 객체가 재사용되지 않도록 해야 한다는 것을 알립니다. IL2CPP에서 이 작업을 수행하는 로우레벨 API는 Contents/Frameworks/il2cpp/libil2cpp/gc/GCHandle.h 파일에서 확인할 수 있습니다. 다시 말하지만, 이것은 공개 API는 아니지만 조사해 보는 것도 재미있을 것입니다. GCHandle::New 함수에 중단점을 설정해 보겠습니다. 프로젝트를 계속 진행하면 이 호출 스택을 볼 수 있습니다:

Start 메서드에 대해 생성된 코드가 GCHandle_Alloc_m11을 호출하여 결국 GCHandle을 생성하고 가비지 컬렉터에 새 루트 객체가 있음을 알리는 것을 확인할 수 있습니다.
IL2CPP 런타임이 가비지 컬렉터와 상호작용하여 어떤 오브젝트가 보존해야 하는 루트인지 알려주는 몇 가지 내부 API 메서드를 살펴봤습니다. IL2CPP가 어떤 가비지 수집기를 사용하는지에 대해서는 전혀 언급하지 않았습니다. 현재 보임-데머스-바이저 GC를 사용하고 있지만, 가비지 수집기를 깔끔한 인터페이스 뒤에 분리하기 위해 많은 노력을 기울였습니다. 현재 오픈 소스인 CoreCLR 가비지 수집기의 통합을 연구할 계획이 있습니다. 이 통합 기능의 출시일은 아직 확정되지 않았지만, 공개 로드맵에서 업데이트를 확인해 주세요.
늘 그렇듯, IL2CPP의 GC 통합은 이제 막 표면을 드러냈습니다. IL2CPP와 GC의 상호 작용 방식에 대해 자세히 알아보는 것을 추천합니다. 여러분의 인사이트도 공유해 주세요.
다음 시간에는 IL2CPP 코드를 테스트하는 방법을 살펴보면서 IL2CPP 내부 시리즈를 마무리하겠습니다.
