Game Server

[C++/게임서버] select 모델

FroK 2022. 9. 28. 22:30

지난번 포스트를 통해서 단순히 논블로킹을 적용하고 while문으로 도배를 하는건 굉장히 CPU를 낭비하는 일이라고 소개를 했다. 그렇기 때문에 다양한 입출력 모델을 사용한다고 이야기를 했다. 오늘은 select 모델을 소개하겠다.

 

Select 모델은 select 함수를 기반으로 한 모델이라 이름이 이렇게 명명되었다.

사실 뒤에서 소개할 IOCP 모델하고 비교를 한다면, 서버 입장에서는 사용하기 애매한 물건이다. 하지만 클라이언트 입장에서는 자신만 처리할 필요가 없기 때문에, IOCP같은 고급 기법까지 갈 필요도 없다. 사실 모기 하나 잡겠다고 다연장 로켓포를 날리는 수준이다.

그래서 사실 클라이언트에서 비동기로 쓰기 좋은 모델이라 볼 수 있겠다. 그리고 윈도우/리눅스 둘 다 사용되는 모델이기도 하다. (IOCP는 윈도우 전용이다.)


먼저 컨셉은 소켓 함수 호출이 성공할 시점을 미리 알 수 있다.

현재 문제 상황은 이렇다. 수신 버퍼가 데이터가 없는데 read를 하거나, 송신 버퍼가 꽉 찼는데 write를 하는 것과 같다. 이런 경우는 블로킹 소켓은 조건이 만족되지 않아 블로킹되는 상황을 예방하고, 논블로킹의 경우 조건이 만족되지 않아서 불필요하게 반복 쳌 하는 상황을 예방한다.

 

이 모델은 일단 socket set이 필요하다. 그리고 다음과 같은 과정을 거친다.

  1. 읽기[ ] 쓰기 [ ] 예외(OOB(Out of band)) [ ]  관찰 대상을 등록한다 (이 때 OutOfBand는 send() 마지막 인제 MSG_OOB로 보내는 특별한 데이터이다. 받는 쪽에서도 recv OOB 세팅을 해야지만 읽을 수 있다.)
  2. select(readSet, writeSet, exceptSet) -> 관찰 시작
  3. 적어도 하나의 소켓이 준비되면 return -> 낙오자는 알아서 제거
  4. 남은 소켓 체크해서 진행

 

그리고 이러한 socket set을 만드는 과정은 다음과 같다.

  1. fd_set read;
  2. FD_ZERO : 비운다. ex) FD_ZERO(set);
  3. FD_SET : 소켓에 넣는다. ex) FD_SET(socket, &set); 
  4. FD_CLR : 소켓에서 제거한다.. ex) FD_CLR (socket, &set);
  5. FD_ISSET : 소켓에 set이 들어있는가를 체크한다. 있다면 0아닌 값을 리

먼저 예제를 위해서 다음과 같은 구조체를 생성했다.

struct Session
{
   SOCKET socket;
   char recvBuffer[BUF_SIZE] = {};
   int32 recvBytes = 0;
   int32 sendBytes = 0;
};

 

이후 select에서 사용하는 FD를 설정하는 과정이다. 사실 이 과정을 프로그래밍하다보면 SOCKET을 file descriptor를 사용을 하는 리눅스가 생각나게 한다. (

vector<Session> sessions;
sessions.reserver(100); // 먼저 공간을 100 정도 잡아두자.

fd_set reads;
fd_set writes;

while(true)
{
   FD_ZERO(&reads);
   FD_ZERO(&writes);
   
   // 먼저 리슨 소켓에 읽기 set을 넣자.
   FD_SET(listenSocket, &reads);
   
   // 소켓 등록
   for (Session& s : sessions)
   {
      // 데이터를 받았다면 read 셋을 등록
      if(s.recvBytes <= s.sendBytes)
         FD_SET(s.socket, &reads);
      // 데이터를 보낸다면
      else
         FD_SET(s.socket, &writes);
   }
   
   // 맨 처음은 리눅스와 파라미터를 맞추기 위한 것(윈도우 사용X)
   // 옵션 : 마지막 타임아웃 인자!
   //timeval timeout;
   //timeout.tv_set;
   //timeout.tv_uset;
   int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
   
   if(retVal == SOCKET_ERROR) break;	// 이상상황
   
   // &reads와 writes에 남은 것들은 준비된 소켓(준비되지 않았다면 select에서 알아서 제거)
   
   // ListnerSocket 체크
   if(FD_ISSET(listenSocket, &reads))
   {
      // ACEEPT 가능!
      SOCKADDR_IN clientAddr;
      int32 addrLen = sizeof(clientAddr);
      SOCKET clinetSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
      if(clinetSocket != INVALID_SOCKET)
      {
         sessions.push_back(Session{ clientSocket });
      }
   }
   
   // 나머지 소켓 체크
   for(Session& s : sessions)
   {
      if(FD_ISSET(s.socket, &reads))
      {
         int32 recvLen = ::recv(s.socket, s.recvBuffer, BUF_SIZE, 0);
         if(recvLen <= 0)
         {
            // 연결 끊긴 상황
            // TODO : sessions에서 제거
            continue;
         }
         
         s.recvBytes = recvLen;
      }
      
      
      if(FD_ISSET(s.socket, &writes))
      {
         // send는 보낸 값을 return 한다.
         // 블로킹 모드 -> 모든 데이터를 보낸다.
         // 논블로킹 모드 -> 일부만 보낼 수 있다.(상대방 수신 버퍼 상황에 따라서)
         int32 sendLen = ::send(s.socket, s.recvBuffer[s.sendBytes], BUF_SIZE, 0);
         if(sendLen == SOCKET_ERROR)
         {
            // 연결 끊긴 상황
            // TODO : sessions에서 제거
            continue;
         }
         
         s.sendBytes += sendLen;
         // 모든 데이터를 보냈다. (Echoing이 완료되었다)
         if(s.recvBytes == s.sendBytes)
         {
            s.recvBytes = 0;
            s.sendBytes = 0;
         }
      }
   }
}

물론 커널 상 논블로킹이여도 한 번에 다 보낼수 있고 다 받을 수 있게 설계가 되어있다 하지만 이런 경우가 아닐 경우도 있을 수 있으니 이에 대한 상황도 처리를 진행했다.

 

결국 제일 중요한 것은 연결된 소켓 집단들을 전부 등록한 뒤 select를 통해서 IO에 대한 이벤트가 발생한 경우, 이 이벤트가 성공한 socket에 대한 정보만 남겨놓는다는 점을 통해서 저번에 코드를 짠 코드보다 효율적이게 보인다.

 

장점으로는 비교적 구현이 가능하며 낭비가 줄어든다는 점이다. 하지만 단점이 존재한다. 물론 매번 등록해야된다는 점 또한 단점이긴 한데, 한계가 있는데 FD_SETSIZE가 64라는 점을 보면 알 수 있다. 물론 이는 한 번에 64명밖에 받을 수 없다 이런 말은 아니다. 단일 셋이 아니라 fd_set을 여러개를 쓰면 되겠지만, 귀찮다는 점이다.  

결국 배열로 구현되어있다.