UE5 TWeakObjectPtr<...>

에크까망·2024년 9월 28일

들어가기 전에

  • 단순화하기 위해서 문체를 단정적으로 사용하지만 지극히 개인 의견일 뿐입니다.
  • 본문과 관련해서 오류 지적이나 의견이 있으시면 꼭 댓글 부탁드립니다.
  • 글 작성 시점은 2024/09입니다.
  • 글 작성 기준은 UE5.4.4입니다.
  • Garbage Collection 포스팅을 먼저 보신 것으로 가정합니다.

들어가며

TWeakObjectPtr<>은 GC Referencing은 하지 않으면서 Object가 유효한지 확인하고, 유효하면 값을 가져올 수 있는 기능을 제공합니다. 이름 그대로 약한 참조이며 C#의 WeakReference와 동일합니다. 즉, GC를 막지는 않으면서 값은 안전하게 확인 후 가져올 수 있는 기능을 제공합니다.

이번 포스팅에서는

  • TWeakObjectPtr<>의 대략적인 내부 구현
  • UPROPERTY(...)와 TWeakObjectPtr<..> 동시 사용 가능할 때 선택 가이드

등에 대해서 살펴보겠습니다. 들어가기 전에에 항상 언급하듯이 UE5.4.4 기준이며, 다른 버전에서는 궁금하실 때 따로 확인해 보시기 바랍니다. 하지만 크게 다르지는 않을 것입니다.

당연히 TWeakPtr<...>와는 아무 관련이 없습니다.

작동 방식

FWeakObjectPtr<...>을 위해서 GUObjectArray: FUObjectArray가 온 힘을 다해서 도와주는 형상입니다.

구성

동작

FUObjectArray는 global 변수 GUObjectArray의 타입으로, 엔진에 존재하는 모든 UObject를 Cluster Chunk로 나뉘어져 있는 Array 형태로 관리합니다. UObjectBase에 있는 int32 InternalIndex가 그 Array에서의 Index를 나타냅니다.

IndexToObject를 통해서 InternalIndex로부터 UObjectBase를 가져올 수 있는데, 중간에 FUObjectItem이 Wrapper로 한번 감싸서 가지고 있습니다. 이 중 눈여겨볼 부분은 FUObjectItem::SerialNumber입니다. 이 SerialNumberFWeakObjectPtr가 UObject로 참조를 걸 때 FUObjectArray::AllocateSerialNumber(...)로 받아오는 값이며, FUObjectArray는 SerialNumber를 FUObjectItem에 함께 저장하게 됩니다.

UMyObject* NewObject = NewObject<UMyObject>(this);
TWeakObjectPtr<UMyObject> WeakReferencedObject = NewObject;

결국 위와 같이 하고 나면 WeakReferencedObject에 저장되는 값 딱 2개는

  • ObjectIndex - NewObject의 InternalIndex
  • ObjectSerialNumber - NewObject를 감싸고 있는 FUObjectItem에 할당된 SerialNumber

입니다.

FORCEINLINE_DEBUGGABLE bool FWeakObjectPtr::SerialNumbersMatch(FUObjectItem* ObjectItem) const  
{  
    const int32 ActualSerialNumber = ObjectItem->GetSerialNumber();  
    ObjectSerialNumber); // serial numbers should never shrink  
    return ActualSerialNumber == ObjectSerialNumber;  
}

WeakObjectPtr은 Get(...)이나 IsValid(...)에서 유효성 검사를 할 때, 자신이 가지고 있는 ObjectIndex에 해당하는 FUObjectItem이 저장하고 있는 SerialNumber와 자신이 참조를 걸 때 가져두었던 ObjectSerialNumber가 일치하는지만 확인하면 됩니다. 이것으로 끝입니다.

SerialNumber 할당/해제 방식

여기서 중요한 점은 SerialNumber가 언제 Counting되고 범위는 어디까지냐는 것입니다. 간략하게나마 살펴보겠습니다.

우선 Weak로 참조가 걸릴 때 작동되는 Code입니다.

void FWeakObjectPtr::operator=(const class UObject *Object)  
{  
    if (Object // && UObjectInitialized() we might need this at some point, but it is a speed hit we would prefer to avoid  
       )  
    {       ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);  
       ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);  
       checkSlow(SerialNumbersMatch());  
    }    
    else  
    {  
       Reset();  
    }
}

코드를 보면 WeakObj로 참조가 걸릴 때만 GUObjectArray.AllocateSerialNumber가 호출되는 것을 알 수 있습니다.

AllocateSerialNumber는 아래와 같이 되어 있습니다.

int32 FUObjectArray::AllocateSerialNumber(int32 Index)  
{  
    FUObjectItem* ObjectItem = IndexToObject(Index);  
    checkSlow(ObjectItem);  
  
    volatile int32 *SerialNumberPtr = &ObjectItem->SerialNumber;  
    int32 SerialNumber = *SerialNumberPtr;  
    if (!SerialNumber)  
    {       // Open around PrimarySerialNumber as if we fail/abort a transaction we dont need to undo this, simply allow it to grow for the next use  
       UE_AUTORTFM_OPEN({  
          SerialNumber = PrimarySerialNumber.Increment();  
          UE_CLOG(SerialNumber <= START_SERIAL_NUMBER, LogUObjectArray, Fatal, TEXT("UObject serial numbers overflowed (trying to allocate serial number %d)."), SerialNumber);  
          int32 ValueWas = FPlatformAtomics::InterlockedCompareExchange((int32*)SerialNumberPtr, SerialNumber, 0);  
          if (ValueWas != 0)  
          {             // someone else go it first, use their value  
             SerialNumber = ValueWas;  
          }       
          });    
        }    
    checkSlow(SerialNumber > START_SERIAL_NUMBER);  
    return SerialNumber;  
}

간략히 살펴보면,

  1. Index에 해당하는 ObjectItem을 가져오고
  2. 해당 ObjectItem에 SerialNumber가 할당된 적이 없으면 Thread-Safe하게 PrimarySerialNumber를 +1시켜서 할당
  3. SerialNumber가 할당된 적이 있으면 그대로 사용

ObjectItem의 SerialNumber가 0으로 초기화되는 곳은, bool UObject::ConditionalFinishDestroy()에서 호출하는 GUObjectArray.ResetSerialNumber(this);입니다. 따라서 GC(flag도 확인하지만)되고 나면 WeakObj는 Invalid로 처리될 수 있습니다. TWeakObjectPtr에 있는 ObjectIndex가 가리키는 FUObjectItem의 SerialNumber가 일치하지 않기 때문입니다.

주의

위에서 짚고 넘어갈 중요한 점 세 가지를 살펴보겠습니다.

  1. SerialNumber Counter는 엔진 전체에 하나입니다.
  2. SerialNumber Counter가 int32 한계 값인 2,147,483,647을 넘어 minus로 떨어져서 START_SERIAL_NUMBER(1000)보다 작은 값이 되면 오류 처리됩니다.
  3. SerialNumber Counter는 어떤 UObject가 만들어지고 그 Object가 Weak로 최초 참조가 걸릴 때 '1'씩 증가합니다.

즉, 아래 코드가 약 21억 회 넘게 작동하면 UObject가 중간에 GC되어서 몇 개가 존재하든지 관계없이 오류 처리됩니다.

UMyObject* NewObject = NewObject<UMyObject>(this);
TWeakObjectPtr<UMyObject> WeakReferencedObject = NewObject;

충분한 값이라면 값이지만, 그래도 발생 가능성이 있다는 것은 알고 넘어가시기 바랍니다.

AActor Referencing

Garbage Collection 포스팅에서 AActor의 경우 World(정확히는 Level)에서 참조하고 있으므로 AActor는 따로 UPROPERTY(...)가 아니어도 GC되지 않음을 살펴보았습니다. 그렇다면 UObject 계열이고 Blueprint에서 접근할 필요가 없다고 가정했을 때, Destroy가 호출되고 GC가 되었음을 확인만 하면 되는 상황에서

  1. UPROPERTY(...) TObjectPtr<AMyActor> MyActor;
  2. TWeakObjectPtr<AMyActor> WeakMyActor;
  3. AMyActor*를 일반 멤버 변수로 두고, static void AddReferencedObjects(....)에서 처리

위 셋 중 어느 것이 가장 가볍고 빠를까요? 하나씩 따져 보겠습니다.

  1. 1번은 간단하지만 Reflection 관련 정보가 추가되고 GC-Referencing Chain에 등록됩니다.
  2. 2번은 Reflection 처리는 추가되지 않지만, SerialNumber 증가와 Thread-Safe 등을 고려한 유효성 확인(내부적으로는 atomic하게 구현)하는 추가 작업이 수행됩니다.
  3. 3번은 GC-Referencing Chain에 등록되는 것이 전부입니다.

따라서, Blueprint에서 접근할 필요가 없다면 '3'번이 가장 가볍다고 생각됩니다. 3번의 경우 값을 참조할 때 nullptr인지만 확인하면 충분합니다. static void AddReferencedObjects로의 Pointing은 UCLASS당 하나만 저장된다는 점도 기억해 두시기 바랍니다.

언제 사용하는 게 좋을까?

그렇다면 TWeakObjectPtr<...>은 언제 사용하는 것이 좋을까요? 당연히 UObject* 참조 변수를 UObject 계열이든 아니든 GC를 막지는 않고 Valid한지 확인해 가면서 사용할 때는 다른 대안이 없습니다. 또한 AGameState, AGameMode처럼 Application 생명 주기에서 새로 만들어지는 횟수가 적은 것을 대상으로 할 때는 더욱 적합할 것입니다.

profile
Game Client Programmer

0개의 댓글