
게임서버는 주로 Window의 IOCP로 이루어지게 된다. IOCP를 이해하기 위해서는 논블로킹 소켓과 Overlapped IO의 지식이 필수기 때문에 하나씩 공부해보도록 하겠다.
// 클라이언트의 의사 코드임
// 이 프로그램은 11.22.33.44 기기에서 실행된다고 가정
mainI() {
s = socket(TCP);
s.bind(any_port);
s.connect("55.66.77.88:5959");
s.send("hello");
s.close();
}
main() {
s = socket(TCP);
s.bind("0.0.0.0:5959"); // 특정 포트 바인드
s.listen(); // 연결 대기
s2 = s.accept(); // 연결
print(getpeeraddr(s2)); // 클라이언트의 endpoint 출력해보기
while (true) {
r = s2.recv(); // 소켓에서 데이터를 받는다.
if (r.length <=0 ) {
break;
}
print(r);
}
s2.close();
}
소켓은 각각 송신 버퍼(send buffer)와 수신 버퍼(receive buffer)를 하나씩 가지고 있는다.
참고로 소켓은 OS에서 관리하는 객체이다.
IO 작업이 완료될 때까지 스레드가 멈추는 방식이다.
char buf[1024];
recv(sock, buf, 1024, 0); // 데이터 올 때까지 **스레드 멈춤**
호출시 결과를 즉시 반환하며, 프로그램이 블로킹하지 않고 진행하도록 한다. 완료 불가능 할 시 "would block"에러를 반환한다.
fcntl(sock, F_SETFL, O_NONBLOCK); // 논블로킹 설정
char buf[1024];
int len = recv(sock, buf, 1024, 0);
if (len > 0) { process(buf); } // 성공
else if (errno == EWOULDBLOCK) {} // 다음에 재시도
성공would block을 반환하게 된다.would block은 쉽게 말해서 "블로킹 걸렸어야 하는 상황인데 난 논블로킹 소켓이라 내가 처리했다." 이런 의미에 가깝다.while(true) {
r = s.send(dest, data);
if(r == EWOULDBLOCLK) {
continue;
// 블로킹 걸릴 상황이여서 송신을 안했다.
}
if (r == Ok) {
// 성공 처리
}
else {
// 실패 처리
}
}
3-way-handshake로 연결이 이뤄지기 때문에 발생하게 되는 것이다.3wh는 보안때 잠깐 배웠는데 정리하자면 "안녕"->"누구?"->"나야" 이렇게 정보를 주고받으며 연결을 하는 것을 의미한다.🤔 그렇다면 connect을 했는데 연결이 잘 된건지 확인하려면 어떻게 하면 될까?
result = s.connect();
if (result == EWOULDBLOCK) {
while(true) {
byte emptyData[0]; // 길이가 0인 배열
result = s.send(emptyData);
if (result == OK) {
// 연결 성공 처리
}
else if (result == ENOTCONN) {
// 연결이 아직 진행중
}
else {
// 연결 실패
}
}
}
List<Socket> sockets;
void NonBlockSocketOperation() {
while(true) {
foreach(s in sockets) {
(result, data) = s.receive();
if (data.length > 0) {
print(data);
}
else if (result != EWOULDBLOCK) {
// 소켓 오류 처리
}
}
}
}
s = socket(TCP);
s.SetNonBlocking(true);
,,,
while(true) {
(socket, result) = s.accept();
,,,
}
,,,
쉽게 이런식으로 논블록킹 모드로 설정하면 된다.
void NonBlockSocketOperation() {
while(true) {
select(sockets, 100ms);
foreach(s in sockets) {
(result, data) = s.receive();
if (data.length > 0) {
print(data);
}
else if (result != EWOULDBLOCK) {
// 소켓 오류 처리
}
}
}
}
void SelectVersion() {
while(true) {
fd_set readfds;
FD_ZERO(&readfds);
int max_fd = 0;
// O(n) 준비
foreach(s in sockets) {
FD_SET(s.fd, &readfds);
max_fd = max(max_fd, s.fd);
}
// 대기
select(max_fd+1, &readfds, NULL, NULL, &timeout);
// O(n) 확인
foreach(s in sockets) {
if (FD_ISSET(s.fd, &readfds)) {
(result, data) = s.receive();
if (data.length > 0) print(data);
else if (result != EWOULDBLOCK) error(s);
}
}
}
}
fd_set: 집합 비트맵, 이걸 이용하는 이유는 한번에 많은 소켓을 확인하기 위해서다. 비트1 개로 소켓 1개 확인. (1024개 비트)FD_ZERO를 통해 0으로 처음 세팅함. FD_SET을 이용해 소켓을 비트화 한다. poll()은 리눅스에서 사용가능한 방법중 하나이다.
struct pollfd {
int fd; // 소켓 번호 (fd=3,7,150000 등 무제한!)
short events; // 감시할 이벤트 (읽기/쓰기 등)
short revents; // 발생한 이벤트 (커널이 채움)
};
struct pollfd fds[10]; // 동적 배열 (select 1024 제한 해결!)
while(true) {
// 1. 배열 채우기 (O(n))
for(int i = 0; i < sockets.size(); i++) {
fds[i].fd = sockets[i].fd;
fds[i].events = POLLIN; // 읽기 감시
fds[i].revents = 0; // 리셋
}
// 2. 대기 (소켓3,7에 데이터 도착시 깨어남)
int ready_count = poll(fds, sockets.size(), 10); // 10ms 타임아웃
// 3. 준비된 소켓만 처리
for(int i = 0; i < sockets.size(); i++) {
if (fds[i].revents & POLLIN) { // 데이터 있음!
char buf[1024];
recv(fds[i].fd, buf, sizeof(buf), 0);
process_packet(buf);
}
}
}
epoll()은 poll()의 업그레이드 버전이고 현재 가장 많이 쓰이는 방법이다. 뒷부분에서 자세히 다루도록 하겠다.
Window에서 제공하는 비동기 IO방식이다. 논블로킹으로 I/O작업을 요청하면 바로 리턴하고 OS가 백그라운드에서 처리한 후 완료를 알려준다.
지금까지의 논블록 소켓의 장점과 단점은 다음과 같다.
예를 들어 UDP의 경우 recv버퍼에 데이터그램만큼의 공간이 없다면 would block이 발생하게 된다. 재시도를 해도 계속 없으면 계속 리턴을 할꺼고 그러면 결국 자원 낭비로 이어질 수 있다.
또한 서버측에서 recv로 데이터를 받게 되면 커널 recv버퍼에 있는 데이터를 user layer에 있는 recv 버퍼로 복사해야 하기 때문에 메모리에 중복 저장이 된다.
⚠️ 참고로 커널 버퍼 - 유저 버퍼 이렇게 나눠서 값을 저장하는건 단점이라기 보다 의도된 설계에 가깝다. 커널 버퍼에 저장되는 포인터를 메모리 안정성을 이유로 유저 버퍼에서는 값만 복사해 가는 개념이다.
😃 이러한 단점을 한번에 해결해 주는 기법이 Overlapped 인 것이다!!!
기존의 논블록 소켓은 다음과 같이 데이터를 다루게 된다.
Overlapped IO는 조금 다르게 처리하게 된다.
논블로킹: NIC(송신 하드웨어) -> DMA -> 커널 버퍼 -> 유저 버퍼
IOCP: NIC -> DMA -> 유저 버퍼 (바로 받음.)
void OverlappedSocketOperation() {
var overlappedSendStatus;
(result, length) = s.OverlapedSend(data, overlappedSendStatus);
if (length > 0) {
// 이러면 성공한거임.
}
else if (result == WSA_IO_PENDING) {
// overlaeed I/O 진행중.
while(true) {
(result, length)
= GetOverlappedResult(s, overlappedSendStatus);
if (length > 0) {
// 잘 보냈다는 뜻.
}
else {
// 아직 IO pending중.
}
}
}
}
Overlapped 전용 함수를 사용하면 버퍼의 메모리 블록 자체를 사용하게 된다. 즉, 커널 buffer를 거치지 않고 바로 사용자 버퍼로 오게 되는 것이다.
<일반 recv 3단계>
네트워크 ──1──> [커널 버퍼] ──2──> [사용자 버퍼] ──3──> [앱 로직]
↑ ↑ ↑
원본 중간 저장! 최종 사용
<Overlapped IO>
네트워크 ────────────────> [사용자 버퍼] ──> [앱 로직]
직접!
| 일반 버전 함수 | Overlapped I/O 전용 함수 |
|---|---|
| send | WSASend |
| sento | WSASendTo |
| recv | WSARecv |
| recvfrom | WSARecveFrom |
| connect | ConnectEx |
| accept | AcceptEx |
추가적으로 논블록 소켓에서는 상태 확인 후에 일을 하기 때문에 리액터(reactor)라고 부르고 Overlapped는 일단 먼저 저지르고 결과를 확인하기 때문에 프로액터(proactor)패턴이라고 부른다.
| reactor | proactor |
|---|---|
| 논블록 소켓 | Overlapped I/O |
| I/O를 시도한다. (성공 혹은 실패) | I/O를 시행한다. (무조건 성공) |
| 실패할 때는 I/O 기능을 기다린 후 재시도를 한다. | I/O완료를 기다린다. |
| 성공할 때는 상황을 종료한다. | 상황을 종료한다. |
근데 어쨋든 Overlapped방식이나 poll방식도 결국 반복문을 돌아야 처리할 수 있어서 부하가 크다. 그래서 나온 방식이 epoll과 IOCP라는 것이다.
epoll은 소켓이 I/O 가능 상태가 되면 이를 감지해서 사용자에게 알림을 해 주는 역할을 한다. 이때 어떤 소켓이 I/O가능 상태인지 알려주게 된다.
epoll = new epoll();
foreach(s in sockets) {
epoll.add(s, GetUserPtr(s));
}
events = epoll.wait(100ms);
foreach(event in events) {
s = event.socket;
// 위 epoll.add에 들어갔던 값을 얻는다.
userPtr = event.userPtr;
type = event.type;
if (type == ReceiveEvent) {
(result, data) = s.recv();
if (data.length > 0) {
//수신된 데이터를 처리한다.
Process(userPtr, s, data);
}
}
}
여기서 Level은 전압 자체가 있음을 의미하고 Edge는 전압의 변화과 있음을 의미하는 전자공학 용어이다.
그러면 epoll에서는 무슨 말이냐면 레벨 트리거는 "소켓이 IO가능하다."라는 의미이고, 에지 트리거는 "소켓이 IO 원래 안됐는데 이제는 된다."라는 의미이다.
여기서 소켓 통신을 예로 들어보자. 소켓 버퍼에 읽을 데이터가 있는 경우를 1, 없는 경우를 0으로 생각할 수 있다.
소켓 버퍼에 100바이트의 데이터가 도착했을 때, 소켓의 상태는 0에서 1로 변경된다. 이 때는 LT나 ET 모두 이벤트를 발생 시킨다.
이제 사용자가 소켓 버퍼에서 데이터를 읽는다고 했을 때, 100byte를 다 읽지 못하고 30byte만 읽었다고 하자. 그렇다면 여전히 소켓 버퍼에는 70byte의 데이터가 남아 있다.
이 다음 상황에서, 레벨 트리거링을 하면, 소켓 버퍼에 데이터가 남아있기 때문에 여전히 이벤트가 발생하지만, 엣지 트리거링을 하면 소켓 버퍼의 상태(데이터가 존재하는 상태)가 변하지 않았기 때문에 이벤트가 발생하지 않는다.
즉, LT에서는 남아있는 데이터를 보는게 아니라 recv버퍼에 데이터 있음만을 감지하기 때문에 처리 못했으면 넘기고 다시 반복문을 돌면서 해결한다. 하지만 ET의 경우에는 깨워진 상태에서 반복하며 남은 데이터를 처리하는 것이다.
epoll = new epoll(EPOLLET); // ET 모드!
events = epoll.wait(100); // CPU 0% + 게임 프레임
foreach(event in events) {
s = event.socket;
userPtr = event.userPtr;
if(event.type & ReceiveEvent) {
// ✅ ET 필수: 버퍼 완전 비우기!
while(true) {
(result, data) = s.recv();
if(result <= 0 || EWOULDBLOCK) break;
Process(userPtr, s, data); // 30B + 70B 모두!
}
}
}
버퍼 상태: 000011110000 (1=데이터 있음)
LT (레벨 트리거): "1인 동안 계속 울림!" 🔄
epoll_wait: "소켓3,5,6,7에 데이터! 또! 또! 또!"
ET (엣지 트리거): "상태 바뀔 때 딱 1회 울림!" 📢
epoll_wait: "소켓3에 새 데이터 도착!" (한 번만)
⚠️ 하나의 epoll 인스턴스를 여러 스레드가 공유하고 있다면 이벤트가 발생하면 대기중인 모든 스레드가 깨어나는 구조이다. (Thundering Herd(천둥 떼))
Window전용 고성능 IO라고 생각하면 된다. IOCP는 Overlapped IO가 완료되면 이를 감지해 사용자에게 알려주는 역할을 한다.
iocp = new iocp(); // (1)
foreach (s in sockets) {
iocp.add(s, GetUserPtr(s)); // (2)
s.OverlappedReceive(data[s], receiveOverlapped[s]);
}
events = iocp.wait(100ms);
foreach(event in events) {
// (5)
userPtr = event.userPtr;
ov = event.overlappedPtr;
s = GetSocketFromUserPtr(userPtr);
if (ov == receiveOverlapped[s]) {
// overlapped receive가 성공했으니 받은 데이터를 처리한다.
Process(s, userPtr, data[s]);
s.OverlappedReceive(data[s], receiveOverlapped[s]); // (6)
}
}
iocp.add(s, GetUserPtr(s)) -> 소켓 s의 완료 이벤트를 이 iocp로 보내고 userPtr를 식별자로 붙이라는 뜻이다. (2)이러한 IOCP의 특성을 이용해 보통 스레드풀을 만들게 된다.