[UE 5.5] UClass::CreateSparseClassData 스레드 레이스 — 비동기 로딩 중 Shipping 크래시 (아키타입 SCD가 null 인 상태로 memcpy)

※ 이 글은 아래 영문 질문의 한국어 버전입니다. 9월 3일에 등록하고 재현 방법까지 보충했으나 아직 회신을 받지 못해, 한국어 지원 그룹으로 다시 문의드립니다.

[Content removed]

[환경 / 영향]

UE 5.5 라이선시 소스 빌드, Shipping, Android/iOS/Windows 라이브 RPG.

현재 저희 최대 크래시 버킷입니다. 누적 약 163만 건(안드로이드 하루 약 6,000대, 일일 안드로이드 크래시의 약 40%). 5.5 적용 이후 모든 제품 버전에서 일정한 발생률을 보이므로, 콘텐츠 리그레션이 아니라 엔진 레벨의 상시 레이스로 판단하고 있습니다.

(본문의 코드는 Release-5.5 기준이며, 5.6 의 CreateSparseClassData 구현도 동일한 구조로 보입니다.)

[저희 쪽 원인 분석]

UClass::CreateSparseClassData() (CoreUObject/Private/UObject/Class.cpp) 기준으로 세 가지가 겹친 것으로 보입니다.

(1) 초기화 완료 전에 포인터가 발행됨 — SparseClassData = FMemory::Malloc(…) 로 멤버에 먼저 대입한 뒤 InitializeStruct / CopyScriptStruct 가 그 멤버 위에서 수행됩니다. 락 없이 읽는 GetSparseClassData / GetOrCreateSparseClassData 는 초기화 중인 메모리를 가리키는 non-null 포인터를 관측할 수 있습니다.

(2) 생성자 간 동기화 부재 — 비동기 로딩 스레드(CDO 직렬화)와 게임 스레드가 같은 클래스에 대해, 또는 GetArchetypeForSparseClassData() 를 통한 부모/자식 체인에 대해 동시에 생성 경로에 진입할 수 있습니다.

(3) Shipping 에서는 check(SparseClassData == nullptr) 가 컴파일 제거되어 이중 진입이 조용히 진행됩니다. memcpy(src=null) 은 자식 SCD 생성이 “현재 생성 중인” 부모 아키타입 SCD 를 복사하는 상황과 정확히 일치합니다.

저희가 접근 가능한 최신 스트림을 확인했으나, 복사 지점 주변의 null 지연 처리(방어 코드)만 있고 발행/생성 레이스 자체에 대한 수정은 찾지 못했습니다.

[검토 중인 수정 — 단일 .cpp, ABI 변경 없음]

생성 경로를 static FCriticalSection 으로 직렬화하고(재진입 가능하므로 락 안에서 부모 체인 생성이 일어나도 안전), 로컬 버퍼에 완전히 구성한 뒤 release 배리어와 함께 마지막에 발행합니다. 읽기 경로는 수정하지 않습니다(포인터 의존 로드라 ARM64 에서도 안전하다고 보았습니다).

FScopeLock Lock(&GSparseClassDataCreationLock);

if (SparseClassData) { return SparseClassData; } // 레이스에서 밀렸으면 재사용

void* NewSCD = FMemory::Malloc(…);

SparseClassDataStruct->InitializeStruct(NewSCD);

/* 기존 아키타입 복사 로직 그대로, 대상만 NewSCD */

FPlatformMisc::MemoryBarrier(); // 완전히 만들어진 뒤에만 발행

SparseClassData = NewSCD;

FCoreUObjectDelegates::OnPostInitSparseClassData.Broadcast(…);

[문의사항]

(1) 이미 알려진 이슈인가요(UE-xxxxx)? 5.5 / 5.6 에 대한 공식 수정 계획이나, 저희가 백포트할 수 있는 기존 CL 이 있는지 알고 싶습니다.

(2) 위 접근에 문제가 있을까요? 특히 (a) 락을 잡은 채로 OnPostInitSparseClassData 를 브로드캐스트하게 되는데 리스너 쪽 데드락 위험이 있는지, (b) 전역 락 하나로 모든 클래스의 최초 생성이 직렬화되는 점이 문제가 될지 궁금합니다.

(3) 권장되는 공식 패턴이 따로 있는지요(atomic CAS 발행, 또는 비동기 로딩 중 SCD 생성을 게임 스레드로 제한하는 방식 등).

발생량이 커서 공식 수정이 없다면 다음 릴리스에 로컬 패치를 넣을 예정입니다. 가이드 부탁드립니다.

[Attachment Removed]

재현 방법
결정적 재현 케이스는 분리하지 못했습니다. 라이브 서비스 규모에서 통계적으로만 재현됩니다(안드로이드 기준 하루 약 6,000대).

(1) UE 5.5 Shipping 빌드(check() 가 컴파일 제거된 상태).

(2) 스파스 클래스 데이터를 가진 블루프린트 클래스(저희 경우 AnimBP 계열)가 반복적으로 런타임 비동기 로딩되는 일반 플레이(레벨 스트리밍, 온디맨드 에셋 로드).

(3) 비동기 로딩 스레드가 CDO 를 직렬화(SerializeSparseClassData -> CreateSparseClassData)하는 동안, 같은 클래스 또는 아키타입 체인상의 부모 클래스 SparseClassData 가 다른 스레드에서 동시에 생성/접근될 때 크래시가 발생합니다.

플레이 40분에서 23시간 이상 시점까지 산발적으로 발생하며, 포그라운드 상태 / 가용 메모리 충분 / 특정 기기·OS·버전 편중 없음입니다(5.5 적용 이후 전 제품 버전에서 일정한 발생률).

개발 빌드에서는 CreateSparseClassData 의 Malloc·멤버 대입과 아키타입 CopyScriptStruct 사이에 인위적 지연(예: FPlatformProcess::Sleep)을 넣은 뒤, SCD 를 가진 블루프린트 클래스의 비동기 로드와 게임 스레드의 동일 클래스 스파스 데이터 접근을 동시에 유발하면 레이스 윈도가 넓어져 확인할 수 있습니다.

[Attachment Removed]

[호출 스택] — 본문에 표시되지 않아 보충합니다.

모든 샘플이 FAsyncLoadingThread 에서 동일한 스택을 보입니다.

UBlueprintGeneratedClass::SerializeDefaultObject

-> UClass::SerializeSparseClassData

-> UClass::CreateSparseClassData

-> UScriptStruct::CopyScriptStruct (FAnimSubsystem_Base)

-> memcpy (SIGSEGV, src = 0x0, size = 0x10)

즉 아키타입 SparseClassData 포인터가 null 인 상태에서 CopyScriptStruct 가 복사를 수행하며, 런타임 비동기 로딩 중 가용 메모리가 충분한 상황에서 발생합니다.

플랫폼별 CrashSight 결함군은 Android / iOS / Windows 세 곳에서 모두 동일한 패턴입니다.

[Attachment Removed]

안녕하세요.

문의하신 크래시는 부모 애님 블루프린트 클래스 CDO의 PostLoad와 자식 클래스의 SparseClassData 생성이 같은 FAnimSubsystem_Base를 두고 충돌하는 문제로 확인됩니다.

애님 블루프린트 클래스의 SparseClassData에 포함된 FAnimSubsystem_Base의 ExposedValueHandlers 배열은 CDO의 PostLoad 시점에 PatchValueHandlers가 구성합니다. 이때 비동기 로딩 스레드가 자식 클래스 CDO를 직렬화하면서 CreateSparseClassData에서 부모 SparseClassData를 복사함과 동시에 게임 스레드가 부모 CDO의 PostLoad에서 같은 배열을 구성하면서 배열 크기는 증가했지만 메모리는 아직 할당되지 않은 상태의 배열을 읽으면 memcpy의 src가 nullptr이 되어 크래시가 발생할 수 있습니다.

아래는 5.5 기준 게임 스레드에서 부모 CDO의 PostLoad가 ExposedValueHandlers 배열을 비우고 다시 구성하는 코드와 비동기 로딩 스레드에서 자식 클래스가 부모 SparseClassData를 복사하는 부분입니다.

AnimSubsystem_Base.cpp:14-26

// FAnimSubsystem_Base::PatchValueHandlers
ExposedValueHandlers.Reset();
...
FExposedValueHandler& NewHandler = ExposedValueHandlers.AddDefaulted_GetRef();

Class.cpp:6270

// UClass::CreateSparseClassData
SparseClassDataArchetypeStruct->CopyScriptStruct(SparseClassData, SparseArchetypeData);

검토 중이신 방안의 CreateSparseClassData의 락은 SparseClassData 생성 코드가 여러 스레드에서 동시에 실행되는 것만 막고 PatchValueHandlers는 해당 락을 잡지 않으므로 해당 크래시를 막지 못할 수 있습니다. OnPostInitSparseClassData는 엔진 소스와 플러그인에 리스너가 없으므로 락 안에서 브로드캐스트해도 데드락의 위험은 없지만 PatchValueHandlers와의 충돌을 막지 못해 여전히 크래시가 발생할 수 있습니다.

해당 이슈는 아래 CL에서 FAnimSubsystem_Base에 크리티컬 섹션을 추가하여 PatchValueHandlers가 배열을 구성하고 복사 대입 연산자가 원본에서 대상으로 복사하는 동안 원본과 대상 양쪽의 락을 잡아 두 동작이 동시에 실행되지 않도록 하는 방식으로 수정되었습니다.

CL 37997548

https://github.com/EpicGames/UnrealEngine/commit/f350a174e40cd603bd8c635deb32d41b938206ec

CL 38418221

https://github.com/EpicGames/UnrealEngine/commit/18780c2d3f4d942cdd42e5687b301abd196bd8b6

감사합니다.

[Attachment Removed]

감사합니다.

[Attachment Removed]