KoreanFoodie's Study
[C++ 게임 서버] 3-8. Select 모델 본문
[C++ 게임 서버] 3-8. Select 모델
핵심 :
1. Select 모델을 사용하면 소켓의 유효성을 데이터를 주고 받기 전에 미리 체크할 수 있다.
2. 관찰하고자 하는 소켓을 fd_set 에 넣고, select 하면 관찰이 시작되며, 적어도 하나의 소켓이 준비되면 리턴된다(낙오자는 알아서 제거됨)
3. 세션을 이용해서 소켓을 관리할 수 있다. 세션은 두 기기간의 통신이 연결되었을 때 구축되고, 끊겼을 때 파괴되는 양방향 링크라고 생각하면 된다.
이번 시간에는 select 함수가 핵심이 되는 Selet 모델에 대해 알아보자. Select 모델을 사용하면 소켓 함수 호출이 성공할 시점을 미리 알 수 있다. 이것은 아래와 같은 상황에서 큰 이점으로 작용한다 :
- 블로킹 소켓 : 조건이 만족되지 않아서 블로킹되는 상황 예방
- 논블로킹 소켓 : 조건이 만족되지 않아서 불필요하게 반복 체크(spin)하는 상황 예방
사실 원리는 간단하다. 우리는 Select 모델은 Socket Set 가 존재하고, 우리는 '관찰할' 소켓들을 이 Socket Set 에 넣는다.
관찰을 시작한 후, 적어도 하나의 소켓이 준비되면 리턴을 한다. 이때, 준비가 되지 않은(버퍼가 비어있다거나, 가득 차 있다거나) 소켓들은 알아서 제거된다. 이제 남은 소켓을 체크해서 진행하면 된다. 정리하면 다음과 같을 것이다 :
- 읽기[ ] 쓰기[ ] 예외(OOB)[ ] 관찰 대상 등록 -> OutOfBand는 send() 마지막 인자 MSG_OOB로 보내는 특별한 데이터. 받는 쪽에서도 recv OOB 세팅을 해야 읽을 수 있음
- select(readSet, writeSet, exceptSet); -> 관찰 시작
- 적어도 하나의 소켓이 준비되면 리턴 -> 낙오자는 알아서 제거됨
- 남은 소켓 체크해서 진행
즉, 읽기 소켓 1, 2, 3 이 있다고 하고, 이를 관찰 대상에 넣었다. 그런데 1, 3 소켓이 지금 사용불가능한 상태라면, 관찰 시 2번 소켓만 목록에 남을 것이다. 😉
이제 간략한 API 를 보자.
// 관찰 set 만들기
fd_set set;
SOCKET s;
// FD_ZERO : 비운다
ex) FD_ZERO(set);
// FD_SET : 소켓 s를 넣는다
ex) FD_SET(s, &set);
// FD_CLR : 소켓 s를 제거
ex) FD_CLR(s, &set);
// FD_ISSET : 소켓 s가 set에 들어있으면 0이 아닌 값을 리턴한다
ex) FD_ISSET(s, &set);
위의 API 를 이용하여 서버에서 select 모델을 구현할 것이다.
참고로, fd_set 은 다음과 같이 생긴 녀석이다 :
/*
* Select uses arrays of SOCKETs. These macros manipulate such
* arrays. FD_SETSIZE may be defined by the user before including
* this file, but the default here should be >= 64.
*
* CAVEAT IMPLEMENTOR and USER: THESE MACROS AND TYPES MUST BE
* INCLUDED IN WINSOCK.H EXACTLY AS SHOWN HERE.
*/
#ifndef FD_SETSIZE
#define FD_SETSIZE 64
#endif /* FD_SETSIZE */
typedef struct fd_set {
u_int fd_count; /* how many are SET? */
SOCKET fd_array[FD_SETSIZE]; /* an array of SOCKETs */
} fd_set;
보면 fd_array 에 관찰할 소켓을 넣는데, 최대 갯수가 64 개라는 것을 알 수 있다. 즉, 그 이상의 소켓 갯수를 관찰하고 싶으면, fd_set 을 여러 개 만들어야 할 것이다! 😮
추가적으로, 이번부터 Session 이라는 개념을 활용하여 소켓을 관리할 것이다.
struct Session
{
SOCKET socket = INVALID_SOCKET;
char recvBuffer[BUFSIZE] = {};
int32 recvBytes = 0;
int32 sendBytes = 0;
};
이제 진짜 서버 쪽 구현을 보자. bind 및 listen 까지는 이전 단계와 동일하다.
vector<Session> sessions;
sessions.reserve(100);
fd_set reads;
fd_set writes;
일단 while 문 아래 들어갈.. Session 리스트와 read/write 소켓 셋을 만든다.
// 소켓 셋 초기화
FD_ZERO(&reads);
FD_ZERO(&writes);
// ListenSocket 등록
FD_SET(listenSocket, &reads);
// 소켓 등록
for (Session& s : sessions)
{
if (s.recvBytes <= s.sendBytes)
FD_SET(s.socket, &reads);
else
FD_SET(s.socket, &writes);
}
그리고선 소켓 셋을 초기화하고, ListenSocket 을 등록하고, 세션별로 소켓을 등록한다.
참고로, 위의 소켓 셋 초기화는 실제로 while loop 안에서 loop 마다 진행한다. 그 이유는, select 를 할 때 우리가 낙오자를 날리는데 이때 유효 소켓이 남기 때문이다!
이때 recvBytes 와 sendBytes 의 크기에 따라 Read/Write 소켓을 구분해서 Set 에 넣어주는데, 이는 지금 우리가 만들어 주는 것은 일종의 에코 서버 이다. 추후 코드를 보면, sendBytes 가 더 큰 녀석이 Read 소켓이 되는 것을 알 수 있다 😅
일단 '미래예지'를 통해 얘기하자면... recvBytes 는 클라가 서버에게 준 데이터의 크기이고, sendBytes 는 서버가 다시 클라에게 주는 데이터의 크기이다. 만약 recv <= send 이면, 보내줄 것을 다 보냈다는 뜻이므로 read 소켓이 될 것이고, 만약 recv > send 이면 100 을 받았는데 아직 30밖에 못 보냈다는 뜻이므로, write 소켓이 될 것이다(서버 입장에서). 😀
// [옵션] 마지막 timeout 인자 설정 가능
int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
if (retVal == SOCKET_ERROR)
break;
추가적으로 timeout 인자를 설정한다. 참고로, select 의 인자는 다음과 같은데,
아까 예외 처리 소켓은 안 쓴다고 했으니 nullptr 로 넣어 주었다.
만약 마지막 인자를 timeval 타입 인자로 넣어주면, 정해진 시간동안 기다렸다가 timeout 을 뱉는다. 여기서는 nullptr 를 넣어서, 무한 대기를 하게 된다 🤣
// Listener 소켓 체크
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 });
}
}
그리고 나서, Listener 소켓을 체크한다. 클라이언트와 소켓 연결이 가능하면, session 에 해당 소켓을 넣어준다.
// 나머지 소켓 체크
for (Session& s : sessions)
{
// Read
if (FD_ISSET(s.socket, &reads))
{
int32 recvLen = ::recv(s.socket, s.recvBuffer, BUFSIZE, 0);
if (recvLen <= 0)
{
// TODO : sessions 제거
continue;
}
s.recvBytes = recvLen;
}
// Write
if (FD_ISSET(s.socket, &writes))
{
// 블로킹 모드 -> 모든 데이터 다 보냄
// 논블로킹 모드 -> 일부만 보낼 수가 있음 (상대방 수신 버퍼 상황에 따라)
int32 sendLen = ::send(s.socket, &s.recvBuffer[s.sendBytes], s.recvBytes - s.sendBytes, 0);
if (sendLen == SOCKET_ERROR)
{
// TODO : sessions 제거
continue;
}
s.sendBytes += sendLen;
if (s.recvBytes == s.sendBytes)
{
s.recvBytes = 0;
s.sendBytes = 0;
}
}
}
이제 세션별로 Read/Write 소켓을 체크한다. 먼저 Read 소켓을 체크한 후, Write 소켓을 체크한다.
::send 에서 데이터를 일부만 보낼 수 있음을 알 수 있으며, 데이터를 전부 보내면 세션별로 sendBytes 와 recvBytes 의 크기를 다시 초기화해 준다.
왼쪽은 클라, 오른쪽은 서버 로그로, 위와 같이 전송이 잘 되는 것을 확인할 수 있다!
'Game Dev > Game Server' 카테고리의 다른 글
[C++ 게임 서버] 3-10. Overlapped 모델 (이벤트 기반) (0) | 2023.10.17 |
---|---|
[C++ 게임 서버] 3-9. WSAEventSelect 모델 (0) | 2023.10.16 |
[C++ 게임 서버] 3-7. 논블로킹 소켓 (0) | 2023.09.25 |
[C++ 게임 서버] 3-6. 소켓 옵션 (0) | 2023.09.22 |
[C++ 게임 서버] 3-5. UDP 서버 실습 (0) | 2023.09.22 |