나만의 작은 도서관

[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() 등을 통해 자버리게 된다.

 

두 가지 상황에 대한 예시

  1. Tick() 내부 로직이 무거워 목표 프레임 시간 초과(60 FPS → 16.67ms, 실제 25ms 소요)
    • 엔진은 대기하지 않고, 곧바로 다음 프레임을 실행. 초과된 시간(약 8ms)에 의한 FPS 감소 발생
  2. 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이 항상 같은 순서로 실행된다고 보장할 수 없다. 따라서, 여러 개체가 특정 캐릭터의 위치를 바꾸려는 경우, 그 값의 갱신 순서는 항상 같지 않아 잘못된 위치로 갱신될 수 있다는 것이다. 따라서, 여러 흐름에서 캐릭터의 위치에 영향을 주어야 하는 경우, 실제로 위치를 바꾸는 흐름은 하나만 남기고, 나머지는 위치 변경에 대해 요청만 해야 한다.