graph TD
classDef runtime fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000
classDef app fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000
classDef external fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#000
Core["Runtime<br/>IoRing · Listener · Session<br/>RingBuffer · SendQueue<br/>GlobalQueue · JobQueue · JobTimer"]:::runtime
Obs["RuntimeObservability<br/>logging · profiling hooks"]:::runtime
Media["RuntimeMedia<br/>HLS/media helper"]:::runtime
Web["RuntimeWeb<br/>HTTP parser · Router · HttpSession"]:::app
Proxy["RuntimeProxy<br/>TcpProxyServer · bridge · TLS/SNI"]:::app
GameRuntime["RuntimeGame<br/>PacketSession · Room · RoomManager"]:::app
Examples["app/examples<br/>module smoke and integration apps"]:::external
Core --> Obs
Core --> Media
Core --> Web
Core --> Proxy
Core --> GameRuntime
Web --> Examples
Proxy --> Examples
GameRuntime --> Examples
서버 런타임 구현 포트폴리오
io_uring 기반 C++ 기본 런타임과 선택형 web/proxy/game 모듈
1 핵심 요약
Runtime
프로토콜에 독립적인 Runtime이 IoRing, Listener, Session, JobQueue, JobTimer를 제공
I/O
Multishot Accept/Recv, Provided Buffer, MSG_RING 기반 이벤트 처리
Lifecycle
self_ref_, pending_io_, keep_alive로 CQE 지연과 UAF 문제 대응
Apps
Web/Proxy/Game은 기본 런타임 위에서 동작을 검증하는 선택형 모듈로 분리
2 프로젝트 개요
| 항목 | 내용 |
|---|---|
| 프로젝트명 | iouring-runtime — io_uring 기반 C++ 서버 런타임 |
| 개발 범위 | 기본 Runtime + RuntimeObservability/RuntimeMedia + 선택형 RuntimeWeb/RuntimeProxy/RuntimeGame |
| 연동 클라이언트 | C++ DirectX 11 아이소메트릭 던전 크롤러 — 클라이언트 포트폴리오 |
| 공개 저장소 | iouring-runtime 중심. 기존 libiouring-core, libiouringweb, multiplayer-dungeon-rpg-server 기능을 통합 런타임/예제로 흡수 |
| 게임 프로토콜 | Binary Header [size:uint16][msgId:uint16] + Protobuf 3 payload |
2.1 io_uring 기반 서버를 직접 만든 이유
Linux 환경에서 서버를 개발하면서, 기존 epoll 기반 네트워크 라이브러리를 사용하는 대신 Linux의 커널 I/O 인터페이스인 io_uring을 직접 활용해 공통 서버 런타임을 구현해보고 싶었습니다.
epoll은 readiness를 통지받은 뒤에도 별도의 read/write 시스템콜을 호출해야 합니다. 반면 io_uring은 Submission Queue와 Completion Queue를 커널과 공유 메모리로 매핑하고, Multishot Accept/Recv를 활용하면 하나의 SQE로 여러 연결과 수신 이벤트를 처리할 수 있습니다. 이 특성을 앱 하나에 묶어두지 않고, TCP 세션 수명관리와 버퍼 관리, 크로스 링 태스크 전달, 타이머와 작업 큐까지 포함한 재사용 가능한 기본 런타임으로 정리하는 것이 이 프로젝트의 핵심이었습니다.
2.2 런타임과 애플리케이션을 분리한 이유
초기 ServerCore는 런타임 계층(IoRing, Session, BufferPool)과 애플리케이션 계층(IoLoop, GameRoom, PacketSession)이 한 레이어에 혼재되어 있었습니다. PlayerId, RoomId 같은 게임 전용 타입까지 코어에 포함되어 있어, HTTP 서버나 프록시처럼 게임과 무관한 서버를 만들 때도 불필요한 게임 의존성을 함께 가져와야 했습니다.
이 문제를 해결하기 위해 include/iouring_runtime/core와 src/runtime에는 네트워크 I/O, 세션 관리, 버퍼 관리, 이벤트 디스패치, 타이머와 작업 큐 같은 범용 기능만 남겼습니다. HTTP 라우팅과 정적 파일 응답은 RuntimeWeb, TCP/TLS 중계는 RuntimeProxy, 패킷 프레이밍과 Room 계층은 RuntimeGame이 담당합니다. 예제 앱들은 이 모듈들이 실제로 링크되고 실행되는지 보여주는 검증 표면이며, 문서의 중심은 기본 런타임의 구조와 문제 해결 과정입니다.
2.3 구현 계층과 문서 범위
실제 구현은 CMake 타깃과 공개 헤더 경계를 기준으로 나뉩니다. 기본 타깃 iouring_runtime::Runtime은 프로토콜을 모르는 TCP 런타임이고, 선택 모듈은 이 타깃을 링크해 각 프로토콜의 편의 API만 추가합니다. 따라서 RuntimeWeb, RuntimeProxy, RuntimeGame은 서로의 상위/하위 계층이 아니라 모두 같은 Runtime 위에 올라가는 병렬 모듈입니다.
2.4 현재 런타임 모듈
| 모듈 | CMake 타깃 | 구현 위치 | 책임 |
|---|---|---|---|
| Core runtime | iouring_runtime::Runtime |
src/runtime, include/iouring_runtime/core |
io_uring 생성/디스패치, accept, 세션 수명, recv/send 큐, 타이머, 작업 큐, backpressure |
| Observability | iouring_runtime::RuntimeObservability |
src/modules/observability |
로깅과 프로파일링 훅 |
| Media | iouring_runtime::RuntimeMedia |
src/modules/media |
HLS 등 미디어 보조 기능 |
| Web | iouring_runtime_web::RuntimeWeb |
src/modules/web |
HTTP 파싱, 라우팅, 파일/스트리밍 응답, worker wrapper |
| Proxy | iouring_runtime_proxy::RuntimeProxy |
src/modules/proxy |
downstream TCP listener, upstream connector, bidirectional bridge, TLS/SNI, metrics |
| Game | iouring_runtime_game::RuntimeGame |
src/modules/game |
패킷 프레이밍, PacketSession, PlayerRegistry, Room, RoomManager |
전체 조합은 BUILD_WEB=ON, BUILD_PROXY=ON, BUILD_GAME=ON, BUILD_TESTS=ON으로 동시에 켜서 검증했습니다. 현재 저장소의 테스트는 tests/core, tests/io, tests/ring, tests/job, tests/web, tests/proxy, tests/game, tests/media, tests/observability로 나뉘며, 예제 앱은 app/examples 아래에서 각 모듈이 실제 프로세스로 구동되는지 확인하는 역할만 맡습니다.
2.5 기본 Runtime 책임
기본 Runtime은 TCP 서버를 만들 때 반복해서 필요해지는 저수준 기능만 책임집니다. 프로토콜 파서, TLS 정책, 패킷 스키마, 저장소 드라이버, 게임 도메인 객체는 코어에 넣지 않았습니다.
| Runtime이 담당하는 것 | Runtime 밖에 둔 것 |
|---|---|
io_uring 생성과 CQE 배치 디스패치 |
HTTP 파싱/라우팅 |
Listener accept flow와 SessionFactory |
WebSocket/TLS/프록시 정책 |
Session self-ownership, recv 재등록, send queue drain |
게임 패킷 스키마와 도메인 객체 |
| Provided Buffer Ring, send/recv buffer, short-write retry | DB/storage 구현 |
| inactivity timeout hook과 backpressure | 앱별 설정과 운영 정책 |
GlobalQueue, JobQueue, JobTimer, cross-ring task |
예제 앱의 화면/API 설계 |
코어를 직접 사용할 때의 흐름은 IoRing::Create → BufferPool → SessionFactory → Listener::Start → ring.Dispatch 루프 → Session::OnRecv/Session::Send → Disconnect 또는 DisconnectAfterFlush입니다. 선택 모듈은 이 흐름 위에 프로토콜별 세션과 서버 wrapper를 얹을 뿐, 커널 I/O 수명과 이벤트 루프의 기준은 동일하게 유지됩니다.
2.6 코어 라이브러리 구조
graph TD
classDef session fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000
classDef pool fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#000
classDef queue fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000
subgraph IoWorker["IoWorker"]
IoRing["IoRing"]
Listener["Listener"]
JobTimer["JobTimer"]
end
IoRing --> Session
Listener --> Session
JobTimer --> Session
Session["Session (N개)"]:::session
Session --> BufferPool
BufferPool["BufferPool"]:::pool
BufferPool --> GlobalQueue
BufferPool --> JobQueue
GlobalQueue["GlobalQueue"]:::queue
JobQueue["JobQueue (Room별)"]:::queue
GlobalQueue <-->|"MSG_RING (크로스 링)"| JobQueue
3 io_uring 네트워크 엔진
ServerCore의 네트워크 엔진은 Linux io_uring을 기반으로 합니다. 커널과 유저스페이스가 Submission Queue(SQ)와 Completion Queue(CQ)를 공유 메모리로 매핑하므로, I/O 요청 제출과 완료를 최소한의 시스템콜로 처리할 수 있습니다.
IoRing 클래스는 io_uring 인스턴스를 RAII로 관리하며, 현재 스레드의 링을 참조합니다. 핵심 기능은 다음과 같습니다.
- Multishot Accept — 하나의 SQE로 여러 연결을 수락합니다. 새 연결마다 CQE만 추가로 발생하므로 SQE를 다시 제출할 필요가 없어 accept 오버헤드를 줄일 수 있습니다.
- Multishot Recv + Provided Buffer —
IOSQE_BUFFER_SELECT플래그를 사용해 커널이 Provided Buffer Ring에서 버퍼를 선택하고, 해당 버퍼에 수신 데이터를 직접 기록합니다. 수신 경로의 버퍼 관리 비용과 복사 비용을 줄일 수 있습니다. - MSG_RING —
IORING_OP_MSG_RING으로 다른 링에 태스크를 직접 전달합니다.
3.1 Dispatch() — CQE 배치 처리
Dispatch()는 CQ에 쌓인 완료 이벤트를 배치 단위로 dispatch해 처리합니다. user_data의 bit 0을 이용해 MSG_RING 태스크와 일반 I/O 이벤트를 구분합니다.
IoRing.cpp — Dispatch(): CQE 루프
bool IoRing::Dispatch(std::chrono::milliseconds timeout) {
std::vector<EventHandlerRef> keep_alive;
unsigned head = 0; unsigned count = 0;
io_uring_for_each_cqe(ring_, head, cqe) {
const std::uint64_t data = cqe->user_data;
if (data & 0x1ULL) { // MSG_RING 태스크 (bit 0 플래그)
auto* task = reinterpret_cast<std::move_only_function<void()>*>(
data & ~0x1ULL);
(*task)(); delete task;
} else if (data != 0) { // 일반 I/O 이벤트
auto* ev = reinterpret_cast<IoEvent*>(data);
auto owner = ev->Owner();
if (owner) {
keep_alive.push_back(owner); // 배치 중 소멸 방지
owner->Dispatch(ev, result, flags);
}
}
++count;
}
io_uring_cq_advance(ring_, count);
return true;
}MSG_RING 태스크는 힙에 할당된 move_only_function의 주소에 bit 0을 세팅해 user_data에 인코딩합니다. 일반 I/O 이벤트는 IoEvent*를 통해 소유 핸들러로 디스패치되며, keep_alive 벡터는 배치 처리 중 핸들러가 먼저 파괴되는 일을 막습니다.
sequenceDiagram
participant IW as IoWorker
participant D as Dispatch
participant CQ as CQ
participant MR as MSG_RING
participant H as Handler
IW->>D: Dispatch(1ms)
Note over D: keep_alive 벡터 생성
D->>CQ: for_each_cqe()
CQ-->>D: CQE 배치
loop 각 CQE 순회
Note over D: bit 0 분기
alt bit = 1
D->>MR: 포인터 복원
MR->>MR: (*task)(), delete task
else bit = 0
D->>H: weak_ptr → shared_ptr 승격
alt owner 유효
H->>H: Dispatch(ev, result, flags)
Note over H: keep_alive.push(owner)
else owner == nullptr
Note over D,H: 파괴된 세션 → skip
end
end
end
D->>CQ: cq_advance(count)
Note over D: keep_alive 소멸 →<br/>이 시점에서야 Session 파괴 가능
3.2 Provided Buffer Ring
Provided Buffer Ring은 단일 mmap으로 메타데이터(io_uring_buf 배열)와 실제 데이터 영역을 연속으로 할당합니다.
RingBuffer::Create()는meta_size + data_size를 한 번에mmap하여 TLB 미스를 줄입니다.- 초기화 시 모든 버퍼를
io_uring_buf_ring_add로 커널 링에 등록합니다. - 커널은 이 링에서 사용 가능한 버퍼를 직접 선택해 recv 데이터를 기록하므로, 유저스페이스에서 수신 버퍼를 매번 지정하지 않아도 됩니다.
3.3 버퍼 고갈과 패킷 단편화
Provided Buffer Ring을 실제 트래픽에 붙이면서 두 가지 문제가 드러났습니다.
첫 번째는 버퍼 고갈(ENOBUFS) 입니다. 동시 접속이 몰려 provided buffer가 전부 소진되면 커널이 ENOBUFS를 반환하며 Multishot Recv가 자동 해제됩니다. 이 상태를 별도로 감지하지 않으면 해당 세션의 수신이 조용히 멈추기 때문에, CQE 플래그를 확인한 뒤 RegisterRecv()로 Multishot Recv를 재등록하는 복구 경로가 필요했습니다.
두 번째는 패킷 단편화입니다. TCP 스트림 특성상 하나의 게임 패킷이 여러 CQE에 걸쳐 도착하는데, provided buffer는 CQE 단위로 잘려 나오므로 단편화된 패킷을 zero-copy로 바로 조립할 수 없습니다. 이 경우에는 별도의 재조립 버퍼가 필요합니다.
Session::OnRecv()에서는 이 두 경우를 Fast Path / Slow Path로 분리해 처리합니다. 완전한 패킷이 하나의 CQE에 담겨 오는 일반 경로(Fast Path)에서는 F_MORE 플래그를 확인해 --pending_io_를 갱신하고, 커널 버퍼를 span으로 직접 참조해 파싱한 뒤 즉시 Return()으로 반납합니다. 패킷이 단편화된 경우(Slow Path)에는 서브클래스 PacketSession이 별도의 RecvBuffer에 복사해 완전한 패킷이 모일 때까지 재조립하고, ENOBUFS 복구는 RegisterRecv()를 다시 호출해 Multishot Recv를 재등록하는 방식으로 처리합니다. 수신 직후 버퍼를 반납해 커널 버퍼 점유 시간을 짧게 유지하면, 특정 세션의 지연이 다른 세션의 버퍼 고갈로 이어지는 상황을 줄일 수 있습니다.
4 세션 수명관리
4.1 self_ref_ + pending_io_ 패턴
클라이언트 대량 접속·해제를 반복하는 스트레스 테스트에서 SEGFAULT가 간헐적으로 발생했습니다. 크래시 지점은 항상 CQE 콜백에서 Session 멤버에 접근하는 시점이었지만, 재현 빈도가 불안정해 원인 특정에 시간이 걸렸습니다.
원인은 세션 수명과 커널 I/O 완료 타이밍의 불일치였습니다. 클라이언트가 연결을 끊으면 애플리케이션은 Session 객체를 즉시 파괴하지만, 커널에는 아직 완료되지 않은 SQE(Multishot Recv, Send 등)가 남아 있습니다. 이후 커널이 해당 SQE의 CQE를 발행하면 이미 해제된 메모리에 커널이 데이터를 덮어쓰는 Use-After-Free가 발생합니다. 해제된 영역이 아직 다른 객체로 재할당되지 않은 상태에서는 조용히 지나가고, 재할당된 뒤에야 크래시로 드러나기 때문에 재현이 불규칙했던 것입니다.
해결의 핵심은 세션 파괴 시점을 애플리케이션이 아니라 커널의 I/O 완료 신호가 결정하도록 구조를 뒤집는 것이었습니다. 이를 위해 Session에 두 개의 멤버를 추가했습니다.
include/servercore/io/Session.h
// Counts in-flight io_uring ops whose CQEs reference Session members.
// self_ref_ must not be released until this reaches zero.
// Accessed from I/O thread only — no synchronization needed.
int pending_io_ = 0;
// Self-ownership: keeps Session alive during active I/O.
// Released only via TryRelease() after all in-flight ops complete.
std::shared_ptr<Session> self_ref_;self_ref_— 세션이 자기 자신을 소유하는shared_ptr입니다. 외부에서 모든 참조를 놓더라도self_ref_가 남아 있는 동안 세션은 유지됩니다.pending_io_— 커널에 제출됐지만 아직 CQE를 받지 못한 SQE의 수입니다. SQE 제출 시++pending_io_, CQE 수신 시--pending_io_를 수행합니다.
세션의 실제 소멸 시점은 애플리케이션이 아니라 커널의 완료 신호에 의해 결정됩니다. TryRelease()는 pending_io_ > 0이면 그대로 반환하고, pending_io_가 0이 되는 순간 — 즉 커널이 모든 in-flight I/O를 완료한 시점 — OnDisconnected() 콜백을 호출한 뒤 self_ref_.reset()으로 자기 소유를 해제합니다. 이렇게 수명의 최종 결정권을 커널 쪽에 넘겨두면, 애플리케이션 타임라인과 커널 타임라인이 어긋나 발생하던 UAF가 원리적으로 생길 수 없게 됩니다.
pending_io_가 0이 되지 않으면 세션이 영원히 살아남아 누적되는 문제를 해결하기 위해 Disconnect()는 단순 cancel이 아니라 shutdown(SHUT_RDWR)을 SQE로 제출합니다. 커널이 소켓을 shutdown하면 Multishot Recv는 EOF(res == 0)로 반드시 종료 CQE를 발행하고, in-flight Send도 에러 CQE로 즉시 종료되므로, 모든 SQE가 유한 시간 안에 회수됩니다.
sequenceDiagram
participant App as Application
participant Sess as Session
participant Ring as IoRing
participant Kern as Kernel
participant Disp as Dispatch
Note over App,Disp: Phase 1 — 세션 생성
App->>Sess: Accept → 세션 생성
Note over Sess: self_ref_ = shared_from_this()<br/>pending_io_ = 0
Note over App,Disp: Phase 2 — I/O 등록
Sess->>Ring: RegisterRecv()
Note over Sess: ++pending_io_
Ring->>Kern: io_uring_submit(SQE)
Note over App,Disp: Phase 3 — CQE 수신
Kern-->>Disp: CQE 발생
Disp->>Disp: keep_alive.push(owner)
Disp->>Sess: Dispatch(ev, result, flags)
Note over Sess: !more → --pending_io_
Note over App,Disp: Phase 4 — 연결 해제
Sess->>Ring: Disconnect()
Ring->>Kern: PrepCancel + PrepShutdown
Note over Sess: ++pending_io_ (각각)
Note over App,Disp: Phase 5 — 해제
Kern-->>Disp: Cancel/Shutdown CQE
Note over Sess: --pending_io_ (각 CQE)
Sess->>Sess: TryRelease()<br/>pending_io_ == 0 →<br/>self_ref_.reset()
4.2 CQE 배치 처리와 소멸 지연
앞 절의 pending_io_ 메커니즘만으로는 부족한 경계 케이스가 하나 더 있었습니다. Dispatch()는 성능을 위해 여러 CQE를 한 번에 배치로 가져와 순회하는데, 이 배치 안에는 동일 세션의 이벤트가 여러 개 섞여 있을 수 있습니다. 앞쪽 이벤트를 처리하는 과정에서 해당 세션이 TryRelease()로 파괴 조건을 만족하면, 같은 배치의 뒤쪽 이벤트가 이미 소멸한 객체를 참조하는 UAF가 다시 발생합니다.
해결책은 배치 순회 동안 참조 카운트를 임시로 +1 유지하는 것입니다. CQE마다 IoEvent::Owner()로 weak_ptr를 shared_ptr로 승격해 로컬 keep_alive 벡터에 보관하면, 처리 중간에 self_ref_.reset()이 호출되더라도 세션은 배치가 끝날 때까지 살아 있습니다. 배치 순회가 완료되고 keep_alive가 스코프를 벗어나는 시점에서야 실제 소멸이 일어나므로, 뒤쪽 이벤트도 항상 유효한 객체를 참조합니다.
4.3 IoEvent의 weak_ptr 참조
pending_io_ 메커니즘과 keep_alive 배치 보호까지 갖춘 뒤에도, 한 가지 시나리오에서는 세션이 논리적으로 죽었지만 물리적으로 살아남는 구간이 발생했습니다. Heartbeat 타임아웃이나 강제 킥처럼 커널 I/O가 끝나기 전에 세션을 끊어야 하는 상황에서, 만약 IoEvent가 소유자를 shared_ptr로 붙잡고 있으면 in-flight I/O가 전부 완료될 때까지 세션 객체가 불필요하게 유지됩니다.
이를 피하기 위해 IoEvent는 소유자를 weak_ptr로만 참조합니다. CQE 처리 시 Owner()에서 weak_ptr → shared_ptr 승격을 시도해 nullptr가 반환되면 해당 이벤트는 건너뜁니다. 이벤트 객체가 세션의 수명을 연장하지 않으므로, 세션이 논리적으로 종료된 뒤에 도착하는 CQE도 자연스럽게 무시됩니다.
5 멀티스레드 아키텍처
5.1 Ring-per-thread와 세션 종속 문제
io_uring은 ring-per-thread 모델로 동작합니다. Multishot Accept로 연결을 수락하면 해당 세션은 Accept를 수행한 링에 종속되고, 이후의 Recv/Send도 모두 그 링에서 처리됩니다. 여기서 두 가지 문제가 파생됩니다. 세션이 accept된 링에 고정되기 때문에 특정 워커에 연결이 몰리면 부하가 편중되고, Room이나 Match처럼 여러 세션에 걸친 로직에서 다른 링에 있는 세션으로 데이터를 보내려면 별도의 크로스 링 통신이 필요합니다.
부하 편중은 SO_REUSEPORT를 이용했습니다. 각 IoWorker가 동일한 포트에 자체 Listener를 바인드해두면 커널이 accept()를 워커 간에 분배하므로, 연결 단위로 자연스럽게 나눠집니다.
크로스 링 통신은 IORING_OP_MSG_RING opcode를 씁니다. 다른 링의 CQ에 태스크를 커널 경로로 직접 삽입하는 방식이라 유저스페이스 뮤텍스나 이벤트 fd 같은 중간 단계가 필요 없고, 세션을 다른 링으로 마이그레이션하지 않고도 Room 브로드캐스트 같은 크로스 링 작업을 처리할 수 있습니다.
5.2 SO_REUSEPORT — OS 레벨 부하 분산
IoWorker::Start()는 링 생성 후 SO_REUSEPORT로 바인드된 Listener를 만들고, 독립 스레드에서 이벤트 루프를 구동합니다.
5.3 MSG_RING — 링 간 제로카피 태스크 전달
RunOnRing()은 태스크 포인터의 bit 0을 플래그로 인코딩해 대상 링으로 전달합니다. 같은 링이면 즉시 실행하고, 다른 링이면 IORING_OP_MSG_RING SQE를 제출합니다. IOSQE_CQE_SKIP_SUCCESS를 사용해 소스 링에서 불필요한 완료 CQE가 생성되지 않도록 했습니다.
IoRing.cpp — RunOnRing(): MSG_RING 태스크 전달
bool IoRing::RunOnRing(std::move_only_function<void()> task) noexcept {
if (t_current_ == this) { task(); return true; } // 같은 링이면 즉시 실행
if (t_current_ && t_current_->ring_) {
auto* task_ptr = new std::move_only_function<void()>(std::move(task));
std::uint64_t tagged = reinterpret_cast<std::uint64_t>(task_ptr) | 0x1ULL;
io_uring_sqe* sqe = io_uring_get_sqe(t_current_->ring_);
io_uring_prep_msg_ring(sqe, Fd(), 0, tagged, 0);
sqe->flags |= IOSQE_CQE_SKIP_SUCCESS; // 소스 링 CQE 억제
io_uring_submit(t_current_->ring_);
return true;
}
Post(std::move(task)); return true;
}sequenceDiagram
participant S as Source Ring
participant K as Kernel
participant T as Target Ring
Note over S: Step 1: 태스크 인코딩<br/>tagged = ptr | 0x1
S->>K: io_uring_prep_msg_ring()
Note over S: IOSQE_CQE_SKIP_SUCCESS<br/>소스 링에 CQE 미삽입
K->>K: SQE 처리<br/>tagged data를 CQ에 삽입
K->>T: CQE 직접 삽입 (타겟 CQ)
Note over T: Dispatch()<br/>user_data & 0x1 검사<br/>bit 0 == 1 → 태스크!<br/>ptr = data & ~0x1<br/>(*task)(), delete task
Note over S: 같은 링 최적화: 즉시 실행<br/>폴백: Post() → 뮤텍스 큐
6 대칭형 이벤트 루프 — IoWorker
모든 IoWorker는 I/O와 게임 로직을 동일한 단일 루프에서 실행합니다. 별도의 게임 로직 전용 스레드를 두지 않아 컨텍스트 스위치를 줄였고, 8ms 실행 예산을 둬 하나의 Room이 한 틱 동안 I/O를 독점하지 않도록 했습니다.
io_worker.cpp — Run(): IO + 게임 로직 단일 루프
void IoWorker::Run() {
IoRing::SetCurrent(ring_.get());
while (running_) {
ring_->Dispatch(std::chrono::milliseconds(1)); // 1. I/O 이벤트
ring_->ProcessPostedTasks(); // 2. 크로스 스레드 태스크
timer_.DistributeExpired(); // 3. 타이머 만료 작업
while (auto* q = global_queue_.TryPop())
q->Execute(std::chrono::steady_clock::now()
+ std::chrono::milliseconds(8)); // 4. 게임 로직 (8ms 예산)
}
}flowchart TD
classDef phase fill:#f0f8ff,stroke:#007acc,stroke-width:2px,color:#333
P1["Phase 1: Dispatch"]:::phase
P2["Phase 2: PostedTasks()"]:::phase
P3["Phase 3: Timer"]:::phase
P4["Phase 4: GameLogic"]:::phase
P1 --> P2
P2 --> P3
P3 --> P4
P4 --> P1
| 단계 | 처리 대상 | 함수 / 설명 |
|---|---|---|
| Phase 1 | I/O 이벤트 | ring_->Dispatch() — CQE를 배치로 수확하고 처리 |
| Phase 2 | 크로스 스레드 태스크 | ProcessPostedTasks() — 다른 스레드에서 넘어온 태스크 실행 |
| Phase 3 | 타이머 | timer_.DistributeExpired() — 만료된 작업 분배 |
| Phase 4 | 게임 로직 | GlobalQueue에서 JobQueue를 꺼내 8ms 예산 내에서 실행 |
6.1 JobQueue & 스케줄링
게임서버에서 Room 하나는 하나의 독립적인 게임 월드입니다. 여러 Room이 소수의 IoWorker 스레드를 공유하기 때문에, 특정 Room의 로직이 오래 걸리면 같은 스레드에 배정된 다른 Room의 틱이 밀리는 문제가 발생합니다. 이를 해결하기 위해 JobQueue + GlobalQueue 구조로 스케줄링을 구현했습니다.
6.1.1 JobQueue — 락-프리 직렬 실행
JobQueue는 Room·Match 등 게임 오브젝트가 상속하는 직렬 실행 큐입니다. 외부 스레드에서 Push(job)를 호출하면 큐에 적재되고, 실제 실행은 단일 스레드에서만 이루어지므로 게임 로직 내부에서는 뮤텍스가 필요하지 않습니다.
sequenceDiagram
participant S as Session (IoWorker A)
participant JQ as JobQueue (Room)
participant GQ as GlobalQueue
participant W as IoWorker B
S->>JQ: Push(패킷 핸들러)
Note over JQ: 큐에 작업 적재
JQ->>GQ: 첫 Push 시 자동 등록
W->>GQ: Phase 4에서 JobQueue 꺼냄
W->>JQ: Execute(deadline = now + 8ms)
loop 작업 순회
JQ->>JQ: job 실행
alt deadline 초과
JQ->>GQ: 미완료 → 재등록
end
end
Note over JQ: 모든 작업 완료 시 GlobalQueue에서 제거
실행 흐름의 핵심 세 단계:
Batch Swap — 뮤텍스를 잡고, 현재 큐 전체를 로컬
batch변수와 교환한 뒤 즉시 해제합니다. 이후 순회 중에는 락을 잡지 않으므로, Push와 Execute가 동시에 진행되어도 경합이 최소화됩니다.시간 예산 실행 —
Execute(deadline)은steady_clock::now()가 deadline(8ms)을 넘지 않는 동안만 작업을 꺼내 실행합니다. 8ms는 60fps 기준 한 프레임(16.6ms)의 절반으로, 나머지 시간을 I/O 처리에 할당하기 위한 값입니다.이어 실행 — 예산 초과로 남은 작업이 있으면 큐 앞에 복원하고,
JobQueue자신을 다시GlobalQueue에 등록합니다. 다음 Phase 4에서 이어서 실행되므로 작업이 유실되지 않습니다.
6.1.2 GlobalQueue — 워크 스틸링 분배
GlobalQueue는 실행 대기 중인 JobQueue들의 중앙 저장소입니다. 여러 IoWorker가 Phase 4에서 경쟁적으로 JobQueue를 꺼내 실행하는 워크 스틸링 패턴으로 동작합니다.
graph LR
classDef worker fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000
classDef gq fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000
classDef room fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#000
GQ["GlobalQueue"]:::gq
W1["IoWorker 0"]:::worker
W2["IoWorker 1"]:::worker
W3["IoWorker 2"]:::worker
W4["IoWorker 3"]:::worker
R1["Room A<br/>JobQueue"]:::room
R2["Room B<br/>JobQueue"]:::room
R3["Room C<br/>JobQueue"]:::room
R4["Room D<br/>JobQueue"]:::room
R5["Room E<br/>JobQueue"]:::room
GQ --> W1
GQ --> W2
GQ --> W3
GQ --> W4
W1 --> R1
W1 --> R5
W2 --> R2
W3 --> R3
W4 --> R4
- 어떤
IoWorker든 여유가 있으면GlobalQueue에서JobQueue를 가져가므로, Room이 특정 스레드에 고정되지 않고 부하가 자연스럽게 분산됩니다. - 하나의
JobQueue는 동시에 하나의 스레드에서만 실행되므로 직렬성이 보장됩니다.
6.1.3 JobTimer — 게임 틱 스케줄링
JobTimer는 steady_clock 기반의 지연 실행 타이머입니다. Room의 게임 틱(20Hz)이나 버프 지속시간 같은 시간 기반 이벤트를 스케줄링합니다.
- 등록 —
Reserve(delay, callback)으로 지정 시간 후 실행할 작업을 등록합니다. - 분배 —
IoWorker의 Phase 3에서DistributeExpired()가 호출되면, 만료된 타이머의 콜백을 해당JobQueue에 Push합니다. 콜백은 타이머 스레드가 아니라JobQueue의 직렬 실행 흐름에서 수행되므로, 게임 로직과의 동기화 문제가 없습니다. - 반복 틱 — 게임 틱처럼 주기적인 작업은 콜백 내부에서 자기 자신을 다시
Reserve하는 방식으로 반복합니다.
7 DB 접근 실험과 현재 예제 백엔드
초기 서버에서는 PostgreSQL 쿼리를 IoWorker 스레드에서 동기 libpq로 바로 호출하면 쿼리 실행이 끝날 때까지 해당 워커가 블로킹되고, 같은 스레드에 배정된 다른 Room들도 영향을 받는 문제가 발생했습니다. 이 문제를 해결하기 위해 io_uring의 비동기 DB 접근과 전용 워커 풀 방식 두 가지를 시도해 봤습니다.
첫째는 PQsendQuery + POLL_ADD로 PostgreSQL 소켓 fd의 readiness를 io_uring에 등록해 결과를 이벤트 루프 안에서 수확하는 방식입니다. 한 번 시도해 봤지만 실질적인 이점이 없었습니다. POLL_ADD는 readiness 알림 역할만 하고, 실제 recv(2)는 PQconsumeInput() 안에서 libpq가 자체 버퍼로 직접 호출합니다. 그래서 io_uring 고유 최적화인 Provided Buffer Ring이나 Multishot Recv는 DB 경로에 전혀 적용되지 않고, 순수 성능만 보면 epoll + 비동기 libpq와 동일한 구조가 됩니다.
둘째는 전용 워커 스레드 풀에 쿼리를 던지고 블로킹 libpq로 실행하는 방식입니다.
상태 머신(Connecting/QuerySent/ReadingResult)과 POLL_ADD 재등록 로직이 전부 사라지고 쿼리 하나는 PQexec 한 줄이 되며, DB 워커가 블로킹 상태로 오래 머물러도 IoWorker 스레드는 영향받지 않아 실패가 격리됩니다. 추후 sqlpp11 같은 동기 ORM 래퍼를 얹더라도 인터페이스가 그대로 맞물립니다.
DB 워커와 콜백 처리
워커 풀 방식에서 남는 문제는 DB 워커가 완료한 콜백을 어느 스레드에서 실행하느냐입니다. DB 워커 스레드에서 그대로 실행하면 Room 상태에 접근하는 순간 데이터 레이스가 발생하므로, 결과를 원래 IoWorker 스레드로 돌려보내야 합니다.
IoRing은 thread_local IoRing* Current()로 현재 스레드에 바인딩된 링을 노출합니다. PgConnectionPool::Execute() 호출 시점에 그 포인터를 캡처해두면, DB 워커는 PQexec 완료 직후 캡처된 링의 Post(lambda)로 콜백을 밀어넣기만 하면 됩니다. IoWorker는 매 루프에서 ProcessPostedTasks()로 이 큐를 비우므로, 콜백은 자연스럽게 원래 스레드에서 실행되고 Room 상태에 락 없이 접근할 수 있습니다.
sequenceDiagram
participant GL as 게임 로직 (Room)
participant Pool as PgConnectionPool<br/>(facade)
participant WP as PgWorkerPool<br/>작업 큐
participant DW as DB 워커 스레드
participant PG as PostgreSQL
participant W as IoWorker<br/>(원래 스레드)
GL->>Pool: Execute(sql, params, cb)
Note over Pool: IoRing::Current() 캡처
Pool->>WP: Submit(job + reply_ring)
Note over GL: 게임 로직 계속 실행
WP->>DW: condvar notify_one
DW->>PG: PQexec(sql) [블로킹]
PG-->>DW: PGresult
DW->>W: reply_ring->Post(callback)
Note over W: 다음 루프 Phase
W->>W: ProcessPostedTasks()
W->>GL: cb(result) 실행
PgConnectionPool — 워커-커넥션 1:1 매핑
PgConnectionPool은 얇은 facade이고, 실제 동작은 내부의 PgWorkerPool이 담당합니다. PgWorkerPool은 설정된 connections_per_worker 수만큼 스레드를 스폰하고, 각 스레드에 PgConnection 하나씩을 1:1로 고정합니다. 스레드별로 자기 전용 커넥션만 쓰므로 커넥션 접근에 락이 필요 없고, 공유 상태는 std::mutex + std::condition_variable로 보호되는 작업 큐 하나뿐입니다. 스레드가 작업을 가져가면 전용 커넥션 위에서 블로킹 PQexec를 실행합니다.
커넥션 풀 자체의 역할은 세 가지입니다.
- 사전 연결 — 서버 시작 시점에 모든 커넥션을
PQconnectdb로 열어둡니다. 쿼리가 들어올 때마다 TCP 핸드셰이크와 인증을 반복하는 비용을 피합니다. - 동시 쿼리 상한 — 워커 스레드 수가 곧 동시 쿼리 상한입니다. 트래픽이 몰려도 PostgreSQL 쪽으로 가는 부하는 이 상한을 넘지 않습니다.
- 백프레셔 — 상한을 초과한 요청은 작업 큐에 쌓여 순서대로 처리됩니다. DB 서버가 수용 가능한 속도 이상으로 요청이 밀려드는 상황을 자연스럽게 흡수합니다.
| 상황 | 풀 없음 | 워커 풀 |
|---|---|---|
| 1000명 동시 로그인 | 1000개 커넥션 시도 → DB 거부 | 풀 크기(예: 20)만 사용, 나머지는 작업 큐에서 대기 |
| 초당 5000 쿼리 | 5000번 커넥션 생성/해제 | 20개 커넥션 재사용 |
현재 iouring-runtime/app/examples/game/dungeon_full_server 예제는 배포와 smoke test가 쉬운 형태를 우선해 DbService 인터페이스 뒤에 SQLite와 in-memory 백엔드를 제공합니다. 기본 실행은 gameserver.db SQLite 파일을 사용하고, 자동화 검증은 GAMESERVER_DB=memory로 외부 DB 없이 실행합니다. PostgreSQL 워커 풀 설계는 별도 운영형 백엔드로 확장 가능한 방향을 설명하는 설계 기록이고, 현재 main의 full server 예제에는 SQLite/Memory 백엔드만 포함되어 있습니다.
8 선택 모듈과 예제 앱의 역할
예제 앱은 포트폴리오의 중심 구현이라기보다, 기본 Runtime이 서로 다른 프로토콜 계층에서도 같은 방식으로 동작하는지 확인하기 위한 실행 가능한 검증 표면입니다. 세부 기능 소개는 최소화하고, 어떤 런타임 기능을 검증했는지만 남겼습니다.
| 영역 | 예제 위치 | 검증한 런타임 표면 |
|---|---|---|
| Core TCP | app/examples/runtime/core_echo, core_idle_echo |
직접 IoRing/Listener/Session을 구성하는 기본 TCP 서버 흐름, inactivity timeout, DisconnectAfterFlush |
| Web | app/examples/web/* |
RuntimeWeb의 HTTP parser, radix router, file/streaming response, request timeout, send backpressure |
| Proxy | app/examples/proxy/tcp_reverse_proxy |
HTTP 객체 없이 downstream/upstream 세션을 bridge하는 TCP proxy 흐름, TLS/SNI, metrics snapshot |
| Game | app/examples/game/dungeon_packet_echo, dungeon_full_server |
RuntimeGame의 packet framing, PacketSession, Room, RoomManager, cross-ring broadcast |
| Activity server | app/activity-server |
런타임을 별도 C++ 백엔드 프로세스에서 소비하는 패키징/배포 경로 |
RuntimeWeb은 HTTP 파싱과 라우팅, 파일/스트리밍 응답을 제공하지만 세부 앱 로직은 코어와 분리됩니다. RuntimeProxy는 HTTP request/response 객체를 만들지 않고 TCP byte stream을 직접 중계합니다. RuntimeGame은 게임 도메인 전체가 아니라 패킷 세션과 Room 실행 모델만 제공합니다. 이 경계를 유지했기 때문에 같은 Runtime이 포트폴리오 정적 파일 서버, TCP reverse proxy, 패킷 게임 서버, 활동 백엔드처럼 서로 다른 실행 단위에서 재사용될 수 있었습니다.
예제 던전 서버의 인증, 전투, 절차적 던전, 파티/채팅, SQLite/Memory 백엔드 같은 도메인 기능은 RuntimeGame을 사용한 통합 검증 대상으로만 다룹니다. 런타임 관점에서 중요한 점은 PacketSession이 코어 Session의 수명관리와 send/recv 경로를 그대로 사용하고, Room 작업은 JobQueue/GlobalQueue를 통해 직렬성과 워크 스틸링을 동시에 얻는다는 점입니다.
9 벤치마크 — io_uring vs epoll
ServerCore(io_uring)가 기존 epoll 대비 실제로 어떤 차이를 보이는지 검증하기 위해, 동일한 게임 로직을 두 I/O 백엔드로 구현해 비교했습니다.
9.1 테스트 환경
- 환경: WSL2 Ubuntu 24.04, localhost, Intel i7 (8코어)
- 도구:
game_bench/echo_bench(tools/run_bench.sh) - 게임 로직: Login → EnterGame → Move(20Hz) → Attack(2Hz, 4종 스킬), 두 서버 동일
| 서버 | I/O 백엔드 | 설명 |
|---|---|---|
| io_uring | io_uring | IoWorker가 I/O 디스패치와 Zone 틱을 단일 루프에서 처리, ServerCore 사용 |
| Epoll | epoll | 베이스라인 epoll 구현, ServerCore 미사용 |
9.2 Echo 벤치마크
게임 로직 없이 io_uring 코어의 순수 I/O 성능만 측정했습니다. 64바이트 페이로드, pipeline=1 조건입니다.
| 클라이언트 | 처리량 (echo/s) | p50 (μs) | p99 (μs) |
|---|---|---|---|
| 10 | 218,498 | 30 | 157 |
| 50 | 305,183 | 152 | 367 |
| 100 | 326,913 | 293 | 594 |
100 클라이언트에서 최대 처리량 327K echo/s, p50 레이턴시 293μs를 기록해 서브 밀리초 수준의 응답성을 확인했습니다.
9.3 브로드캐스트 지연 — 단일 존
C_MOVE 수신 → S_MOVE 브로드캐스트 완료까지의 지연(μs)입니다. 모든 봇이 하나의 존에 몰린 조건입니다.
| 봇 수 | 구성 | io_uring p50 | Epoll p50 |
|---|---|---|---|
| 40 | 4스레드 | 838 | 3,322 |
| 200 | 4스레드 | 10,001 | 4,419,583 |
| 400 | 8스레드 | 3,025 | — |
- 40봇: io_uring이 서브 밀리초, Epoll은 3ms 수준
- 200봇: Epoll이 뮤텍스 경합으로 초 단위에 진입, io_uring은 10ms 수준 유지
- 400봇: io_uring은 여전히 3ms 수준을 유지, Epoll은 서버 포화로 측정 불가
9.4 Tail Latency — p99
| 봇 수 | 구성 | io_uring p99 | Epoll p99 |
|---|---|---|---|
| 40 | 1스레드 | 1,887 | 6,067 |
| 200 | 4스레드 | 24,191 | 11,794,952 |
200봇 p99 기준, io_uring은 24ms 수준을 유지한 반면 Epoll은 11.8초까지 증가했습니다.
9.5 처리량
S_MOVE + S_ATTACK + S_DAMAGE 수신 메시지/초 기준입니다.
| 봇 수 | 구성 | io_uring (msg/s) | Epoll (msg/s) | 배율 |
|---|---|---|---|---|
| 40 | 1스레드 | 37,288 | 37,288 | 1.0× |
| 200 | 4스레드 | 950,656 | 523,846 | 1.8× |
소규모(40봇)에서는 차이가 없지만, 200봇부터 Epoll이 뮤텍스 경합으로 처리량이 제한되기 시작합니다.
9.6 멀티 Room — Room 샤딩 효과
봇을 여러 Room으로 분산하면 Room당 약 20명이 되어, 단일 존의 O(N²) 브로드캐스트 병목이 해소됩니다.
| 규모 | 구성 | io_uring p50 (μs) | Epoll p50 (μs) |
|---|---|---|---|
| 200봇 / 10 Room | 4T×50C | 846 | 897 |
| 400봇 / 20 Room | 4T×100C | 920 | 691 |
Room 샤딩을 적용하면 두 서버 모두 서브 밀리초 수준으로 수렴합니다. 실제 게임에서는 Room 분산이 기본이므로, io_uring의 이점은 대규모 단일 존에서 두드러지게 드러납니다.
9.7 결론
| 항목 | io_uring | Epoll |
|---|---|---|
| 강점 | 400봇까지 ms 수준 지연 유지, 배치 제출로 시스템콜 최소화 | 소규모에서 구현이 단순, SQE 오버헤드 없음 |
| 병목 | Zone 틱이 IO와 스레드 공유 | 200봇 이상에서 브로드캐스트 뮤텍스 경합 |
두 백엔드의 차이는 단일 존에 봇이 몰릴수록 뚜렷해집니다. 40봇 구간에서는 차이가 크지 않지만, 200봇을 넘어서면 epoll은 브로드캐스트 경로의 뮤텍스 경합으로 p99가 초 단위까지 벌어지는 반면, io_uring 쪽은 400봇까지도 밀리초대를 유지합니다. 반대로 Room 샤딩으로 부하를 분산하면 두 서버 모두 서브 밀리초로 수렴해 차이가 사라지는데, 이는 io_uring의 이점이 “작은 방이 많은” 상황이 아니라 “한 방에 사람이 많은” 상황에서 드러난다는 것을 보여줍니다.
10 관련 레포
| 레포 | 역할 |
|---|---|
| iouring-runtime | 현재 기준 메인 구현. Runtime, RuntimeWeb, RuntimeProxy, RuntimeGame, web/proxy/game 실행 앱 포함 |
| libiouring-core | 통합 이전 io_uring 코어 런타임 히스토리 |
| libiouringweb | 통합 이전 HTTP/web layer 히스토리 |
| multiplayer-dungeon-rpg-server | dungeon_full_server로 흡수된 멀티플레이 던전 RPG 서버 히스토리 |
| game-client | 연동 DirectX 11 클라이언트 |