사실 웹상에는 먼저 이걸 공부한, 은혜로운 선배들이 잘 정리해둔 글을 엄청 많이 찾아볼 수 있다. 구현 자체야 충분히 다른 깔끔하고 훌륭한 포스트를 참고한다면 얼마든지 가능하지만, 이해에 측면에 있어서는 과연? 그 어떤 강의나 포스트를 보아도 결국 이해해야하는 것은 자기 자신이기 때문에 자신만의 언어로 정리해둘 필요가 있다. 정리를 하는 중에는 분명히 덜 이해한 부분도 튀어나오고, 좀 더 궁금해지는 부분도 생기기 마련. 그래서 시간을 들여서 정리를 한번 해보려고 함.
일단 서버사이드. 개념적인 이론적인 설명은 생략하고 구현 중심으로 설명함.
개념적인 설명은 여기 이 시리즈! [링크] 이해에 많이 도움이 됨.
구현적인 부분에서 IOCP는 쉽게 말해
서버소켓 생성, 주소값 할당, 클라이언트 Accept, Recv, Send 등이 발생하는 Main Thread 가 있고, 이러한 함수들의 결과값이 리턴되는 Completion Thread가 존재.
아.. 그리고 일반적으로 썼던 accept 함수가 아니라 AcceptEx 함수를 쓸거다. Accept 함수를 쓰게되면 accept 부분만 따로 쓰레드를 만들어서 처리를 해주어야 함. 사실 그렇게 해도 크게 상관은 없다.
아무튼 구현 순서는 이렇다.
1. IOCP Port 생성
2. Completion Thread 생성
3. Winsock 라이브러리 초기화
4. Listen Socket 생성
5. 주소값 할당
6. 클라이언트 소켓 할당
7. 아까 생성한 IOCP와 Listen Socket을 연결.
8. Completion Thread에서 GetQueuedCompletionStatus 함수 처리
..들어가기에 앞서, 우리는 OVERLAPPED 구조체를 상속받은 클래스가 하나 필요함. 어째서 OVERLAPPED 구조체를 상속받아야 하냐면, WSASend/WSARecv/AcceptEx/GetQueuedCompletionStatus 함수에서 공통적으로 OVERLAPPED 객체의 주소값을 필요로 하기 때문이다. 예를 들어서 WSARecv의 인자 중 하나로 OVERLAPPED 객체의 주소값 (0xb19d56) 같은걸 넘겨줬다면, Recv 가 완료된 시점에서 GetQueuedComplet... 이거 못해먹겠다. GQCS 로 줄임. GQCS 에서도 아까 넣어줬던 주소값(0xb19d56) 으로 값을 뱉어내기 때문이다. 즉, 일종의 키 값인 셈. 굳이 '상속'을 받아야 하는 이유는, OVERLAPPED 객체의 주소값이 곧 이를 상속받은 클래스의 주소값과 동일하기 때문에.. 어차피 시작 주소만 알고있으면 되니까... 시작 주소만 OVERLAPPED 객체이면 상관이 없음.
1. IOCP Port 생성
CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0,0);
일반적으로 이 함수를 호출해서 생성한다. return값으로 HANDLE 타입의 변수 하나를 던져주는데 이것을 key값으로 여기저기서 쓰인다. 테스트 예제를 만들 경우 전역으로 두는게 편하다.
2. Completion Thread 생성
일반적으로 시스템에서 사용 가능한 쓰레드만큼 생성해준다. 지금은 서버 사이드만 생각하니 ..
SYSTEM_INFO SystemInfo; GetSystemInfo(&SystemInfo);
이런식으로 시스템 정보를 구해온 뒤, 사용 가능한 스레드 수 만큼 beginthreadex 함수를 써서 스레드를 시작해줌.
_beginthreadex(NULL, 0, CompletionThread, (LPVOID)m_hCompletionPort, 0, NULL);
네번째 인자로 m_hCompletionPort라는 녀석을 넣어주는데, 아까 1번에서 사용한 함수의 리턴값이다. 어차피 CompletionThread에서 GCQS 함수의 첫번째 인자로 사용해주어야 하기 때문에, CompletionThread의 arg 값으로 넣어준다. 만약 전역으로 선언했다면 굳이 arg 값을 줄 필요는 없다.
3. Winsock라이브러리 초기화 / 4. Listen 소켓 생성 / 5. 주소값 할당
사실 Overlapped IO모델까지 공부했다면 당연히 아는 내용. 기존과 달라진 부분만 서술한다.
라이브러리 초기화는 동일하고, Listen Socket 생성 부분에서 약간 달라지는데... WSASocket 함수를 써서 소켓을 만들어주는게 아닌 모양이다. 물론 써도 상관은 없는데 이럴 경우 AcceptEx 함수를 쓸 수 없는 모양. 내가 만든 라이브러리에서는 AcceptEx를 사용해야하 하기 때문에 socket 함수를 통해서 Listen Socket 을 생성해준다. 다만, 세번째 인자로 IPPROTO_TCP 을 넣어준다.
ListenSock = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
이렇게 해주면 됨. 이 이후로 진행되는 bind/listen 함수는 동일하기 때문에 생략함.
6. 클라이언트 소켓 할당
AcceptEx 함수에 대해 아주아주 간단히 설명하고 넘어가자면, 미리 사용 가능한 소켓을 모두 할당해두고, 클라이언트의 접속 시도가 감지되면 GCQS 함수로 값을 받아들여서, 그 이후에 클라이언트의 주소 값을 받아오는 구조로 되어있다.
아무튼 이러이러한 이유로 클라 접속 이전에 미리 클라이언트 소켓을 할당해두어야 함.
int NetworkInterface :: InitOvlp( HANDLE_DATA &data)
{
memset(&data.remoteAddr, 0, sizeof(SOCKADDR_IN));
memset(&data.localAddr, 0, sizeof(SOCKADDR_IN));
int zero = 0;
if(m_ListenSock != INVALID_SOCKET)
{
data.hSock = socket( AF_INET, SOCK_STREAM, IPPROTO_TCP );
if(data.hSock != INVALID_SOCKET)
{
data.state = NET_EVENT_ACCEPT;
data.recvSize = 0;
setsockopt( data.hSock, SOL_SOCKET, SO_SNDBUF, (char *) &zero, sizeof(zero) );
setsockopt( data.hSock, SOL_SOCKET, SO_RCVBUF, (char *) &zero, sizeof(zero) );
memset( data.buffer, '\0', sizeof(data.buffer));
data.recvContext.buf = data.buffer;
data.recvContext.len = BUFFSIZE;
AcceptEx( m_ListenSock, data.hSock, &data.buffer[0],HANDLE_DATA::initialRecvSize,
HANDLE_DATA::addrlen, HANDLE_DATA::addrlen,
&data.recvSize, (LPOVERLAPPED)&data );
return 0;
}
}
return -1;
}
폰트 참 거지같네. 낯설었던 게 setsockopt 함수와 인자가 주렁주렁 들어가는 acceptEx 함수였는데, setsockopt 함수명만 봐도 알수있듯이 소켓의 옵션을 지정해주는 함수. 일단 정리는 여기가 좀 되어있음. getsockopt 으로 소켓옵션에 어떤 것이 있는지 얻어올 수 있는 함수도 있다.
acceptEx 함수에 대해 약간 얘기하자면.. 참. 이거 쓰려면 mswsock.h 인크루드 해야함. 아래는 함수 원형
AcceptEx (
IN SOCKET sListenSocket,
IN SOCKET sAcceptSocket, __out_bcount_part(dwReceiveDataLength+dwLocalAddressLength+dwRemoteAddressLength,
*lpdwBytesReceived) IN PVOID lpOutputBuffer,
IN DWORD dwReceiveDataLength,
IN DWORD dwLocalAddressLength,
IN DWORD dwRemoteAddressLength,
OUT LPDWORD lpdwBytesReceived,
IN LPOVERLAPPED lpOverlapped
);
sListenSocket : 아까 4번에서 생성한 Listen Sock.
sAcceptSocket : 위에서 생성한, 클라이언트가 쓸 소켓
lpOutputBuffer : 만약 접속과 동시에 받아올 Data가 있다면 이리로 받아오게 됨. 버퍼의 시작주소를 넣어주면 됨
dwReceiveDataLength : 버퍼에 받을 데이터 사이즈. 다만, 뒤에 받을 주소값의 크기 까지 고려해서 사이즈를 넣어주어야 한다. 만약 0을 넣어준다면 접속과 동시에 데이터 받는 것을 거부함.
dwLocalAddressLength / dwRemoteAddressLength : 주소값의 크기. 보통 sizeof(ADDRSOCK_IN) + 16 씩 넣어준다.
lpdwBytesReceived : 만약 접속과 동시에 받은 데이터가 있다면, 여기에 그 데이터의 크기값이 들어가게 됨.
lpOverlapped : 아까 초장에 언급했던 OVERLAPPED 구조체의 주소값을 넣어주면 된다. 클라이언트 접속이 발생한다면 GCQS 함수에서 이 주소값을 도로 뱉어냄.
7. 아까 생성한 IOCP와 Listen Socket을 연결.
CreateIoCompletionPort((HANDLE)m_ListenSock, m_hCompletionPort, 0, ThreadCount);
아까 가장 처음에 써줬던 함수를 다시 써줌. 이번엔 넣는 인자값이 다름. 딱히 뭔가 더 설명은 필요 없는듯 ;ㅅ;
8. Completion Thread에서 GetQueuedCompletionStatus 함수 처리
위에서 설명은 많이 한 거 같고... 코드 먼저 봄.
unsigned int __stdcall CompletionThread(LPVOID pComPort)
{
HANDLE hCompletionPort = (HANDLE)pComPort;
LPPER_HANDLE_DATA _PerHandleData = NULL;
DWORD n = 0;
int sendbytes = 0;
ULONG key = 0;
BOOL bCompState = false;
while(true)
{
_PerHandleData = NULL;
bCompState = GetQueuedCompletionStatus(hCompletionPort, &n, &key, (LPOVERLAPPED*)&_PerHandleData, 1000);
if(bCompState == FALSE || _PerHandleData == NULL)
{
if(_PerHandleData != NULL)
_PerHandleData->state = NET_EVENT_CLOSE;
else
continue;
}
// _PerHandleData 에 대한 처리
// ...
}
return 0;
}
아까 beginthreadex 함수를 통해서 생성해줬던 CompletionThread. 인자로 CompletionKey를 넣어줬기 때문에 이거 받아주고... 뭐, 사실 보기만 한다면 그다지 복잡할 거 없다. 저 GQCS 함수가 핵심. 원형은 아래와 같다.
WINBASEAPI
BOOL
WINAPI
GetQueuedCompletionStatus(
__in HANDLE CompletionPort,
__out LPDWORD lpNumberOfBytesTransferred,
__out PULONG_PTR lpCompletionKey,
__out LPOVERLAPPED *lpOverlapped,
__in DWORD dwMilliseconds
);
사실 이런 함수들의 인자값이라는건 잘 보면 다들 비슷비슷함.
CompletionPort : 아까 메인스레드에서 생성해줬던 Copmletion Port를 넣어줌.
lpNumberOfBytesTransferred : 이번 인자값으로 송/수신된 바이트 수 리턴. 기존 Send/Recv 함수의 경우 송/수신이 완료되었을 경우에나 return이 되기 때문에 송/수신된 바이트 수를 return 해줄 수 있었지만 이번에 쓰는 WSASend/WSARecv는 송/수신이 완료된 시점에 이런 바이트 수를 얻어올 수 있다.
lpCompletionKey : 의미가 있기도 하고 없기도 한 값... 같다. 정체를 알 수가 없다. 어떤 예제에서는 쓰이고 어떤 예제에서는 전혀 쓰이지 않는다. 일단은 OVERLAPPED 를 상속받아서 key값으로 쓰는 구조를 차용하고 있기 때문에 이 코드에선 딱히 의미있게 쓰이진 않는다. 다만 OVERLAPPED 구조체 따로, 소켓,소켓 주소등의 정보가 담긴 구조체 따로 쓰고자 한다면 여기다가 소켓의 주소값을 시작주소로 한 클래스를 써주는 것 같다.
lpOverlapped : 얘가 핵심!!! 이 인자로 아까 AcceptEx 같은데서 쓰인 OVERLAPPED 객체의 주소값이 들어온다. 다만, 정말 '주소값'이기 때문에 메모리가 미리 확보되어있어야 한다. 나는 메인스레드에서 메모리풀을 사용해서 미리 할당해두는 방식을 씀.
dwMilliseconds : 타임아웃 값. INFINITE 값을 넣어도 상관은 없다.
그리고 6번에 좀 추가할 내용이 있음.
8번까지 잘 진행했다면 이제 클라가 접속하면 GQCS 함수가 값을 뱉을텐데, 이 때 서버쪽에서 해줄 일이 있다.
// 아까 위에서 [PerHandleData 에 대한 처리] 부분에 들어갈 녀석.
// 나는 따로 함수로 뺌.
LPPER_HANDLE_DATA pHandle = _PerHandleData
GetAcceptExSockaddrs(
&pHandle->buffer[0],
HANDLE_DATA::initialRecvSize,
pHandle->addrlen,
pHandle->addrlen,
(SOCKADDR**)&(plocal),
&locallen,
(SOCKADDR**)&(premote),
&remotelen);
memcpy( &pHandle->localAddr, plocal, sizeof(sockaddr_in) );
memcpy( &pHandle->remoteAddr, premote, sizeof(sockaddr_in) );
setsockopt( pHandle->hSock, SOL_SOCKET, SO_UPDATE_ACCEPT_CONTEXT,
(char *) &m_ListenSock, sizeof(m_ListenSock));
CreateIoCompletionPort( (HANDLE) pHandle->hSock, m_hCompletionPort, 0, 0 );
클라이언트의 주소값을 얻어오고, 클라이언트 소켓을 CompletionPort와 연결시켜주는 작업이 필요함. 이제까지 나온 적 없던 Local Sockaddr / Remote SockAddr 개념이 나오는데 여기에 약간의 설명이 있다.
GetAcceptExSockaddrs 함수에 대해서는 여기 참고.
일단 구현 부분에 있어서는 당장 이 정도만 하면 소규모 클라 접속, send/recv 정도는 됨.
역시, 찾다보니까 알아볼만한 것들이 더 생김.
> 소켓의 재활용. AcceptEx의 경우 소켓을 미리 할당해두고 있어야 하기 때문에, 꼭 재사용이 가능해야 함. 이것과 관련된 포스트들
http://teraphonia.tistory.com/113
http://itability.tistory.com/34
다음은 클라이언트 사이드. 그리고 그 다음은 실제 코드.
위에 쓴 예제를 만들때 본격적으로 참고한 글
: http://yongpa.tistory.com/20
지적 환영 '3' /
'스터디 > Server' 카테고리의 다른 글
Rails あれこれ (0) | 2019.03.27 |
---|---|
VagrantBox + CentOS+ Ruby on Rails (0) | 2019.03.20 |
IOCP를 사용한 서버 라이브러리 제작 (0) | 2016.07.09 |
AcceptEX 개객기야 (0) | 2016.07.05 |
일단 IOCP 채팅서버 만드는 건 끝! (0) | 2016.06.28 |