IOCP 공부노트: (3) IOCP Echo Server

게임 서버 프로그래밍/네트워크 개념정리

2020. 7. 15. 22:36

더보기

 

1:1 echo server , 1:N echo server (Select)에 이어서 IOCP를 이용해서 Echo Server를 만들어 보았다.

이게 뭐라고... 3일 정도 소요한 것 같다. 생각보다 오류가 여러 곳에서 터졌었는데.. 심지어 함수 파라미터

값을 바꿔 넣어서 통신이 안된건 굉장히 빡을 칠만한 오류였다. 그럼에도 꽤 도움 되는 연습이었는데

채팅방 프로그램을 만들면서 느꼈지만 서버에서 터져버리면 오류 찾기가 굉장히 힘들어진다.

특히 소켓 문제가 아니라 패킷 설계라던지 초기화 문제라던지.. 이런 곳에서 터지면 버그 찾기가 굉장히

어렵다는 것을 느꼈다. 간단히 코드와 설명으로 마무리 짓겠다.

 

1. Server Code

#include <iostream>
#include <string>
#include <WinSock2.h>
#include <process.h>

#pragma comment (lib,"ws2_32.lib")

const int PORT_NUM = 32000;
const int PCK_BUFSIZE = 1024;

struct WSAPlayerIO {
	
	WSAOVERLAPPED overlapped;
	WSABUF wsaBuf;
	SOCKET socket;
};



u_int __stdcall PlayerIOworkThread(LPVOID pData);
void ErrHandling(const char* message);
HANDLE CreateNewCompletionPort(DWORD dwConcurThreadCount);
BOOL AssociateDeviceWithCompletionPort(HANDLE socket, HANDLE hIocp, DWORD dwCompletionkey);

int main() {

	WSADATA wsaData;
	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
		ErrHandling("startup error!");
	}

	SOCKET ListenSocket = WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
	if (ListenSocket == INVALID_SOCKET) {
		ErrHandling("ListenSocket error!");
	}

	HANDLE hIOCP = CreateNewCompletionPort(0);
	
	SYSTEM_INFO sysInfo;
	GetSystemInfo(&sysInfo);

	DWORD threadCount = sysInfo.dwNumberOfProcessors * 2;
	for (int i = 0; i < threadCount; i++) {
		_beginthreadex(nullptr, 0, PlayerIOworkThread,hIOCP, 0, NULL);
		
	}

	sockaddr_in servAddr;
	memset(&servAddr, 0, sizeof(servAddr));
	servAddr.sin_family = AF_INET;
	servAddr.sin_port = htons(PORT_NUM);
	servAddr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);

	if (bind(ListenSocket, (sockaddr*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR) {
		ErrHandling("bind error!");
		closesocket(ListenSocket);
		
	}

	if (listen(ListenSocket, SOMAXCONN) == SOCKET_ERROR) {
		ErrHandling("listen error!");
		closesocket(ListenSocket);
	}

	sockaddr_in clntaddr;
	memset(&clntaddr, 0, sizeof(clntaddr));
	int clntAddrLen = sizeof(clntaddr);
	WSAPlayerIO* PlayerIO = nullptr;
	DWORD receiveLen;
	DWORD flags = 0;
	
	while (true) {
		
		SOCKET clientSocket = accept(ListenSocket, (sockaddr*)&clntaddr, &clntAddrLen);
		if (INVALID_SOCKET == clientSocket) {
			ErrHandling("socket error!");
		}
		std::cout << "Welcome !" << std::endl;
		if (AssociateDeviceWithCompletionPort((HANDLE)clientSocket, hIOCP, 0) == FALSE) {
			ErrHandling("Associate Device cp Error!");
		}
		
		PlayerIO = new WSAPlayerIO();
		memset(&(PlayerIO->overlapped),0,sizeof(PlayerIO->overlapped));
		PlayerIO->socket = clientSocket;
		
		PlayerIO->wsaBuf.len = PCK_BUFSIZE;
		PlayerIO->wsaBuf.buf = new char[PCK_BUFSIZE];
		memset(PlayerIO->wsaBuf.buf, 0, strlen(PlayerIO->wsaBuf.buf));

		
		if (WSARecv(clientSocket, &PlayerIO->wsaBuf, 1, &receiveLen, &flags, &PlayerIO->overlapped, NULL) == SOCKET_ERROR) {

			if (WSAGetLastError() != WSA_IO_PENDING) {
				
				ErrHandling("WSARecv Error!");
			}
		}

	}

	closesocket(ListenSocket);
	WSACleanup();

	return 0;
}

u_int __stdcall PlayerIOworkThread(LPVOID pData)
{
	
	HANDLE params = pData;
	DWORD transferredBytes;
	DWORD flags = 0;
	DWORD completionKey;
	WSAPlayerIO* PlayerIO;
	while (true) {
	
		if(GetQueuedCompletionStatus(params,&transferredBytes,&completionKey,
			(LPOVERLAPPED*)(&PlayerIO),INFINITE) ==0)
		{
			ErrHandling("Get queue from IOCP Error!");
			closesocket(PlayerIO->socket);
			delete PlayerIO;
			return 1;
		}
		
		if (transferredBytes == 0) {
			ErrHandling("RecvBytes is zero");
			closesocket(PlayerIO->socket);
			
			delete PlayerIO;
			continue;
		}

		PlayerIO->wsaBuf.len = transferredBytes;
		
		std::cout << "메시지 수신 : " << PlayerIO->wsaBuf.buf << std::endl;


		if (WSASend(PlayerIO->socket, &PlayerIO->wsaBuf, 1, &transferredBytes, flags,
			NULL, NULL) == SOCKET_ERROR)
		{
			if (WSAGetLastError() != WSA_IO_PENDING) {
				std::cout << WSAGetLastError() << std::endl;
				ErrHandling("WSASendError!");
				
			}

		}

		
		PlayerIO->wsaBuf.len = PCK_BUFSIZE;
		PlayerIO->wsaBuf.buf = new char[PCK_BUFSIZE];
		memset(PlayerIO->wsaBuf.buf, 0, strlen(PlayerIO->wsaBuf.buf));

		if (WSARecv(PlayerIO->socket, &PlayerIO->wsaBuf, 1, &transferredBytes, &flags, &PlayerIO->overlapped, NULL) == SOCKET_ERROR) {
			
			if (WSAGetLastError() != WSA_IO_PENDING) {
				std::cout << WSAGetLastError() << std::endl;
				ErrHandling("WSARecvError!");
			}
		}
	}

	return 1;
}

void ErrHandling(const char* message)
{
	std::cout << message << std::endl;
	exit(1);
}

HANDLE CreateNewCompletionPort(DWORD dwConcurThreadCount)
{
	return CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, dwConcurThreadCount);
}

BOOL AssociateDeviceWithCompletionPort(HANDLE socket, HANDLE hIocp, DWORD dwCompletionkey)
{
	hIocp = CreateIoCompletionPort(socket, hIocp, dwCompletionkey, 0);
	
	return socket == NULL ? FALSE : TRUE;
}

 

2. Client Code

#include <iostream>
#include <WinSock2.h>
#include <WS2tcpip.h>
#include <string>
#pragma comment(lib,"ws2_32.lib")


const int PORT_NUM = 32000;
const int PCK_BUFSIZE = 1024;



void ErrHandling(const char* pMsg);
int main() {
	
	WSADATA wsaData;
	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
		ErrHandling("wsastartup error!");
	}

	SOCKET sock = WSASocket(AF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
	if (sock == INVALID_SOCKET) {
		ErrHandling("socket init error!");
	}

	sockaddr_in addr_in;
	memset(&addr_in, 0, sizeof(addr_in));
	addr_in.sin_family = AF_INET;
	addr_in.sin_port = htons(PORT_NUM);
	inet_pton(AF_INET, "127.0.0.1", &addr_in.sin_addr);

	if (connect(sock, (sockaddr*)&addr_in, sizeof(addr_in)) == SOCKET_ERROR) {
		ErrHandling("connect error!");
	}
	
	DWORD flag = 0;
	while (true) {
		
		char SendBuf[PCK_BUFSIZE] = { 0, };
		std::cin >> SendBuf;
		std::cout << strlen(SendBuf) << std::endl;
		int sendbytes = send(sock,SendBuf,strlen(SendBuf),0);
		if (sendbytes < 0) {
			ErrHandling("connect is finish");
		}
		std::cout << "send 잘 됨 ?" << std::endl;
		char RecvBuf[PCK_BUFSIZE] = { 0, };
		int recvbytes = recv(sock, RecvBuf, PCK_BUFSIZE, 0);
		if (recvbytes > 0) {
			std::cout << RecvBuf << std::endl;
		}
		else {
			ErrHandling("recv error!");
		}
	}
}

void ErrHandling(const char* pMsg)
{
	std::cout << pMsg << std::endl;
	exit(1);
}

 

3. 메모할 것

HANDLE hIOCP = CreateNewCompletionPort(0);
	
	SYSTEM_INFO sysInfo;
	GetSystemInfo(&sysInfo);

	DWORD threadCount = sysInfo.dwNumberOfProcessors * 2;
	for (int i = 0; i < threadCount; i++) {
		_beginthreadex(nullptr, 0, PlayerIOworkThread,hIOCP, 0, NULL);
		
	}

Thread에 위임될 함수와 Parameter

 

 _beginthreadex 함수에 사용자 정의 Parameter를 꾸겨 넣을수 있다. 여기서는 HANDLE 값을 넘겨주었지만 인터넷상에서 Parameter값을 설정한 것들을 보면 기본적으로 iocp Handle 변수를 가지고 있는 구조체나 클래스였다. 이것을 통해 무엇을 넘겨줄 수 있을까 생각을 해보았다. 일단 한 가지 의문이 드는 것이 작업자 스레드에 위임될 함수는 종류가 하나뿐일까 하는 것이다. 이론상 스레드의 상한은 Processor 수의 2배정도로 잡는 것이 효율적이라고 했으니 위의 로직 한 번이면 더 이상 스레드를 생성하지 않는 것이 옳다. 이름을 PlayerIOworkThread라고 지었는데.. 처음엔 스레드에 위임할 함수들이 여러 종류인줄 알았다. IOCPServer 정도면 적당할 것이다.

 

u_int __stdcall PlayerIOworkThread(LPVOID pData)
{
	
	HANDLE params = pData;
	DWORD transferredBytes;
	DWORD flags = 0;
	DWORD completionKey;
	WSAPlayerIO* PlayerIO;
	while (true) {
	
		if(GetQueuedCompletionStatus(params,&transferredBytes,&completionKey,
			(LPOVERLAPPED*)(&PlayerIO),INFINITE) ==0)
		{
			ErrHandling("Get queue from IOCP Error!");
			closesocket(PlayerIO->socket);
			delete PlayerIO;
			return 1;
		}
		
		if (transferredBytes == 0) {
			ErrHandling("RecvBytes is zero");
			closesocket(PlayerIO->socket);
			
			delete PlayerIO;
			continue;
		}

		PlayerIO->wsaBuf.len = transferredBytes;
		
		std::cout << "메시지 수신 : " << PlayerIO->wsaBuf.buf << std::endl;


		if (WSASend(PlayerIO->socket, &PlayerIO->wsaBuf, 1, &transferredBytes, flags,
			NULL, NULL) == SOCKET_ERROR)
		{
			if (WSAGetLastError() != WSA_IO_PENDING) {
				std::cout << WSAGetLastError() << std::endl;
				ErrHandling("WSASendError!");
				
			}

		}

		
		PlayerIO->wsaBuf.len = PCK_BUFSIZE;
		PlayerIO->wsaBuf.buf = new char[PCK_BUFSIZE];
		memset(PlayerIO->wsaBuf.buf, 0, strlen(PlayerIO->wsaBuf.buf));

		if (WSARecv(PlayerIO->socket, &PlayerIO->wsaBuf, 1, &transferredBytes, &flags, &PlayerIO->overlapped, NULL) == SOCKET_ERROR) {
			
			if (WSAGetLastError() != WSA_IO_PENDING) {
				std::cout << WSAGetLastError() << std::endl;
				ErrHandling("WSARecvError!");
			}
		}
	}

	return 1;
}

넘어오는 것들

 

 외부로부터 넘어오는 파라미터가 2가지가 있다. 하나는 이 작업자스레드의 LPVOID 형의 파라미터 그리고 나머지 하나는 GetQueueCompletionStatus의 파라미터중 LPOVERLAPPED 형의 파라미터이다. 첫 번째 것은 위의 _beginthreadEX 함수에서 논했던 파라미터이고 두 번째 것은 밑에서 다룰 Overlapped 기능을 지원하는 Send/Recv 함수에서 넘겨 주는 Overlapped 형의 (사실상 모든 타입을 구겨 넣을수 있음) 파라미터이다.

 

무엇을 넘겨 받을까?

 

 아직 실제로 개발환경에서 구현을 해 본것이 아니라서 추측만 할 뿐이다. 이 함수의 파라미터로 들어오는 변수가 GetQueueCompletionStatus 파라미터를 결정하는 결정권자가 될 것이다. 참고로 내가 헷갈렸던 부분이 있었다. 처음에 넘겨 받는 매개변수들이 클라이언트와 통신할 구조체를 주고 받는 줄 알았다. (지금 생각하면 멍청..) 그래서 구조체를 필요한 기능을 넣어서 통신을 해야겠다 생각했더니 결국 전달 받는건 (당연하지만) 버퍼라는 것. 아무튼간에 실제 데이터는 어차피 나중에 직렬화돼서 buf안에 들어갈테니 이 함수에서 결정할 것은 어떤 방식으로 데이터를 보낼 것이냐(UDP,TCP) 그리고 송수신,연결 종료등 소켓통신과 관련된 부분이다.

 

 함수의 파라미터가 결정권자가 된다고 했었는데, 이 파라미터로 보통 WorkerThread 함수가 사용되는 클래스 타입의 변수를 받는다. 근데 여기서도 의문이 생긴것이 이 함수도 결국 파라미터로 받아올 클래스 내부에서 정의된 것인데 굳이 파라미터로 넘기지말고 this 포인터로 멤버 변수와 멤버 함수를 사용하면 되지 않냐는 것이다. 

 결론부터 말하자면 안된다. 이유는 이 함수는 일반 함수가 아닌 CALLBACK 함수이다(__stdcall == WINAPI). 따라서 콜백 함수를 멤버 함수로 집어넣으려면 static이 아니고서는 안된다. 콜백 함수를 호출하는 라이브러리는 해당 함수의 주소만 알뿐.. 어떤 클래스의 멤버인지 그런 것들은 알 수가 없기 때문이다. static 얘기가 나왔으니 당연 this 포인터를 사용할 수 없다는 것을 알 것이다. 전역으로 선언된 멤버 함수는 객체 생성 이전에 생성되기 때문에 this 포인터를 쓸 방법이 없다. 그렇기 때문에 자신이 속한 클래스를 저렇게 매개변수로 넘겨주지 않나 추측한다.

 

 그리고, 나는 따로 CompletionKey를 통해 어떤 변수도 받아오지 않았는데 CompletionKey 자리에도 구조체든 클래스든 받아올 수 있다. 결론을 말하자면 WorkerThread 위임 함수 파라미터로 속해있는 클래스를 받아오고 (반대로 넘겨줄 때는 this 포인터로 넘겨준다) GetQueueCompletionStatus의 함수 파라미터 (key, overlapped)를 통해 두 가지 버젼의 값을 받아 올 수 있다.

 

sockaddr_in clntaddr;
	memset(&clntaddr, 0, sizeof(clntaddr));
	int clntAddrLen = sizeof(clntaddr);
	WSAPlayerIO* PlayerIO = nullptr;
	DWORD receiveLen;
	DWORD flags = 0;
	
	while (true) {
		
		SOCKET clientSocket = accept(ListenSocket, (sockaddr*)&clntaddr, &clntAddrLen);
		if (INVALID_SOCKET == clientSocket) {
			ErrHandling("socket error!");
		}
		std::cout << "Welcome !" << std::endl;
		if (AssociateDeviceWithCompletionPort((HANDLE)clientSocket, hIOCP, 0) == FALSE) {
			ErrHandling("Associate Device cp Error!");
		}
		
		PlayerIO = new WSAPlayerIO();
		memset(&(PlayerIO->overlapped),0,sizeof(PlayerIO->overlapped));
		PlayerIO->socket = clientSocket;
		
		PlayerIO->wsaBuf.len = PCK_BUFSIZE;
		PlayerIO->wsaBuf.buf = new char[PCK_BUFSIZE];
		memset(PlayerIO->wsaBuf.buf, 0, strlen(PlayerIO->wsaBuf.buf));

		
		if (WSARecv(clientSocket, &PlayerIO->wsaBuf, 1, &receiveLen, &flags, &PlayerIO->overlapped, NULL) == SOCKET_ERROR) {

			if (WSAGetLastError() != WSA_IO_PENDING) {
				
				ErrHandling("WSARecv Error!");
			}
		}

	}

 

사용자 정의 버퍼 , 데이터 버퍼

 

다른 예제들을 보면 WSABuf 의 버퍼를 같은 구조체에 UserBuffer를 하나 만들어서 가리키게 한다. 나는 그냥 클라이언트 접속이 이루어질 때마다 메모리 할당을 해주었다. 어차피 PlayerIO가 주소를 복사해 들고가니 해제권을 넘겨주는거나 마찬가지라 관리만 잘하면 메모리 누수는 일어나지 않는다. PlayerIO도 마찬가지 인데 메모리에 할당된 주소를 넘겨주기 때문에 콜백 함수 부분에서 처리하면 그만이다.

 

글을 여러 번 지웠다 썼다 했었다.. WSABuf에 대해서 궁금한 것들이 생겼는데 정확히 이해가되지 않았다. 먼저, WSABuf는 커널에서 관리되는 버퍼이며 사용자에게 제공되는 버퍼이기 때문에 자칫 잘못해 I/O 완료 도중에 건드리게 되면 문제가 될 수 있다. 만약 통신이 동기로 이루어진다면 유저 버퍼가 커널 버퍼에 바로 복사가 된다. 하지만 비동기로 이루어 진다면 I/O 완료 통지가 올 때까지 I/O PENDING 상태로 복사가 미루어진다. 그렇기 때문에 외부에서 WSABuf를 건드릴 경우 메모리 손상이 일어날 수 있다.

 

콜백함수에서는 GetQueuedCompletionStatus 함수를 통해 I/O가 완료 됐는지를 확인하고 있다. 즉 WSARECV나 WSASEND가 사용되고 I/O 완료가 된 후에 다음 로직으로 넘어가니 함수 내부에서는 메모리 손상이 일어나지 않는다. 그리고 WSARECV를 콜하는 부분에서도 다른 유저가 접속할 때마다 새로운 메모리에 할당하고 현재 가리키는 메모리는 main 문 안에서는 영원히 접근할 수 없는 상태가 되니 문제가 되지않는다.

 

Overlapped 구조체

 

내 코드는 WSARecv의 WSAOVERLAPPED 타입의 매개변수로 Player구조체의 OVERLAPPED 타입 멤버 변수를 넘겨주고 있다. 그리고 콜백함수에서 GQCS()를 통해서는 Player 구조체로 값을 전달받는다. 조금 이상하지 않는가? 넘겨준건 구조체의 OVERLAPPED 타입인데 받는건 Player 구조체다. 정확한 답은 찾지 못했다.. 질문은 ThrowBUG 사이트에 올려놓았는데.. 

 

내가 궁금한건 두 가지다. 첫 째는 위에서 말한 것처럼 넘겨준 것은 OVERLAPPED 타입인데 그를 멤버로 가지고 있는 구조체 정보를 어떻게 파악할 수 있는가? 원래 OVERLAPPED IO 방식 통신에서는 OVERLAPPED 내부의 hEvent 변수를 통해 원하는 정보를 주고받았다는데 IOCP를 사용할 때는 OVERLAPPED 구조체를 포함한 구조체를 파악할 수 있는건지.. 궁금하다. 두 번째는 일부 예제에서 OVERLAPPED를 넘기지 않고 보내고 싶은 정보가 담긴 구조체를 OVERLAPPED 형식으로 캐스팅해서 보낸다는 것이다.. 이건 어떻게하는건지 모르겠는게.. 그런 식으로 보내니 핸들 오류 (Socket Error 6)가 나더라. 추측컨데 이건 OVERLAPPED 타입을 상속받은 클래스나 구조체를 보내는게 아닐까싶다.

 

내일까지 답변이 온다면 수정할 것이며 테스트를 통해 OVERLAPPED 구조체를 좀 파악해야겠다. 게임 서버 개발에 앞서 어떤 구조체를 전달하느냐는 굉장히 중요한 요소이기때문에 쉬이 넘어가지 못할 것 같다.

 

// 답변완료

Throw bug가 아닌 타 사이트에서 그럴듯한 답변이 달렸다. 일단.. 일종의 트릭이지만 구조체의 맨 앞에 정의된 변수의 주소는 구조체 자체의 주소와 일치한다고 한다. 따라서 OVERLAPPED 구조체를 늘 구조체의 앞에 설정한 것이다! 

 

따라서, WSASEND,WSARECV 함수의 매개변수로 OVERLAPPED 구조체를 보냈을 때 GQCS 함수에서 OVERLAPPED를 포함한 구조체를 캐스팅한 경우는 결국 같은 주소를 받는 것과 일치한 것이다. 

 

이 내용은 윤성우의 TCP/IP 소켓 책에서도 등장한다. 사놓고 제대로 읽지 않은 탓에 굉장한 시간을 낭비했지만.. 좋은 공부가 됐다. 이 내용은 절대 까먹지 않을 것 같다.

'게임 서버 프로그래밍 > 네트워크 개념정리' 카테고리의 다른 글

IOCP 공부노트: (2) IOCompletionPort  (0) 2020.07.13
IOCP 공부 노트: (1) _beginthreadex , CreateThread  (0) 2020.07.10
객체 리플리케이션  (0) 2020.03.02
압축  (0) 2020.02.24
직렬화  (0) 2020.02.24