나만의 작은 도서관

[TIL][C++] 250505 MMO 서버 개발 11일차: TCPRecvBuffer, atomic exchange 함수, WSASend() 병렬 호출과 OS 관리 버퍼의 동기적 네트워크 이벤트 수행, 하나의 소켓, 겹치지 않는 sendEvent 본문

Today I Learn

[TIL][C++] 250505 MMO 서버 개발 11일차: TCPRecvBuffer, atomic exchange 함수, WSASend() 병렬 호출과 OS 관리 버퍼의 동기적 네트워크 이벤트 수행, 하나의 소켓, 겹치지 않는 sendEvent

pledge24 2025. 5. 6. 01:42
주의사항: 해당 글은 일기와 같은 기록용으로, 다듬지 않은 날것 그대로인 글입니다. 

TCPRecvBuffer

  • TCP는 바이트 스트림 형식으로 데이터가 수신되기 때문에 온전한 패킷이 다 들어왔다는 보장도, 패킷 단위로 읽는다는 보장도 없다. WSARecv 같은 경우도 인자로 넘겨준 numOfBytes의 크기도 하나의 패킷에 대한 크기가 아니다.
  • 이렇듯 각 메시지에 대해 경계를 TCP에서 알 수 없는 문제를 “메시지 경계(Boundary) 문제”라고 한다.

WSARecv의 numOfBytes

  • WSARecv를 등록하고 완료 패킷에 들어간 다음부터는 인자로 넘겨준 numOfBytes의 값은 변하지 않는다. 즉, WSARecv를 하나만 걸어줬어도 완료 패킷 이후에 데이터가 수신되었다면 numOfBytes와 실제 OS에 수신받은 데이터 바이트 수는 다를 수 있다는 것이다.
// OS recvBuffer
// [OOOOOOOOOOOOOOOOOOOOOOOO..........]
// [[---numOfBytes---]OOOOOO..........]

// WSABuf(WSARecv인자로 넣어준 recv버퍼)
// [[---numOfBytes---]................]

메시지 경계 문제 해결법

  • 메시지 경계 문제 해결법은 여러 가지 존재한다. 가장 보편적으로 사용되는 방법은 헤더를 추가하는 방법이지만 아래와 같은 방식도 존재한다.
    • 고정 길이 사용: 모든 메시지의 길이를 고정한다. 메시지 타입이 더 이상 추가되지 않는 환경에서 적합하다.
    • 구분자 사용(delimiter): 문자열을 구분자를 통해 파싱 할 때처럼 각 메시지 시작 또는 끝에 구분자를 추가하는 방법이다.

헤더 방식을 사용한 packet parsing

  • 메시지 경계 문제를 해결하기 위해 헤더 방식을 사용한다면 헤더를 읽고 헤더에 적힌 payload의 크기를 확인하여 패킷을 파싱 해야 한다.
  • 파싱을 시도할 때 아직 전송이 완료되지 않는 패킷이 recvBuffer에 존재할 수 있기 때문에 매 파싱 후에 일부 데이터는 recvBuffer에 유지시켜줘야 한다.
// packet parsing (Before)
[[---Pkt1---][-------Pkt2--..........]

// packet parsing (After)
[............[-------Pkt2--..........]
  • 위에서 볼 수 있듯 파싱 한 자리 뒤로 패킷이 남아있게 된다. 즉, WSARecv를 등록할 때마다 패킷의 위치가 점점 뒤로 밀린다는 것이다.
  • 이를 해결하기 위해 매 파싱마다 남은 패킷을 앞으로 당길 수 있겠지만, 이는 너무 복사 비용이 많이 들기 때문에 더 효율적인 방식인 두 포인터를 사용한다.

두 포인터(Two Pointer)를 이용한 RecvBuffer 관리

  • 두 포인터 방식을 이용하면 RecvBuffer는 읽어서 처리한 위치를 가리키는 커서와 데이터가 쓰여있는 마지막 위치를 가리키는 커서가 존재하게 된다.
[............[-------Pkt2--..........]
//           ^             ^
//           |             |
//          readPos        writePos
  • RecvBuffer와 커서를 관리하기 위해, readPos와 writePos가 같아질 경우에만 각 커서를 맨 앞으로 당기고, 그렇지 않은 경우(데이터가 하나라도 남아있는 경우)는 계속해서 뒤로 커서가 이동한다.
  • 만약, 정ㅇㅇㅇㅇㅇㅇㅇㅇ말 운이 좋지 않아서 readPos와 writePos가 같아지는 경우가 지속해서 나오지 않아 커서가 너무 뒤로 간 경우, 억지로 남은 데이터를 앞으로 끌고 온 다음 커서를 맨 앞으로 당긴다.
    • 너무 뒤로 간 경우의 기준은 다양하게 세울 수 있다.(전체 버퍼 크기의 70%나 수신 속도 따른 분석값) 가장 간단한 방법으로는 버퍼의 chunkUnit을 만들어서 버퍼 크기를 chunk단위의 배수로 한 다음 여유 chunk의 개수가 위험할 정도로 줄어들 때 당기는 방법이 있다.

atomic exchange 함수

  • 예전에 한 번 정리한 것 같지만 찾을 수 없어 한 번 더 정리한다.
  • exchange함수는 인자로 넣어준 값으로 해당 변수의 값을 원자적으로 변경하는 함수로, 반환값은 이전 변숫값을 반환한다. 따라서 아래 두 코드는 원자적 연산을 하는가를 제외한 논리적 흐름은 동일하다.
if(_sendRegisterd == false){
  _sendRegisterd = true;
  func();
}

// 이전 값이 true.
// _sendRegisterd = true -> if문 안들어감
// 이전 값이 false
// _sendRegisterd = true -> if문 들어감
if(_sendRegisted.exchange(true) == false)
    func();

WSASend() 병렬 호출과 OS 관리 버퍼의 동기적 네트워크 이벤트 수행

  • WSASend는 하나의 소켓에 대해 여러 스레드가 병렬적으로 여러 송신 이벤트를 등록할 수 있지만, OS의 송신 버퍼는 등록된 송신 이벤트들을 I/O queue에 집어넣고 동기적으로 수행한다. 즉, 아무리 병렬적으로 WSASend를 걸어둬도 송신 데이터 복사는 순차적으로 일어난다는 것이다.
  • WSASend를 아무리 빨리 걸어도 앞에 먼저 온 WSASend가 있다면 대기시간이 필연적으로 발생한다는 것이다. 결국 서버 프로그램은 각 WSASend를 등록했을 때 힘들게 시스템콜 대기 시간을 기다렸지만 내부에서도 대기시간이 발생한다.
thread1 ...WSASend(pkt1)------->완료 통지
thread2 ...WSASend(pkt2)--------->완료 통지
thread3 ...WSASend(pkt3)----------->완료 통지
...
threadN ...WSASend(pkt4)------------->완료 통지

// OS
SendBuffer[[pkt1][pkt2]......] <--- I/O Queue [[WSASend(pkt3)][WSASend(pkt4)]......]

하나의 소켓, 겹치지 않는 sendEvent

  • 아무런 정책이 없다면 하나의 소켓에 대해 sendEvent는 여러 스레드에서 발생할 수 있다. 어제 알아보았듯 Room Update와 귓속말 전송은 독립적으로 패킷을 전송하기 때문에 각 이벤트를 서로 다른 스레드가 맡아 전송하면 WSASend에 대한 완료 패킷을 GQCS로 처리하기도 전에 WSASend가 걸릴 수 있다.
// sendEvent가 overlapped
thread1 ...WSASend(pkt1)------->완료 통지
thread2 .....WSASend(pkt2)--------->완료 통지
  • 위처럼 하나의 소켓에 대해 sendEvent가 중첩(overlapped)되어 처리되는 상황이 발생하는데, 이 상황 자체는 문제가 되지 않으나 시스템콜 횟수가 다소 많아 성능적으로 조금 아쉬울 수 있다.
  • 따라서, Scatter-Gather를 사용하여 하나의 세션에 대해선 중첩된 sendEvent를 가지지 않고, recv처럼 단 하나의 sendEvent만 가지도록 한다.
// sendEvent를 Scatter-Gather방식으로 관리
thread1 ...sendQueue.push(pkt1)
thread2 ...sendQueue.popAll()--->WSASend(pkt1, pkt2, ..., pktN)--------->완료 통지
  • 위처럼 작동 방식을 변경하면 여러 스레드에서 같은 소켓에 대해 WSASend를 부르지 않고 단 하나의 스레드가 담당하게 된다.

recv처럼 완료 통지를 받은 워커스레드가 이벤트 재등록

  • 현재 사용하는 recv 정책은 recv 완료 통지를 받은 스레드가 WSARecv를 등록한다. 이처럼 send도 send 완료 통지를 받은 스레드가 sendQueue에 쌓인 데이터를 WSASend를 통해 등록한다. 이로써 하나의 소켓에 대해서는 하나의 sendEvent만 존재하게 되었다.
// recv 정책
1. recvEvent 등록(WSARecv) 
2. 완료 패킷을 어느 쓰레드가 처리
3. 해당 쓰레드가 1번 과정을 하며 무한 반복
=> recvEvent 등록은 단 한 번만 하므로 하나만 존재

// send 정책
1. sendQueue에 있는 패킷들을 pop하고 이를 sendEvent에 등록(WSASend)
2. 완료 패킷을 어느 쓰레드가 처리
3. 해당 쓰레드가 1번 과정을 하며 무한 반복
=> 실제 sendEvent등록은 완료 패킷을 받은 쓰레드만 하고,
나머지 쓰레드들은 송신 데이터를 sendQueue에 push

위 방식의 장단점

  • 위 방식을 사용하면 송신 데이터를 모아서 전송하기 때문에 WSASend 호출에 의한 시스템콜 호출 횟수가 크게 줄어들어 서버의 성능 면에서 이점을 가지게 된다.
  • 반면, 완료 패킷을 처리하는 스레드만이 WSASend를 호출할 수 있기 때문에 sendQueue에 push 된 송신 데이터들은 이전에 없던 대기 시간이 발생하게 된다.
sendQueue -[[pkt4][pkt3][pkt2][pkt1]]->

// 대기 시간
--------------------------------------> (WSASend)
Push[pkt1]--------waitTime------------>
...Push[pkt2]-------waitTime---------->
.........Push[pkt3]----waitTime------->
..............Push[pkt4]----waitTime-->

결론

  • MMO 장르의 관점에서 바라보았을 때 서버의 반응을 높이는 것보다 공평함과 패킷 처리의 효율성을 가져가는 것이 좋다. (FPS와 같이 빠른 반응을 요구하지 않기 때문) 따라서, MMO 게임 서버를 제작하겠다고 한다면 약간의 대기 시간을 가지는 대신 서버의 안정성을 챙길 수 있는 위 방식이 유리하게 작용한다.