이번엔 논블로킹에 대해 알아보자. 한 작업이 완료될 때까지 CPU 연산을 하지 않고 대기 상태에 빠지는 것을 블로킹이라고 배웠는데, 논블로킹은 반대로 보면 된다. 작업이 완료될 때까지 대기하는 것이 아니라, 작업 요청을 보낸 다음 다른 작업을 할 수 있다. 그리고 완료가 되었을 때, 반환된 값을 검증하면 된다.
논블로킹 소켓을 사용하는 방법은 간단하다. Socket
을 생성하고 논블록 유무를 함수를 통해 정해주면 된다.
u_long on = 1; // 0:블로킹, 1:논블로킹
::ioctlsocket(listenSocket, FIONBIO, &on)
listenSocket
은 서버 소켓이며, FIONBIO
는 소켓에서 실행할 커맨드이다. on
은 주소형태로 보내야 하며, 주석의 내용처럼 논블로킹 모드를 지정할 수 있다.
논블로킹으로 전환을 했으니, 이제 송수신을 해보자.
while(true)
{
SOCKET clientSocket = ::accept(listenSocket, (SOCKADDR*)&clientAddr, &addrLen);
if (clientSocket == INVALID_SOCKET)
{
// 원래 블록했어야 했는데.. 너가 논블로킹으로 하라며
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 블로킹 외 다른 오류라면 스탑!
break;
}
cout << "Client Conneted!" << endl;
// Recv
while (true)
{
char recvBuffer[1000];
int32 recvLen = ::recv(clientSocket, recvBuffer, sizeof(recvBuffer), 0);
if (recvLen == SOCKET_ERROR)
{
// 원래 블록했어야 했는데..
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 블로킹 외 다른 오류라면 스탑!
break;
}
else if (recvLen == 0)
{
// 연결 끊김
break;
}
cout << "Recv Data Len = " << recvLen << endl;
// send
while (true)
{
if (::send(clientSocket, recvBuffer, recvLen, 0) == SOCKET_ERROR)
{
if (::WSAGetLastError() == WSAEWOULDBLOCK)
continue;
// 블로킹 외 다른 오류라면 스탑!
break;
}
cout << "Send Data ! Len = " << recvLen << endl;
break;
}
}
}
accept
함수를 통해 리슨 소켓에 조인한 클라이언트 소켓을 받아온다. 그 후 while문에서 클라이언트에서 송신한 데이터를 수신버퍼를 통해 데이터를 받아온다. 정상적으로 데이터를 받았으면 그대로 클라이언트 소켓에 데이터를 보낸다. 여기까진 블로킹 소켓과 설명이 비슷하지만, 소스코드 중간중간에 if (::WSAGetLastError() == WSAEWOULDBLOCK)
라는 조건문이 있을 것이다.
일단 데이터를 송수신한다. 그리고 블로킹 유무를 체크한다.
만약 블로킹 소켓이였다면, recv
, send
함수를 호출했을 때, 버퍼가 가득 찼었다면 대기현상에 빠졌을 수도 있다. 하지만 논블로킹 소켓이기 때문에 대기현상에 빠지지 않고 즉시 값을 리턴하며, 그 다음 구문으로 넘어간다. 즉, 정상적으로 다음 작업을 처리할 수 있다는 뜻이다. 이 때, 데이터 송수신이 완전히 끝나지 않았다면 WSAWOULDBLOCK
이라는 값이 에러를 뱉는 함수에 반환된다. 블로킹이 풀릴 때 까지 송수신을 하면 된다.
논블로킹 소켓을 사용하면 하나의 스레드에서 여러 개의 소켓을 관리할 수 있다는 장점이 있다. 블로킹 소켓으로 여러 소켓을 다루려면 그의 개수에 맞게 스레드를 생성해야 되는데, 이제 대기상태에 빠지지 않기 때문에 여러 소켓과 송수신할 수 있다.
하지만 단점이 있다. 이 소스코드를 실행하면 대기 상태에 빠지지 않고 CPU가 계속 일할 것이다. 작업이 없을 때 최대한 CPU에 여유를 주는 것이 좋을 것이다. 언제 풀릴지 모르는 WSAWOULDBLOCK
을 계속 체크하는 것도 비효율적이다.
이러한 문제를 해결하기 위한 함수가 있다. 바로 select
라는 함수다.
Select
함수는 소켓 리스트 중 하나라도 송/수신 처리가 가능한 소켓이 생길 때까지 블로킹한다. 만약 소켓이 하나라도 송수신이 가능해진다면 블로킹이 끝나고 그 소켓을 사용자에게 알려준다. 또한 블로킹하는 시간, 즉 타임아웃을 정할 수 있다. 송수신 처리가 가능한 소켓이 나타나기 까지 특정 시간동안 블로킹한다.
int WSAAPI select(
[in] int nfds,
[in, out] fd_set *readfds,
[in, out] fd_set *writefds,
[in, out] fd_set *exceptfds,
[in] const timeval *timeout
);
nfds : 현재는 무시해도 되는 값이다.
readfds : Read작업을 할 수 있는 소켓이 있는지 체크하는 포인터
write : Write작업을 할 수 있는 소켓이 있는지 체크하는 포인터
exceptfds : 예외사항이 발생한 소켓이 있는지 체크하는 포인터
timeout : 최대 블로킹 시간을 정할 수 있다. NULL로 지정할 경우 결과값이 반환될 때까지 블로킹한다.
반환값은 select에 의해 작업을 할 수 있는 대기 중인 소켓의 개수를 반환한다. 타임아웃이 만료되면 0을, 그 외 오류가 반환된다.
vector<Session> sessions;
sessions.reserve(100);
fd_set reads;
fd_set writes;
while (true)
{
// 소켓 셋 초기화
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);
}
// [옵션] 마지막 timeout 인자 설정 가능
int32 retVal = ::select(0, &reads, &writes, nullptr, nullptr);
if (retVal == SOCKET_ERROR)
break;
// 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 });
}
}
// 나머지 소켓 체크
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;
}
}
}
}
FD_Set
FD_SET
함수를 통해 소켓을 select에 등록할 수 있다. 등록된 소켓들은 FD_ISSET
함수를 통해 I/O가 가능한지 판별할 수 있다. 만약 소켓으로부터 수신이 가능해진다면 FD_ISSET(clientSocket, &reads)의 리턴값이 0이 아닌 값이 나오므로, 조건문에서 이를 True
로 인식한다. 그럼 이곳에서 recv
를 호출하여 버퍼를 받아올 수 있다.
소스코드 전문은 이곳 에서 확인할 수 있다.
게임서버 프로그래밍 교과서(저:배현직),
[C++과 언리얼로 만드는 MMORPG 게임개발 시리즈] Part4: 게임서버(강사 : 루키스)를 학습하고 정리한 내용입니다.