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

[C++] World Server 2: Double Buffer 도입기

업데이트:

TL;DR — Double Buffer는 여러 Session Actor가 비동기적으로 게시한 event를 World Server의 60Hz tick 입력으로 확정하고, producer와 consumer의 소유권을 분리하기 위해 도입했습니다. 그러나 worker와 A/B slot을 나눈 것만으로 pipeline이 병렬화되지는 않았습니다. Ingress는 Queue 수집과 deadline swap으로 분리하고, Outbound는 완성된 batch를 Publisher가 독립적으로 처리하도록 바꾼 뒤 세 번째 slot을 추가했습니다.

Table of contents

Open Table of contents

들어가며

앞선 Fixed-Step과 Bounded Catch-Up 글에서는 World Coordinator가 60Hz의 absolute deadline과 authoritative tick을 유지하는 과정을 살펴봤습니다. 고정된 시간 간격으로 simulation을 실행하고, deadline을 놓치면 catch-up batch로 밀린 tick을 따라잡는 구조입니다.

하지만 시간 규칙을 정한 것만으로 여러 클라이언트의 입력이 같은 기준에서 처리되지는 않습니다. Client packet은 서로 다른 시점에 도착하고, IOCP completion과 Session Actor의 실행 순서도 고정되어 있지 않습니다. 계속 들어오는 event 중 어디까지를 현재 tick의 입력으로 확정할지 별도의 경계가 필요했습니다.

이 문제를 해결하기 위해 Ingress와 Outbound에 Double Buffer를 도입했습니다. 구현 과정에서는 자료구조에 A/B slot이 있다는 사실과 실제 pipeline 병렬성은 서로 다르다는 점도 확인했습니다. 이 글은 초기 구조에서 발견한 직렬화 지점과 이를 claim 기반 Ingress, 독립적인 Publisher와 Outbound Triple Buffer로 바꾼 과정을 다룹니다.

이 글에서 다루는 내용:

사전 지식: server-authoritative simulation, fixed-step game loop, mutex와 condition variable에 대한 기본 이해를 전제로 합니다.


1. Double Buffer가 필요한 이유를 정리한다

NetworkRuntime과 World Server는 서로 다른 상태와 수명을 소유합니다.

NetworkRuntime
  socket accept / recv / send
  TCP packet framing
  connection과 I/O lifetime
  Session Actor scheduling
  To-World event 게시

World Server
  client identity와 shared state 관리
  simulation과 rule evaluation
  authoritative state 반영
  응답 대상과 payload 결정

NetworkRuntime은 packet type과 payload를 안전하게 복원하지만, packet이 요청한 World State 변경을 직접 수행하지 않습니다. Session Actor도 session-local I/O state만 직렬화하고 World State를 변경하지 않습니다. 여러 session이 공유하는 판정 순서와 mutation은 World Coordinator가 담당합니다.

Session Actor
  → application event 게시
  → shared World State를 변경하지 않음

World Coordinator
  → 확정된 event batch 소비
  → simulation rule 실행
  → authoritative state 변경

프로젝트에서 이 경계를 통과하는 event가 NrToWorldEvent입니다. 연결 수명이나 client packet을 Runtime 소유 payload와 함께 World로 넘기지만, Actor에게 World State 수정 권한을 제공하지는 않습니다.

1.1 Fixed-Step만으로는 입력의 끝을 정할 수 없다

Fixed-Step은 World를 언제, 얼마만큼 전진시킬지 결정합니다. 그러나 계속 추가되는 event 중 어디까지를 현재 tick에 포함할지는 결정하지 않습니다.

Fixed-Step
  → World를 언제 전진시키는가

Double Buffer
  → 현재 tick에서 어떤 입력까지 소비하는가

Bounded Catch-Up
  → deadline을 놓쳤을 때 시간축을 어떻게 복구하는가

Ingress Double Buffer의 swap은 비동기 event stream에 epoch cutoff를 만듭니다. Swap 이전까지 write된 event는 현재 sealed batch에 포함되고, 이후 도착한 event는 반대편 write slot에 적재되어 다음 epoch에서 처리됩니다.

flowchart LR
    Client["Client"] -->|"request packet"| Runtime["Network Runtime"]
    Runtime -->|"application event"| Ingress["Ingress write slot"]
    Ingress -->|"sealed input batch"| Coordinator["World Coordinator"]
    Coordinator -->|"authoritative mutation"| State["World State"]
    Coordinator -->|"sealed output batch"| Outbound["Outbound Publisher"]
    Outbound -->|"response packet"| Client

Double Buffer가 모든 packet의 도착 순서를 결정적으로 만드는 것은 아닙니다. 비동기 network에서 실제 도착 순서는 달라질 수 있습니다. 대신 Actor Worker가 event를 즉시 World에 반영하지 않고, 명시적인 epoch cutoff와 Coordinator의 commit 순서로 처리하게 합니다.


2. Slot을 ownership protocol로 만든다

Double Buffer를 단순히 배열 두 개를 번갈아 사용하는 최적화로 보지 않았습니다. 중요한 것은 각 slot을 어느 thread가 사용할 수 있는지입니다.

Empty → Writing → Sealed → Reading → Empty
상태소유자허용되는 작업
Empty없음다음 writer가 획득할 수 있습니다.
WritingProducerevent 또는 outbound record를 씁니다.
SealedBuffer완성된 batch를 reader에게 게시합니다.
ReadingConsumerpayload를 읽지만 수정하지 않습니다.

한 slot이 Writing인 동안 consumer는 해당 slot을 읽을 수 없습니다. Reading인 동안 producer는 payload를 reset하거나 덮어쓸 수 없습니다. Consumer가 read claim을 반납한 뒤에만 slot이 다시 Empty가 됩니다.

2.1 Claim이 긴 작업에서 mutex를 분리한다

Mutex는 event 전체를 적재하거나 simulation을 실행하는 동안 유지하지 않습니다. 다음과 같은 짧은 상태 전환만 보호합니다.

Claim을 획득한 뒤 payload 접근은 해당 thread의 단독 소유권으로 처리합니다. Batch의 각 element를 읽고 쓸 때마다 mutex를 획득하지 않습니다.

2.2 Epoch와 generation이 stale release를 막는다

Slot index만 비교하면 이전 역할에서 얻은 claim이 재사용된 slot을 잘못 해제할 수 있습니다. Ingress claim은 slot index와 role generation을 함께 보관하고, read batch는 epoch까지 연결합니다.

Slot A / Generation 4 / Epoch 100
Slot A / Generation 6 / Epoch 102

두 claim은 같은 물리 slot을 가리키지만 같은 ownership이 아닙니다. 현재 generation과 일치하지 않는 claim은 stale claim으로 거부합니다.

Ingress와 Outbound는 같은 ownership 원칙을 사용하지만 실행 조건은 다릅니다.

구간ProducerConsumer경계
IngressWorldIngressPumpWorld CoordinatorCoordinator의 WaitSwap()
OutboundWorld CoordinatorOutbound Publisher완성된 batch 게시와 FIFO 소비

Ingress는 Coordinator가 tick 경계를 결정합니다. Outbound는 Coordinator가 생성한 결과를 Publisher가 나중에 소비하므로, producer seal과 consumer dequeue가 서로 독립적인 계약이 필요했습니다.


3. 첫 구현은 worker를 나누고도 직렬화되었다

첫 구조에서도 Ingress Pump, Coordinator와 Outbound Publisher는 각각 별도 thread를 사용했습니다. 그러나 worker 협업은 Coordinator가 plan을 보내고 같은 epoch의 completion을 기다리는 방식이었습니다.

Coordinator
  → Ingress Plan N 게시

Ingress Pump
  → deadline까지 event drain
  → ingress N seal
  → Ingress Completion N 게시

Coordinator
  → Completion N 대기
  → ingress N 처리
  → simulation N 실행
  → outbound N seal
  → Outbound Plan N 게시

Outbound Publisher
  → outbound N 제출
  → read slot release
  → Outbound Completion N 게시

Coordinator
  → Completion N 확인 후 다음 outbound batch 진행

자료구조에는 A/B slot이 있었지만 Coordinator가 worker의 작업 시작과 완료 순서를 모두 조정했습니다. 서로 다른 slot을 사용할 수 있는 구간에도 다음 단계가 completion을 기다렸기 때문에 실행은 사실상 single-thread pipeline처럼 이어졌습니다.

이 구조도 correctness 측면에서는 의미가 있었습니다.

하지만 worker의 존재와 pipeline 병렬성은 같은 의미가 아니었습니다. A/B storage가 있어도 Coordinator가 매 단계의 completion을 기다리면 두 slot은 lifetime을 분리할 뿐, 동시에 작업을 진행할 수는 없게 됩니다.


4. Ingress를 coordinator의 tick plan에서 분리한다

먼저 Ingress에서 tick용 plan/completion을 제거했습니다. Pump는 Coordinator가 매 tick 보내는 plan을 기다리지 않고, NetworkRuntime의 ToWorldEvent Queue에서 event를 가져와 현재 write slot을 채웁니다. Coordinator는 자신의 absolute deadline에서 swap만 요청합니다.

Ingress Pump
  write claim 획득
  → ToWorldEvent Queue에서 bounded batch pop
  → write slot에 event commit
  → write claim 반납

  Queue가 비어 있으면
  → write claim 반납
  → ToWorldEvent Queue의 event notification 대기

World Coordinator
  absolute deadline 대기
  → WaitSwap(epoch)
  → sealed read batch 획득
  → event decode와 validation
  → fixed-step simulation
  → read claim 반납

Pump는 먼저 짧은 write claim을 획득해 ToWorldEvent Queue에서 bounded batch pop을 시도합니다. Queue가 비어 있으면 event를 0개 commit해 claim을 즉시 반납한 뒤, Queue에 새 event가 게시될 때까지 기다립니다. 즉 Queue notification을 기다리는 긴 구간에는 write slot을 점유하지 않습니다.

Coordinator가 deadline에 WaitSwap(epoch)을 요청하면 진행 중인 bounded transfer가 있을 때만 해당 transaction의 종료를 기다립니다. Swap을 위해 ToWorldEvent Queue를 빌 때까지 drain하지 않습니다. 현재 write slot에 옮기지 못한 event는 Queue에 남고, swap 이후 반대편 write slot으로 이동합니다.

sequenceDiagram
    participant Runtime as Network Runtime
    participant Queue as ToWorldEvent Queue
    participant Pump as Ingress Pump
    participant Buffer as A/B Buffer
    participant World as World Coordinator

    Runtime->>Queue: To-World event 게시
    Pump->>Queue: Bounded batch pop
    Pump->>Buffer: Write B에 event 적재
    World->>Buffer: Swap epoch N 요청
    Buffer-->>World: Sealed Read B 제공
    par 현재 epoch 처리
        World->>World: Tick N simulation과 commit
    and 다음 epoch 입력 수집
        Runtime->>Queue: 새 event 게시
        Pump->>Queue: 다음 batch pop
        Pump->>Buffer: Write A에 event 적재
    end

Tick 수와 catch-up 판단은 계속 Coordinator가 소유합니다. Ingress Buffer는 Queue의 event를 저장하는 owner가 아니라, Queue에서 꺼낸 bounded batch의 write/read ownership과 epoch swap만 담당합니다.

Catch-up batch에서도 sealed ingress event는 한 번만 소비합니다. 현재 epoch에서 client별 최신 입력 상태를 확정한 뒤 밀린 simulation tick을 순서대로 전진시킵니다. Catch-up 도중 도착한 packet은 반대편 write slot 또는 ToWorldEvent Queue에 남고 다음 ingress epoch에서 처리됩니다.


5. 초기 Outbound는 publication 완료 뒤 다음 simulation으로 넘어갔다

첫 구조에서는 Coordinator가 tick simulation을 끝낸 뒤 outbound batch를 완성하고 Publisher에 넘겼습니다. Publisher는 완성된 batch를 NetworkRuntime에 제출한 뒤 read slot을 반납했습니다. Coordinator는 이 publication 완료와 outbound role swap을 확인한 뒤 다음 tick simulation을 시작했습니다.

Coordinator
  ingress N 소비
  → simulation N
  → outbound N 생성과 seal

Outbound Publisher
  outbound N 제출
  → read slot 반납

Coordinator
  publication N 완료 확인
  → outbound role swap 완료
  → simulation N+1 시작

Ingress, Coordinator와 Outbound가 서로 다른 worker에서 실행되더라도, 다음 simulation의 시작 조건에 이전 publication 완료가 포함되어 있었습니다. 이미 authoritative 결과가 확정된 뒤의 전송 준비까지 Coordinator의 tick 진행 절차가 블로킹되는 구조였습니다.

5.1 Double Buffer로 publication과 다음 simulation을 겹친다

첫 개선에서는 Publisher가 outbound N을 처리하는 동안 Coordinator가 ingress N+1과 simulation N+1을 실행하도록 바꿨습니다.

Outbound Publisher
  outbound N 제출 ───────────────┐

Coordinator                      │
  ingress N+1 소비               │
  → simulation N+1              │
  → outbound N+1 생성 ──────────┘
                   tick 종료에서 합류

실제로 outbound를 제출하는 과정과 simulation N+1이 처리되는 과정이 병렬적으로 진행됐지만, 다음과 같은 상태가 다시 발생했습니다.

Slot A → Publisher가 Reading N
Slot B → Coordinator가 Writing N+1

Coordinator가 simulation을 처리하고 나서 outbound를 만들고 이를 Publisher에게 위임하는 과정에서 다음 outbound에 필요한 slot을 기다려야 하는 상황이 발생합니다.

tickStartedAt
→ ingress N+1 소비
→ simulation N+1
→ outbound N+1 생성
→ outbound N+1 seal과 role swap
   └─ publication N 완료와 Slot A 반납 대기
→ tickCompletedAt

Publisher가 빠르면 이 대기는 거의 발생하지 않습니다. Simulation 처리 비용이 작고 Publisher가 tick 종료 전에 Slot A를 반납하면 Double Buffer도 publication을 임계 경로에서 제외할 수 있습니다. 반대로 두 작업이 함께 길어지면 publication 시간이 현재 tick의 execution tail에 합류합니다.

여기에서 책임을 다시 나눴습니다. Coordinator는 authoritative simulation과 outbound 결과 확정까지만 담당합니다.

이미 완성된 결과를 NetworkRuntime으로 제출하고 slot을 반납하는 과정은 Outbound Publisher가 독립적으로 진행하게 만드는 것입니다.


6. 완성된 Outbound를 Publisher가 독립적으로 처리한다

변경한 구조에서는 Coordinator가 완성된 outbound batch를 순서가 보장된 ready queue에 게시한 뒤 Publisher의 완료를 기다리지 않고 바로 다음 tick을 시작합니다.

Publisher는 가장 오래된 ready batch를 직접 가져와 NetworkRuntime에 제출하고, 처리가 끝난 slot을 Empty로 돌려놓습니다.

Coordinator
  simulation N
  → outbound N 완성
  → ready batch로 게시
  → publication 완료를 기다리지 않음

Outbound Publisher
  가장 오래된 ready batch 소비
  → NetworkRuntime에 제출
  → slot 반납

이 구조에서 publication의 순서와 backpressure는 Outbound가 책임집니다. Coordinator는 Publisher가 언제 batch를 가져갔는지 확인하지 않고 다음 fixed-step deadline과 simulation을 계속 관리합니다.

6.1 Tick 시작 전에 Ingress와 Outbound를 함께 준비한다

Publisher가 사용할 outbound batch는 여전히 Coordinator가 simulation을 끝낸 뒤 seal합니다.

이 상태에서 Coordinator는 다음 tick에서 사용할 outbound batch slot을 tick의 시작 시점으로 옮겨 확보하게 됩니다.

이전 tick 완료
  ∥ Publisher가 이전 outbound 처리

다음 fixed-step deadline
  → Ingress read batch 준비
  → Outbound write slot 준비
  → tickStartedAt
  → simulation과 outbound 생성
  → outbound batch seal과 ready 게시
  → tickCompletedAt

이 순서는 이전 tick이 끝난 뒤 다음 tick의 deadline이 도착할 때까지의 간격을 Publisher가 사용할 수 있게 됩니다.

Coordinator는 새 tick을 측정하기 전에 Ingress input과 Outbound write 공간이 모두 준비됐는지 확인하고, 준비된 상태에서만 simulation을 시작합니다. Tick 시작 조건을 한곳으로 모으면서 publication 대기가 simulation 종료 시간에 붙는 것도 막을 수 있게 된 것입니다.

모든 outbound slot이 사용 중이면 Coordinator는 다음 tick 시작 전에 빈 slot을 기다립니다. 따라서 여전히 Coordinator는 outbound slot 획득을 위해 블로킹이 될 수 있습니다.

6.2 세 번째 slot이 한 batch의 publication 지연을 흡수한다

그래서 Publisher는 총 3개의 slot을 사용합니다. 두 slot만 사용하면 Reading N + Writing N+1 상태에서 다음 write 공간이 없습니다. 세 번째 slot을 추가하면 다음 상태까지 허용할 수 있습니다.

Slot A → Publisher가 Reading N
Slot B → Ready N+1
Slot C → Coordinator가 Writing N+2

Coordinator는 N+1을 ready batch로 게시한 뒤 Publisher의 N 완료를 기다리지 않고 N+2를 위한 write slot을 준비할 수 있습니다.

모든 slot이 점유된 상태에서는 Coordinator가 여전히 빈 slot이 생길 때까지 기다려야 하지만, Coordinator와 Publisher의 time-slice를 최대한 사용할 수 있다는 장점이 있습니다.

반면 Ingress에는 세 번째 slot을 추가하지 않았습니다. Coordinator가 deadline에 swap을 요청하면 진행 중인 bounded Queue-to-slot transfer만 끝낸 뒤 현재 write slot을 바로 read slot으로 넘기는 구조이기 때문입니다.

또한 아직 slot으로 옮기지 못한 event는 ToWorldEvent Queue에 남습니다. Outbound에서 사용할 세 번째 slot의 역할을 이 event queue에서 대신해주고 있다고 볼 수도 있겠습니다.


7. 동일한 4 MiB 조건에서 실행 결과를 비교한다

구조 변경의 목표는 모든 tick을 무조건 빠르게 만드는 것이 아니었습니다. Publisher가 이전 batch를 처리하는 동안 simulation이 다음 outbound batch를 만들었을 때, publication 지연이 현재 tick의 execution tail에 합류하는 빈도를 줄이는 것이었습니다.

다음 조건에서 publication 완료가 tick 종료에 합류하는 2-slot 구조와 Publisher가 독립적으로 처리하는 3-slot 구조를 비교했습니다.

PhaseExecution p99: DoubleExecution p99: TripleOverrun: DoubleOverrun: TripleStart lag p99: DoubleStart lag p99: Triple
Early14.534 ms14.753 ms0.285%0.352%16.511 ms16.583 ms
Mid16.607 ms15.648 ms0.954%0.629%16.625 ms16.485 ms
Late11.341 ms12.353 ms0.126%0.182%16.631 ms16.507 ms

두 구현을 합친 6회의 반복 실행, 즉 총 12개의 server run은 모두 client 100/100이 완료됐고 worker failure 없이 정상 종료했습니다. Overrun은 두 구현 모두에서 발생했으며 일부 run은 Performance Gate를 넘었습니다.

Mid 구간에서는 Triple Buffer의 execution p99가 5.78% 낮고 overrun ratio가 0.325%p 낮았습니다. Simulation과 publication이 동시에 길어지는 pressure 구간에서 세 번째 slot이 한 batch의 지연을 흡수하고, publication 완료를 tick 종료 경로에서 분리한다는 설계 의도와 일치하는 결과입니다.

반면 Early execution p99는 1.51%, Late는 8.92% 높았습니다. Start lag 차이도 모든 phase에서 1% 미만이었습니다. Publisher가 tick 종료 전에 slot을 반납하는 낮은 pressure 구간에서는 기존 Double Buffer도 기다릴 이유가 없으므로 Triple Buffer를 통해 얻는 이익이 거의 없었던 것입니다.

저부하 구간의 차이를 buffer lock, worker wake-up, cache locality, CPU scheduling 중 하나로 단정할 수는 없습니다. 프로세스 CPU 평균은 Double 10.47%, Triple 10.33%로 비슷했습니다. Working set peak 중앙값은 Double 58.25 MiB, Triple 63.11 MiB로 세 번째 slot을 추가한 Triple Buffer가 약 4.86 MiB 높았습니다.

이 결과에서 확인한 범위는 다음과 같습니다.


8. 4 MiB는 100-client benchmark의 용량 경계다

Benchmark의 outboundPayloadByteCapacityPerSlot은 4 MiB로 고정했습니다. 이 값은 100-client workload의 batch 상한을 보수적으로 잡기 위한 설정입니다.

가장 큰 application payload는 최대 8,186 bytes이고 catch-up batch는 최대 4 tick을 처리합니다. Client마다 processed tick당 최대 크기 payload record 하나가 만들어지는 경우를 기준으로 다음과 같이 계산했습니다.

100 clients × 4 catch-up ticks × 8,186 bytes
= 3,274,400 bytes
≈ 3.12 MiB

여기에 같은 epoch의 control packet과 chunking 여유를 포함해 payload storage를 slot당 4 MiB로 올려 잡았습니다. Recipient와 record 배열은 이 payload byte capacity와 별도로 고정 용량을 가집니다. Triple Buffer에서는 payload storage만 4 MiB × 3 slots = 12 MiB를 예약합니다.

Peak memory를 구조적으로 낮추려면 publication 단위를 바꿔야 합니다.

  1. Outbound record를 bounded chunk로 seal하고 Publisher가 chunk를 순차적으로 제출합니다.
  2. Session 또는 recipient shard로 독립적인 batch를 나누고 여러 Publisher가 병렬로 제출합니다.

첫 방식은 peak payload storage를 줄이지만 chunk 사이의 순서를 관리해야 합니다. 두 번째 방식은 병렬성을 늘릴 수 있지만 session별 packet FIFO, replication snapshot의 chunk 일관성과 failure 처리가 더 복잡해집니다. 현재 4 MiB 설정은 이 설계를 도입하기 전, 100-client benchmark의 입력 조건을 고정하기 위한 용량 경계입니다.


정리하며

첫 구현은 worker와 A/B slot을 분리했지만 Coordinator가 각 단계의 완료를 기다려 실행이 직렬화되었습니다.

그래서 Ingress는 ToWorldEvent Queue 수집과 deadline swap으로 분리했습니다. Outbound는 publication과 다음 simulation을 병렬로 실행했지만, 두 slot만으로는 tick 종료 시점에 이전 Publisher를 다시 기다리게 됐습니다.

최종적으로 Coordinator는 완성된 outbound batch를 게시한 뒤 publication 완료를 기다리지 않습니다. Publisher가 게시된 batch를 순서대로 처리하고, 세 번째 slot이 한 batch의 publication 지연을 흡수합니다. Coordinator는 다음 tick을 시작하기 전에 Ingress input과 Outbound write 공간을 함께 준비하며, 모든 slot이 사용 중이면 빈 slot을 기다려 읽지 않은 batch를 덮어쓰지 않습니다.

핵심 요약:

참고 자료


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


공유하기:

이전 글
[C++] 결과 반환과 상세 진단을 위한 공통 파이프라인
다음 글
[C++] World Server 1: Fixed-Step과 Bounded Catch-Up