[C++]논블로킹 소켓(feat.Select)

강병우·2023년 8월 18일
0

네트워크

목록 보기
3/7

논블로킹

이번엔 논블로킹에 대해 알아보자. 한 작업이 완료될 때까지 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

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
);

출처 Microsoft Docs

파라미터

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_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이 아닌 값을 리턴한다.

FD_SET함수를 통해 소켓을 select에 등록할 수 있다. 등록된 소켓들은 FD_ISSET함수를 통해 I/O가 가능한지 판별할 수 있다. 만약 소켓으로부터 수신이 가능해진다면 FD_ISSET(clientSocket, &reads)의 리턴값이 0이 아닌 값이 나오므로, 조건문에서 이를 True로 인식한다. 그럼 이곳에서 recv를 호출하여 버퍼를 받아올 수 있다.

소스코드 전문은 이곳 에서 확인할 수 있다.


게임서버 프로그래밍 교과서(저:배현직),
[C++과 언리얼로 만드는 MMORPG 게임개발 시리즈] Part4: 게임서버(강사 : 루키스)를 학습하고 정리한 내용입니다.

0개의 댓글

관련 채용 정보