게임 서버는 통상적으로 iocp 입출력 모델로 제작된다. 하지만 이번에 알아볼 모델은 가장 기본이 되는 select 모델이다.
소켓 함수 호출이 성공할 시점을 미리 알 수 있다.
현재 문제상황은
이다.
Select모델은 미리 관찰을 해 이런 상황을 예방하는 모델이다.
관찰을 하는 방식이기 때문에 블로킹 소켓, 논블로킹 소켓에 모두 사용가능하다.
select는 set에 있는 소켓들을 관찰한다. select를 작동하는 순서는 다음과 같다.
여기서 예외(OutOfBend)란?
예외(OutOfBand)는 send() 마지막 인자 MSG_OOB로 보내는 특별한 데이터가 된다.
받는 쪽에서도 recv OOB 세팅을 해야 읽을 수 있음. 긴급상황을 알리는 등 특이한 상황에 쓴다.
이를 통해 구현을 시작해보자.
select 모델은 데이터가 많다고 가정해야 보기쉽다.
Session을 정의해보자. session은 socket, 버퍼 등이 존재한다.
const int32 BUFSIZE = 1000;
struct Session {
SOCKET socket = INVALID_SOCKET; // 소켓
char recvBuffer[BUFSIZE] = {}; // recv된 데이터들
int32 recvBytes = 0; // recv한 길이 -> recvBuffer에서 사용되고 있는 바이트 수
int32 sendBytes = 0; // send한 길이 -> recvBufer에서 보낸 길이
};
논블로킹 소켓 세팅까지의 과정이다.
WSAData wsaData;
if (::WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
return 0;
SOCKET listenSocket = ::socket(AF_INET, SOCK_STREAM, 0);
if (listenSocket == INVALID_SOCKET)
return 0;
u_long on = 1;
if (::ioctlsocket(listenSocket, FIONBIO, &on) == INVALID_SOCKET)
return 0;
SOCKADDR_IN serverAddr;
::memset(&serverAddr, 0, sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_addr.s_addr = ::htonl(INADDR_ANY);
serverAddr.sin_port = ::htons(7777);
if (::bind(listenSocket, (SOCKADDR*)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
return 0;
if (::listen(listenSocket, SOMAXCONN) == SOCKET_ERROR)
return 0;
fd_set들을 정의해주자.
vector<Session> sessions; // clientSocket을 가지고 있을 session들의 묶음.
sessions.reserve(100); // 데이터가 많다고 가정. 불필요한 복사를 막기위해 저장소 100으로 설정
fd_set reads; // read할 socket들의 set
fd_set writes; // write할 socket들의 set
set을 초기화하는 과정이다.
while (true) {
// 소켓 셋 초기화
FD_ZERO(&reads);
FD_ZERO(&writes);
// ListenSocket 등록
FD_SET(listenSocket, &reads);
for (Session& s : sessions) {
if (s.recvBytes <= s.sendBytes) // recv바이트가 sendbyte보다 크면 읽을 게 많다는 것. read에 등록
FD_SET(s.socket, &reads);
else
FD_SET(s.socket, &writes);
}
listenSocket의 경우 듣고 있는 것, 즉 accept 할 대상이 있는지 읽는 상황 → read에 넣어준다.
우리는 지금 에코서버를 만드는 중이므로 recvBytes가 sendBytes보다 크면 읽기만 하는 식으로 제작해준다.
이제 본격적으로 select를 진행해보자.
int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
if (retVal == SOCKET_ERROR)
break;
파라미터의 첫 인자는 리눅스와 함수 인터페이스를 맞추기위한 것이므로 윈도우라면 신경 쓰지 않아도된다. → 0넣어주기
파라미터의 마지막인자는 timeout 인자이다.
안넣어주면 하나라도 준비된게 없으면 무한 대기한다.
사용법
timeval timeout;
timeout.tv_sec; // 몇s
timeout.tv_usec; // 몇ms
값을 넣어주고 마지막 인자에 넣어주면된다.
select를 실행한 후 set에 남아 있는 친구들은 준비가 완료된 친구들이다.
if (FD_ISSET(listenSocket, &reads)) {
SOCKADDR_IN clientAddr;
int32 addrLen = sizeof(clientAddr);
SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
if (clientSocket != INVALID_SOCKET) {
cout << "Client Connected" << endl;
sessions.push_back(Session{ clientSocket });
}
}
readSet에 listenSocket이 있으면 accept를 진행하고, 그를 통해 Session을 만들어 sessions리스트에 넣어준다.
for (Session& s : sessions) {
if (FD_ISSET(s.socket, &reads)) {
int32 recvLen = ::recv(s.socket, s.recvBuffer, BUFSIZE, 0);
if (recvLen <= 0) {
// 연결이 끊어진 상태, session제거해주기
continue;
}
s.recvBytes += recvLen;
}
}
session의 recvBuffer에 온 데이터를 저장하고 recvBytes를 올려준다.
if (FD_ISSET(s.socket, &writes)){
int32 sendLen = ::send(s.socket, &s.recvBuffer[s.sendBytes], s.recvBytes - s.sendBytes, 0);
if (sendLen == SOCKET_ERROR) {
// 연결이 끊기
continue;
}
s.sendBytes += sendLen;
if (s.recvBytes == s.sendBytes) {
s.recvBytes = 0;
s.sendBytes = 0;
}
}
블로킹 모드는 모든 데이터를 보내지만 논블로킹 모드의 경우 가끔 상대방 수신 버퍼 상황에 따라 데이터의 일부만 보낼 수 있다. 그러므로 sendLength를 정확히 받아서 늘려주자.
select모델은 내가 관찰하고 싶은 set들을 각각 만들고, 매번 루프를 돌 때 마다 초기화, 등록을 반복해야한다. 바로 select가 실행될 때 미대상자들이 알아서 제거 되기 때문이다.
select 모델의 장점: queue를 사용할 때 pop을 바로 하지 않고 empty로 비어있는 지 검사를 하는 것 처럼, 호출 준비가 되어 있는지 한 번 검사를 하고 들어간다.
select 모델의 단점: FD_SETSIZE라는 변수를 한 번 보면 64로 되어있다. set의 최대 크기가 64라는 것이다. 검사 socket이 64개를 넘어간다면, set을 여러 개 만들어 주어야한다.. set이 늘어난 만큼 코드를 더 반복해야한다