UE5 TWeakObjectPtr<...>

에크까망·2024년 9월 28일

들어가기 전에

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

들어가며

TWeakObjectPtr<> 은 GC Referncing 은 하지 않으면서 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();  
    }}

Code 를 보면 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_NUMER(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개의 댓글