스레드 동기화
FCriticalSection, FScopeLock, FRWLock으로 안전한 멀티스레딩 구현
Race Condition이란?
동기화가 필요한 이유
여러 스레드가 공유 데이터에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 문제를 Race Condition이라고 합니다.
Count 읽기: 5
Count + 1 = 6
Count 쓰기: 6
Count 읽기: 5
Count + 1 = 6
Count 쓰기: 6
기대값: 7, 실제값: 6 (Race Condition 발생!)
// 위험한 코드 - Race Condition 발생 가능
class FUnsafeCounter
{
public:
int32 Count = 0;
void Increment()
{
// 여러 스레드에서 동시 호출 시 문제 발생!
Count++; // 읽기 -> 증가 -> 쓰기가 원자적이지 않음
}
};
FCriticalSection
Mutex 기반 상호 배제
FCriticalSection은 언리얼의 Mutex 구현입니다. 한 번에 하나의 스레드만 임계 영역(Critical Section)에 진입할 수 있습니다.
class FThreadSafeCounter
{
public:
void Increment()
{
// 락 획득
DataLock.Lock();
// 임계 영역 - 한 스레드만 실행
Count++;
// 락 해제
DataLock.Unlock();
}
int32 GetCount()
{
DataLock.Lock();
int32 Result = Count;
DataLock.Unlock();
return Result;
}
private:
FCriticalSection DataLock;
int32 Count = 0;
};
Lock() 후 Unlock()을 호출하지 않으면 데드락이 발생합니다. 예외 발생 시에도 Unlock이 호출되지 않을 수 있습니다!
FScopeLock (권장)
RAII 스타일 자동 잠금 해제
FScopeLock은 RAII(Resource Acquisition Is Initialization) 패턴을 사용합니다. 스코프를 벗어날 때 자동으로 락이 해제되어 데드락을 방지합니다.
class FThreadSafeInventory
{
public:
void AddItem(const FString& ItemName)
{
// FScopeLock 생성 시 자동으로 Lock()
FScopeLock Lock(&InventoryLock);
// 임계 영역
Items.Add(ItemName);
// 예외가 발생해도 안전!
if (Items.Num() > MaxCapacity)
{
return; // 여기서도 자동 Unlock
}
ProcessItem(ItemName);
} // 스코프 종료 시 자동 Unlock()
bool RemoveItem(const FString& ItemName)
{
FScopeLock Lock(&InventoryLock);
int32 Index = Items.Find(ItemName);
if (Index != INDEX_NONE)
{
Items.RemoveAt(Index);
return true;
}
return false;
}
TArray<FString> GetAllItems()
{
FScopeLock Lock(&InventoryLock);
return Items; // 복사본 반환
}
private:
FCriticalSection InventoryLock;
TArray<FString> Items;
int32 MaxCapacity = 100;
};
항상 FScopeLock을 사용하세요. Lock()/Unlock()을 직접 호출하는 것은 권장되지 않습니다.
FRWLock (읽기/쓰기 락)
다중 읽기, 단일 쓰기
FRWLock은 읽기 작업은 여러 스레드가 동시에, 쓰기 작업은 단독으로 수행할 수 있게 합니다. 읽기가 많은 경우 성능이 향상됩니다.
동시 접근 가능
Writer만 접근, 나머지 대기
class FThreadSafeCache
{
public:
// 읽기 - 여러 스레드 동시 가능
FString GetValue(const FString& Key)
{
FRWScopeLock ReadLock(CacheLock, SLT_ReadOnly);
if (const FString* Found = Cache.Find(Key))
{
return *Found;
}
return FString();
}
// 쓰기 - 단독 접근
void SetValue(const FString& Key, const FString& Value)
{
FRWScopeLock WriteLock(CacheLock, SLT_Write);
Cache.Add(Key, Value);
}
// 읽기 중 조건부 쓰기
void SetIfNotExists(const FString& Key, const FString& Value)
{
// 먼저 읽기 락으로 확인
{
FRWScopeLock ReadLock(CacheLock, SLT_ReadOnly);
if (Cache.Contains(Key))
{
return; // 이미 존재하면 종료
}
}
// 쓰기 락으로 업그레이드 (주의: 다시 확인 필요)
FRWScopeLock WriteLock(CacheLock, SLT_Write);
if (!Cache.Contains(Key)) // Double-check
{
Cache.Add(Key, Value);
}
}
private:
FRWLock CacheLock;
TMap<FString, FString> Cache;
};
Atomic 연산
락 없이 스레드 안전
단순한 정수 연산은 FPlatformAtomics 또는 std::atomic을 사용하면 락 없이도 스레드 안전하게 처리할 수 있습니다.
#include "HAL/PlatformAtomics.h"
class FAtomicCounter
{
public:
// 원자적 증가
int32 Increment()
{
return FPlatformAtomics::InterlockedIncrement(&Counter);
}
// 원자적 감소
int32 Decrement()
{
return FPlatformAtomics::InterlockedDecrement(&Counter);
}
// 원자적 읽기
int32 GetValue() const
{
return FPlatformAtomics::AtomicRead(&Counter);
}
// 원자적 교환 (Compare-And-Swap)
bool CompareExchange(int32 Expected, int32 NewValue)
{
return FPlatformAtomics::InterlockedCompareExchange(
&Counter, NewValue, Expected) == Expected;
}
private:
volatile int32 Counter = 0;
};
// TAtomic 사용 (더 간단)
class FSimpleAtomicCounter
{
public:
void Increment() { Counter++; }
void Decrement() { Counter--; }
int32 GetValue() const { return Counter.Load(); }
private:
TAtomic<int32> Counter{0};
};
FCriticalSection
- 복잡한 데이터 구조에 적합
- 여러 연산을 원자적으로 묶음
- 오버헤드가 상대적으로 큼
Atomic 연산
- 단순 정수/포인터에 적합
- 단일 연산만 원자적
- 락보다 훨씬 빠름
Game Thread와 Render Thread 동기화
스레드 간 안전한 통신
// Game Thread에서 Render Thread로 작업 전달
void AMyActor::UpdateRenderData()
{
// 데이터 복사 (참조가 아닌 값 캡처!)
FVector CapturedLocation = GetActorLocation();
FLinearColor CapturedColor = MyColor;
ENQUEUE_RENDER_COMMAND(UpdateMyRenderData)(
[CapturedLocation, CapturedColor](FRHICommandListImmediate& RHICmdList)
{
// Render Thread에서 실행
// 캡처된 데이터만 사용 (this 접근 금지!)
ProcessRenderData(CapturedLocation, CapturedColor);
}
);
}
// Render Thread 완료 대기 (블로킹)
void AMyActor::WaitForRenderThread()
{
// Render Thread의 모든 작업 완료 대기
FlushRenderingCommands();
// 이후 Render Thread 결과 안전하게 사용
}
// 백그라운드에서 Game Thread로 복귀
void AMyActor::ProcessInBackground()
{
AsyncTask(ENamedThreads::AnyBackgroundThreadNormalTask, [this]()
{
// 무거운 계산 (백그라운드)
TArray<float> Results = HeavyCalculation();
// Game Thread로 결과 전달
AsyncTask(ENamedThreads::GameThread, [this, Results = MoveTemp(Results)]()
{
// Game Thread에서 안전하게 UObject 접근
ApplyResults(Results);
});
});
}
- 람다에서 this를 참조 캡처하지 마세요
- 데이터는 항상 값으로 복사해서 캡처하세요
- Game Thread의 UObject는 Render Thread에서 접근 불가
실전 예제: 스레드 안전 큐
Producer-Consumer 패턴
template<typename T>
class TThreadSafeQueue
{
public:
// Producer: 아이템 추가
void Enqueue(T Item)
{
FScopeLock Lock(&QueueLock);
Queue.Enqueue(MoveTemp(Item));
}
// Consumer: 아이템 가져오기
bool Dequeue(T& OutItem)
{
FScopeLock Lock(&QueueLock);
return Queue.Dequeue(OutItem);
}
// 비어있는지 확인
bool IsEmpty() const
{
FScopeLock Lock(&QueueLock);
return Queue.IsEmpty();
}
// 모든 아이템 가져오기
TArray<T> DequeueAll()
{
FScopeLock Lock(&QueueLock);
TArray<T> Result;
T Item;
while (Queue.Dequeue(Item))
{
Result.Add(MoveTemp(Item));
}
return Result;
}
private:
mutable FCriticalSection QueueLock;
TQueue<T> Queue;
};
// 사용 예시: 비동기 로그 시스템
class FAsyncLogger
{
public:
void Log(const FString& Message)
{
// 어느 스레드에서든 호출 가능
LogQueue.Enqueue(Message);
}
// Game Thread에서 Tick마다 호출
void ProcessLogs()
{
TArray<FString> Messages = LogQueue.DequeueAll();
for (const FString& Msg : Messages)
{
UE_LOG(LogGame, Log, TEXT("%s"), *Msg);
}
}
private:
TThreadSafeQueue<FString> LogQueue;
};
핵심 요약
- FCriticalSection - 기본 Mutex, 상호 배제 보장
- FScopeLock - RAII 패턴, 자동 락 해제 (권장)
- FRWLock - 읽기/쓰기 분리, 읽기 많을 때 성능 향상
- Atomic 연산 - 단순 타입에 락 없이 동기화
- ENQUEUE_RENDER_COMMAND - Render Thread로 작업 전달
- 값 캡처 - 스레드 간 데이터 전달 시 복사 필수
- 단순 정수/bool: TAtomic 또는 FPlatformAtomics
- 복잡한 데이터: FScopeLock + FCriticalSection
- 읽기 많은 캐시: FRWScopeLock + FRWLock
도전 과제
배운 내용을 직접 실습해보세요
FCriticalSection을 멤버 변수로 선언하고, FScopeLock으로 RPG 인벤토리 데이터에 대한 다중 스레드 접근을 보호하세요. Lock 범위를 최소화하여 성능 영향을 줄이세요.
FPlatformAtomics::InterlockedIncrement()와 InterlockedExchange()를 사용하여 락 없이 카운터와 플래그를 안전하게 업데이트하세요. 킬 카운트, 동시 접속자 수 등에 적용하세요.
다중 락 시나리오에서 락 순서를 고정하여 데드락을 방지하세요. UE5의 DeadlockDetector와 ThreadSanitizer를 활용하여 잠재적 데드락을 탐지하는 방법을 학습하세요.