나만의 작은 도서관
[TIL][C++] 251009 MMO 서버 개발 111일차: [언리얼] TQueue는 Thread-Safe 할까?, Tick 함수 내부 로직이 무거워서 다음 프레임까지 처리하지 못했다면 어떻게 될까?, Tick 관련 함수는 단일 스레드 처리 → 즉, 직렬 처리를 한다. 본문
Today I Learn
[TIL][C++] 251009 MMO 서버 개발 111일차: [언리얼] TQueue는 Thread-Safe 할까?, Tick 함수 내부 로직이 무거워서 다음 프레임까지 처리하지 못했다면 어떻게 될까?, Tick 관련 함수는 단일 스레드 처리 → 즉, 직렬 처리를 한다.
pledge24 2025. 10. 9. 18:20주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다.
[언리얼] TQueue는 Thread-Safe 할까?
- C++ 표준인 std::queue의 경우 멀티스레드 환경을 고려한 자료 구조가 아니기 때문에 Thread-Safe 하지 않다. 그렇다면 TQueue는 Thread-Safe 할까?
- 결론만 이야기하자면 상황에 따라 다르다. 상황에 따라 다르다는 말을 한 이유가 궁금하다면 TQueue의 주석을 보면 알 수 있다. 주석은 아래와 같다.
/**
* Template for queues.
*
* This template implements an unbounded non-intrusive queue using a lock-free linked
* list that stores copies of the queued items. The template can operate in two modes:
* Multiple-producers single-consumer (MPSC) and Single-producer single-consumer (SPSC).
*
* The queue is thread-safe in both modes. The Dequeue() method ensures thread-safety by
* writing it in a way that does not depend on possible instruction reordering on the CPU.
* The Enqueue() method uses an atomic compare-and-swap in multiple-producers scenarios.
*
* @param T The type of items stored in the queue.
* @param Mode The queue mode (single-producer, single-consumer by default).
* @todo gmp: Implement node pooling.
*/
template<typename T, EQueueMode Mode = EQueueMode::Spsc>
class TQueue
{
...
}
- 대충 주석 내용을 정리하면 아래와 같다.
- TQueue는 “락 프리 연결 리스트”의 구조로 만들어졌다.
- TQueue는 MPSC와 SPSC 2가지 모드가 있다. 이 2가지 모드 모두 thread-safe 하다.
- 결론적으로 thread-safe 하다는 것인데, 이건 어디까지나 각 모드의 제약사항을 지켰을 때를 의미한다. 즉,
- MPSC는 Enqueue를 하는 Producer가 여러 개여도 되지만 Dequeue를 하는 Consumer가 한 개여야 하고,
- SPSC는 Enqueue를 하는 Producer와 Dequeue를 하는 Consumer가 각각 한 개씩만 있어야 한다는 것이다.
EqueueMode를 지정하여 사용하여 선언 예시
// TQueue의 기본 Mode는 Spsc이므로, Spsc는 생략 가능
TQueue<int, EQueueMode::Spsc> SpscQueue;
TQueue<int, EQueueMode::Mpsc> MpscQueue;
- 결론적으로 단일 Consumer인 경우 TQueue는 Thread-Safe 하다.
Consumer가 여러 개라면?
- Consumer가 여러 개인 경우, 즉, 멀티스레드 환경에서 여러 스레드가 동시에 Dequeue를 한다면 TQueue대신 TLockFreeQueue를 사용하면 된다고 한다.
Consumer : Producer 개수에 따른 최종 정리
- 1 : 1인 경우: TQueue <T> 또는 TQueue <T, EQueueMode::Spsc>
- 1 : N인 경우: TQueue<T, EQueueMode::Mpsc>
- N : 1 또는 N : N인 경우 TLockFreeQueue <T>
[언리얼] Tick 함수 내부 로직이 무거워서 다음 프레임까지 처리하지 못했다면 어떻게 될까?
- 언리얼의 각 액터들은 Tick 함수를 가질 수 있으며, 액터가 아니더라도 World 틱, 레벨 틱 등 다양한 틱들이 존재한다. 그렇다면 이 Tick 함수들이 처리가 끝나기 전에 다음 프레임을 실행해야 하는 시점이 되면 어떻게 될까?
- 결론적으로 프레임은 지연된다. 언리얼 엔진의 Tick()은 게임 루프 내에서 매 프레임마다 호출되며, 프레임의 전체 소요 시간(FrameTime)은 Tick() 처리 포함 모든 연산의 총합이기 때문. 만약 Tick 내부 코드가 너무 무거워 목표 프레임(60 FPS라면 16.67ms)을 초과하면 엔진은 그만큼 프레임을 늦게 갱신한다.
정리
- 언리얼은 모든 Tick()이 완료되어야 다음 프레임으로 넘어간다.
- Tick() 처리가 늦었지만 FPS가 떨어지며(프레임 드롭), 프레임을 건너뛰지는 않는다.
- 대신 DeltaTime이 커져서 물리나 애니메이션 등 시간에 의존하는 로직의 보정이 더 많아진다.
Q. 반대로 너무 빨리 Tick 처리되어 다음 프레임까지 시간이 남았다면?
- 그냥 남은 시간만큼 대기하게 된다. 엔진 내부에서 FApp::GetDeltaTime()을 통해 프레임 사이의 경과 시간을 계산하고 계산 결과를 통해 다음 프레임 실행 여부를 판단하게 되는데, 만약 시간이 여유롭다면 엔진은 FPlatformProcess::Sleep() 등을 통해 자버리게 된다.
두 가지 상황에 대한 예시
- Tick() 내부 로직이 무거워 목표 프레임 시간 초과(60 FPS → 16.67ms, 실제 25ms 소요)
- 엔진은 대기하지 않고, 곧바로 다음 프레임을 실행. 초과된 시간(약 8ms)에 의한 FPS 감소 발생
- Tick() 내부 로직이 가벼워 목표 프레임보다 일찍 끝남(5ms 소요)
- FPlatformProcess::Sleep() 등을 통해 남은 시간만큼 대기한 뒤 다음 프레임을 시작. 즉, 일정한 프레임 속도를 유지하려고 “의도적 대기”를 수행.
이렇듯 언리얼의 메인 루프는 “가능하면 FPS를 유지하되, 처리량이 많아 목표 시간을 초과하면 프레임 지연을 허용”하는 방식으로 동작한다.
Q. 언리얼은 단 하나의 개체라도 Tick 로직이 끝나지 않았다면 프레임 지연을 결정할까?
- 그렇다. 언리얼은 하나라도 Tick이 끝나지 않으면 전체적으로 프레임이 밀리게 된다.
[언리얼] Tick 관련 함수는 단일 스레드 처리 → 즉, 직렬 처리를 한다.
- 캐릭터의 움직임에 대해 여러 Tick에서 직접 변경하려고 할 수 있다. 이는 잘못된 접근법인데, 왜냐하면 같은 TickGroup에 속하는 대상들은 매 프레임 Tick이 항상 같은 순서로 실행된다고 보장할 수 없기 때문이다.
언리얼에서 Tick 관련 함수는 95% 하나의 스레드에서 처리된다.
- 언리얼의 Tick 관련 함수들은 95% 하나의 스레드에서 처리된다. 따라서, 멀티스레드 관련 고민은 하지 않아도 된다.
- 언리얼은 게임 메인 루프에서 Tick 관련 함수를 처리하며, 내부적으로 TickGroup이 존재하여, 그룹 단위로 순차적으로 수행한다. TickGroup에 대한 예시는 아래와 같다.
enum ETickingGroup
{
TG_PrePhysics, // 1. 물리 시뮬레이션 전
TG_StartPhysics, // 2. 물리 시작 (비동기 물리 시작점)
TG_DuringPhysics, // 3. 물리 계산 중
TG_EndPhysics, // 4. 물리 완료 후
TG_PostPhysics, // 5. 물리 후처리
TG_PostUpdateWork, // 6. 업데이트 후 작업
TG_LastDemotable, // 7. 마지막 강등 가능
TG_NewlySpawned, // 8. 새로 생성된 액터
};
- TickGroup의 경우, 이전 그룹이 100% 완료된 후에 처리가 시작되는 동기 방식이다.
- 위에서 언급한 것처럼, TickGroup 내에서 Tick을 처리하는 건 직렬적으로 수행하는 것은 맞지만 같은 그룹 내에 서로 다른 Tick이 항상 같은 순서로 실행된다고 보장할 수 없다. 따라서, 여러 개체가 특정 캐릭터의 위치를 바꾸려는 경우, 그 값의 갱신 순서는 항상 같지 않아 잘못된 위치로 갱신될 수 있다는 것이다. 따라서, 여러 흐름에서 캐릭터의 위치에 영향을 주어야 하는 경우, 실제로 위치를 바꾸는 흐름은 하나만 남기고, 나머지는 위치 변경에 대해 요청만 해야 한다.