Overlapped IO Model + heap

2022. 2. 11. 21:19게임서버/win socket 프로그래밍

320x100

안녕하세요 대학생 개발자입니다.

 

마지막 글에서 동기화에 대한 얘기와 overlapped io모델에 필요한 구조체들에 대한 간단한 언급을 했었습니다.

이번 글에서는 진짜 overlapped io모델에 대한 얘기를 하려고 합니다.

 

아 그리고 그 이전에 동적 할당과 힙에 대한 잠깐의 논의를 해보고 넘어가려고 합니다.

int * ptr = new int[100];

for(int i=0; i< 140; i++)
{
    ptr[i] = 10;
}

delete ptr;

다음과 같은 코드가 있습니다.

여기서 에러가 나오는 부분은 어디인가라고 물었을 때 대부분의 입문자분들은 ptr[i] = 10; 부분이라고 말할 겁니다.

이렇게 말씀하시는 이유는 알겠으나 일단은 에러가 어디서나냐는 질문에는 틀린 겁니다.

 

Visual Studio와 같은 IDE를 사용하시는 분들이라면 아마 IDE에서 ptr[i] = 10 부분에서 인덱스 범위를 벗어났다는 문제를 알려줄 텐데요...

그래서 많은 분들이 저기서 에러가 날 거다라고 추측하시는 분들이 계십니다.

뭐... 에러가 난다... 아주 틀린 말은 아닙니다.

우리가 이를 알기 위해서는 가상 메모리 구조에 대한 얘기를 해야 되지만... 그 얘기는 c언어 기본 부분에서 다루고 있기 때문에 스킵하고 바로 결론으로 가겠습니다.

만약 ptr에서 할당받은 배열이 하나의 페이지 끄트머리에 있는 배열이었다면 140번째 인덱스가 실제 커밋이 안된 메모리일 확률이 있습니다.

물론 그렇게 되게 주지는 않을 것 같습니다.

지금과 같은 코드에서는 ptr을 delete 하는 과정에서 에러가 터지게 됩니다.

역시 콜 스택을 보니 FreeHeap이라는 함수에서 문제가 생기는군요.

이 이유에 대해서는 오브젝트 풀에 대한 얘기를 할 때 말씀드렸었기 때문에 스킵하도록 하겠습니다.

 

음... delete[] ptr이 아니라서 문제냐 라고 말씀하시는 분도 계실겁니다.,

하지만 기본 타입에 대해서 delete와 delete[]는 동일한 작업(malloc)을 수행합니다.

소멸자가 존재하는 클래스 타입일 경우에는 c++에서 추가 할당을 요구합니다만,

이경우에는 생성자도 소멸자도 존재하지 않기때문에 delete[]가 아니라서 에러라는 말은 맞지 않습니다.

 

뭐.. 물론 저렇게 쓰는게 잘한것이라고 할순 없겠지만요

 

아주 운이 좋지 않다면 delete에서 에러가 나지 않고 다음번 HeapAlloc을 할 때 문제가 나게 될 겁니다.

 

만약 delete를 하기이전에 또다시 new를 호출(HeapAlloc호출)을 만나게 된다면 그 부분에서 문제가 생길겁니다.

void Log(const char* dataStr)
{
	FILE* file = nullptr;
	while (file == nullptr)
		fopen_s(&file, "log.txt", "ab");
	fwrite(dataStr, strlen(dataStr), 1, file);
    fclose(file);
}
int main()
{
	int* ptr = new int[100];
	for (int i = 0; i < 140; i++)
		*(ptr +i) = 0x12345678;
	//만약 여기서 문제가 생겼다면..?
	if (true)
	{
		Log("Critical Error Occured\n");
	}
	delete[] ptr;
}

코드를 다음과 같이 변경했습니다.

if문에서 만약에 특정 버그가 발생해서 로그를 남겨야되는 상황이고 로그는 파일을 열어서 그 파일에 저장하려는 목적이라면 여기서 문제가 생길것입니다.

fopen_s함수는 내부적으로 CreateFile함수를 호출하고있고 CreateFile은 내부에서 HeapAlloc을 사용하고 있습니다.

그렇기 문에 다음과같은 문제가 발생하게 될것입니다.

다음과같은 중단점을 트리거 했다는 로그가 남습니다.

그리고 이 문제는 HeapAlloc에서 문제가 생기게됩니다.

앞으로 우리는 문제가 생겼을 때 메모리 덤프라는 걸 남길 건데요...

만약 new에서 heap corruption가 발생한 상황이라면, 덤프를 남기는 행동조차 불가능해질 겁니다.

덤프를 남기는 API조차도 CreateFile을 호출하고 이는 내부에서 HeapAlloc을 사용합니다.

그리고 HeapAlloc을 호출함과 동시에 한 번 더 펑 터지게 될 겁니다.

즉 남는 건 아무것도 없고 원인 파악도 불가능해지게 된다는 의미입니다.

덤프가 안 남으니까요...

 

이유는 힙의 구현이 리스트와 같이 이번 노드가 다음노드를 참조하고 그리고 그 노드가 다음노드를 참조하는식의 방식으로 구현되어있기 때문입니다.

제가 만들었던 오브젝트풀 부분을 보신다면 이부분에 대한 구현이 담겨있습니다.

그렇기때문에 뒤를 밀어서 그 리스트를 밀어버린다면 그 리스트가 깨지게 되므로 이런 문제가 발생하게 되는겁니다.

 

 

이 방법은 실제로 해결방안이 될 수는 있으나, 많은 사람들이 실제로 사용하지 않는 방법으로써 이런 것도 있구나 정도로 받아들이시면 될 것 같습니다.

아마 c++ standard library, c runtime library, window api마저도 전부다 프로세스에 기본으로 주어지는 Heap을 사용하고 있을 겁니다.

그리고 문제는 당연히 우리의 유저 코드에서의 문제로 인해 Heap Corruption이 발생한 경우겠죠...

위의 라이브러리나 api에서 Heap Handle값을 바꿔치기하면 되지 않겠냐고 하실 수 있습니다.

조금 딥하게 코드를 보게 되면 HeapHandle이라는 값이 존재하긴 합니다만 API상으로 변경하는 방법이 존재하지 않고 명시되어있는 문건도 한 번도 본적 없는걸로 알고 있습니다.

 

즉, 유저 코드의 동적 할당을 모두 CreateHeap을 해서 새로 만든 힙을 통해서 받으면 이 Heap Corruption이 발생해도 문제가 되지 않을 겁니다.

왜냐면 기본 프로세스 힙은 건들지도 않았기 때문입니다.

 

그리고 생각해보면 성능면에서 향상도 어느 정도 기대해볼 수 있습니다.

Heap이라는 것은 하나의 메모리(공유자원)를 여러 스레드에서 사용하는 것이죠.

당연히 Heap에서도 lock(동기화)를 걸고 들어갑니다.

하지만 힙이 두 개라면 그런 작업이 줄어들 겁니다.

API를 호출할 때 사용되는 heap과 우리가 동적 할당을 할 때의 heap이 다르기 때문에 동기화 작업이 필요 없어지므로 이 부분에서 성능 향상을 기대해볼 수 있을 것 같습니다.

 

뭐... 이건 분명 말씀드리지만 제가 권고드리는 방식도 아닐뿐더러 실제 이렇게 사용하시는 사례가 너무 극소수이기 때문에 내용을 이해정도만 하고 넘어가시는 걸 권고드립니다.

 

추가 부분입니다.

아주 단순하게 예시를 하나 만들어봤습니다

힙을 하나 생성하고 operator new와 delete를 오버로딩해서 내가 만든 힙에서 할당을 받게하는 심플한 예제입니다.

그렇게한다면 내가 만든 힙에대해서 문제가 생긴다면 프로세스힙에는 지장이 없기때문에 CreateFile을 호출함에 있어서 발생되는 HeapAlloc을 보장해줄 수 있습니다.

void Log(const char* dataStr)
{
	FILE* file = nullptr;
	while (file == nullptr)
		fopen_s(&file, "log.txt", "ab");
	fwrite(dataStr, strlen(dataStr), 1, file);
    fclose(file);
}
//예외및 에러는 따로 처리하지 않음.
void* operator new(size_t size)
{
	void* block = HeapAlloc(newHeap, 0, size);
	return block;
}
void operator delete(void* block){ HeapFree(newHeap, 0, block);}
int main()
{
    //따로 옵션 지정은 하지않음.
    myHeap = HeapCreate(0,0,0);
    if(myHeap == nullptr) return -1;
    int*ptr = new int[100];
    for(int i=0; i< 140; i++) *(ptr+i) = 0x12345678;
    //이 구간에서 문제가 생긴다면..?
    Log("Critical Error Occured\n");
    delete[] ptr;
}

다음과 같은 코드에서는 Log에서 문제가 발생하지 않습니다.

delete[] ptr에서 문제가 생기겠죠.

그리고 다행히 로그가 남았습니다

하지만 우리는 delete[]ptr에서 문제가 생기길 희망할 수도있습니다.

그때는 모든 에러에 대한 핸들링을 우리가 원하는 프로시져로 등록을 할 수 있을겁니다.

그런 방법으로 로그를 남기고 리턴을 하는 방식으로 구현할 수 있겠습니다.

그에관한 코드는 일단 오버랩 io로 넘어가야 되기 때문에 다른글에서 소개할 수 있으면 소개하도록 하겠습니다.

 

 

자... 그럼 Overlapped io에 대한 얘기로 넘어가 보도록 하겠습니다.

int WSAAPI WSARecv(
  [in]      SOCKET                             s,
  [in, out] LPWSABUF                           lpBuffers,
  [in]      DWORD                              dwBufferCount,
  [out]     LPDWORD                            lpNumberOfBytesRecvd,
  [in, out] LPDWORD                            lpFlags,
  [in]      LPWSAOVERLAPPED                    lpOverlapped,
  [in]      LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
int WSAAPI WSASend(
  [in]  SOCKET                             s,
  [in]  LPWSABUF                           lpBuffers,
  [in]  DWORD                              dwBufferCount,
  [out] LPDWORD                            lpNumberOfBytesSent,
  [in]  DWORD                              dwFlags,
  [in]  LPWSAOVERLAPPED                    lpOverlapped,
  [in]  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);

우리가 자주 마주하게 될 함수입니다..

 

아마 대부분의 Overlapped io를 설명하는 글이나 기본 책에서는 에코 서버와 같은 핑퐁 방식의 서버를 예시로 들고 있는 걸로 알고 있습니다.

struct SOCKETINFO{
    WSAOVERLAPPED over;
    WSABUF buffer;
    //SOCKET,sockaddr_in등...그외 필요한데이터
}

그러므로 OVERLAPPED를 다음과 같이 사용하는 케이스가 많습니다...

뭐 가장 익숙하고 흔히 책에서 보는 방식입니다.

책에서 이런 구조체의 예시를 드는 이유는 핑퐁 방식의 recv가 끝나야 send를 하고 send가 끝나야 recv를 하는 방식이기 때문입니다.

Overlapped io 방식에서 하나의 OVERLAPPED객체는 하나의 작업마다 필요합니다.

즉 서버에서 하나의 세션에게 full duplex방식을 지원하고 싶다면 저렇게 세션 하나당 하나의 오버랩 객체가 들어가게 되선 안 될 겁니다.

그럼 우리는 당연히 하나의 작업을 수행할 때마다 WSAOVERLAPPED객체를 필요로 하기 때문에 기본적으로 모든 세션의 WSAOVERLAPPED객체는 2개 이상이 될 겁니다.

 

일단 WSASend와 WSARecv의 파라미터를 확인해보도록 하겠습니다.

소켓은 건너뛰도록 하겠습니다.

LPWSABUF라는 새로운 버퍼를 요구하고 있습니다.

이 버퍼는 다음과 같이 생겼습니다.

typedef struct _WSABUF {
  ULONG len;
  CHAR  *buf;
} WSABUF, *LPWSABUF;

뭐... 큰 특이점이 없습니다만, 함수에 들어가는 파라미터 이름을 잘 보시면 lpBuffers입니다 s가 붙었습니다.

추가로 바로 다음 파라미터로 bufferCount가 있습니다.

그리고 포인터를 받습니다. 이는 버퍼를 여러 개를 담을 수 있게 해 주겠다는 의미로 해석할 수 있습니다.

그저 ULONG하나와 CHAR* 하나라면 call by value로 받아도 상관없을 겁니다.

 

하지만 포인터로 받고 있습니다.

 

우선 링 버퍼에 대한 문제점을 보안할 수 있는 방법으로 이걸 쓸 수 있겠는데요.

우리의 링 버퍼의 최대 단점이라고 하는 경우가 이런 경우가 있을 겁니다.

DirectEnqueue or DirecDequeueSize가 1일 경우... 이럴 경우는 이도 저도 하지 못합니다.

이런 상황에서 버퍼를 여러 개 넣을 수 있다면 지금 WritePointer와 BeginPointer두개를 넣어서 이를 두 개의 버퍼로 사용하게 한다면 지금 우리의 링 버퍼의 단점을 보안할 수 있을 좋은 무기로 보입니다.

 

lpNumberOfBytesRecv, Sent 얘내들은... 몇 바이트를 보냈는지에 대한 리턴을 call by reference방식으로 해주는 겁니다. 뭐... 이제껏 써왔던 recv보다는 좀 더 나아 보입니다.

하나의 int 파라미터를 통해서 실패와 성공 여부를 전달할 때는 까다로운 부분이 존재합니다만...

앞으로 이를 통해서 문제가 없어 보입니다.

 

하지만 여기서 의문점이 있습니다.

 

파라미터를 분명 포인터로 전달받길 원하고 있습니다.

그렇다는 말은 버퍼를 주면 값을 담아줄게라는 의미인데...

비동기로 호출하고 나면 우리는 그 함수를 리턴해버리고 나가버릴 겁니다.

즉 동적 할당을 해서 달라는 의미인가?? 싶을 수도 있습니다.

이 이유는 msdn에 명시되어있습니다.

[out] lpNumberOfBytesSent

A pointer to the number, in bytes, sent by this call if the I/O operation completes immediately.
Use NULL for this parameter if the lpOverlapped parameteris not NULL to avoid potentially erroneous results. 
This parameter can be NULL only if the lpOverlapped parameter is not NULL.

다음에 나올 lpOverlapped객체가 null이 아니라면 여기를 통해서 반환해주겠다는 의미가 됩니다.

즉... 만약 사용자가 LPWSAOVERLAPPED를 인자로 넣지 않는다면 동기 함수처럼 쓰게 해 주고 그리고 반환된 리턴 값을 통해서 예전의 recv를 호출했던 것과 동일하게 io를 기다렸다가 작업을 하면 된다 이겁니다.

 

즉 Overlapped io를 쓰실 거면 nullptr을 전달하시면 된다는 겁니다.

LPOVERLAPPED_COMPLETION_ROUTINE은... Overlapped io모델 1에서는 사용되지 않습니다.

저번 글에서 말씀드렸듯 Overlapped io모델 1의 경우에는 이벤트를 기반으로 하기 때문에 완료가 되었다면, 이벤트를 통해서 알려주게 될 것이므로 예전의 커널 이벤트를 기다렸던 것과 동일하게 이벤트를 생성하고 이벤트를 기다리는 식으로 코드를 짜게 될 겁니다.

 

간단하게 큰 구조를 본다면 대략 이런 느낌이 될 것 같습니다.

InitialNetwork();

while(true)
{
    SOCKET sock = accept(...);
    //accept err 체크 코드
    
    event[curEventIdx++] = WSACreateEvent();
	//이벤트 체크 코드    
    WSAOVERLAPPED over;
    memset(&over,0,sizeof(over));
    //WSABUF및... 그외 WSARecv에 들어갈 인자
    WSARecv(...);
}
unsigned __stdcall WorkerThread(void* param)
{
    while(true)
    {
        DWORD idx = WSAWaitForMultipleEvents(...);
        idx -= WSA_WAIT_EVENT_0;
        WSAResetEvent(idx);
        
        bool result = GetOverlappedResult(...);
        // 어떤 IO인지와 몇바이트 받았는지 여기서 확인가능
        // 처리 로직...
    }
}

다음과 같은 구조로 나올 것 같습니다.

물론 중간의 에러 체크 부분이나 이런 부분은 의사 코드 형태이기 때문에 하지 않았습니다.

 

우선 코드를 쭉 읽으면서 어떤 형태인지 전달하는 게 효율적일 것 같습니다.

listensocket은 블로킹 소켓으로 갑니다.

accept부분도 비동기로 갈 수 있긴 하지만 그렇게 할 경우 코드가 더 복잡해지고 수정에도 편하지 않습니다.

그리고 저 블로킹 자체로써 이미 cpu를 먹지 않고 있고 accept에 대한 조절을 해주는 역할을 하고 있습니다.

 

WSACreateEvent는 파라미터가 존재하지 않습니다.

그럼 SECURITY_ATTRIBUTE 같은 것도 들어가지 않으니 커널 오브젝트가 아닌 건가 하실 수 있지만 커널 오브젝트입니다.

그리고 유감스럽게도 이 이벤트는 only manual reset입니다. auto reset이란 게 존재하지 않습니다.

 

그리고 accept와 동시에 WSARecv를 호출하고 있습니다.

비동기 함수에 대한 정리는 예전 글에서 했기 때문에 넘어가겠습니다만, 여기서 중요한 부분은 Recv를 등록한다는 개념입니다.

작업을 완료하면 알려줄게라는 개념이기 때문에 내가 지금 당장 읽을 게 없더라도 작업을 걸어놓는 개념입니다.

그러므로 WSARecv, WSASend와 같은 함수들은 내가 요청을 한다고 바로 완료되는지 알 수가 없습니다.

그래서 높은 확률로 함수의 리턴과 동시에 SOCKET_ERROR가 리턴이 될 건데, 이 경우에는 WSAGetLastError를 통해서 WSA_IO_PENDING이라면 아무 문제가 없는 케이스이니(논블럭 소켓의 WSAEWOULDBLOCK과 동일한 상황) 진행하시면 되겠습니다.

 

예전의 동기 io의 경우에는 select와 같은 io가 가능한지 판단해주는 모델이 존재했었고 그 모델에게 물어보고 되면 받고 아니면 받지 않았습니다.

왜냐면... 들어가면 일단 블로킹 걸리니까 그렇습니다...

 

그리고 난 뒤에는 WorkerThread에게 모든 걸 맡겨버립니다.

IO가 완료된다면 WSAWaitForMultipleEvents함수가 리턴하게 될 것이고요, 인덱스를 알려줄 겁니다.

그럼 그 인덱스를 통해서 어떤 소켓의 어떤 IO인지 찾아내야 되겠습니다.

 

그리고 유감스러운 점은 이 Overlapped io모델 1의 경우에는 WSAWaitFor... 함수에서 Event가 시그널이 되었다는 정보 외에는 아무것도 가져올 수 없다는 점입니다.

그러므로 우리는 그에 대한 결과를 받아오기 위해서 WSAOVERLAPPED객체와 몇 바이트 받았는지에 대한 정보를 받을 변수를 GetOverlappedResult함수 호출에 아웃 파라미터로 던지면서 요구해야 됩니다.

정말 안타까운 점입니다...

 

나중의 IOCP와 같은 경우에는 GetQueuedCompletionStatus라는 함수를 호출하게 될 텐데 그 함수는 몇 바이트 받았는지를 포함 모든 데이터를 알려주는 반면에, WSAWaitForMultipleEvents함수는 진짜 이벤트를 기다리는 목적 외에는 아무런 용도로도 사용할 수가 없는 거죠...

 

그리고 이 모델의 최대 단점은 select와 동일하게도 64개의 Event이상은 등록할 수가 없다는 겁니다.

select는 차라리 소켓이 64개였지... 이 모델은 Event가 64개입니다...

그렇기 때문에 64개의 이벤트 이상이 존재한다면... 유감스럽게도 폴링을 해야 됩니다.

이벤트로는 모든 사람을 처리할 수가 없기 때문이죠.

이점이 최대의 단점으로 꼽힐 수 있겠습니다.

 

GetOverlappedResult함수의 경우에는 LPDWORD타입의 포인터를 하나 집어넣게 됩니다.

뭐 API에서 몇 바이트 받았는지 적어줄 테니 버퍼를 달라는 거겠죠.

FIN signal로 인한 종료 케이스는 이 DWORD값이 0이면 recv의 return이 0인 것과 동일한 상황입니다.

하지만... 우리는 무조건 RST가 도착하게 될 겁니다. 그러니까... GetOverlappedResult함수를 호출해서 FALSE가 리턴되고 WSAGetLastError의 값이 10054와 같이 연결 종료와 관련된 코드라면 그냥 Disconnect 해주면 될 겁니다.

늘 해오던 것처럼 말이죠...

 

아마 Overlapped모델은 많은 분들이 IOCP를 가기 전에 거쳐가는 단계 정도로만 생각하실 수도 있습니다만...

IOCP 또한 Overlapped모델 중 하나로 어떤 방식으로 완료 통지를 받을지에 대한 부분입니다.

즉 다른 Overlapped io와 큰 맥락으로 봤을 때 비슷한 방향이라는 의미입니다.

물론 지금의 경우에서는 OverlappedIO1,2 모델을 쓰는 경우는 거의 못 봤습니다.

하지만 그렇다고 해서 중요하지 않고 그냥 거쳐가는 단계로만 여기시기에는 너무나도 중요한 내용입니다.

 

째뜬 글이 길어졌습니다.

다들 오늘도 긴 글 읽어주셔서 감사합니다.

다음번에는 더 좋은 내용으로 찾아뵙도록 하겠습니다.

그럼 안녕히 계세요

320x100

'게임서버 > win socket 프로그래밍' 카테고리의 다른 글

IO Completion Port Introduction  (0) 2022.02.15
Overlapped IO Model2  (0) 2022.02.15
Overlapped IO Introduction  (0) 2022.02.10
L4와 L7그리고 서버  (0) 2021.11.04
Win socket API2  (0) 2021.11.03