본문으로 건너뛰기
뒤로가기

[C++] World Server 1: Fixed-Step과 Bounded Catch-Up

업데이트:

TL;DR — World Server는 고정된 tick을 기준으로 게임 로직을 처리합니다. 밀린 tick을 건너뛰거나 누적된 tick의 delta를 하나로 합쳐서 계산하는 방식 대신, 한 번에 최대 N tick(현재 N=4)을 순서대로 처리하게 됩니다. 이 과정에서 중간 여러 번의 tick 계산 결과를 한 번에 합칠 수 있는 데이터와, 매 tick 마다 갱신이 필요한 데이터를 구분하고 계산 순서를 보존하여 클라이언트에게 전송합니다.

Table of contents

Open Table of contents

들어가며

앞선 글에서는 IOCP completion이 Runtime Session Actor를 거쳐 application frame으로 처리되는 과정을 살펴봤습니다. NetworkRuntime(=NrRuntime)이 packet을 안전하게 복원해도 gameplay state가 즉시 변경되는 것은 아닙니다. Server-authoritative game에서는 다음 질문에 답할 World 실행 경계가 추가로 필요합니다.

Private Server의 World Server를 구현하면서 이 질문을 fixed-step simulationbounded catch-up으로 정리했습니다. 이 글에서는 일반적인 tick scheduling 문제부터 시작해, Ingress Pump와 World Coordinator, Outbound Publisher가 하나의 authoritative tick을 처리하는 흐름을 살펴봅니다.

이 글에서 다루는 내용:

사전 지식: game loop, std::chrono의 duration과 time point, server-authoritative simulation에 대한 기본 이해를 전제로 합니다.


1. NetworkRuntime 다음에는 누가 시간을 결정하는가

NetworkRuntime의 책임은 socket I/O, packet framing과 Runtime Session lifetime입니다. 정상적으로 복원된 gameplay packet은 Session Actor가 NrToWorldEvent로 만들어 To-World Queue에 집어넣고, World Server에서 이 이벤트를 받아 게임의 상태를 업데이트하게 됩니다.

그러면 World Server는 이 이벤트를 기다리고 있다가 들어올 때마다 처리를하면 될까요?

Session Actor가 보장하는 범위

Session Actor는 같은 session의 I/O state를 직렬화 해줍니다. 하지만 서로 다른 Session Actor는 여러 Worker에서 동시에 실행될 수 있습니다.

SessionActor A ── session A의 I/O state 직렬화
SessionActor B ── session B의 I/O state 직렬화
SessionActor C ── session C의 I/O state 직렬화

따라서 World Server에서 이벤트가 들어올 때마다 처리를 하는 구조라면 Actor 스케줄링 정책, Worker에서 Actor를 통해 작업을 처리하는 순서와 속도 등에 의해 게임 플레이의 순서가 결정될 수 있습니다.

여러 Session에서 공유해야 하는 상태

또한 플레이어가 입력한 명령에는 하나의 session에서 시작하더라도 여러 클라이언트에게 영향을 주는 경우가 있습니다.

이러한 상태는 Session Actor가 직접 변경하기도 어렵고, 설령 내부에 이러한 로직을 처리할 수 있게 하더라도, Actor 내부 실행 흐름은 단일 thread로 처리되기 때문에 여러 session에 영향을 주는 데이터에 대한 순서를 보장할 수 없게 됩니다.

Event Handoff 경계

그래서 Session Actor는 packet의 내용을 직접 해석해 게임의 상태를 변경하지 않고, 이를 ToWorldEvent로 만들어 World Server에 전달하게 됩니다.

따라서 이 경계는 단순히 NetworkRuntime과 World Server의 프로젝트의 구분이나 함수 호출을 위한 경계가 아니라 Session 단위의 직렬화와 World 단위의 게임 로직 처리를 구분하기 위한 경계라고 볼 수 있습니다.

전체적인 실행 흐름은 다음과 같습니다.

flowchart TD
    Client["Game Client"]

    subgraph Runtime["NrRuntime"]
        direction TB
        RuntimeRecv["Session Actor
Receive / Frame Decode"]
        Queue["To-World Queue"]
        Gateway["NrGateway
Send Submission"]
        RuntimeSend["Session Actor
Send Queue / IOCP"]

        RuntimeRecv --> Queue
        Gateway --> RuntimeSend
    end

    subgraph World["World Server"]
        direction TB
        Pump["Ingress Pump"]
        Ingress["Sealed Ingress Batch"]
        Coordinator["World Coordinator
Tick / Epoch / Deadline"]
        Outbound["Sealed Outbound Batch"]
        Publisher["Outbound Publisher"]

        Pump --> Ingress
        Ingress --> Coordinator
        Coordinator --> Outbound
        Outbound --> Publisher
    end

    Client --> RuntimeRecv
    Queue --> Pump
    Publisher --> Gateway
    RuntimeSend --> Client

World Server의 결과는 Outbound Publisher / NrGateway를 통해 다시 클라이언트에게 전달됩니다.

게임 플레이 결과를 담은 semantic payload만 World Server에서 결정하고, I/O를 통한 전송은 다시 NetworkRuntime의 책임으로 넘어가게 됩니다.

그래서 이러한 경계 구조를 두면 다음과 같은 내용을 보장할 수 있게 됩니다.

다만 다음 문제를 해결하지는 않습니다.


2. Tick을 결정하는 기준

10Hz World Server를 예로 들면 fixed step은 100ms입니다.(1초에 10번의 처리)

그리고 현재 tick의 완료 시간을 기준으로 다음 tick을 계산하는 구조는 다음과 같습니다.

Tick 1 시작: 100ms
Tick 1 처리:  20ms
다음 deadline = 완료 시각 120ms + fixed step 100ms

Tick 2 시작: 220ms
Tick 2 처리:  20ms
다음 deadline = 완료 시각 240ms + fixed step 100ms

Tick 3 시작: 340ms

10Hz로 설정을 했으니 하나의 tick을 처리할 때 걸리는 예상 시간은 100ms이지만, 완료 시간을 기준으로 인터벌을 두니 실제 두 번째 tick의 시작 시간은 처음 시작 시간에서 인터벌과 처리 시간을 더한 시간으로 결정됩니다. 매 tick의 처리 시간이 다음 주기에 더해지기 때문입니다.

이를 식으로 표현하면 다음과 같습니다.

Dn+1=Cn+ΔtD_{n+1} = C_n + \Delta t

처리 시간이 매번 일정하더라도 완료 시각 CnC_n이 계속 뒤로 이동하므로 실제 흘러가는 시간과 server tick 사이의 차이가 누적됩니다.

그래서 시작 시간을 기준으로 인터벌을 두어 World Server 내부에 일관된 평가, 처리 기준으로 사용할 수 있도록 설정을 했습니다.

시작 시간을 기준으로 두면 매 tick 마다 시뮬레이션에 필요한 delta time을 항상 일관된 수치를 사용할 수 있고, tick 사이의 처리 시간 또한 일정하게 평가할 수 있게 됩니다.

따라서 World Server에는 두 계약이 모두 필요합니다.

  1. 각 authoritative tick은 동일한 fixed delta로 simulation합니다.
  2. 다음 deadline은 실제 완료 시각이 아니라 기존에 예정된 시간축에서 fixed delta를 더한 시간을 사용합니다.

3. 예정된 Deadline을 기준으로 Tick Schedule을 유지한다

simulation에 같은 fixed delta를 전달하는 것과 별도로, 다음 deadline을 이전 deadline에서 전진시킵니다.

deadline을 별도로 두는 이유는 tick 내부 simulation 처리에 필요한 시간이 tick과 tick 사이의 기준 시간보다 오래 걸리는 경우, 몇 tick을 catch-up 할지 결정하기 위한 계산의 기준이 필요하기 때문입니다.

Dn+1=Dn+ΔtD_{n+1} = D_n + \Delta t

10Hz라면 예정된 deadline은 처리 시간과 관계없이 다음 순서를 유지합니다.

Tick 1 deadline: 100ms
Tick 2 deadline: 200ms
Tick 3 deadline: 300ms
Tick 4 deadline: 400ms

Tick 1이 120ms에 끝나도 Tick 2 deadline은 220ms가 아니라 200ms입니다. Tick 1의 20ms 처리 시간은 다음 주기에 더해지지 않습니다.

Tick 1 deadline: 100ms
- Tick 1 처리에 350ms 소모
- Tick 1 완료 시간 : 450ms

이렇게 특정 Tick의 처리 시간이 fixed delta에서 주어진 시간보다 오래 걸린다면

현재 구현의 fixedStep은 simulation에 전달할 fixed delta이면서 tick schedule을 전진시키는 간격입니다. 같은 값을 두 계약에 사용하지만, simulation의 변화량과 deadline 계산은 구분해서 다룹니다.

WorldFixedStepSchedule은 monotonic clock인 std::chrono::steady_clock을 사용합니다. Wall clock 보정으로 시간이 뒤로 이동하지 않는 clock에서 simulation deadline의 선후 관계를 비교합니다.

schedule.fixedStep_ = fixedStep;
schedule.nextDeadline_ = startedAt + fixedStep;

// batch 처리로 여러 tick을 처리한 경우(처리한 tick의 개수 = stepCount)
// tick을 실제로 stepCount 만큼 내부에서 처리하여 tick 전진이 됨
// deadline도 이에 맞춰 전진
schedule.nextDeadline_ +=
    fixedStep_ * static_cast<Clock::duration::rep>(stepCount);WorldFixedStepSchedule.cpp

3.1 실행 시간과 Simulation 시간은 다르다

Fixed-step World에서 serverTick은 simulation의 논리 시간입니다. 실제 함수가 실행된 wall-clock 시각과 일치할 필요는 없습니다.

Wall-clock time: 100ms  200ms  300ms  400ms
Server tick:       100    101    102    103
Simulation delta: 0.1s   0.1s   0.1s   0.1s

일시적인 지연으로 Tick 101과 102가 320ms에 연속 실행되어도 각 tick에는 0.1초의 fixed delta가 전달됩니다. 두 tick을 0.2초짜리 큰 fixed delta 하나로 합치지 않습니다.

이 구분은 movement, collision, cooldown과 round rule이 실행 환경의 순간적인 지연 때문에 서로 다른 delta를 받지 않게 합니다.


4. 늦어진 Fixed-Step을 Bounded Catch-Up으로 처리

현재 시각이 다음 deadline보다 늦으면 실행해야 할 tick이 한 개 이상 쌓입니다. 가장 먼저 due tick 수를 계산합니다.

due=nowDnΔt+1due = \left\lfloor\frac{now - D_n}{\Delta t}\right\rfloor + 1

그다음 한 번의 owner iteration에서 처리할 수를 제한합니다.

steps=min(due,maxCatchUpSteps)steps = \min(due, maxCatchUpSteps)

Private Server의 현재 serving baseline은 60Hz이며 maxCatchUpSteps는 4입니다. 따라서 World Coordinator는 한 batch에서 최대 네 개의 tick만 연속 처리합니다.

4.1 선택할 수 있는 정책

정책장점잃는 것
완료 시각으로 deadline 재설정backlog가 남지 않음장기적인 tick cadence
밀린 server tick 건너뛰기빠르게 wall-clock을 따라감simulation tick과 gameplay event 의미
여러 tick을 큰 variable delta로 병합함수 호출 횟수가 줄어듦fixed-step physics와 rule의 입력 조건
밀린 tick을 무제한 처리backlog를 빠르게 소비할 수 있음owner loop의 bounded 실행 시간
bounded catch-uptick 순서와 fixed delta를 보존하면서 batch 크기를 제한지속 과부하에서는 backlog가 남음

Private Server는 마지막 정책을 선택했습니다. maxCatchUpSteps보다 많은 tick이 밀려 있으면 나머지를 버리지 않고 다음 iteration의 backlog로 남깁니다.

WorldFixedStepSchedule은 이 정책을 다음과 같이 구현합니다.

const Clock::duration overdue = now - nextDeadline_;
const std::uint64_t dueStepCount =
    static_cast<std::uint64_t>(overdue / fixedStep_) + 1;
const std::uint64_t stepCount =
    std::min<std::uint64_t>(dueStepCount, maxCatchUpSteps_);WorldFixedStepSchedule.cpp

4.2 Bounded Catch-Up은 과부하 해결책이 아니다

Catch-up 제한은 한 batch가 끝없이 길어지는 것을 막습니다. 하지만 한 tick을 처리하는 평균 시간이 fixed step보다 길다면 처리할수록 더 많은 tick이 밀립니다. Bounded catch-up만으로 처리 용량 부족을 해결할 수는 없습니다.

그래서 현재 구현은 지속 overrun에서 임의로 fixed delta를 늘리거나 server tick을 버리지 않습니다. Backlog와 연속 overrun을 metric으로 노출하고, 자동 감속이나 controlled stop 임계치는 실제 workload evidence가 생긴 뒤 결정하도록 남겼습니다.


5. World Coordinator가 시간축을 소유

Double-buffered 실행에서는 Pump, Coordinator와 Publisher가 서로 다른 worker thread에서 동작합니다. Worker가 여러 개라고 해서 tick 시간축도 여러 곳에서 변경하면 epoch와 buffer ownership을 함께 맞추기 어렵습니다.

Private Server에서는 WorldDoubleBufferedTickCoordinator 하나가 다음 값을 소유합니다.

sequenceDiagram
    participant C as World Coordinator
    participant I as Ingress Pump
    participant W as World Phase
    participant O as Outbound Publisher

    C->>I: plan(epoch, scheduled deadline)
    I->>I: To-World event drain
    I-->>C: sealed ingress completion(epoch)
    C->>C: due tick / catch-up 계산
    loop 최대 maxCatchUpSteps
        C->>W: fixed delta tick 처리
        W-->>C: typed result
        C->>C: 단계별 canonical commit
    end
    C->>O: sealed outbound publication(epoch)
    O-->>C: publication completion(epoch)
    C->>C: deadline += fixedStep * processedTickCount

Ingress completion과 outbound completion은 worker 간 작업 barrier입니다. Completion이 도착했다고 임의의 새 deadline을 만들지 않습니다. Coordinator는 실제로 완료한 tick 수만큼 기존에 예정된 deadline을 전진시킵니다.

nextPlan_.serverTick += processedTickCount;
nextPlan_.sealDeadline +=
    config_.fixedStep * processedTickCount;WorldDoubleBufferedTickCoordinator.h

이 단일 변경 권한 덕분에 worker notification timing과 authoritative server tick을 분리할 수 있습니다.


6. 하나의 Catch-Up Batch 처리

Catch-up batch는 단순히 ProcessTick을 여러 번 호출하는 loop가 아닙니다. Input을 어느 tick에 귀속하고, 각 tick의 mutation과 outbound를 어떤 순서로 확정할지도 함께 정의해야 합니다.

World Server의 한 batch는 다음 순서를 사용합니다.

1. 같은 epoch의 sealed ingress read slot 획득
2. ingress event를 순서대로 한 번 소비
3. due tick 수와 bounded step count 계산

4. 각 server tick:
   - immutable tick input / read view 구성
   - movement와 physics 계산
   - movement 결과를 canonical entity state에 commit
   - gameplay typed result 계산
   - resource, score, round와 lifecycle 결과 commit
   - durable outbound event 기록

5. 모든 catch-up tick 성공:
   - 마지막 committed tick 기준 state snapshot 계획

6. outbound batch seal
7. ingress read slot release
8. tick, epoch와 예정된 deadline 전진

6.1 Ingress Event는 Batch에서 한 번 소비

같은 sealed ingress batch를 catch-up tick마다 반복해서 읽으면 하나의 command가 여러 번 적용될 수 있습니다. Coordinator는 catch-up tick loop에 들어가기 전에 ingress event를 한 번만 소비합니다.

for (const psnr::runtime::NrToWorldEvent& event : readBatch.events)
{
    static_cast<void>(
        eventConsumer.Handle(event, WorldInboundMode::DoubleBuffered));
}

for (std::uint32_t offset = 0; offset < stepCount; ++offset)
{
    const std::uint32_t serverTick =
        nextPlan_.serverTick + offset;
    // fixed-step simulation과 단계별 commit
}WorldDoubleBufferedTickCoordinator.h

Ingress event를 소비한 뒤 확정된 session set과 command state를 각 tick의 input builder가 사용합니다. 하나의 ingress batch에 같은 session의 movement command가 여러 개 있다면 기존 admission 정책에 따라 마지막 유효 command가 현재 command state에 남습니다.

6.2 Catch-Up은 Client Input Replay가 아니다

Catch-up의 목적은 server simulation 시간축 복구입니다. 과거 client input을 각 catch-up tick으로 다시 분배하는 replay 기능이 아닙니다.

현재 정책은 다음 동작을 추가하지 않습니다.

6.3 실패한 Tick 뒤에는 다음 Tick을 실행하지 않는다

Catch-up batch의 두 번째 tick에서 simulation이나 gameplay 처리에 실패했다면 세 번째 tick을 계속 실행하지 않습니다. 완료되지 않은 tick을 건너뛰고 뒤의 server tick을 commit하면 authoritative sequence에 구멍이 생기기 때문입니다.

현재 Coordinator는 첫 실패에서 loop를 중단하고 worker에 실패를 반환합니다. 이미 완료한 앞선 tick 전체를 rollback하는 transaction은 제공하지 않으므로, 각 phase의 commit 경계와 실패 가능 지점을 명시적으로 유지해야 합니다.

따라서 현재 catch-up을 정리하면 패킷 처리량을 늘리는 기능이 아니라, 밀린 simulation tick을 처리하기 위한 정책이라고 볼 수 있습니다.

하나의 catch-up batch 에서는 다음과 같이 작업이 실행됩니다.

Ingress read buffer 획득
-> event/packet N개를 한 번만 consume
-> command/session state 확정

-> Tick 100 simulation
-> Tick 101 simulation
-> Tick 102 simulation
-> Tick 103 simulation

따라서 batch로 네 tick을 처리를 하더라도 다음과 같은 상태를 가지게 됩니다.

그래서 batch가 길어지게 되는 경우, World Server에서 클라이언트가 전송한 패킷을 실제 게임 로직에 반영하는 시점이 뒤로 밀리 수 있고, 클라이언트 입장에서는 서버의 레이턴시를 체감할 가능성이 발생합니다.

maxCatchUpSteps는 하나의 batch가 무제한으로 길어지는 것을 막습니다. 또한 여러 tick을 batch로 묶어 처리하면 ingress handoff와 snapshot publication을 매 tick 반복하지 않으므로, 같은 수의 tick을 각각 정상 경로로 처리하는 것보다 짧은 wall-clock 시간에 simulation을 전진시킬 수 있습니다. 단, simulation 자체가 대부분의 비용을 차지하거나 지속 과부하로 deadline을 회복하지 못하면 이 이점만으로 ingress 대기 시간을 해소할 수 없습니다.

정상 tick N회:

N × (
  ingress plan/wait/seal
  + ingress event consume
  + simulation/gameplay
  + snapshot 계획
  + outbound seal/publication
)

catch-up batch에서 N tick:

ingress plan/wait/seal 1회
+ ingress event consume 1회
+ N × simulation/gameplay
+ tick별 durable event 기록
+ 최종 snapshot 계획 1회
+ outbound seal/publication 1회

6.4 batch 처리 중에 도착한 클라이언트의 명령 처리

클라이언트가 전송하는 명령에는 단조 증가하는 inputSequence가 있습니다. World Server에서는 이 sequence 값을 확인해 클라이언트의 최신 상태를 갱신하게 됩니다. 물론 이 최신 상태는 다음 sequence가 도착하기 전까지만 유효합니다.

정상 실행에서는 client command가 다음 ingress epoch에 포함되고, Coordinator가 해당 epoch를 소비한 뒤 simulation에 반영됩니다. 하지만 ingress slot이 seal된 뒤 catch-up batch가 실행되는 동안 도착한 command는 현재 batch 중간에 끼어들지 않습니다. 이 command는 NrRuntime의 To-World Queue에서 다음 ingress plan을 기다립니다.

정해진 시간 내로 tick simulation이 처리된다면 클라이언트의 명령은 항상 다음 ingress epoch에 포함되어 바로 그 다음 tick의 simulation에 반영이 됩니다.

하지만 catch-up batch가 실행되는 경우에는 여러 tick이 처리되고 있더라도 한 번 sealed 처리된 ingress 에서 패킷을 읽지 않고, 그 내부에서 처리를 하게 됩니다.

그리고 중간에 도착한 패킷은 To-WOrld Queue에서 대기를 하게 됩니다.

sequenceDiagram
    participant Client as Game Client
    participant Runtime as NrRuntime
    participant Pump as Ingress Pump
    participant World as World Coordinator

    Client->>Runtime: inputSequence N / control state
    Runtime->>Runtime: To-World Queue에 event 보관
    Note over World: catch-up batch에서 Tick 100~102 처리
    Note over Runtime,Pump: batch 중 도착한 event는 다음 ingress까지 대기
    loop 밀린 simulation tick
        World->>World: 기존 control state로 fixed-step simulation
    end
    World->>World: deadline을 처리한 tick 수만큼 전진
    World->>Pump: 다음 ingress epoch plan
    Pump->>Runtime: 대기 중인 event drain
    Pump-->>World: sealed ingress batch
    World->>World: sequence 검증 후 최신 control state 확정
    World->>World: 다음 server tick부터 새 state 반영

예를 들어 Tick 99 처리에서 지연이 발생하고, 그 사이 같은 클클라이언트의 패킷 100, 101, 102가 도착했다고 가정합니다. 여기서 숫자는 적용할 server tick이 아니라 클라이언트 패킷의 입력 순서입니다.

Epoch 9
  마지막으로 확정된 control state: command 99
  Tick 99 처리 중 지연 발생

Epoch 10
  Tick 100, 101, 102 catch-up
  -> command 99의 control state를 각 fixed-step에서 계속 사용
  -> batch 실행 중 도착한 command 100, 101, 102는 queue에서 대기

Epoch 11
  command 100, 101, 102를 ingress로 drain
  -> inputSequence 순서 검증
  -> 같은 entity의 control state를 차례대로 갱신
  -> simulation 전 최종 상태는 command 102
  -> Tick 103부터 command 102의 state 사용

따라서 패킷 100, 101, 102를 Tick 100, 101, 102에 하나씩 늦게 replay하지 않습니다. 같은 ingress 경계에서 연속된 control state가 들어오면 마지막 유효 state가 남고, 이미 소비한 ingress batch의 event도 다음 epoch로 다시 복사하지 않습니다. 이 때문에 한 번의 catch-up이 이후 모든 command를 한 batch씩 영구적으로 미루는 FIFO backlog로 남지 않습니다.

이 정책이 우선하는 것은 모든 client 입력 이력을 재현하는 것보다 authoritative simulation tick이 absolute deadline을 다시 따라잡는 것입니다. Catch-up batch가 세 tick의 simulation 시간을 한 tick 주기보다 짧은 wall-clock 시간에 처리하면 deadline과 현재 시각의 차이가 줄어듭니다. deadline을 회복한 다음 epoch부터는 ingress가 다시 정상 cadence로 열리고, 새 command도 평상시의 handoff 지연으로 돌아갑니다.

대신 다음 trade-off가 있습니다.

결과적으로 이 흐름은 모든 패킷의 실행을 보장하는 queue가 아니라, 클라이언트의 최신 요구를 보존하면서 server simulation 시간축의 복구를 우선하는 정책입니다.

Catch-up batch로 인한 일시 지연에서는 중간 패킷을 축약해 입력 backlog가 누적되는 것을 막고, 지속 과부하는 latency와 backlog metric으로 별도로 드러냅니다.


7. Replaceable State와 Durable Event를 분리

Catch-up tick을 모두 simulation한 뒤 모든 중간 snapshot을 client에게 보내면 일시적인 server 지연이 network burst로 이어질 수 있습니다. 반대로 모든 outbound를 마지막 상태 하나로 합치면 gameplay 사건이 사라질 수 있습니다.

이를 구분하기 위해 outbound를 두 성격으로 나눴습니다.

분류예시Catch-up 정책
Replaceable statecontrolled entity 위치, remote replica state, overview snapshot마지막 committed tick의 최신 상태로 합침
Durable eventspawn, despawn, resource 획득, death, round 전환, lifecycle 결과생성된 server tick 순서대로 보존

예를 들어 네 tick을 catch-up했다면 entity 위치 snapshot은 Tick 103의 최종 상태 한 번만 발행할 수 있습니다.

Catch-up simulation:
  Tick 100 -> position state A
  Tick 101 -> position state B
  Tick 102 -> position state C
  Tick 103 -> position state D

State publication:
  Tick 103 -> position state D 한 번 발행

하지만 Tick 101에서 resource를 획득하고 Tick 102에서 entity가 사망했다면 두 사건을 마지막 위치 D로 대체할 수 없습니다.

Durable publication order:
  Tick 101 -> ResourceAcquired
  Tick 102 -> EntityDeath
  Tick 103 -> FinalStateSnapshot

Coordinator는 각 tick이 완료될 때 durable record를 먼저 기록합니다. Catch-up batch의 모든 tick이 성공한 경우에만 마지막 tick 범위로 state snapshot을 한 번 계획합니다.

eventConsumer.RecordDurableTickOutbound(
    serverTick, batchLastServerTick, committedJoinedSessions);

// 모든 catch-up tick이 성공한 뒤 한 번 호출합니다.
eventConsumer.PublishControlledEntityStates(
    nextPlan_.serverTick,
    batchLastServerTick,
    committedJoinedSessions);WorldDoubleBufferedTickCoordinator.h

8. 100명 × 2개 World Host에서 Catch-Up을 측정

“Tick이 느리다”는 표현에는 서로 다른 문제가 섞일 수 있습니다.

관측값질문
Execution duration한 coordinator batch의 실제 실행 시간이 얼마나 걸렸는가?
Start lag예정 deadline보다 얼마나 늦게 시작했는가?
Due tick count시작 시점에 몇 tick이 실행 대상이었는가?
Processed tick count이번 batch에서 몇 tick을 실제로 처리했는가?
Current backlog이번 batch 뒤에도 몇 tick이 남았는가?

Execution duration이 fixed step을 넘으면 execution overrun입니다. 하지만 execution이 길었다고 반드시 catch-up이 발생한 것은 아닙니다. 반대로 execution이 짧더라도 이전 scheduling 지연 때문에 여러 tick이 due 상태가 될 수 있습니다. 이 차이를 실제 workload에서 확인하기 위해 baseline을 측정했습니다.

8.1 측정 조건

하드웨어 및 실행 환경은 다음과 같습니다.

항목
CPUIntel Core Ultra 5 226V, 8 cores / 8 logical processors
OSWindows 11 IoT Enterprise LTSC, build 26100
World Host독립 process 2개, 127.0.0.1:27015127.0.0.1:27016
ClientHost마다 actual NrClient 100개, 총 200개
AdmissionHost마다 초당 20 client
Tick / round60Hz, 10,800 tick, 약 180초
Catch-up 상한한 coordinator batch에서 최대 4 tick
Control workloadclient마다 500ms 간격, boost 확률 10%
반복Host process를 새로 시작하는 독립 run 3회

두 controller는 각 반복에서 World Host 두 개를 동시에 실행했습니다. Client와 server가 모두 같은 머신의 localhost에서 실행되므로, 이 측정은 외부 network latency가 아니라 같은 머신에서 두 World Host가 CPU와 memory를 공유할 때의 fixed-step 실행을 확인하기 위한 참고 자료로 사용해야 합니다.

8.2 P99는 Tick Budget 안에 있지만 순간 Overrun은 발생한다

Percentile은 각 run의 running phase 전체 sample에 nearest-rank를 적용했습니다. 60Hz의 한 tick budget은 약 16.67ms입니다.

반복HostExecution p99Execution maxOverrun ratioStart lag p99Catch-up batchMax due / backlog
119.898ms25.688ms0.037%16.335ms492 / 0
129.777ms32.933ms0.084%16.400ms592 / 0
219.552ms29.970ms0.037%16.532ms782 / 0
229.657ms22.590ms0.037%16.507ms742 / 0
319.214ms32.104ms0.019%16.404ms532 / 0
329.447ms22.292ms0.009%16.431ms632 / 0

Execution p99는 여섯 run 모두 9.214~9.898ms로 한 tick budget 안에 있었습니다. 하지만 execution max는 22.292~32.933ms였으므로 순간 overrun까지 없었던 것은 아닙니다. 각 run의 가장 긴 execution sample 여섯 개를 확인하면 다섯 개는 processedTickCount=1이었습니다. 따라서 execution max만으로 catch-up 발생 여부를 판단할 수 없습니다.

Start lag p99는 16.335~16.532ms, max는 23.076~32.419ms였습니다. Start lag은 simulation 계산 시간만이 아니라 OS scheduling, worker wake-up과 직전 작업의 영향을 포함하므로 execution duration과 별도로 해석해야 합니다.

8.3 발생한 Catch-Up은 모두 Two-Step 안에서 끝난다

각 run에서 두 tick 이상을 처리한 catch-up batch는 49~78회였습니다. 모든 catch-up sample의 dueTickCountprocessedTickCount는 2였고, 설정한 maxCatchUpSteps=4에 도달한 batch는 없었습니다.

여섯 run은 모두 다음 correctness 조건을 만족했습니다.

Host process의 평균 CPU는 8.55~9.22%, peak working set은 52.03~53.18MiB였습니다. 이 값은 workload를 재현할 때의 보조 환경 정보이며, client controller나 운영체제를 포함한 머신 전체 사용량은 아닙니다.

이 baseline은 현재 머신과 workload에서 bounded catch-up이 지연된 tick을 건너뛰지 않고 회복했다는 근거입니다. 최대 수용 인원, Host 수 증가에 따른 선형 확장이나 외부 network 환경의 latency에 대한 측정 지표로는 사용할 수 없습니다. 지속 overrun에서 자동으로 tick을 버리거나 process를 종료할 threshold도 이 세 번의 측정만으로 정하지 않았습니다.


9. 테스트와 실측은 서로 다른 실패를 잡는다

실측에서 backlog가 0이었다고 해서 모든 scheduling interleaving이 안전하다고 증명되지는 않습니다. 반대로 scripted clock 테스트가 통과해도 실제 OS scheduling과 gameplay workload에서 tick budget을 지킬 수 있는지는 알 수 없습니다. Private Server는 두 검증을 분리합니다.

검증 수단확인하는 내용
Scripted clock 테스트Deadline 전진, tick 연속성, catch-up 상한과 event ordering
Fleet baseline실제 IOCP client, gameplay와 동시 Host 실행에서 execution, start lag와 backlog

9.1 예정된 Deadline이 Drift하지 않는다

TakesSequentialTicksAtMonotonicDeadlines는 10Hz schedule에서 다음 동작을 확인합니다.

startedAt:       0ms
first deadline: 100ms

now = 100ms
  -> Tick 50 한 개 획득
  -> next deadline = 200ms

now = 300ms
  -> Tick 51, 52 두 개 획득
  -> next deadline = 400ms

두 번째 batch를 300ms에 처리해도 deadline은 300ms + 100ms로 새로 만든 값이 아닙니다. 기존 200ms deadline에서 실제로 넘긴 두 step만큼 전진한 결과입니다.

9.2 Catch-Up 제한 뒤에도 Tick을 건너뛰지 않는다

BoundsCatchUpWithoutSkippingOverdueTicks는 due tick이 다섯 개이고 최대 catch-up 수가 세 개인 상황을 사용합니다.

첫 호출: Tick 100, 101, 102
두 번째 호출: Tick 103, 104

첫 호출의 overrun은 true이지만 남은 server tick을 버리지 않습니다. 같은 현재 시각으로 다시 호출하면 연속된 다음 tick부터 backlog를 반환합니다.

9.3 Coordinator Flow를 함께 검증한다

Schedule 계산 외에도 Coordinator와 event consumer 테스트에서 다음 계약을 확인합니다.

이 테스트들은 특정 wall-clock 속도나 benchmark 결과를 주장하지 않습니다. 대신 schedule, tick sequence, event ordering과 publication policy가 코드 변경 뒤에도 유지되는지 확인합니다.


정리하며

Fixed-step World Server에서 중요한 것은 simulation 함수에 일정한 delta를 전달하는 것만이 아닙니다. 어떤 실행 주체가 server tick과 deadline을 소유하고, 지연된 tick과 그 과정에서 발생한 event를 어떻게 처리하는지까지 하나의 계약으로 정의해야 합니다.

Private Server에서는 World Coordinator가 예정된 deadline과 authoritative mutation 순서를 소유합니다. 밀린 tick은 fixed delta로 순서대로 처리하지만 한 batch의 수는 제한합니다. Catch-up 도중 만들어진 중간 state snapshot은 마지막 committed tick으로 합치고, gameplay와 lifecycle event는 server tick 순서를 유지합니다.

핵심 요약:

참고 자료


이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.


공유하기:

이전 글
[C++] World Server 2: Double Buffer 도입기
다음 글
[C++] World Server 3: 게임 로직의 계산과 상태 반영을 분리하다