
스레드에서 UObjects와 AActors를 직접 수정하는 것은 Unreal의 가비지 컬렉터 구조상 지원되지 않을 수 있으니 주의하세요. 연산은 비동기적으로 처리하고, 결과는 반드시 메인 스레드에서 반영하는 것이 좋습니다.
아래 내용은 FRunnable을 기준으로 설명하지만, 다른 멀티스레딩 방식도 유사하게 동작합니다.
스레드를 시작할 때, 생성자를 통해 복사, 포인터, 참조 등 원하는 방식으로 파라미터를 전달할 수 있습니다.
FMyThread(int _i, AMyActor* _Actor, FVector& _Vector) {
i = _i;
Actor = A_Actor;
FVector& Vector = _Vector;
}
초기화 리스트를 사용하면 멤버 참조도 초기화할 수 있고, 변수명을 재사용하면서 코드 포맷도 더 간결하게 만들 수 있습니다:
FMyThread(int i, AMyActor* Actor, FVector& Vector) : i{i}, Actor{Actor}, Vector{Vector} {}
FVector& Vector; // Global reference, cannot be initialized by other means than the above
실제로 스레드 내부 또는 외부 어디에서든 변수에 접근하거나 값을 읽고, 함수를 호출하거나 수정하는 것이 가능합니다.
uint32 FMyThread::Run() {
Actor->bIsBeingThreaded = true; // Variable from another class
while (!bShutdown) {
Actor->DoThreadedWork(); // Function from another class
}
return 0;
}
이때 가장 중요한 제약은 동시 쓰기(concurrent writing)입니다.
변수를 여러 스레드에서 읽는 것은 스레드 세이프하지만, 여러 스레드가 동시에 하나의 변수에 값을 쓰면 데이터 레이스가 발생할 수 있습니다. 특히 TArray에 Add 등으로 컨테이너 크기를 변경하는 작업이 동시에 일어나면, 해당 배열의 모든 포인터가 무효화되어 EXCEPTION_ACCESS_VIOLATION(접근 위반 예외)이 발생할 수 있습니다.
이를 방지하는 방법은 여러 가지가 있습니다.
스레드 함수 내에서 최대한 많은 변수를 지역 변수로 선언하고, 파라미터는 복사로 생성자에 전달하는 방식을 선호하세요. 단, 큰 배열을 복사할 경우 비용이 많이 들 수 있습니다.
한 스레드가 컨테이너에서 작업을 수행할 때, 다른 스레드에서는 Add(), Remove(), SetNum() 등 메모리 재할당이 발생할 수 있는 연산을 피하세요. 여러 스레드가 서로 다른 인덱스에 값을 읽고 쓰는 것은 가능하지만, 같은 인덱스에 동시에 접근하거나 요소를 추가/삭제하는 것은 피해야 합니다.
“FlipFlop” 컨테이너처럼 각 스레드가 작업할 때만 컨테이너에 접근하도록 락을 걸고, 작업이 끝나면 다른 스레드가 사용할 수 있도록 해제하는 구조를 사용할 수 있습니다.
Move Semantics(이동 시맨틱)를 활용하는 방법도 있습니다. 이에 대해서는 나중에 더 자세히 다룹니다.
C++11과 Unreal은 다음과 같은 다양한 스레드 세이프 도구도 제공합니다.
여러 스레드를 같은 클래스 내에서 실행하는 경우, 멤버 변수 앞에 thread_local 키워드를 붙이면 각 스레드마다 해당 변수의 독립적인 인스턴스를 갖게 됩니다. 즉, 스레드 간에 변수 값이 공유되지 않고, 각 스레드가 자신만의 값을 안전하게 사용할 수 있습니다.
변수를 atomic으로 감싸면(예: std::atomic\<int> i;) 해당 변수에 대한 읽기와 쓰기가 항상 스레드 세이프하게 처리됩니다. 또한 동시 쓰기 시 동기화 방식을 지정할 수 있지만, 문법이 다소 복잡할 수 있습니다(상세 내용은 링크 참고). 원자적 연산은 동기화 오버헤드로 인해 성능 저하가 발생할 수 있으므로, 스레드 간 통신이 꼭 필요한 경우에만 사용하는 것이 좋습니다.
std::atomic은 int, bool 등 비교적 단순한 타입만 감쌀 수 있으며, TArray 같은 컨테이너에는 사용할 수 없습니다. 구조체를 atomic으로 선언할 수는 있지만, 이 경우 구조체 전체의 대입만 스레드 세이프할 뿐, 내부 멤버의 개별 수정은 보장되지 않습니다(자세히 보기). 링크에서는 std::mutex(또는 FCriticalSection)에서 상속받아 Struct.Lock()으로 멤버 접근 시 락을 거는 대안을 제시합니다.
Unreal의 TAtomics는 더 이상 권장되지 않으므로 std::atomic을 사용하세요.
FPlatformAtomics::InterlockedAdd(&i, 10)과 같은 연산을 사용하면, 변수 자체를 atomic으로 선언하거나 FCriticalSection을 사용하지 않고도 직접 원자적(atomic) 연산을 수행할 수 있습니다. 읽기(Read), 더하기(Add), 저장(Store), 증가(Increment), 감소(Decrement)와 같은 값 변경뿐 아니라, And, Or 등 논리 연산도 지원합니다. FThreadSafeBool, FThreadSafeCounter와 같이 이러한 기능을 기본적으로 구현한 클래스도 제공됩니다.
TQueue는 하나의 컨테이너에 대해 스레드 세이프한 원자적 동시 쓰기를 지원합니다. 일반적으로 하나 이상의 스레드(설정된 EQueueMode에 따라)가 요소를 추가(Enqueue)하고, 다른 하나의 스레드가 결과를 꺼내는(Dequeue) 방식으로 사용됩니다. TQueue\<TArray\<int>>처럼 다른 컨테이너도 큐에 넣을 수 있습니다.
FIFO(선입선출) 큐이므로, Enqueue()로 앞쪽에 요소를 추가하고, Dequeue()로 뒤쪽에서 제거합니다. 이 컨테이너는 락-프리(lock-free) 리스트 기반으로, 컨테이너 접근 시 일반적인 뮤텍스 오버헤드를 피할 수 있습니다. Single-producer 모드(SPSC)에서는 경쟁 상태(contention)가 없지만, Multi-producer 모드(MPSC)에서는 완전히 자유롭지는 않습니다.
TCircularQueue는 TCircularBuffer 기반의 변형으로, 역시 락-프리이면서 FIFO 구조를 갖고 있습니다. 단, SPSC(단일 생산자-단일 소비자)만 지원하며, 용량이 고정(2의 거듭제곱-1로 반올림)되어 있습니다. 사용 예시도 참고하세요.
#include "Containers/CircularQueue.h"
class FMyClass : public FRunnable {
FMyClass(uint32 QueueSize) : Queue{QueueSize} { // Capacity initialized from list
TCircularQueue<int> QueueB(QueueSize); // Capacity initiliazed from a constructor
}
TCircularQueue<int> Queue;
};
마지막으로, 공식 문서에는 없지만 엔진 소스에 깊이 숨겨진 TSafeQueue라는 클래스가 있습니다. TSafeQueue는 최소한의 락으로 동작하는 FIFO(선입선출) 방식의 Multi-producer Multi-consumer queue입니다.
#include "UnrealAudio/Private/UnrealAudioUtilities.h"
using namespace UAudio;
TSafeQueue<int> Queue;
TLockFreePointerList는 TQueue와 유사하게 리스트 기반으로 구현된 컨테이너로, 스레드 세이프하며 ABA 문제에 강한 포인터 저장소입니다. FIFO(선입선출), LIFO(후입선출), 무순서 등 다양한 방식으로 사용할 수 있으며, 엔진의 TaskGraph 내부에서 활용됩니다.
Chaos::TListThreadSafe는 락-프리(lock-free) 단일 연결 리스트(single-linked list)로, 데이터를 읽어올 때 TList로 추출하는 방식(다소 복잡한 구조, TList 섹션 참고)을 사용합니다. Multi-producer Multi-consumer(다중 생산자-다중 소비자) 시나리오를 지원하는 것으로 보입니다.
FCriticalSection은 Unreal에서 std::mutex를 구현한 것으로, 현재 스레드가 해당 섹션을 빠져나올 때까지 모든 다른 스레드를 잠금(lock) 상태로 만들어 경쟁 상태(race condition)를 방지합니다. 두 개 이상의 스레드가 동시에 같은 락에 진입하면, 하나를 제외한 나머지 스레드는 일시 정지(컨텐션)되므로 성능 저하가 발생할 수 있습니다. 여러 개의 뮤텍스를 사용할 때 각기 다른 스레드가 서로 필요한 락을 점유하면 데드락(deadlock)이 발생할 수 있으니 주의해야 합니다.
FCriticalSection Section;
uint32 FMyThread::Run() {
Section.Lock();
/* Threadsafe section */
Section.Unlock();
return 0;
}
FScopeLock은 동일한 작업을 수행하지만, 스코프(중괄호)가 종료되면 자동으로 락을 해제합니다.
FCriticalSection Section;
uint32 FMyThread::Run() {
{
FScopeLock Lock(&Section);
/* Threadsafe until the closing brace */
}
return 0;
}
FScopeLock은 스코프가 끝날 때 자동으로 락을 해제해주기 때문에 더 안전하게 사용할 수 있습니다. 반면, FCriticalSection은 스레드 세이프가 필요한 부분만 정확히 감싸는 등 더 세밀한 제어가 가능합니다.
FSpinLock은 FCriticalSection의 변형으로, 스레드를 잠재우는 대신 락을 획득할 때까지 반복적으로 시도(바쁘게 대기, busy-wait)합니다. 락이 걸리는 코드 구간이 매우 짧고, 스레드를 잠재우는 것보다 CPU를 계속 사용하며 기다리는 것이 더 빠른 경우에만 사용하는 것이 좋습니다.
TSharedPtr는 스레드 세이프한 원자적(atomic) 연산 모드를 제공합니다. 다만, 일반적인 atomic 연산과 마찬가지로 성능 저하가 발생할 수 있습니다.
50,000개의 소수를 계산하는 것보다 더 유용한 작업을 수행하려면, 스레드가 계산을 마쳤을 때 게임 스레드에서 알림을 받거나 이벤트를 실행해야 할 필요가 있습니다. 하지만 앞서 소개한 클래스들(FRunnable의 Run()과 Exit() 모두 새 스레드에서 실행되며, 게임 스레드에서 실행되는 "포스트 실행" 함수는 없음)을 보면 이를 직접적으로 지원하는 간단한 방법이 제공되지 않는다는 점을 알 수 있습니다.
가장 기본적이고 단순한 방법은, 스레드 클래스 내부 또는 외부에서 원하는 함수를 직접 호출하는 것입니다.
uint32 FMyThread::Run() {
/* Threaded code */
Actor->ThreadCallback();
Actor->bIsBeingThreaded = false; // Preferably atomic
return 0;
}
기술적으로는 불가능하지 않지만, 한 가지 중요한 주의점이 있습니다. 스레드 함수 내부에서 직접 호출하면 해당 코드 역시 같은 스레드(즉, 게임 스레드가 아닌 별도 스레드)에서 실행됩니다. 따라서 의도하지 않은 코드가 멀티스레드 환경에서 실행되는 것을 피하려면, 직접 호출보다는 (atomic 등으로) 안전하게 값을 기록해두고, 게임 스레드의 EventTick 등에서 그 값을 읽어 처리하는 방식을 권장합니다.
대안으로, 해당 호출을 게임 스레드에서 실행되는 AsyncTask로 감쌀 수 있습니다. 이렇게 하면 앞서 언급한 대부분의 문제를 피할 수 있습니다.
AsyncTask(ENamedThreads::GameThread, [this, Vector] () {
Actor->ThreadCallback(Vector);
});
단, 아주 짧은 메시지를 반복적으로(예: 초당 1000회) 보내야 하는 경우에는 AsyncTask 방식이 비효율적일 수 있으니, 이런 상황에서는 atomic 변수에 직접 값을 기록하는 “직접” 호출이 더 적합합니다.
Delegate는 본질적으로 함수 포인터입니다. 바인딩된 함수를 호출하며, 파라미터 전달, 반환값 처리 등이 가능합니다. 한 번에 여러 함수를 호출하는 Multicast, 직렬화 및 블루프린트에서 사용 가능한(하지만 느린) Dynamic, 바인딩되지 않으면 메모리 사용이 적은 Sparse, 선언한 클래스만 호출할 수 있는 Event 등 다양한 형태가 있습니다. UFunction, UObject, shared pointer, 람다 등 다양한 방식으로 바인딩할 수 있습니다.
더 자세한 내용은 아래 링크를 참고해주세요.
// Declaring a Singlecast, one-parameter void Delegate (done before classes)
DECLARE_DELEGATE_OneParam(FMyDelegate, FVector /*, ParameterName (optional) */ )
// Create, bind and execute (use .AddUFunction() and .Broadcast() if Multicast)
FMyDelegate Delegate;
Delegate.BindUFunction(this, FName("MyFunction")); // "this" can be replaced by any class
Delegate.ExecuteIfBound(FVector(Vector));
실행(Execute) 또는 브로드캐스트(Broadcast) 시, 해당 델리게이트에 바인딩된 ‘리스너’ 함수들은 호출된 스레드에서 일반 함수처럼 실행됩니다. 만약 게임 스레드 등 특정 스레드에서 실행되길 원한다면 AsyncTask로 감싸서 호출하세요.
TFunctions (즉, 람다)는 함수를 익명으로 전달할 수 있게 해주며, 예를 들어 코드에서 순환 참조를 피하고 싶을 때 유용하게 사용할 수 있습니다.
/* Passing a function returning a vector anonymously through a TFunctionRef in the constructor */
// Caller
TFunctionRef<void (FVector)> Callback = [&](FVector Vector) {
ThreadCallback(Vector); // What the lambda will execute when called
};
auto* Thread = new FMyThread( /*Parameters, */ Callback );
// MyThread .h
FMyThread( /*Parameters, */ TFunctionRef<void (FVector)> Callback) : Callback{Callback} {}
TFunctionRef<void (FVector)>& Callback; // Can be called like a normal function
이 함수들은 호출된 스레드에서 그대로 실행된다는 점에 유의하세요. TFunction은 함수의 복사본을 저장하고, TFunctionRef는 복사 비용 없이 참조만 사용하며, TUniqueFunction은 MoveTemp()를 통해 전체가 이동됩니다.
더 자세한 내용은 아래 링크를 참고해주세요.
개별 연산마다 새로운 스레드를 시작하는 것도 가능하지만, 이는 비용이 많이 들고 반복적으로 실행할 경우 게임 스레드의 성능도 저하시킬 수 있습니다. 가능한 한 작업을 묶어서 한 번에 처리하거나, 스레드를 계속 살아 있게 두고 필요할 때만 깨워서 작업을 수행하도록 하는 것이 더 효율적입니다.
WaitForCompletion()은 FRunnable이 완전히 종료될 때까지 호출한 스레드를 대기(stall)시켜, 스레드가 완전히 멈춘 후에만 다음 코드가 실행되도록 보장합니다.
~AMyClass() { // Put in the destructor of holder class for orderly destruction
Thread->Kill(); // Calls your implementation of Exit()
Thread->WaitForCompletion(); // Stalls the caller until completion
delete Thread;
Thread = nullptr;
}
Kill()을 호출해도 스레드 내부 로직이 한두 프레임 더 실행될 수 있으므로, 참조를 너무 일찍 삭제하면 크래시가 발생할 수 있습니다.
따라서 스레드가 참조하는 클래스의 소멸자에서 위 코드를 구현해야 안전하게 객체를 파괴할 수 있습니다. 이렇게 하면 스레드가 실행 중인 상태에서 Play In Editor를 종료할 때 에디터가 크래시 나는 것도 방지할 수 있습니다.
블루프린트의 Delay 노드와 비슷하게, Sleep()은 호출된 스레드를 리소스 소모 없이 잠재웁니다. 게임 스레드에서 호출하면 화면이 멈추는 현상이 발생할 수 있으니, 일반적으로는 별도 스레드에서 사용하는 것이 좋습니다.
FPlatformProcess::Sleep(0.01f); // Time in seconds
주로 일정 간격마다 특정 bool 값이 true로 바뀌었는지 확인하고, 아니라면 계속 대기하는 식으로 활용합니다.
ConditionalSleep() allows defining directly the condition to check for, through a lambda:
bool bResume;
FPlatformProcess::ConditionalSleep([this]() -> bool {
return bResume;
});
FRunnable에는 Suspend() 함수도 제공되지만, 실제로는 멀티스레딩을 지원하지 않는 플랫폼(FFakeThread, FSingleThreadRunnable 등)에서만 효과가 있으며, 해당 ‘스레드’의 Tick()을 비활성화하는 방식으로 동작합니다.
Thread->Suspend(true); // Suspends or resumes depending on the passed value
FScopedEvent를 선언하면, 스코프가 종료될 때 해당 스레드는 자동으로 대기 상태에 들어갑니다. 이때 Event.Get()으로 포인터를 다른 스레드에 전달한 뒤, 그 스레드에서 Event.Trigger()를 호출하면 대기 중인 스레드의 실행이 재개됩니다.
{
FScopedEvent Event;
DoThreadedWork(Event.Get());
/* Sleep at the closing brace until the other thread calls Event.Trigger(); */
}
FEvent 역시 Event->Trigger()를 사용하지만, Event->Wait()을 통해 언제든지 호출할 수 있습니다. 이 클래스는 추상 클래스이므로, 반드시 포인터로 풀에서 인스턴스를 가져오고 반환해야 합니다.
예시를 참고하세요.
FEvent* Event;
FMyClass() { // Constructor
Event = FGenericPlatformProcess::GetSynchEventFromPool(false);
}
~FMyClass() { // Destructor
FGenericPlatformProcess::ReturnSynchEventToPool(Event);
Event = nullptr;
}
예시로는 워커 스레드가 작업을 마칠 때까지 메인 스레드가 대기하도록 하거나, 반대로 워커 스레드를 메인 스레드가 다시 호출할 때까지 잠재우는 경우가 있습니다.
더 자세한 내용은 아래 링크를 참고해주세요.