'Development'에 해당되는 글 44건

  1. 2008.05.23 Erlang 관련 URL
  2. 2008.03.25 Name mangling in C++
  3. 2008.02.05 BFD 라이브러리
  4. 2007.04.28 Error LNK2005 : _DllMain@12 already defined
  5. 2007.04.24 The Function Pointer Tutorials
  6. 2007.02.13 애플리케이션 개발시의 메모리 디버깅 : 메모리 누수 발견 기법
  7. 2007.01.02 TCP/IP를 기반으로 한 온라인 게임 제작 : 크리티컬섹션과 세마포어 , 뮤텍스
  8. 2006.12.27 TryEnterCriticalSection
  9. 2006.12.27 '_beginthreadex': identifier not found, even with argument-dependent lookup
  10. 2006.12.22 WIN32 메모리 관리


Erlang 관련 URL

Development 2008. 5. 23. 16:50
erlang 관련 URLs

http://www.erlang.org/doc/ (공식 사이트)
http://groups.google.com/group/erlangstudy/web/erlang-otp-r12b-release-highlight (erlang study 관련 구글그룹)


http://tempe.st/2007/05/erlang-ruby-and-php-battle-it-out/




:

Name mangling in C++

Development/C / C++ 2008. 3. 25. 15:59
C++은 C언어를 지원한다. 그래서 예전의 C소스를 가져다가 사용할 수 있다.

C 언어는 symbol에서 함수의 이름만으로 찾을 수 있다. 이 것은 C언어가 같은 함수 이름을
사용하는 것을 허용하지 않기 때문인데, C++에서는 다형성으로 인해서 같은 이름을 사용할 수 있다. 그래서 함수이름만으로 찾는 것이 불가능하다.

이런 것을 위해서 내부적으로 함수 이름을 변경해주는데 이것이 name mangling 이다.

다음과 같이 된다.

int foo(void)  {return 1;}
int foo(int ) {return 1;}
void goo(void) { int i=foo(), j=f(0); }



int _foo_i (void)  {return 1;}
int _foo_v (int ) {return 1;}
void _goo_i (void) { int i=_foo_i(), j=_foo_v(0); }




참조 : http://en.wikipedia.org/wiki/Name_mangling#Name_mangling_in_C.2B.2B




:

BFD 라이브러리

Development 2008. 2. 5. 13:51
BFD 라이브러리란 오브젝트 파일 형식이 어떤 것이던 동일한 루틴을 사용해 오브젝트 파일에 접근하기 위해 GNU에서 만든 라이브러리 패키지. BFD는 크게 Front-end와 Back-end 두 부분으로 나누어져 있다.

Front-end는 라이브러리 사용자에게 제공되는 Interface이며, Back-end는 특정 오브젝트 파일 형식을 접근할 수 있는 함수와 자료로 이루어져 있다.


아래는 GNU의 BFD Library 의 Document 페이지이다.
http://www.gnu.org/software/binutils/manual/bfd-2.9.1/html_mono/bfd.html






:

Error LNK2005 : _DllMain@12 already defined

Development/C / C++ 2007. 4. 28. 09:55
Error LNK2005 : _DllMain@12 already defined

cpp파일 내에서 DllMain 함수를 직접 구현하여 사용할 때
CString 등을 위해서 afx.h 를 include 시키게 되면 발생하는 에러이다.


일반적으로 이 LNK2005 에러는 다른 라이브러리 내에 DllMain이 포함되어 있는 경우에는
http://support.microsoft.com/kb/148652 에서 지시하는 대로 해결을 할 수가 있지만
cpp 파일내에서 DllMain을 직접 구현하여 사용할 경우에는 해당이 되질 않는다.

하지만 CString은 써야겠고...
이럴 때에는 afx.h 를 include시키지 말고

atlstr.h를 include시켜 사용하면 해결 할 수 있다.

// #include <afx.h>
#include <atlstr.h>


// afxstr.h에 정의되어 있는 CString
typedef ATL::CStringT< TCHAR, StrTraitMFC< TCHAR > > CString;




thx to K.H






:

The Function Pointer Tutorials

Development/C / C++ 2007. 4. 24. 15:46

The Function Pointer Tutorials

  Extensive Index
  2.  The Syntax of C and C++ Function Pointers
  3.  How to Implement Callbacks in C and C++ ?
  4.  Functors to encapsulate C and C++ Function Pointers
  5.  Topic Specific Links




-------------------------------------------------------------------------------
Function Pointer에 대한 Tutorial이 있는 곳.
Lars의 홈페이지
출처 : http://www.newty.de/fpt/index.html

:

애플리케이션 개발시의 메모리 디버깅 : 메모리 누수 발견 기법

Development/C / C++ 2007. 2. 13. 16:19
출처 : http://www.ibm.com/developerworks/kr/library/opendw/20061219/
[오픈 디벨로퍼웍스]는 여러분이 직접 필자로 참가하는 코너입니다. 이번에는 김현우 님이 작성한 애플리케이션 개발시의 메모리 부족 에러에 대비하는 메모리 누수 발견 프로그램 개발 방법을 함께 살펴봅니다.

필자는 DVD 레코더와 셋톱박스의 복합 모델을 개발하는 팀에 소속되어 있다. 현재 유럽에서는 아날로그 방송을 디지털로 서서히 대체하고 있기 때문에, 관련 제품의 개발 요청이 쇄도하고 있다.
얼마 전 유럽을 타깃으로 3개의 유사 모델(D197, D198, D199)을 개발하고 있을 때의 일이다. 우여곡절 끝에 기본 모델인 D197 개발을 마치고 양산 시켰으며, D198도 완료하여 QA 그룹에 테스트를 의뢰한 후 결과를 기다리고 있었다. 팀원들 모두, D197 모델이 별 이상 없었으니 부가기능을 조금 추가한 D198 역시 무난히 양상 단계로 넘어갈 것이라고 판단, 모처럼의 한가한 시간을 보내고 있었다.
그런데 그 순간 옆자리에서 통화하는 소리가 들렸다.
"네, 세트가 멎었다고요?"
테스트 그룹으로부터 D198 세트가 오동작 한다는 연락을 받은 것이다. 우리는 즉시 테스트 그룹으로 달려갔다. 노트북을 D198에 연결하고 에러 메시지를 확인했다. 디버그 화면에는 메모리 부족을 나타내는 경고 메시지가 반복해서 출력되고 있었다. 사용할 수 있는 메모리가 바닥나서 더 이상 동작하지 못하고 멈춰버린 것이었다.
D198의 DRAM은 시스템을 동작시키고 남을 넉넉한 크기여서, 정상적인 경우라면 메모리 부족 문제가 발생할 이유가 없었다. D197 모델에서는 없었던 메모리 문제가 왜 발생했을까? 팀원들은 오늘도 일찍 퇴근하기는 틀렸다는 생각에 투덜거리며 D198에서 새로 추가된 코드를 중심으로 살펴보기 시작했다.

메모리 누수

자바나 C# 같은 언어에는 가비지 컬렉터(Garbage Collector)가 있어서, 알아서 메모리 관리를 해주지만 프로그래밍 언어의 대표주자 C/C++ 에서의 메모리 할당과 해제는 프로그래머의 몫이다. 따라서 애플리케이션이 메모리를 할당 받아서 사용했다면, 사용이 끝난 뒤에는 반드시 반납해서 해당 메모리를 재사용할 수 있도록 해야 한다. 그러나 프로그래머도 사람인지라 할당 받은 메모리를 실수로 해제하지 않는 경우가 발생할 수 있다. 이러한 경우 해제되지 않은 메모리는 사용이 끝난 뒤에도 남아있어 공간만 차지하는 상태로 있게 된다. 이것을 메모리 누수라고 한다.
언뜻 생각해보면 메모리를 할당 받고 해제하는 것이 무슨 어렵고 복잡한 일인가? 의문을 가질 수도 있겠지만, 실제로 코딩을 하다 보면 그렇게 간단한 문제가 아니다.
상용코드는 여러 프로그래머들이 개발하고 유지보수하기 때문에 시간이 지나면 일관성을 잃고 난해해지기 쉬울 뿐 아니라 그 양도 방대해진다. 때문에 복잡해진 애플리케이션의 상태나 조건을 후임 개발자가 꿰뚫고 있기는 쉽지 않다.
여기에 새로운 기능을 추가하다 보면 예상치 못한 상태에 빠지기도 하고 복잡해진 조건에 누락되는 부분이 생겨 메모리 누수가 발생하기 쉽다. 특히 문제의 루틴이 단발적으로 사용되는 것이 아니라면 루틴이 실행될 때마다 메모리 누수가 누적되어 언젠가는 메모리 부족 문제가 터질 수밖에 없게 된다.

메모리 감시자

짧은 분량의 간단한 프로그램이라면 차분히 코드를 살펴보면서 메모리 누수를 찾아 나설 수도 있다. 실제로 느긋하게 코드를 읽어 나가는 것은 메모리 누수뿐 아니라 야근하며 작성했던 코드의 결함을 찾아내는 가장 좋은 방법일 수도 있다.
그 러나 방대한 분량의 상용 코드에서 생긴 문제라면 어떻겠는가? 거기에 상용 프로그램 개발에 느긋한 일정을 주는 회사를 본 적이 있는가? 긴박한 일정에 메모리 누수까지 생겼다면 과중한 스트레스로 이력서를 고쳐 쓰고 취업 사이트를 뒤지게 될지도 모르겠다.
방대한 프로그램에 숨겨진 감쪽같은 메모리 누수는 사막에서 잃어 버린 바늘과도 같다. 맨 손으로 사막에서 바늘을 찾으려는 것은 너무도 무모한 짓이다. 사막에서 바늘을 찾으려면 도구가 필요하다. 이를테면 금속탐지기 같은 것이 있으면 큰 도움이 될 것이다. 우리도 메모리 누수를 찾기 위한 금속탐지기부터 구해 보도록 하자.
필자는 도입부의 문제 상황에서 다른 팀원들과 함께 코드를 살펴보는 대신에 메모리 감시 모듈을 작성하여 코드에 추가하는 작업을 했다. 물론 다른 팀원들이 열심히 코드를 분석하는 동안에 다른 코드를 작성하고 있었기 때문에, 자칫 딴짓을 하는 것처럼 보였을 수도 있었겠지만 결국은 필자의 방법으로 문제를 해결할 수 있었다. 두 세시간 정도의 시간이 지나자 코드 분석을 하던 팀원들은 하나 둘씩 지쳐서 포기하기에 이르렀는데, 그 즈음에 필자의 프로그램이 완성되었다.
필자가 개발한 메모리 감시자는 메모리의 할당과 해제 상태를 저장하고 리포트 하는 기능을 하는데, 감시 모듈을 포함시킨 D198을 부팅시켜 기본동작을 수행시켜 본 후 메모리 상태를 확인해 보았다. 예상대로 D198에서 새롭게 추가된 유료방송 처리 부분이 문제였다.
우리는 리포트 받은 부분의 코드를 살펴보고 곧 문제를 찾아내어 메모리 누수를 해결할 수 있었다. 그리고 덤으로 세트에 주는 영향은 미미하지만 감추어져 있던 또 다른 메모리 누수(몇 년 정도 동작 시키면 세트를 멈추게 할 수도 있는 문제)도 찾아낼 수 있었다.

메모리 감시자의 구현

메모리 감시자가 하는 일은 거창해 보이지만 구현은 생각보다 간단하다. C언어에서는 malloc이나 calloc을 이용하여 메모리를 할당하고, free를 이용하여 메모리를 해제한다. 마찬가지로 C++에서는 new와 delete가 같은 동작을 한다. 메모리 감시자는 링크드 리스트로 구현되며, malloc이 호출 되면 할당한 메모리의 주소를 리스트에 저장해 두었다가 free로 해당주소를 해제하면 저장된 리스트에서 해제된 주소 값을 찾아 삭제한다.
이러한 동작으로 메모리 감시자는 현재 할당되어 있는 메모리의 주소만을 보관하고 있게 된다. 따라서 메모리 감시자를 포함한 어플리케이션을 실행시킨 후 리포트를 확인하면, 비정상적으로 여러 번의 메모리 할당을 받은 부분을 어렵지 않게 발견할 수 있다. 메모리 감시자는 헤더 파일 하나(TraceMem.h)와 소스 파일 하나(TraceMem.c)로 구성된다. 각각의 구현 방법에 대해 설명하도록 하겠다.


TraceMem.h

메모리 감시자가 애플리케이션이 할당 받은 동적 메모리의 주소 값을 저장하는 역할을 하지만, 메모리 감시자 자체도 링크드 리스트로 구현되기 때문에 스스로가 동작하는 동안에도 동적 메모리의 할당을 하게 된다. 즉, 애플리케이션에서 할당한 메모리 주소를 메모리 감시자의 링크드 리스트에 저장하기 위해 다른 메모리를 할당해야 하는 것이다.
그러면 메모리 감시자는 주소 값을 저장하기 위해 할당 받은 메모리의 주소를 또 다시 저장하려 들것이다. 이 때문에 순환참조가 발생되며, 메모리 누수 잡기를 시작도 하기 전에 시스템이 다운되는 상황이 벌어지게 된다. 이러한 문제를 피하기 위해 메모리 감시자가 스스로의 목적에 의해 사용하는 동적 메모리의 할당과 해제는 메모리 감시자의 감시를 받지 않도록 할 필요가 있다.
아래 코드를 살펴보면 매크로를 이용해서 _MALLOC와 _FREE를 정의하였다. _MALLOC와 _FREE는 realloc을 이용하였는데, realloc은 malloc과 free의 기능도 모두 할 수 있는 범용적인 메모리 함수다. 본래 realloc은 malloc이나 calloc으로 할당한 메모리의 크기를 변경시킬 때 사용하는 함수이다. 기존에 할당 받은 주소를 NULL로 주면 메모리를 새로 할당하는 malloc의 역할을 하고, 새로 할당할 메모리의 크기를 0으로 하면 기존의 메모리만 해제시켜 버리기 때문에 free와 같은 역할을 한다.
메모리 감시자는 malloc과 free가 호출될 때 할당되거나 해제된 주소를 관리하므로, 메모리 감시자 코드를 구현하는 데는 malloc이나 calloc을 사용하지 않고 realloc을 사용한 _MALLOC과 _FREE를 사용할 것이다.
TraceMem은 메모리 감시자의 몸체가 되고 MemItem은 메모리 주소 값을 저장하는 리스트의 노드이다. STL을 사용할 경우는 더욱 편리하게 리스트를 구현할 수 있지만, C에서도 사용할 수 있도록 링크드 리스트를 직접 구현해 보겠다.

#define _MALLOC(p)	realloc(NULL, p)
#define _FREE(p) realloc(p, 0)

void* dbgMalloc(int size, char* file, int line);
#define malloc(n) dbgMalloc(n, __FILE__, __LINE__)

void dbgFree(void* ptr);
#define free(p) dbgFree(p)

typedef struct _MemItem
{
void* ptr;
char* file;
int line;
unsigned long size;
int num;
struct _MemItem *next;
} MemItem;

typedef struct _TraceMem
{
MemItem* head;
MemItem* tail;
int num;
} TraceMem;

TraceMem* TraceMemCreate();
void TraceMemDelete(TraceMem*);
void TraceMemPrint(TraceMem* self);
TraceMem* TraceMemGetSummary(TraceMem* self);

extern TraceMem* traceMem;

TraceMem.c

dbgMalloc과 dbgFree는 malloc과 free를 대신하여 수행되며 메모리 감시자에 메모리 주소를 추가하고 삭제하는 역할을 하며 아래 코드는 메모리 감시자의 몸체 부분으로 임베디드 환경에서도 사용할 수 있도록 C를 객체지향 스타일로 작성한 것이다.

void* dbgMalloc(int size, char* file, int line)
{
void* ptr = _MALLOC(size);

TraceMemAdd(traceMem, ptr, file, line, size);

return ptr;
}

void dbgFree(void* ptr)
{
TraceMemRemove(traceMem, ptr);

_FREE(ptr);
}

TraceMem* traceMem = NULL;

/* 링크드 리스트 노드 생성자 */
MemItem* MemItemCreate(void* ptr, char* file, int line, unsigned long size)
{
MemItem* self = (MemItem*) _MALLOC(sizeof(MemItem));

self->file = (char*)_MALLOC(strlen(file) + 1);
strcpy(self->file, file);
self->line = line;
self->size = size;
self->num = 1;
self->ptr = ptr;

return self;
}

/* 소멸자 */
void MemItemDelete(MemItem* self)
{
if (self == NULL)
{
return;
}

_FREE(self->file);
_FREE(self);
}

void MemItemPrint(MemItem* self)
{
printf(" ++ [%s:%d:%p] : %d/%d\n", self->file, self->line, self->ptr, self->num, self->size );
}

/* 메모리 감시자 생성자 */
TraceMem* TraceMemCreate()
{
TraceMem* self = (TraceMem*) _MALLOC(sizeof(TraceMem));

self->head = MemItemCreate(0, "Head", 0, 0);
self->tail = MemItemCreate(0, "Tail", 0, 0);

self->head->next = self->tail;
self->tail->next = NULL;

self->num = 0;

return self;
}

/* 소멸자 */
void TraceMemDelete(TraceMem* self)
{
while ( self->head->next != self->tail )
{
MemItemDelete( TraceMemPop(self, self->head->next) );
}

if (self->num != 0)
{
printf(" ++++ ERROR : TraceMem has Items %d", self->num);
}

_FREE(self->head);
_FREE(self->tail);
_FREE(self);
}

/* 주소값을 이용한 노드 검색 */
MemItem* TraceMemFindPtr(TraceMem* self, void* ptr)
{
MemItem* iter;

for ( iter = self->head->next; iter != self->tail; iter = iter->next )
{
if (ptr == iter->ptr)
return iter;
}

return NULL;
}

int TraceMemPush(TraceMem* self, MemItem* item)
{
MemItem* next = self->head->next;

self->head->next = item;
item->next = next;

return (self->num++);
}

MemItem* TraceMemPop(TraceMem* self, MemItem* item)
{
MemItem *iter;

for ( iter = self->head; iter != self->tail; iter = iter->next )
{
if (iter->next == item)
{
iter->next = iter->next->next;
item->next = NULL;
self->num--;

return item;
}
}

return NULL;
}

/* 메모리 정보를 리스트에 추가 */
int TraceMemAdd(TraceMem* self, void* ptr, char* file, int line, unsigned long size)
{
MemItem *tar;

if ( (tar = TraceMemFindPtr(self, ptr)) == NULL)
{
MemItem* item = MemItemCreate(ptr, file, line, size);
TraceMemPush(self, item);
}
else
{
TraceMemPrint(self);
}

return 0;
}

/* 메모리 정보를 리스트에서 제거 */
int TraceMemRemove(TraceMem* self, void* ptr)
{
MemItem *tar;

if ( (tar = TraceMemFindPtr(self, ptr)) != NULL)
{
MemItemDelete( TraceMemPop(self, tar) );
}
else
{
TraceMemPrint(self);
}

return 0;
}

/* 메모리 감시자 내용 출력 */
void TraceMemPrint(TraceMem* self)
{
MemItem *iter;

printf("\n ++ TraceMemPrint\n");

for(iter = self->head->next; iter != self->tail; iter = iter->next)
{
MemItemPrint(iter);
}
}

/* 파일명과 라인수로 노드를 찾는 함수 */
MemItem* TraceMemFindFileLine(TraceMem* self, char* file, int line)
{
MemItem* iter;

for ( iter = self->head->next; iter != self->tail; iter = iter->next )
{
if (line == iter->line && strcmp(file, iter->file) == 0)
return iter;
}

return NULL;
}

/* 정리된 메모리 상태를 주는 함수 */
TraceMem* TraceMemGetSummary(TraceMem* self)
{
MemItem *iter, *tar;
TraceMem* sum = TraceMemCreate();

for (iter = self->head->next; iter != self->tail; iter = iter->next)
{
if ( (tar = TraceMemFindFileLine(sum, iter->file, iter->line)) == NULL)
{
MemItem *item = MemItemCreate(0, iter->file, iter->line, iter->size);
TraceMemPush(sum, item);
}
else
{
tar->num ++;
tar->size += iter->size;
}
}

return sum;


Test 수행하기


#include <iostream>
#include "TraceMem.h"
using namespace std;
int main()
{
traceMem = TraceMemCreate();
int *ptr = (int*) malloc(100); // Line 11
int *ptr2 = (int*) malloc(200); // Line 12
for (int i=1; i&lt;=100; i++)
{
malloc(i); // Line 16
if (i%4 == 0)
{
malloc(i); // Line 19
}
}

free(ptr);
free(ptr2);
TraceMem* summary = TraceMemGetSummary(traceMem);
TraceMemPrint(summary);

TraceMemDelete(summary);
TraceMemDelete(traceMem);

return 0;
}
> Report
++ TraceMemPrint
++ [D:\Works\VCxx\MemTest\Main.cpp:16:00000000] : 100/ 5050
++ [D:\Works\VCxx\MemTest\Main.cpp:19:00000000] : 25/ 1300


위의 예는 Main.cpp의 11, 12 번째 줄에서 할당한 메모리는 정상적으로 해제한 반면 16, 19번째 줄에서 할당한 메모리는 해제하지 않았다. Report는 해제되지 않은 메모리를 보여주는데, Main.cpp의 16번째 줄의 두 숫자 100은 해제되지 않은 메모리의 개수이고 5050은 메모리 사이즈의 합이다. 테스트 코드에서는 1부터 100까지의 크기로 메모리를 할당했었기 때문에, 할당된 횟수는 100회이고 사이즈는 1부터 100의 합인 5050이 된다.

결론

이상으로 메모리 감시자를 이용하여 메모리 누수를 손쉽게 찾을 수 있는 방법에 대해 알아봤다. 소개한 코드에서는 malloc과 free를 사용한 경우를 예로 들었지만 calloc에 대해서도 적용할 수 있으며, 약간의 매크로 트릭과 연산자 오버로딩을 이용하면 C++의 new와 delete에도 적용할 수 있다. 물론 소개한 알고리즘을 응용하여 가비지 컬렉터가 없는 여타의 언어에 대해서도 모두 적용할 수 있다.
메모리 감시자는 간단하지만 부담스러운 가격의 상용 디버깅 툴을 사용하지 않더라도 메모리 누수를 찾아내는 강력한 기능을 제공한다. 더구나 대부분 C를 이용하여 작성되는 임베디드 환경에서라면, 그나마도 변변한 디버깅 툴도 없는 실정이어서 특히 유용한 툴이 될 수 있을 것이다.
:

TCP/IP를 기반으로 한 온라인 게임 제작 : 크리티컬섹션과 세마포어 , 뮤텍스

Development/C / C++ 2007. 1. 2. 16:45

프로그램의 세계 1999년 12월에 특집 기사

글쓴이 : 배재현(goldbat@ncsoft.co.kr) ( NC소프트 )

HTML Formated & Rearranged : Daeyeon, Jeong




             
TCP/IP 를 기반으로 한 온라인 게임 제작




최근들어 온라인 게임들이 점점 대중화되며 인기를 끌고 있다. 특히 수천 명이 하나의 서버에서 게임 내 가상스페이스를 공유하며 플레이하는 그래픽 머드의 개발과 동작원리는 게임 개발을 시작하려는 사람들에게 많은 관심의 대상이 되고 있다. 3부에서는 간단한 그래픽 머드의 서버와 클라이언트 프로그램을 구현하고 이를 통해 그 구조를 살펴본다.

 온라인 게임과 싱글유저 게임은 사실 별다른 차이점이 없다. 이를 RPG로 한정하고 생각한다고 해도 유일하게 다른 점은 한가지뿐이다. 현재 내가 플레이하고 있는 게임의 여러 자원들, 즉 게임 내의 나의 분신인 게임 캐릭터와 캐릭터가 싸우고 있는 게임 속의 몬스터, 캐릭터가 가지고 있는 아이템, 옆을 걸어 지나가는 마을주민 등이 게임이 플레이 중인 자기 PC에 있는냐 아니면 랜이나 전화선으로 연결된 온라인 상의 어느 곳에 존재하느냐 일뿐이다.

 그러나 이러한 멀티유저 게임과 싱글유저 게임의 차이점은 게임제작에서 실제 개발뿐만이 아니라 기획단계부터 많은 제약을 받게 된다. 싱글유저라면 간단하게 만들 수 있는 RPG의 퀘스트도 멀티유저 온라인 RPG라면 엄청난 일이 된다.

 예를 들어 뒷산에 있는 어떤 보스급 몬스터를 죽이면 꽤 좋은 아이템을 주는 이벤트를 만든다고 할 때 싱글유저 게임이라면 별 문제가 아닐 수도 있지만 멀티유저 게임이라면 문제가 틀리다. 같은 게임을 플레이중인 플레이어가 한 명이 아니라 수백 또는 수천 명이 될 수도 있기 때문에 몬스터가 주는 아이템이 좋을 경우 한 명의 용감한 용사가 아니라 수백 명이 말 그대로 인해전술로 몬스터 한마리를 잡기 위해 몰려들 수도 있다. 이럴 경우 그 몬스터는 당연히 몇 분만에 제대로 싸워보지도 못하고 죽게 될 것이고, 플레이어의 숨막히는 모험을 기대한 기획자의 기획은 실패하게 된다. 멀티유저게임은 이런 기획상의 문제를 극복한다고 해도 실제 개발에서 해결해야할 문제가 많이 남아있다.


동기화 (Synchronization)

 멀리 떨어져 있는 많은 게임 플레이어들이 하나의 가상 공간에서 같은 게임을 즐긴다는 것은 분명히 매력적이기는 하지만, 개발자에게는 많은 골치거리를 제공한다. 게이머들이 모두 동일한 환경에서 빠른 네트워크를 통해 게임에 접속해서 게임을 플레이한다면 좋겠지만 불행히 현실은 그렇지 않다. 어떤 사람은 T3 이상의 고속회선을 통해서 게임을 할 수 있고 또 다른 사람은 1400bps의 느린 모뎀에 낮은 클럭의 486PC에서 게임을 할 수도 있다. 이런 상이한 조건의 클라이언트들에게 ‘거의’ 동일한 서비스를 제공하기 위해서는 많은 테크닉이 필요하다.


해킹

 그래픽 머드의 특성상 게임 내의 실제 데이터는 로컬 PC에 존재하게 된다. 로컬 PC에 있는 데이터를 분석하고 조작해서 게임을 편하게 즐기는 단순한 해킹에서부터 TCP/IP의 하위 레이어에 침투해서 패킷을 가로채 분석한 다음 가짜 패킷을 서버에 보내는 전문 해커까지 서버를 공격하는 방법은 다양하다. 개발자는 패킷을 암호화하거나 게임 데이터를 압축해서 이러한 해킹에 대항해야 한다.


서버의 안정성

 훌륭한 기획에 그래픽을 만드는 것까지는 순조롭게 진행이 되다 결국의 서버의 안정성이 확보되지 않아 개발이 실패하는 일도 발생할 수 있다.

싱글유저 게임이라면 이런 문제에 대해 걱정할 필요가 없겠지만, 메가 플레이어가 접속하는 게임이라면 네트워크 문제(이것은 일단 돈으로 해결할 수도 있다)와 수십 개의 스레드를 사용할 때 발생하는 데드락, 시스템 정지, 메모리 참조에러 등 오래 살아있는 서버를 만드는 것이 게임 자체를 만드는 것보다 오히려 어려울 수도 있다.


양질의 회선

 네트워크 게임은 대부분 리얼타임으로 게임이 진행되고 서버/클라이언트 사이에 주고받는 데이터의 양은 다른 서비스와 비교할 수 없을 정도로 많다. 온라인 게임 서버의 네트워크 트래픽은 사용자가 증가할 때마다 산술증가가 아니라 기하급수로 증가한다. 좋은 게임 서버의 개발도 중요하지만 서비스가 시작되면 회선에도 많은 투자를 해야한다.


 

리스트 1 : 시스템을 정지시키는 간단한 프로그램

#include "process.h"
#include "stdio.h"

unsigned __stdcall thread(void* arg)
{
   while (1) {
   }

   return 0;
}

int main()
{
   int i;

   for (i = 0; i < 3000; i++) {
     _beginthreadex(NULL, 0, thread, 0, 0, NULL);
   }
   getch();
   return 0;
}
 


멀티스레드 프로그래밍

 서버 프로그래밍에서 가장 중요한 요소 중의 하나가 스레드(thread)다(스레드의 정의와 스레드 관련 API 함수들에 대해서는 지면관계상 자세히 다루기 힘들기 때문에 생략하겠다. 스레드는 프로세스 내의 작은 프로세스들이라고 이해하고 넘어가도 내용을 이해하는 데는 별다른 문제는 없을 것이다).

 멀티태스킹이 지원되는 OS에서는 동시에 여러 개의 프로세스가 실행되는 것이 가능하다. 윈도우 95나 NT같이 완전한 선점형 멀티태스킹 OS라면 백그라운드로 프린트나 파일 복사 같은 작업을 하고 있더라도 포그라운드로 실행중인 프로그램에 별로 영향을 미치지 않고 여러 개의 작업을 동시에 수행할 수 있다. 멀티태스킹 뿐만 아니라 멀티스레드가 지원되는 OS라면 여러 개의 프로그램을 동시 실행시키는 것뿐만 아니라 한 프로그램 또는 프로세스 안에 여러 개의 자식 프로세스(스레드)들을 만들 수 있다. 한 프로그램 내에서도 현재 작업을 중단하지 않고 여러 개의 일을 수행하는 것도 가능하다. 예를 들어 파일을 읽으면서 읽은 양을 다이얼로그 박스에 표시한다던지, 워드프로세스에서 사용자의 입력과 동시에 맞춤법을 맞추는 등 여러 가지 면에서 편리하게 사용이 가능하다.

편해 보이기는 하지만 이 스레드 또한 양날의 칼이다. 잘 사용하면 문제가 없지만 잘못 사용하면 작업을 빨리 끝내는 것이 아니라 오히려 시스템의 속도를 떨어뜨릴 수도 있다.

아무리 멀티태스킹, 멀티스레드를 지원하는 OS라고 해도 결국은 한정된 자원인 CPU를 나눠서 사용하는 것일 뿐이다. 어떤 컴퓨터에 5개의 프로그램과 이 프로그램들에서 만든 100여 개의 스레드가 실행중이라고 할 때 그냥 보기에는 모두 동시에 실행이 되고 있는 것처럼 보이지만 실제로는 그 컴퓨터에 CPU가 하나가 설치되어 있던 2개 또는 8개 이상의 CPU가 달려있던지 결국은 제한된 CPU의 파워를 타임슬라이스(time slice)로 쪼개서 사용하고 있는 것뿐이다. 아무리 잘 만들어진 멀티태스킹 OS라도 CPU라는 한정된 자원을 나눠 사용하는 프로세스와 스레드들이 서로 긴밀하게 협조하면서 실행되게 만들어지지 않는다면 제대로 작동하지 않게 된다.

리스트 1은 간단한 예제지만 OS의 작동을 거의 멈추게 할 수 있다. beginthreadex() 함수는 이름에서 알 수 있듯이 새로운 스레드를 시작하게 하는 함수다. _beginthread의 세 번째 파라미터는 새로운 스레드로 실행될 루틴의 시작 주소이고 네 번째 파라미터는 이 루틴의 변수값이다. 리스트 1은 아무 것도 하지 않고 무한루프를 도는 스레드 3,000개를 만드는 프로그램이다. 비주얼 C++가 깔려 있다면 컴파일하고 실행하자(이 기사의 모든 예제는 비주얼 C++ 6.0을 기준으로 만들어졌다).


    C:\>cl /MDd test.c
    (/MDd은 멀티스레드 라이브러리를 사용한다는 옵션)
    C:\>test


이 프로그램을 실행시키면 실행환경의 OS가 윈도우 NT 4.0이건 윈도우 2000이건 바로 다운된다. 물론 OS가 완전히 다운되는 것은 아니고 CPU의 대부분을 3,000개의 아무 일도 하지 않는 무한루프가 차지하기 때문에 태스크 스위칭이나 사용자의 입력을 전혀 받지 못하는 상태가 된다. 즉, 사용자의 입장에서는 어떤 입력에도 반응하지 않고 test.exe를 죽이기 위해 태스크 매니저를 띄우려고 해도 아무런 반응이 없는, 사실상 시스템이 죽은 상태가 된다. 아무리 멀티태스킹 OS라고 해도 프로세스가 아무 일도 하지 않는 무한루프 while (1) { }을 실행시키면 전체 시스템의 속도가 많이 떨어질텐데 이런 루틴 3,000개가 동시에 돌아간다고 생각하면 당연한 결과이다.

다음은 약간 다른 버전의 thread() 함수다.

    unsigned __stdcall thread(void* arg)
    {
       while (1) {
             _sleep(50);
       }

       return 0;
    }

 _sleep() 함수는 현재 실행중인 프로세스를 잠시 대기상태가 되게 하는 함수다. 파라미터는 대기상태로 있는 시간이다(단위는 밀리초). 파라미터로 1,000을 주면 1초동안, 60,000을 주면 1분동안 대기상태가 된다(대기상태에 있는 스레드나 프로세스는 CPU를 거의 사용하지 않는다). 그래서 위의 루틴은 0.05초마다 한번씩 루프를 돌게 된다. 다시 컴파일해서 실행시켜보면 사용하는 시스템마다 약간씩 다르겠지만 느려지는 느낌이 들기는 해도 별다른 무리없이 PC를 사용할 수 있다.

멀티 플레이어 온라인 게임의 서버뿐만이 아니라 다른 범용적인 목적의 멀티유저용 프로그램의 서버라도 위와 같은 과다한 스레드의 사용문제에 부딪히게 된다. 동시에 여러 명의 사용자가 접속했을 때 이들의 요구에 동시에 응답하기 위해서는 스레드의 사용이 필수겠지만 수십 개의 스레드를 만들고 무한정 사용자의 입력을 기다릴 수는 없다. 동시 유저가 1,000명이 될 것이라고 가정하고 1,000개의 스레드를 만든다면 이론적으로는 맞지만 놀고 있는 스레드들이 CPU를 대부분 사용하므로 서비스의 전체 속도는 떨어질 것이 확실하다. 이러한 문제를 막기 위해 사용자가 접속할 때마다 스레드를 만든다고 해도, 만약 이 스레드가 필요하지 않은 경우에도 실행이 되고 있다면 같은 문제에 봉착하게 된다.

따라서 충분한 수의 스레드를 만들어 스레드 풀에 넣은 후에 이 스레드들을 사용하기 전까지는 대기상태에 있게 하는 방법이 필요하다. _sleep()은 특정 시간동안 대기상태에 있게 하지만 프로그래머가 정한 특정한 때에만 스레드나 프로세스가 실행되게 하고 싶다면 Win32의 이벤트 오브젝트(event object)를 사용하면 된다. 이벤트 오브젝트를 사용하면 필요하지 않을 때는 대기상태에 두었다가 필요할 때 이벤트를 발생시켜 프로세스를 깨울 수 있다(리스트 2).

CreateEvent()는 이벤트 오브젝트를 만드는 Win32 함수이며, SetEvent()는 이벤트를 발생시키는 Win32 API 함수다. WaitForSingleObject() 함수는 하나의 특정 이벤트를 기다리는 함수로 첫 번째 파라미터는 기다릴 이벤트 오브젝트의 핸들, 두 번째 파라미터는 기다릴 시간이다(단위는 밀리초). 두 번째 파라미터를 1,000으로 주면 WaitSingleObject() 함수는 1초동안 이벤트가 일어나기를 기다린다. INFINITE는 winbase.h에 미리 선언되어 있는 상수로 이벤트를 무한히 기다리게 된다. WaitForSingleObject()는 정해진 시간에 기다리는 이벤트가 발생하지 않으면 WAIT_TIMEOUT을 리턴한다.

CreateEvent() 함수의 두 번째 파라미터는 이벤트가 발생한 후에 이벤트의 신호(signal)를 리셋할 것인지 아니면 자동으로 리셋될 것인지를 TRUE(1)/FAL SE(0) 값으로 결정한다.

이벤트 신호(signal)는 교통신호등의 신호와 같은 거의 같은 의미다. 횡단보도에서 파란불을 기다리는 자동차처럼 WaitForSingle Object()에 있는 프로세스나 스레드는 신호(signal)가 들어오기를 기다리고 있다. 이 값을 TRUE(1)로 주면 한번 신호가 들어간 후에도 계속 신호등은 파란불인 상태로 남아있게 된다. 그래서 뒤에 대기 중이던 차들도 계속 통과하는 것이다. 이 신호를 리셋하기 위해서는 프로그래머가 ResetEvent() 함수를 이용해서 이벤트의 신호(signal)를 리셋해야 한다. FALSE(0)로 주면 자동으로 리셋이 된다. 즉, 한대의 자동차가 통과하고 나면 신호등은 다시 즉시 빨간불이 되어 한번에 한대의 자동차만 지나가게 된다. 이벤트 오브젝트의 모든 사용이 끝나면 CloseHandle() 함수로 오브젝트를 다시 커널에 반환한다. 더 자세한 것은 Win32 레퍼런스 가이드나 비주얼 C++ 헬프를 참고하기 바란다.

 

리스트 2 : 이벤트 오브젝트

#include <windows.h>
#include <process.h>
#include <stdio.h>

HANDLE hEvent;

unsigned __stdcall thread(void* arg)
{
   
while (1) {
   
WaitForSingleObject(hEvent, INFINITE);
   
printf(“Hello world!\n”);
}

return 0;
}

int main()
{

    char c;
    hEvent=CreateEvent(NULL, 0, 0, “testevent”);
    _beginthreadex(NULL, 0, thread, 0, 0, NULL);

    while (1) {
     
    c = toupper(getch());
      if (c == ‘H’)
      {
       
    SetEvent(hEvent);
     
    }
    else if (c == ‘C’)
    {
       
    break;
    }

}

CloseHandle(hEvent);

return 0;

}


리스트 2를 컴파일해서 실행하고 ‘h’키를 누르면 ‘Hello world!’를 도스창에 출력하고 ‘c’키를 누르면 프로그램이 종료된다. 프로그램이 실행되면 처음에는 thread() 루틴은 WaitSingleObject() 함수에서 hEvent 이벤트를 무한히 기다리게 된다. 사용자가 ‘h’키를 누르면 SetEvent() 함수로 hEvent 이벤트를 발생하고 thread()는 대기상태에서 빠져나와 printf(“Hello world!”)를 실행하고 다시 WaitSingleObject() 함수에서 무한히 hEvent 함수를 기다리게 된다. ‘c’값을 눌러 루프를 벗어나면 프로그램은 끝난다. 이때 따로 스레드를 닫지 않아도 thread()를 실행중인 스레드는 메인 프로세스의 자식 프로세스이기 때문에 OS에 의해 자동으로 없어진다. 하지만 thread() 스레드가 끝나고 데이터의 초기화나 다른 작업이 필요할 때는 어떻게 할까? 리스트 3을 보자.

리스트 2에서 한 개의 이벤트 오브젝트를 사용한 것에 반해 리스트 3은 3개의 이벤트 오브젝트를 사용하고 있다.

한번에 여러 개의 이벤트 오브젝트를 기다리기 위해 이벤트 오브젝트 Win32 API 함수 중에서 WaitFor MultipleObjects() 함수를 사용하고 있다. WaitFor MultipleObjects()는 WaitForSingleObject()와 달리 하나의 이벤트 오브젝트가 아니라 한 개 이상의 여러 개의 이벤트를 기다릴 수 있다.

WaitForMultipleObjects() 함수의 첫 번째 파라미터는 기다릴 이벤트의 개수, 두 번째 파라미터는 기다릴 오브젝트들, 세 번째 파라미터는 여러 개의 오브젝트 중 하나를 기다릴 것인지 아니면 여러 개의 이벤트 오브젝트를 모두 기다릴 것인지를 정한다. 이 값을 TRUE(1) 값으로 설정하면 기다리고 있는 여러 이벤트가 모두 발생해야 대기상태를 빠져나가고, FALSE(0) 값을 주면 기다리고 있는 이벤트들 중에서 하나의 이벤트만 발생해도 대기상태를 벗어나게 된다.

WaitForMultipleObjects() 함수의 리턴값은 발생한 이벤트의 인덱스값이나 에러가 발생할 경우의 에러코드다. 리스트 3에서는 만약 hHelloEvent 이벤트가 발생하면 0을, hByeEvent 이벤트가 발생하면 1을 리턴한다.

 

리스트 3 : 복수의 오브젝트 이벤트


#include <windows.h>
#include <process.h>
#include <stdio.h>

HANDLE hHelloEvent;
HANDLE hByeEvent;
HANDLE hCloseEvent;

unsigned __stdcall thread(void* arg)
{
     
HANDLE events[] = { hHelloEvent, hByeEvent };
      DWORD dwReturn;


     
while (1) {
      dwReturn = WaitForMultipleObjects
(2, events, FALSE, INFINITE);

      if (dwReturn == 0) {
      printf(“Hello world!\n”);
     
}

      else if (dwReturn == 1) {
     
break;
      }

      else {

      // 에러
     
}
      } // End of while

      printf(“Bye\n”);
      SetEvent(hCloseEvent);
     
return 0;
}
 

int main()
{

   char c;
   
hHelloEvent=CreateEvent(NULL,0,0,“helloevent”);
   
hByeEvent = CreateEvent(NULL, 0, 0, “byeevent”);    hCloseEvent=CreateEvent(NULL,0,0,“closeevent”);

   _beginthreadex(NULL, 0, thread, 0, 0, NULL);

   while (1) {

    c = toupper(getch());

   
if (c == ‘H’) {
    SetEvent(hHelloEvent);
   
}
    else if (c == ‘C’) {
    SetEvent(hByeEvent);
   
break;
    }

    } // End of While

   WaitForSingleObject(hCloseEvent, INFINITE);

   printf(“Closed”);
   CloseHandle(hHelloEvent);
   CloseHandle(hByeEvent);
   CloseHandle(hCloseEvent);

   return 0;

}



리스트 3을 컴파일하고 실행시킨 후 ‘h’키를 누르면 SetEvent(hHelloEvent)가 hHelloEvent를 발생해서 도스창에 ‘Hello world!’를 출력하는 것은 리스트 2와 같지만 ‘c’키를 누르면 hByeEvent 이벤트가 발생된다. WaitForMultipleObjects()는 hHelloEvent 이벤트 뿐만 아니라 hByeEvent 이벤트 역시 기다리고 있으므로 루프를 벗어나 ‘Bye’출력을 하고 hCloseEvent 이벤트를 발생시킨다. 이때 main()에서는 ‘c’키를 눌렀기 때문에 루프를 벗어나서 다음 라인에 있는 WaitFor SingleObject(hCloseEvent, INFINITE); 에서 hClos eEvent 이벤트를 기다리고 있기 때문에 대기상태에서 벗어나게 된다. 프로그램은 ‘Cloed’를 출력하고 종료된다.

멀티스레드 프로그래밍에서는 이와 같이 이벤트 오브젝트를 이용하거나 전역변수를 사용하면 스레드 간 통신 문제를 해결할 수 있다. 하지만 이외에도 여러 개의 스레드가 같은 데이터를 사용할 때 문제가 발생할 수 있다.

예를 들어 A, B, C 3개의 스레드가

int data = 0;

의 값을 모두 공통으로 사용한다고 할 때, 동시에 2개의 스레드가 data에 1을 더하는 오퍼레이션을 실행한다면 data의 값이 1이 될 것인지 아니면 2가 될 것인지는 아무도 모른다. 이렇게 스레드들이 사용하는 데이터가 숫자값이라면 그냥 틀리는 문제로 넘어가겠지만 만약 링크드 리스트나 트리같은 데이터 구조일 때는 운이 좋으면 데이터의 구조가 깨질 것이고, 운이 나쁘다면 프로그램 자체가 access violation 에러를 발생시키고 다운될 것이다.

이러한 문제를 피하기 위해 Win32 API에는 동기화 함수들(Synchronization Functions)이 준비되어 있다. 이 글에서는 모든 함수들과 오브젝트들을 다룰 수는 없기 때문에 CRITICAL_SECTION과 Interlocked 함수들만 다루도록 하겠다(Win32 동기화 함수들에 대해 더 알고 싶은 사람들은 Win32 API 레퍼런스 매뉴얼을 찾아보거나 MSDN 유저는 동기화(Synchronization)항목을 찾아보기 바란다). 크리티컬 섹션(Critical section)은 중대, 치명적이라는 Critical의 의미대로 프로그램 내에 한번에 한 개의 스레드만 진입이 필요한 영역을 의미한다. 크리티컬 섹션으로 정의된 영역은 어떤 스레드가 크리티컬 섹션에 들어가려고 해도 이미 다른 스레드가 이 영역에 들어가 있는 상태라면 뒤에 진입하려는 시도를 했던 스레드는 대기상태로 들어간다. 그리고 먼저 이 영역에 들어갔던 스레드가 이 영역을 벗어나면 대기상태에 있던 스레드는 크리티컬 섹션으로 들어가게 된다. Win32에서 크리티컬 섹션은 코드에서 크리티컬 섹션이라고 정의하는 것이 아니라(C나 C++에는 불행히 이런 문법이 없다) CRITICAL_SECTION이라는 스트럭처를 이용해서 가상으로 정의해서 사용한다. 즉, 같은 CRITICAL_SECTION 스트럭처를 사용하는 스레드는 같은 크리티컬 섹션으로 진입하는 스레드로 간주된다. 크리티컬 섹션으로 들어가는 API는 Enter Critical Section(), 벗어났다고 알리는 API는 Leave CriticalSec tion()이다. 자세한 내용은 다음 예제를 통해 살펴보자. Interlocked 함수는 이름이 Interlocked로 시작되는 함수들로 특정 32비트 변수에 대해 한 개 이상의 스레드가 동시에 접근하는 것을 막는 함수들이다. Interlocked 함수들은 다음과 같은 것들이 있다.

InterlockedCompareExchange
InterlockedCompareExchangePointer
InterlockedDecrement
InterlockedExchange
InterlockedExchangeAdd
InterlockedExchangePointer
InterlockedIncrement

이 중 예제에서 사용할 InterlokcedIncrement를 보자. 함수의 스펙은 아래와 같다. 이 함수는 32비트 변수값을 1 증가시키는 기능을 한다.

LONG InterlockedIncrement(LPLONG lpAddend);

파라미터는 32비트 변수의 주소이고 리턴값은 증가된값이다. 즉

int nNumber = 1;
int nResult;
nResult = InterlockedIncrement((long*) &nNumber);

의 결과는 nNumber값은 2가 되고 nResult에도 nNumber의 증가값인 2가 저장된다. 아래 예제는 Critical section과 Interlocked 함수를 사용한 한 개의 데이터값을 여러 개의 스레드가 동시에 사용하는 프로그램이다.

리스트 4는 10개의 스레드가 하나의 변수 int data을 랜덤한 시간 간격(0~1000밀리초)으로 1씩 증가시키고 이를 화면에 출력하는 프로그램이다. 이때 크리티컬 섹션을 사용하지 않는다면 int data의 값이 어떻게 될까? data++ 오퍼레이션은 실행시간이 짧기 때문에 data=1, data=2, data=3... 순으로 화면에 그려질 것이다. 낮은 확률이지만 동시에 2개 이상의 스레드가 int data값을 바꿔 data=1022, data=1022, data=1023과 같은 결과가 나올지도 모른다.

Win32 Critical section API를 사용하는 방법은 InitializeCriticalSection()으로 CRITICAL_SECT ION 스트럭처를 초기화하고 크리티컬 섹션에 들어갈 때는 EnterCriticalSection() 함수를 사용하고, 나올 때는 LeaveCriticalSection() 함수를 사용하면 된다. 사용이 끝났다면 DeleteCriticalSection()으로 크리티컬 섹션 오브젝트를 제거한다.

리스트 4를 컴파일하고 실행시키면 data=1 data=2 data=3... 이 화면에 계속 출력되고 아무키나 누르면 실행이 종료될 것이다. main()에서는 0.5초 간격으로 종료가 끝난 스레드의 숫자를 체크하며 루프를 돌다 모든 스레드 종료가 확인되면 프로그램을 끝내게 된다. 종료된 스레드의 숫자는 InterlockedIncrement()를 사용해서 증가시킨다. 동시에 여러 개의 스레드가 int closethread의 값을 증가시키려 해도 한번에 하나의 스레드만 int closedthread의 값을 증가시키기 때문에 데이터의 무결성은 보장된다.

 

리스트 4 : Critical section과 interlocked 함수


#include <windows.h>
#include <stdio.h>
#include <process.h>

CRITICAL_SECTION cs;

HANDLE hCloseEvent;
int data = 0;
int end = 0;
int closethread = 0;

unsigned __stdcall thread(void* arg)
{

   srand((int) arg);

   while (end == 0) {

   _sleep(rand() % 1000); // 0~1초간 기다린다    EnterCriticalSection(&cs);

   // critical section에 진입

   data++;
   printf(“data = %d\n”, data);
   LeaveCriticalSection(&cs); // critical section을 벗어났다
   }

   InterlockedIncrement((long*) &closethread);

   return 0;
}
 

int main()
{
   int i;
   
int seed;

   hCloseEvent=CreateEvent(NULL, 0, 0, “closeevent”);
   InitializeCriticalSection(&cs);

   for (i = 0, seed = 1033; i < 10; i++, seed += 2321)
   {
     
_beginthreadex(NULL, 0, thread, (void*) seed, 0, NULL);
   }

   getch();
   
end = 1;

   while (closethread < 10)
   {
   _sleep(500);
   
}

   DeleteCriticalSection(&cs);

   return 0;
}



리스트 4에서 사용한 크리티컬 섹션이나 다루지 않은 세마포어(semaphore), 뮤텍스(mutex) 등의 동기화(Synchronization) 방법을 사용하면 멀티스레드 프로그램에서 다수의 스레드가 하나의 공용 데이터를 사용한 것에 대한 무결성을 보장할 수 있다. 하지만 크리티컬 섹션, 뮤텍스, 세마포어 등을 이용해서 특정 데이터 사용 전에 락(lock)을 걸고 데이터의 사용이 끝난 후에 락(lock)을 푸는 것으로 데이터의 무결성을 보장할 수 있을지 모르지만, 여러 개의 락을 사용할 때 잘못된 순서/방법으로 사용할 경우 쉽게 데드락(dead lock)을 초래할 수도 있다. 리스트 5를 보자.

 

리스트 5: 데드락 (dead lock)

.....

EnterCriticalSection(&cs1);
db.delete_user(“user1”);

EnterCriticalSection(&cs2); // -> A
db.delete_item(“user1”);

LeaveCriticalSection(&cs2);
LeaveCriticalSection(&cs1);

.....

EnterCriticalSection(&cs2);
item = db.find_item(“user2”); // -> B

EnterCriticalSection(&cs1);
user = db.find_target_user(item->target);
user->damage(10);

LeaveCriticalSection(&cs1);
EnterCriticalSection(&cs2);

.....


리스트 5의 두 코드는 가상의 머그 서버에서 돌아가는 코드로 게임 내의 데이터베이스에 액세스하는 코드들이다. 이 가상의 서버는 동시에 수백 명의 사용자를 감당하기 위해 수십 개의 스레드를 사용하고 있고, 게임 내의 캐릭터가 죽거나 이동/로그인/로그아웃하고 아이템의 사용/이동이 빈번하기 때문에 2개의 CRITICAL_SECTION cs1과 cs2를 사용해서 유저 데이터를 관리한다. cs1은 유저의 데이터를, cs2는 아이템의 데이터를 액세스할 때 사용하는 크리티컬 섹션 오브젝트다.

언뜻 보기에는 맞는 것 같다. 하지만 만약 X라는 스레드가 리스트 5의 A 부분에서 cs2를 사용해서 cs2의 크리티컬 섹션으로 들어가려고 시도중이고, Y라는 스레드는 B 부분에서 cs1의 크리티컬 섹션으로 진입을 시도하고 있다면 X 스레드가 cs2를 벗어나야 Y 스레드가 cs1에 진입할 수 있고, X 스레드는 Y 스레드가 cs1을 벗어나야 cs2로 들어갈 수 있다. X 스레드와 Y 스레드가 서로 돌려줄 수 없는 것을 기다리고 있으므로 영원히 기다려야 한다. 명백한 데드락(dead lock)이다.

위의 데드락은 쉬운 편이라 누구나 쉽게 찾을 수 있는 것이지만 수천 명의 유저가 동시에 플레이 가능한 머그 게임의 서버일 경우, 전체 서버에 사용되는 락(lock) 오브젝트의 숫자가 동시 접속중인 사용자의 2~3배가 되는 경우가 빈번히 일어날 수 있다. 이러한 경우에 데드락을 막는 것은 매우 어렵다. 한 개의 스레드가 데드락이 될 경우 얼마 후에 데드락이 걸린 스레드에 사용된 락에 접근하는 모든 스레드가 같이 데드락에 빠지게 된다. 이런 데드락 문제는 실행환경의 복잡도가 올라갈수록 발생할 확률이 높아지기 때문에 동시 접속자의 숫자가 많을 때 발생하기 쉽고 같은 서버 프로그램이라도 사용자의 숫자가 적다면 발생확률이 낮다. 이런 버그는 개발기간에는 발생 자체가 힘들다. 서비스와 동일한 조건을 맞추기 위해서는 테스트 요원을 수천 명씩 뽑아야 하기 때문에 찾는 것뿐만 아니라 디버깅도 매우 힘들다. 따라서 프로그래머는 이러한 락(lock) 오브젝트들의 사용에 자신만의 규칙을 세우고 코딩 때는 반드시 이 원칙을 지켜서 개발을 해야할 필요가 있다.

리스트 5의 데드락 문제는 두 개의 크리티컬 섹션을 사용할 때 반드시 cs1를 통과한 다음 cs2의 크리티컬 섹션으로 진입하는 것으로 진입 순서를 정하면 해결이 가능하다. 즉, 아래와 같이 사용하면 데드락은 피할 수 있다.

    EnterCriticalSection(&cs1);

    ...

    EnterCriticalSection(&cs2);

    ...

    LeaveCriticalSection(&cs2);

    ...

    LeaveCriticalSection(&cs1);


클라이언트 & 서버 프로그래밍

그래픽 머드는 텍스트 머드에서 발전한 것이고 텍스트 머드는 채팅에서 나왔다. 이런 진화과정을 보면 그래픽 머드의 기본원리가 사실은 채팅과 그렇게 다르지 않다고 생각할 수도 있다. 최근에 실제 상용 서비스 중인 여러 채팅 서비스들이 채팅의 원래 기능인 텍스트의 전송 이외에 여러 가지 부가 서비스를 지원하고 있지만, 채팅 서비스의 기본 서비스는 ‘같은 채팅방에 있는 사람들에게 한사람이 전송하는 문장을 전파한다(broadcast)’일 것이다. 간단한 형태의 채팅서버를 만들고 싶다면 아래의 조건을 만족하는 서버 프로그램을 만들면 된다.

① 소켓을 열고 새로운 사용자가 접속하길 기다린다.
② 사용자가 접속하면 접속자 리스트에 추가한다.
③ 접속자가 문장을 서버로 보내면 모든 접속자에게 문장을 전송한다.
④ 접속이 끊기면 접속자 리스트에서 삭제한다.


클라이언트 쪽은

① 소켓을 열고 서버에 접속한다.
② 사용자가 문자를 입력하고 엔터키를 치면 서버에 문자열을 전송한다.
③ 서버에서 문자열을 보내면 화면에 출력한다.

클라이언트 쪽은 소켓프로그래밍에 대한 약간의 지식이 있다면 그리 어렵지 않게 구현할 수 있을 것이다. 서버쪽 기능들은 앞에서 다룬 멀티스레드를 이용, 2개의 스레드 루틴을 만드는 것으로 간단히 구현이 가능하다. 하나는 사용자의 접속을 처리하는 스레드이고 두 번째는 접속한 사용자를 처리하는 스레드이다. 사용자의 접속을 처리하는 스레드를 ServerThread라 하고 접속한 클라이언트의 패킷처리와 소켓관리를 처리하는 스레드를 UserThread라 부르기로 하자. 이 두 개의 스레드를 간단한 슈도(suedo) 코드로 정의하면 리스트 6과 같다.

 

리스트 6: ServerThread와 UserThread의 슈도코드


ServerThread()
{
   
init_socket(); // 소켓초기화

   
while (true) {
   
socket = accept(); // 새 접속을 기다린다
   
user = new user(socket);

   // 새로운 유저 오브젝트 생성
   
userlist.add(user); // 사용자 추가
   createUserThread(user);
// 새 UserThread()를 만든다
   
}
}

UserThread(user)
{
   while (true) {
   
event = wait_event(user.socket) // 소켓의 이벤트를 기다린다

   if (event == event_close) { // 소켓이 닫혔다는 이벤트 발생

   userlist.delete(user); // 사용자 삭제
   
break; // 루프를 벗어난다
   }

   else if (event == event_read) { // 읽기 이벤트 발생
   
string = read_socket(); // 소켓을 읽는다
   // 접속한 모든 사용자에 string을 보낸다
   userlist.send_string_to_all_user(string);
   
}

   }
}
 


ServerThread()는 처음에 서버 프로그램이 시작할 때 하나가 만들어진다. ServerThread()는 처음에 소켓을 초기화하고 초기화가 끝나면 새 사용자가 접속하기를 무한히 기다린다. 접속을 기다리다 새로운 사용자가 접속할 때마다 ServerThread()는 새로운 User 오브젝트를 만들고 새롭게 열린 소켓을 처리할 UserThread()를 만든다.

UserThread()는 wait_event(user.socket) 소켓에서 발생하는 사건(소켓이 닫혔다거나 아니면 소켓에 읽기 준비가 되었다 등의 이벤트)을 기다린다. 이때 소켓이 끊기거나 읽을 데이터가 소켓에 들어오면 발생한 이벤트를 처리한다. 소켓이 닫히는 이벤트가 발생하면 루프를 벗어나 UserThread()를 끝내고, 읽기 이벤트라면 소켓을 읽고난 후 읽은 문자 데이터를 현재 접속해 있는 접속자들(userlist가 보관하고 있는)에게 보낸다.

알고리즘을 설명하는 슈도코드에서는 이런 편리한 문법이 가능하지만 실제 코딩 때는 이러한 문법이 없으니 이렇게 쉽게 구현하는 것이 어렵다고 생각할 것이다. 하지만 WSA(Windows Socket API)를 사용하면 소켓 핸들에 특정 이벤트를 설정하는 것이 가능하다. 이것은 다음 예제에서 설명하도록 하겠다.

userlist 오브젝트는 사용자의 리스트를 관리하는 오브젝트로 여러 개의 스레드가 사용되기 때문에 내부적으로는 CRITICAL_SECTION이나 뮤텍스(mutex) 또는 세마포어(semaphore)같은 락(lock)을 사용해서 여러 개의 스레드가 동시에 userlist에 접근하더라도 데이터의 무결성을 보장해야 한다. 위의 예는 채팅서버를 만들기 위한 알고리즘이지만 몇 가지 추가 사항을 제외하고는 그래픽 머드 서버를 만드는 것과 별로 다르지 않다. 채팅 서비스에서는 채팅 서버와 클라이언트는 누가 무슨 말을 했는지에 대한 문자열에 관한 정보만 주고받는다. 채팅 클라이언트는 사용자가 타이핑한 문자열을 채팅 서버에 보내고 채팅 서버는 전달받은 문자열을 접속해 있는 사용자들에게 전해준다.

그래픽 머드의 서버/클라이언트도 별반 다르지 않다. 전달되고 주고받는 데이터가 단지 문자열이 아니라 여러 가지 타입의 패킷이라는 것이 다를 뿐이다. 리스트 7은 UserThread()의 그래픽 머드용 버전이다.

 

리스트 7 : 그래픽머드용 UserThread() 슈도코드


UserThread(user)
{
    while (true) {
         event = wait_event(user.socket)
         // 소켓의 이벤트를 기다린다
         if (event == event_close) { // 소켓이 닫히면...
             userlist.delete(user) // 사용자 삭제
             break // 루프를 벗어난다
         }
         else if (event == event_read) {
             // 읽기 이벤트 발생
             packet = read_socket() // 소켓을 읽는다
            user.packet(packet)
         }
    }
}

user::packet(packet)
{
    switch (packet) {
    case move :
           move(packet); // user를 이동한다
           userlist.seemove(user);
           // 접속자들에게 움직임을 알린다
           break;

    case say :
           userlist.seesay(packet); // 채팅을 처리한다
           break;
    }
}

 


그럼 실제 구현된 간략화된 형태의 머그 클라이언트와 서버를 보자(그림 1). 클라이언트는 게임화면과 채팅내용, 메시지가 나타나는 로그(log)창, 그리고 채팅 내용을 입력하는 입력창의 구성으로 되어있다. client.exe를 실행한 후 file 메뉴에서 login을 선택하면 그림 2의 대화상자가 열린다. Address에 서버의 주소를 입력하고 Name에 원하는 게임 내의 캐릭터 이름을 입력하고 확인 버튼을 누르면 본 게임에 접속하게 된다. 테스트 서버/클라이언트가 지원하는 기능은 채팅과 캐릭터의 이동뿐으로 공격같은 것은 되지 않는다. 캐릭터와 배경도 그래픽 이미지가 아니라 아스키 코드로 이루어져 있다. 방향키를 누르면 누른 방향으로 캐릭터(U자)가 이동할 것이다. 하지만 만약 이동할 위치에 다른 유저가 있거나 벽이 있다면 캐릭터는 움직이지 않는다.

서버와 클라이언트 중 클라이언트 쪽은 오직 서버와 통신만 하는 일반적인 윈속(Winsock) 프로그램이기 때문에 별다른 테크닉이 사용되지 않았다. 따라서 이 글에서는 서버 쪽을 중점적으로 설명하겠다.

 

리스트 8 : server.cpp의 일부


World g_world;
int g_nPort = 1001;
SOCKET g_hListener = NULL;
LONG APIENTRY WndProc(HWND hWnd, UINT message,

WPARAM wParam, LPARAM lParam)
{
   switch (message) {

   case WM_CREATE :

   // 서버 윈도우가 생길 때 ServerThread 스레드를 만든다.
   _beginthreadex(NULL, 0, ServerThread, 0, 0, NULL);
   ....
 
}

  unsigned __stdcall ServerThread(void* pArg)
 
{

  char szBuffer[1024];
 
WSADATA wd;

  // winsock 초기화
 
If (WSAStartup(0x0202, &wd) != 0) {

  sprintf(szBuffer, “WSAStartup error code=%x”, WSAGetLastError());
 
SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
  return 0;
  }

  g_hListener = socket(AF_INET, SOCK_STREAM, 0);
 
 
if (g_hListener == INVALID_SOCKET)
  {
     
sprintf(szBuffer, “socket error code=%x”,
     
WSAGetLastError());
     SendMessage(g_hwndLog, WM_USER_LOG, 0,
(LPARAM) szBuffer);
     
return 0;
 
}

  struct sockaddr_in sa;
 
memset(&sa, 0, sizeof(sa));
 
sa.sin_family = AF_INET;
  sa.sin_addr.s_addr = htonl(INADDR_ANY);
  sa.sin_port = htons(g_nPort);

  if (bind(g_hListener, (struct sockaddr*) &sa, sizeof(sa)) != 0)
  {

    sprintf(szBuffer, “bind error code=%x”, WSAGetLastError());
    SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
    return 0;
  }

  if (listen(g_hListener, 5) != 0)
  {

   
sprintf(szBuffer, “listen error code=%x”, WSAGetLastError());
    SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
    return 0;
  }

  while (true)
  {
 
     struct sockaddr_in ca;
     
int clientAddressLength = sizeof(ca);
      int nLength = sizeof(sa);
      SOCKET hSocket = accept(g_hListener,
(struct sockaddr*) &ca, &nLength);

      if (hSocket == INVALID_SOCKET) {
     
continue;
      }

      sprintf(szBuffer, “new connection  %d.%d.%d.%d”,
      sa.sin_addr.S_un.S_un_b.s_b1,
sa.sin_addr.S_un.S_un_b.s_b2,      
sa.sin_addr.S_un.S_un_b.s_b3, sa.sin_addr.S_un.S_un_b.s_b4);

     
Log(szBuffer);

      User* pUser = new User(hSocket);
      g_world.AddUser(pUser);             // 새로운 사용자 등록

      _beginthreadex(NULL, 0, UserThread, pUser,
0, NULL);

      // 새로운 UserThread 스레드를 만든다.
     
pUser->Send(S_NAME);   // 클라이언트에 사용자의 이름을 요구한다

  }  // End of while

  if (g_hListener != 0)
  {
 
    if (closesocket(g_hListener)==SOCKET_ERROR)
     
{
         sprintf(szBuffer, “closesocket error
code=%x”, WSAGetLastError());          SendMessage(g_hwndLog, WM_USER_LOG, 0, (LPARAM) szBuffer);
     
}
 
}

  WSACleanup();
 
return 0;
}
 


리스트 8은 ServerThread()를 실제로 구현한 코드로 클라이언트/서버 중 서버의 코드로 server.cpp에 있는 함수다. 알고리즘은 리스트 6의 슈도코드와 거의 비슷하므로 알고리즘 자체의 이해에는 어려움이 없을 것이다. ServerThread()는 서버의 메인 윈도우가 만들어진 후에 스레드로 실행되는 함수로 우리가 만들 머그 서버의 메인 루틴이다. Winsock 부분은 전형적인 Winsock 서버의 코드들이다. WSAStartup()으로 Winsock을 초기화시키고, 소켓(socket() 테스트 서버가 사용하는 1001번이다)을 열고, 이름을 정하고(bind()) 접속을 기다리고(listen()), 접속이 신청되면 허락한다(accept()).

새로운 접속이 생기면 new User(hSocket)로 새 유저 오브젝트를 만들고 g_world에 등록한다. 그리고 등록한 클라이언트에 접속한 유저의 이름을 묻는 패킷 S_NAME을 보낸다. g_world는 전체 게임세계에 있는 모든 오브젝트를 관리하는 클래스의 인스턴스로 접속한 사람들의 이름/위치/id를 가지고 있고, 전체 게임 내의 맵(map) 데이터를 가지고 있다. 이 맵 데이터를 이용해서 사용자의 캐릭터 이동시 벽이나 장애물 또는 접속해 있는 다른 사용자의 캐릭터들과의 충돌체크를 한다. ServerThread()는 새 접속자의 등록이 끝나면 접속한 사람별로 UserThread() 스레드를 실행한다.

이 테스트용 클라이언트/서버 환경에서 사용되는 패킷은 아래와 같은 구조로 되어 있다. 패킷의 처음 2바이트는 전체 패킷의 길이가 저장되고, 세 번째 바이트에는 패킷의 번호가 저장된다. 네 번째 바이트부터 패킷의 끝까지는 패킷의 몸체(body)가 저장된다.

User::Send(char)는 패킷몸체가 없는 패킷을 보내는 함수다. 패킷몸체가 없고 패킷번호만 보내기 때문에 이 함수로 보내지는 패킷은 항상 3바이트의 크기만 가진다는 것을 알 수 있다. 서버에서 클라이언트로 전달되는 패킷 중 S_MOVE라는 패킷이 있다. 이 패킷은 움직인 유저의 id(4바이트)와 x(4바이트), y(4바이트)로 구성되어 있다.

이 패킷을 인코딩하는 함수는 아래와 같다(SetChar(), SetInteger(), SetShort(), GetChar(), GetInteger(), GetShort()는 서버와 클라이언트가 공통으로 사용하는 util.cpp에 있다).

    char szPacket[512];
    char* pPacket = szPacket + 2;

    pPacket = SetChar(pPacket, S_MOVE);
    // 3번째 바이트에 패킷번호
    pPacket = SetInteger(pPacket, pUser->Id());
    // 4번째 바이트부터 id
    pPacket = SetInteger(pPacket, pUser->X());
    // 8번째 바이트부터 x
    pPacket = SetInteger(pPacket, pUser->Y());
    // 12번째 바이트부터 y
    SetShort(szPacket, pPacket - szPacket);
    // 0번째에 2바이트의 패킷 길이

    클라이언트 측에서 S_MOVE 패킷의 디코딩은 아래와 같다.

    Int nId;
    int nX;
    int nY;

    pPacket = GetInteger(pPacket, nId);
    pPacket = GetInteger(pPacket, nX);
    pPacket = GetInteger(pPacket, nY);

    WSAEVENT hEvents[] = { hEvent1, hEvent2, hEvent3 };

    ...

    WSAWaitForMultipleEvents(3, hEvents, FALSE, WSA_INFINITE, FALSE);

    ...

     

리스트 9, 10 : Server.cpp의 UserThread()와 user.cpp


UserThread()

unsigned __stdcall UserThread(void* pArg)
{
    User* pUser = (User*) pArg;
    enum MODE { PACKET_LENGTH, PACKET_BODY };
    MODE mode = PACKET_LENGTH;

    char szBuffer[512];
    short sPacketLen;
    int nPI = 0;
    int nRI = 0;

    WSAEVENT hRecvEvent = WSACreateEvent();
    int nRead;

    WSANETWORKEVENTS event;

    // 소켓의 읽기와 닫기를 이벤트로 정의
    WSAEventSelect(pUser->Socket(), hRecvEvent, FD_READ | FD_CLOSE);
    while (g_bShutDown == false)
    {

    // 이벤트를 기다림
    DWORD dwReturn =     WSAWaitForMultipleEvents(1,&hRecvEvent,FALSE,WSA_INFINITE, FALSE);

    if (dwReturn == WSA_WAIT_FAILED) { // 에러발생
    int nError = WSAGetLastError();
    }
    else if (dwReturn == 0) { // 이벤트 발생
    // 이벤트 신호를 리셋
    WSAResetEvent(hRecvEvent);
    }

    while ((nRead=recv(pUser->Socket(),(char*)  szBuffer + nPI, 512 - nPI, 0)) > 0)
    {
          nRI += nRead;

    while (nPI < nRI)
    {
        if (mode == PACKET_LENGTH)
        {
            if (nPI + 2 <= nRI)
            {
                // 패킷의 길이
                sPacketLen = szBuffer[0] + (szBuffer[1] << 8) - 2;
                nPI += 2;
                mode = PACKET_BODY;
            }
            else
            {
                break;
            }
        }
        else if (mode == PACKET_BODY)
        {
            if (nPI + sPacketLen <= nRI)
            {
                    // 패킷번호가 맞는지 확인
                    if (szBuffer[nPI] >= C_MAX)
                    {
                    }
                    else
                    {

                        // 받은 패킷 처리
                        pUser->Packet(szBuffer + nPI);
                    }
                   nPI += sPacketLen;
                mode = PACKET_LENGTH;
            }
        }
    }
    if (nPI == nRI)
    {
        nPI = nRI = 0;
    }
    else if (nPI > 0)
    {
        memmove(szBuffer, szBuffer + nPI, nRI - nPI);
        nRI -= nPI;
    }
}

// 발생한 이벤트의 종류 확인
WSAEnumNetworkEvents (pUser->Socket(), hRecvEvent, &event);

// FD_CLOSE 이벤트면 스레드 종료
if ((event.lNetworkEvents & FD_CLOSE) == FD_CLOSE)
{
        g_world.Remove(pUser); // 등록 사용자 삭제
        break;
}

} // End of While

WSACloseEvent(hRecvEvent); // 이벤트 오브젝트 닫기
return 0;

}
 

user.cpp

void User::Packet(char* pPacket)
{

    switch (*pPacket++)
    {
        case C_MOVE :
        if (IsAlive() == true)
        {
            g_world.Move(this, (char) *pPacket);
        }
        break;

        case C_SAY :
        {
                char szBuffer[1024];
                GetString(pPacket, szBuffer,
                sizeof(szBuffer));
                g_world.Say(this, szBuffer);
        }
        break;

        case C_ATTACK :
        {
                if (IsAlive() == true)
                {
                    unsigned int nVictim;
                    GetInteger(pPacket, (int&) nVictim);
                    g_world.Attack(this, nVictim);
                }
        }
        break;

        case C_NAME :
        {
                char szBuffer[1024];
                GetString(pPacket, szBuffer,
                sizeof(szBuffer));
                SetName(szBuffer);
                g_world.Enter(this);
        }
   }
}

void User::Send(char cPacket)
{
       char szPacket[4];
       SetShort(szPacket, 3);
       SetShort(szPacket + 2, cPacket);
       send(m_hSocket, (const char*) szPacket, 3, 0);
}


WSA로 시작하는 함수들은 Win32에서 지원하는 윈속 함수들이다. WSAEventSelect() 함수는 소켓핸들에 네트워크 이벤트가 정의된 이벤트 오브젝트를 설정한다.

설정한 소켓에 지정한 네트워크 이벤트가 발생하면 이벤트 오브젝트에 신호(signal)가 세팅된다. 따라서 만약 이 이벤트 오브젝트를 기다리고 있는 프로세스가 있다면 그 프로세스는 대기상태에서 벗어나게 된다.

WSAEVENT hRecvEvent = WSACreateEvent();

...

WSAEventSelect(pUser->Socket(), hRecvEvent, FD_READ | FD_CLOSE);

pUser->Socket()은 소켓핸들을 리턴하는 User 클래스의 멤버 함수다. 위의 코드는 hRecvEvent 이벤트 오브젝트에 FD_READ와 FD_CLOSE 네트워크 이벤트를 설정하고 pUser->Socket() 소켓에 hRecvEvent 오브젝트를 연결한다. 이렇게 하면 pUser->Socket()에 읽기 준비가 되었거나 소켓이 닫히면 hRecEvent 이벤트가 발생한다. 이때 주의할 것은 하나의 소켓에는 하나의 이벤트 오브젝트만 연결이 가능하다. 즉, 아래와 같은 코드는 잘못된 코드다.

WSAEventSelect(hSocket, hEvent1, FD_READ);
WSAEventSelect(hSocket, hEvent2, FD_CLOSE);

두 번째 줄이 실행될 때 첫줄에서 정의한 FD_READ 이벤트는 취소되고 hEvent2에 정의된 FD_CLOSE 네트워크 이벤트만 정상적으로 작동한다. 소켓에 정의된 이벤트를 모두 취소하고 싶다면 아래와 같이 하면 된다.

WSAEventSelect(hSocket, hEvent, 0);

네트워크 이벤트를 기다리는 함수는 앞에서 다루었던 WaitForMultipleObject()와 비슷한 기능을 하며 한 개 이상의 복수 이벤트를 기다릴 수 있다. 네트워크 이벤트를 기다리는 함수에는 WaitForSingleObject()와 같이 한 개의 이벤트만 기다리는 함수는 없다.

WSAWaitForMultipleEvents(1, &hRecvEvent, FALSE, WSA_INFINITE, FALSE);

WSAWaitForMultipleEvents()에서 사용되는 파라미터 역시 WaitForMultipleObject()와 비슷하다.

첫 번째 파라미터는 기다릴 이벤트 오브젝트의 갯수, 두 번째 파라미터는 기다릴 이벤트 오브젝트들의 시작주소, 세 번째 파라미터는 모든 오브젝트를 기다릴 것인지 아니면 오브젝트들 중의 하나만 기다릴지를 결정한다. 네 번째 파라미터는 기다릴 시간이다. WSA_INFINITE값을 주면 영원히 기다리게 된다. 리턴값은 DWORD값으로 발생한 이벤트의 순서를 의미한다. 리스트 10에서는 한 개의 오브젝트를 기다리고 있으므로 항상 0을 리턴할 것이다. 앞에서와 같이 3개의 이벤트를 기다리고 있을 경우 두 번째 이벤트인 hEvent2가 발생하면 리턴값은 1이 될 것이다.

WSAEnumNetworkEvents(pUser->Socket(), hRecvEvent, &event);

한 개의 이벤트 오브젝트에 여러 개의 네트워크 이벤트를 지정하기 때문에 실제 발생한 네트워크 이벤트를 알아내기 위해서는 WSAEnumNetworkEvents()을 이용해서 발생한 이벤트의 상세 정보를 알아내면 된다. 위의 코드는 event structure에 발생한 이벤트의 상세정보를 채운다. event.lNetworkEvents에 실제 발생한 이벤트의 값이 저장된다. FD_CLOSE 이벤트 처리 때 중요한 것은 소켓이 닫혀 FD_CLOSE 이벤트가 발생하더라도 아직 소켓의 버퍼에 읽지 않은 패킷이 남아 있을 수 있다. 그러므로 FD_CLOSE 이벤트가 발생해서 소켓이 끊어진 것이 확인되더라도 recv()에서 -1이 리턴될 때까지 recv()를 콜할 필요가 있다.
 

리스트 11은 world.cpp와 world.h의 일부이다. World 클래스는 접속한 사용자들의 데이터와 전체 월드의 맵 데이터를 관리한다. 맵 데이터는 캐릭터의 이동시 다른 캐릭터와 벽들 간의 충돌체크에 사용된다. m_mapUser는 C++의 standard library를 사용해서 만든 오브젝트로 접속한 유저의 ID값을 키값으로 User 데이터를 이진트리에 넣어 관리한다(standard library에 관한 자세한 내용은 C++ 레퍼런스 가이드를 참고하기 바란다). m_mapUser 오브젝트와 맵 데이터인 g_pszMap은 동시에 여러 개의 스레드를 사용하기 때문에 크리티컬 섹션으로 관리하고 있다. 만약 m_mapUser 오브젝트를 크리티컬 섹션을 이용해서 한번에 한 스레드가 접근하게 보호하지 않으면 한 스레드가 데이터를 삽입하고 있는 동시에 또 다른 스레드가 데이터를 삭제하려는 시도를 할 수도 있다. 이렇게 되면 m_mapUser의 이진트리 구조는 깨지게 되고 잘못된 메모리 액세스로 프로그램이 다운된다.  

World::AddUser()에서는 새로운 유저가 추가될 때 새롭게 추가되는 유저에 ID값을 부여하게 되는데 ID값은 m_nId 값을 InterlockedIncrement()을 이용해서 하나씩 증가시키면서 얻는다. World::Remove() 멤버 함수는 UserThread()에서 소켓이 끊어졌을 경우에 불려진다. m_mapUser에서 소켓이 끊어진 User 오브젝트를 삭제하고 현재 접속해 있는 모든 유저들에게 이 사실을 알려 클라이언트들의 화면에서 접속이 끊긴 유저의 캐릭터가 사라지게 한다. World::Enter()는 반대로 새로운 유저가 접속했을 경우 전체 유저들에게 새 유저의 접속을 알린다. 네트워크 게임에서 패킷의 양이 많아지고 패킷의 크기가 커지면 커질수록 필연적으로 네트워크 트래픽 증가가 따르게 된다. 테스트 서버의 경우 사용되고 있는 캐릭터의 정보는 이름밖에 없기 때문에 별 문제가 없지만, 실제 게임에서는 캐릭터의 스프라이트 정보는 물론이고 현재의 상태, HP, MP 등 보내야할 데이터가 많을 뿐만 아니라 한 화면에 수십명의 캐릭터가 있을 수 있다. 그렇기 때문에 캐릭터의 정보를 계속 보내는 것이 아니라 처음 화면에 나타날 때 캐릭터의 정보를 보내고 다음에 그 캐릭터의 상태가 변할 때(움직이거나 화면을 벗어나거나 그래픽 데이터가 변경되거나)는 바뀐 캐릭터의 ID 값과 새로운 상태만을 보내준다. 이 테스트 서버에서도 새로운 유저가 접속할 때 S_ENTER 패킷에서 캐릭터의 이름과 위치를 보낸 후 다음 패킷부터는 캐릭터와 ID 값만 가지고 통신을 하게 된다.

 

리스트 11 : world 클래스

#ifndef _WORLD_
#define _WORLD_

class User;

class World {
     protected:
     CRITICAL_SECTION m_csUser;
     CRITICAL_SECTION m_csWorld;
     std::map<unsigned int,User*> m_mapUser;
     unsigned int m_nId;

     public:
     World();
     ~World();


     bool MoveUser(User* pUser,char cDirection);
     void Move(User* pUser,char cDirection);
     void Say(User* pUser,char* pszSay);
     void Attack(User* pAttacker,unsigned int nVictim);
     void Send(char* pPacket,int nPacket);

     // 패킷을 전체 유저에 전송
     User* FindUser(unsigned int nId);
     void Enter(User* pUser);
     void AddUser(User* pUser);
     void Remove(User* pUser);
};
#endif


char g_pszMap[][1024] = {
...............
};

World::World()
{
     InitializeCriticalSection(&m_csUser);
     InitializeCriticalSection(&m_csWorld);
     m_nId = 0;
}

World::~World()
{
     DeleteCriticalSection(&m_csUser);
     DeleteCriticalSection(&m_csWorld);
}

void World::AddUser(User* pUser)
{
     int nId = InterlockedIncrement((long*) &m_nId);
     EnterCriticalSection(&m_csUser);
     pUser->SetId(nId);
     m_mapUser.insert(std::map<unsigned int,User*>::value_type(nId, pUser));
     LeaveCriticalSection(&m_csUser);
}

User* World::FindUser(unsigned int nId)
{
     User* pUser = NULL;
     std::map<unsigned int,User*>::iterator i;
     EnterCriticalSection(&m_csUser);
     i = m_mapUser.find(nId);
     if (i != m_mapUser.end())
     {
         pUser = i->second;
     }

     LeaveCriticalSection(&m_csUser);
     return pUser;
}

void World::Remove(User* pUser)
{
     std::map<unsigned int,User*>::iterator i;
     char szPacket[512];
     char* pPacket = szPacket + 2;
     pPacket = SetChar(pPacket, S_REMOVE);
     pPacket = SetInteger(pPacket, pUser->Id());
     SetShort(szPacket, pPacket - szPacket);
     EnterCriticalSection(&m_csWorld);
     g_pszMap[pUser->Y()][pUser->X()] = ‘ ‘;
     LeaveCriticalSection(&m_csWorld);
     EnterCriticalSection(&m_csUser);
     i = m_mapUser.find(pUser->Id());
     if (i != m_mapUser.end())
     {
        delete i->second;
        m_mapUser.erase(i);
     }
     LeaveCriticalSection(&m_csUser);
     Send(szPacket, pPacket - szPacket);
}


void World::Enter(User* pUser)
{
     char szPacket[512];
     char* pPacket = szPacket + 2;
     bool bPut = false;

     // 비어있는 자리를 찾는다.
     EnterCriticalSection(&m_csWorld);
     for(int nY=10; nY<25 && bPut == false; nY++)
     {
        for (int nX = 11; nX < 65 && bPut == false; nX++)
        {
                if (g_pszMap[nY][nX] == ‘ ‘)
                {
                   pUser->SetX(nX);
                   pUser->SetY(nY);
                   int nNum = strlen(g_pszMap[nY]);
                   g_pszMap[nY][nX] = ‘U’; // [nX] = ‘U’;
                   bPut = true;
                }
        }
     }
     LeaveCriticalSection(&m_csWorld);


     // 새로 접속한 클라이언트에 패킷을 보낸다.
     pPacket = SetChar(pPacket, S_ENTER);
     pPacket = SetInteger(pPacket, pUser->Id());
     pPacket = SetString(pPacket, pUser->Name());
     pPacket = SetInteger(pPacket, pUser->X());
     pPacket = SetInteger(pPacket, pUser->Y());
     SetShort(szPacket, pPacket - szPacket);
     Send(szPacket, pPacket - szPacket);

     std::map<unsigned int,User*>::iterator i;
     User* pTarget;

     // 새로 접속한 pUser를 제외한 전체 유저에 패킷을 보낸다.
     EnterCriticalSection(&m_csUser);
     for (i = m_mapUser.begin(); i != m_mapUser.end(); i++)
     {
        pTarget = i->second;
        if (pTarget != pUser) {
        pPacket=szPacket + 2;
        pPacket= SetChar(pPacket, S_ENTER);
        pPacket=SetInteger(pPacket,pTarget->Id());
        pPacket=SetString(pPacket,pTarget->Name());
        pPacket=SetInteger(pPacket, pTarget->X());
        pPacket=SetInteger(pPacket, pTarget->Y());
        SetShort(szPacket, pPacket - szPacket);
        pUser->Send(szPacket, pPacket - szPacket);
        }
     }
     LeaveCriticalSection(&m_csUser);
}


마치며

이 글에서 사용된 테스트용 서버와 클라이언트는 사실 미완성 버전이다. 전투나 HP 관리, 사용자 데이터의 저장, NPC 처리 등 구현되지 않은 것 투성이다. 처음 이 글을 시작할 때 구현하고 싶었던 것은 네트핵(Nethack)의 멀티유저 버전이었다. 이 글을 읽는 독자들 중 멀티유저 게임의 개발에 관심이 있는 사람이라면 이 미완성 프로그램을 발전시켜 완전한 형태의 네트핵 서버/클라이언트를 만드는 것에 도전해보길 기대하며 글을 마치겠다.

 

프로세스간 동기화 (Interprocess Synchronization)


멀티쓰레드 환경에서 세마포어, 뮤텍스와 크리티컬 섹션 오브젝트는 어떻게 다른가 살펴보자. 아래 코드는 전형적인 뮤텍스 오브젝트의 사용방법이다.

hMutex = CreateMutex (NULL, FALSE, “MyMutexObject”);

// 뮤텍스 오브젝트를 만든다.

...

unsigned __stdcall thread1()
{
   ...

   WaitForSingleObject(hMutex, INFINITE); // 크리티컬 섹션으로 진입
   ...

   ReleaseMutex(hMutex); // 크리티컬 섹션을 빠져나온다.
   ...
}

...

CloseMutex(hMutex); // 사용이 끝난 뮤텍스 오브젝트를 없앤다.

뮤텍스 오브젝트는 만들어질 때 이미 신호(signal)가 셋트되어 있는 상태다. 따라서 처음 WaitiForSingleObject(hMutex)를 콜(call)하는 쓰레드는 기다림없이 바로 크리티컬 섹션으로 들어갈 수 있다. 그리고 ReleaseMutex(hMutex)를 콜하기 전까지 신호(signal)는 리셋되어 있는 상태이기 때문에 다른 쓰레드들은 이 크리티컬 섹션으로 진입할수 없다. 뮤텍스 오브젝트와 크리티컬 섹션 오브젝트 모두 크리티컬 섹션의 기능을 수행하고 사용법도 비슷해 보인다.

둘 간의 차이는 뮤텍스 오브젝트는 interprocess synchronization (프로세스간 동기화)가 가능한 오브젝트이고, 크리티컬 섹션 오브젝트는 그렇지 않다는 것이다. CreateMutex()의 3번째 파라미터는 오브젝트의 이름을 스트링으로 지정하는 항목이다(NULL값으로 만들면 다른 프로세스에서 이 오브젝트를 찾을수 없다). 이렇게 만들어진 오브젝트는 지정한 이름으로 OS의 커널에 등록되어 여러 프로세스가 이 오브젝트를 사용하는 것이 가능하다. 만약 프린트를 하려는 프로그램이 2개 이상이 있다면? 여러 프로그램이 각자의 윈도우에 뭔가를 그리려고 동시에 시도한다면? 모두다 흔히 발생하는 일이다. 이때 프로세스간 동기화가 필요하다. OpenMutex(),OpenSemaphore(),OpenEvent()와 같은 API들을 사용하면 오브젝트의 이름으로 다른 프로세스에서 만든 오브젝트의 핸들을 얻을수 있다.

unsigned __stdcall thread2()
{
   hMutex = OpenMutex(NULL, FALSE, “MyMutexObject);
   WaitForSingleObject(hMutex, INFINITE); // 크리티컬 섹션으로 진입
   ...
   ReleaseMutex(hMutex); // 크리티컬 섹션을 빠져나온다.
}

뮤텍 스 오브젝트는 커널에서 만들어지고 크리티컬 섹션 오브젝트는 프로세서에서 만들어지기 때문에 프로세스간 동기화가 필요하지 않을 경우 크리티컬 섹션 오브젝트를 사용하는 것이 속도면에서 약간 빠르다.



       
소스 제공 : 프로그램의 세계 자료실 Multi.zip

:

TryEnterCriticalSection

Development/C / C++ 2006. 12. 27. 14:50
크리티컬 섹션에 관련된 함수 중의 TryEnterCriticalSection은 EnterCriticalSection함수 대신에
사용할 수 있는 함수인데 이 녀석은 Windows NT이상에만 사용할 수 있기 때문에 사용하기 위해서는 다음과 같은 일을 해주어야 한다.

1. #include <windows.h>   ; TryEnterCriticalSection은 winbase.h에 선언되어 있다.
2. _WIN32_WINNT Define
 두 가지 방법이 있다. 첫번째는 직접 소스코드에 define 해주거나 프로젝트 설정에서 Define해준다.
  1) _WIN32_WINNT 은 windows.h를 include하기 전에 define해준다.

#ifdef WIN32
#ifndef _WIN32_WINNT
#define _WIN32_WINNT 0x0500
#endif
#endif

#include <windows.h>

 2) Project Property - C/C++ - Preprocessor
사용자 삽입 이미지
   
이 때 "_WIN32_WINNT=0x400" 에서 공백문자가 없도록!!

 
:

'_beginthreadex': identifier not found, even with argument-dependent lookup

Development/C / C++ 2006. 12. 27. 10:02
'_beginthreadex': identifier not found, even with argument-dependent lookup

크리티컬 섹션에 관련된 함수를 사용할 때 종종 빌드 에러를 만나게 되는데

VS.NET 에서 Win32 Console 프로젝트 기본 생성시에는 Run-time Library가 Single-Thread Debug로 잡혀있기 때문에 _beginthreadex를 찾지 못하여 빌드 에러가 발생한다.

사용자 삽입 이미지

스레드 사용 코드


사용자 삽입 이미지

빌드 에러



정상적으로 빌드를 하려면 프로젝트의 속성 창을 열어서 'C/C++ - Code Generation' 의
Run-time Library 항목을 Single Thread에서 Multi Thread로 바꾸어 주면 된다.

사용자 삽입 이미지

프로젝트 속성 설정화면






:

WIN32 메모리 관리

Development 2006. 12. 22. 16:01
출처 : http://www.dasomnetwork.com/~leedw/mywiki/moin.cgi

WIN32 메모리 관리
  1. Introduction
  2. Virtual Memory
  3. 구조
  4. 메모리 할당 알고리즘
  5. 관련 함수
    1. 가상 메모리 확보reserve
    2. 확보된 영역에서 특정 페이지를 commit
    3. 가상 메모리 해제free
  6. Memory-Mapped File
  7. Memory-Mapped File과 주소 공간
  8. Memory-Mapped File의 이용
    1. File Kernel Object 생성
    2. File-Mapping Kernel Object 생성
    3. File Data를 프로세스 주소 공간에 mapping
    4. Memory-Mapped File의 해제
  9. Heap
  10. 프로세스의 기본 Heap
  11. 추가적인 heap의 용도
  12. 추가적인 heap의 사용
    1. Heap 생성
    2. Heap으로부터 메모리 블럭 할당
    3. 블럭 크기 변경
    4. Heap 메모리 블럭 해제 및 Heap 제거
  13. Heap을 이용한 new, delete 구현
  14. References

1 Introduction

win32에서는 크게 3가지의 메모리 관리 메커니즘을 제공하고 있다. 이 페이지에서는 각각의 메모리 관리 메커니즘의 특성과 관련 함수, 이용 방법등을 알아보도록 한다.
  1. 가상 메모리(Virtual memory) : 객체들의 큰 배열을 관리할 때 유용(x86 플랫폼이라면 4K이상)
  2. 메모리 맵 파일(Memory-mapped file) : 파일등의 연속된 데이타를 관리하고 프로세스 간의 통신을 할 때 유용
  3. 힙(Heap) : 많은 수의 작은 객체들을 관리할 때 유용

2 Virtual Memory

win32의 프로세스1는 시스템에 생성될 때 4GB 크기의 독립적인 가상 메모리를 가지게 된다. 32비트 메모리 포인터로 0x0에서 시작하여 0xFFFFFFFF까지 4,294,967,296개의 값중 어느 것이나 취할 수 있는데 이것이 한 프로세스가 가지는 4GB의 메모리 공간이 된다. win32이전의 윈도우 3.x나 DOS는 각 프로세스가 독립된 메모리 공간을 갖지 않고 윈도우OS나 DLL등의 윈도우 공유파일, 그리고 일반 윈도우 어플리케이션이 하나의 공통된 메모리 공간을 사용했다. 이런 메모리 구조는 상당히 취약해서 윈도우OS 내의 모든 프로그램들은 이미 메모리에 올려진 다른 프로세스나 나중에 올려지게될 프로세스까지 고려해야 하며 모든 프로세스가 다른 프로세스에 의해 간섭받거나 방해받을 수 있었다. 윈도우OS가 사용하는 코드 및 데이타 영역과 일반 어플리케이션이 사용하는 코드 및 데이타 영역이 구분되지 않아 시스템이 불안정할 수 밖에 없었다. 그러나 win32로 넘어오면서 윈도우OS는 시스템에 의해 일반 프로그램의 접근이 허용되지 않고 각 프로세스마다 독립된 주소공간을 부여받기 때문에 훨씬 안정적으로 동작하게 된다. 물론 이론적으로... :)
자, 그럼 윈도우의 가상 메모리 구조와 관리가 어떻게 이루어져 있는지 살펴보자.

2.1 구조

위에서 모든 프로세스는 자신이 실제 소유하고 있는 메모리와 무관하게 4GB의 독립된 주소 공간을 갖는다고 말했다. 따라서 프로세스 A의 메모리 포인터 0x12345678은 프로세스 B의 메모리 포인터 0x12345678가 가리키는 주소와 무관하게 동작한다. 따라서 프로세스 A와 프로세스 B는 서로의 영역에 접근이 허용되지 않는다. 시스템에 생성되는 각 프로세스는 4GB의 메모리 공간이 주어진다고 했는데 현실적으로 4GB 이상의 메모리를 가지는 시스템이 흔치 않기 때문에 가상 메모리라는 기법을 사용하게 된다. 가상 메모리는 은행의 원리와 동일하다. 은행의 경우 모든 예금주가 한꺼번에 돈을 찾아가지 않는다는 가정 하에 실제 은행 자신이 보유하고 있는 현금보다 더 많은 금액을 고객에게 빌려줄 수 있다. 이와 마찬가지로 win32 시스템은 실제 자신이 보유한 실 메모리의 양보다 더 많은 메모리를 프로세스에게 빌려준다.
win32의 메모리 구조는 win98과 NT계열인 win2000이 약간씩 다른데 전체적인 구조는 다음과 같다.
Windows의 가상 메모리 구조
memorylayout.JPG
각 partition별 기능은 다음과 같다.
Null-pointer Assignment 이 지역은 프로그램상의 메모리 접근 오류를 잡아내기 위하여 할당된 영역이다. 어떤 프로그램이든 이 영역에 읽고 쓰기가 금지되어 있는데 이 지역에 read/write가 행해지면 시스템은 access viloation을 발생시킨다. 시스템으로부터 메모리를 정상적으로 할당 받지 못할 경우 NULL(0x0)을 리턴받게 되는데 바로 이런 NULL pointer assignment를 방지하기 위한 공간이다.
MS-DOS 16-bit Compatibility(win98 only) win9x 에만 존재하는 4MB의 공간으로 MS-DOS와 win16과의 하위 호환성을 유지하기 위한 공간이다. 원래 이 공간 또한 read/write가 금지되어야 하는데 MS사에서 기술적인 몇 가지 이유로 이 공간을 보호하지 못했다고 한다. (이유는 모름.. |) ) win2000에서는 DOS나 win16 어플리케이션을 자신의 유저 주소 공간에서 실행하기 때문이 이 영역이 없다.
User area 프 로세스의 사용자 공간이다. 다른 프로세스에 의해 간섭받지 않는 고유의 공간으로서 이 공간에 exe와 dll 모듈등 자신들만의 데이타를 담게된다. 특기할 만한 것은 win9x에서는 처음 4MB를 제외한 0x400000부터 0x7FFFFFFF인 반면, win NT는 0x10000부터 0x7FFEFFFF로 역시 처음 2GB에서 64KB를 제외한 부분이다. 이는 어떤 프로그램이 실행될 때 메모리 상의 베이스 주소가 다를 수 있다는 것이다. 때문에 프로그램을 만들 때 win NT는 win 9x와 호환성에 주의해야 한다. win NT에 맞추어 프로그램의 베이스 메모리를 0x10000로 맞추었다가는 win9x에서는 접근이 금지된 지역이므로 실행이 불가능하게 된다. (물론 요즘엔 win 9x가 사라져가는 추세라서 그리 큰 영향은 없겠지만...)
64KB Off-Limits(win2000 only) 0x7FFF0000부터 0x7FFFFFFF까지는 win NT에서 처음 64KB의 영역과 마찬가지로 메모리 경계(off-limit)를 나타내는 지역이다. 커널 영역의 접근을 방지하기 위한 일종의 완충지역이라고 보면 된다.
Shared Memory Mapped File(win98 only) win9x 에만 존재하는 이 1GB의 영역은 모든 프로세스들이 공통적으로 접근하고 공유할 수 있는 시스템 dll들과 MMF(memory-mapped files)가 적재되는 곳이다. 3대 시스템 dll이라 불리는 kernel32.dll, user32.dll, gdi32.dll에서 advapi32.dll도 이 영역에 로드된다. win NT에서는 이런 시스템 dll조차 비공유 메모리 영역에 따로 적재하여 사용한다.
Kernel area 마 지막으로 win9x에서는 1GB, win NT에서는 2GB로 예약되어 있는 커널 공간이다. 스케쥴러, 메모리 관리자, 파일 시스템 코드, 네트워크 코드등의 OS 코드와 디바이스 드라이버 레벨의 모듈이 적재되는 곳이다. 원칙적으로는 이 영역에 대한 read/write가 금지되어 있지만, win98에서는 이 영역에 대해 접근이 가능하다. 유저 어플리케이션에서 이 영역을 손상시킬 수 있다는 점 때문에 win98이 자주 다운되고 불안하다는 비난을 받게 된 이유이다.
win32 메모리는 region이라는 단위로 나뉘어져 있다. 메모리 전체를 우리나라 지도라고 봤을 때 region은 한 개의 도로 비유할 수 있다. 윈도우즈 메모리는 region들의 집합으로 이루어져 있고 x86기반의 PC에서는 한 개의 region은 64KB의 크기를 갖는다. 프로그램에서 메모리를 할당받을 때 User area 내에서 할당을 받게 되어 있는데 새로운 메모리 할당시 반드시 64KB의 배수가 되는 시점에서 할당받도록 되어 있다. 이렇게 메모리를 할당받는 것을 allocation granularity라고 부르는데 메모리의 관리의 용이성때문이라고 보면 된다. allocation granularity는 CPU 플랫폼에 의존적이기도 한데 현재는 x86플랫폼뿐 아니라 32비트 알파칩, 64비트 알파, IA64등의 플랫폼에서도 allocation granularity가 64KB이다.
그리고 프로세스가 메모리를 사용하기 전에 예약reserve하게 되는데 이때 예약되는 메모리 region의 크기는 페이지 크기의 배수가 된다. 페이지page는 메모리 할당의 최소 단위로 allocation granularity와 마찬가지로 CPU에 따라 다르다. 대개는 4K나 8K를 쓰는데 인텔 x86 플랫폼은 allocation granularity와 마찬가지로 페이지 사이즈는 4KB이다.2 만약 프로그래에서 10KB의 메모리 공간을 할당받으려 한다면 x86 기반에서는 4K의 배수인 12KB를 할당받게 되는 것이다.
가상 메모리를 예약reserve할 때는 VirtualAlloc()을 사용하며 할당받은 메모리를 반환할 때는 VirtualFree()라는 API를 사용한다.
주의할 것은 메모리를 확보reserved 했다고 해서 그 영역을 바로 사용할 수 있는 것은 아니다. 확보된 영역을 사용하기 위해서는 실제로 물리적 메모리를 이 영역에 맵팽시키는 작업이 필요하다. 이를 commit이라고 하며 이때도 VirtualAlloc() 함수를 사용한다. commit을 할 때 반드시 확보된 영역 전체를 할 필요는 없으며 페이지 단위로도 할 수 있다. 만일 어떤 64KB 공간을 확보했을 때 첫번재와 두 번째 페이지(x86 CPU라면 8KB)만 commit할 수도 있다.

2.2 메모리 할당 알고리즘

pagetable.jpg
<물리적 메모리와 가상 메모리의 매핑 관계. Page table에는 페이지가 RAM에 존재하는지 paging file에 존재하는지에 대한 정보를 가지고 있다.>
옛날 DOS및 윈3.1 시절에는 시스템의 메모리는 그 시스템에 설치된 RAM이 전부였다. 만약 시스템에 1MB의 RAM이 설치되어 있다면 커밋할 수 있는 메모리는 1MB가 전부였던 것이다. 3그러나 오늘날의 현대적 운영체제들은 대부분 시스템에 설치된 RAM 이외에 디스크 파일을 메모리로 간주할 수 있는 메커니즘을 제공하고 있다. 이 때 이 메모리로 간주되는 파일을 페이징 파일(paging file)이라고 한다. 간단히 말해 자신의 시스템에 256MB의 메모리와 256MB의 페이징 파일을 가지고 있다면 물리적 메모리는 512MB가 되는 것이다.
가상 메모리 할당 알고리즘
위 그림은 가상 주소 공간이 물리적 주소로 변환되는 메커니즘을 보여주고 있다.
한 프로세스의 어떤 쓰레드thread4가 프로세스 주소 공간에 있는 데이타에 접근하려고 할 때 그 데이타가 RAM에 존재하고 있다면 CPU는 프로세스의 가상 주소를 해당 물리 주소에 맵핑하고 데이타에 접근하게 된다. 그러나 데이타가 RAM에 없다면 그 데이타가 페이징 파일에 있는지 확인하게 된다. 이 상황을 Page fault라고 한다. page fault가 발생하면 시스템은 페이징 파일내에 데이타가 존재하는지 확인하고 없다면 access violation을 발생시킨다. 접근하려는 데이타가 페이징 파일에 있다면 시스템은 현재 RAM에 비어 있는 페이지가 존재하는지 확인하고 존재하지 않는다면 RAM상의 어떤 페이지 중에 한 페이지를 선택해서 이를 해제free하게 된다.
만일 해제하려는 페이지의 데이터가 RAM에서 변경된 적이 있다면 (이를 데이터가 더럽혀졌다(dirty)라고 한다. :) ) 해당 페이지의 내용을 페이징 파일에 갱신한 후 해당 RAM 페이지를 해제한다. 새로 비워진 페이지에 원래 접근하려고 했던 데이터가 존재하는 페이지를 페이징 파일로부터 로딩하게 되고 CPU가 가상 주소를 물리 주소에 맵핑한 후에야 비로소 데이터에 접근하게 되는 것이다. 만일 시스템에 RAM이 부족하게 되면 page fault가 빈번하게 발생하게 되고 운영체제는 계속해서 RAM과 하드 디스크의 페이징 파일 사이에서 페이즈를 바꾸는 작업에 대부분의 시간을 소모하게 된다. 이런 현상을 thrasing이라고 부르는데 가끔 win2000을 사용하다보면 어느 순간에 이유없이 하드 디스크가 버버벅 거리며 돌아가면서 성능이 저하되는 모습을 본적이 있을 것이다. 이때가 바로 thrashing이 발생한 경우이다.
여기서 한 시스템에 수많은 프로그램들이 동시에 실행된다면 그 많은 프로그램들의 실행 코드나 데이타들을 페이징 파일에서 할당해야 할 테니 페이징 파일이 대책없이 커지지 않을까 하는 의문을 가질 수 있다. 이것을 방지하기 위해 시스템은 프로그램이 실행될 때 실행 파일을 열고 코드와 데이타의 크기를 먼저 확인한 뒤에 실행에 필요한 만큼의 영역을 확보한 후 실행 파일의 이미지 자체를 영역으로 할당한다. 프로그램의 .exe 실행 파일이나 DLL등의 파일 이미지가 주소 공간의 확보된 영역으로 사용될 때 이를 memory mapped file이라고 한다.5

2.3 관련 함수

위에서 설명했듯 가상 메모리 공간을 확보하거나 커밋하기 위해 VirtualAlloc()이라는 API를 쓴다. 이 함수의 프로토 타입은 다음과 같다.
LPVOID VirtualAlloc( 
  LPVOID lpAddress, // address of region to reserve or commit 
  DWORD dwSize,     // size of region 
  DWORD flAllocationType, 
                    // type of allocation 
  DWORD flProtect   // type of access protection 
); 

2.3.1 가상 메모리 확보reserve

lpAddress
확보하고자 하는 가상 주소의 시작 주소를 나타낸다. 시스템이 자동으로 빈 공간을 정하도록 할 때는 NULL을 주는데 만약 특정 주소를 넘겨줄 땐 반드시 User area 범위 내의 주소를 줘야 한다. 그렇지 않을 경우 VirtualAlloc은 NULL을 반환하게 된다. 또한 앞에서도 언급했지만 이 주소 값은 할당 세밀도(allocation granularity)의 단위에 맞춰져야 한다. 예를 들어 6,554,624(64KB*100+1024B)의 주소에서부터 영역을 할당하게 되면 시스템은 이를 내림하여 65,543,600(64KB*100)의 주소부터 영역을 할당하게 될 것이다.
dwSize
이 파라미터는 확보하고 싶은 주소의 크기를 바이트 단위로 나타낸다. 역시 위에서 언급했듯 확보하고 싶은 영역의 크기는 반드시 시스템의 페이지 크기의 배수여야 한다. 예를 들어 x86 플랫폼에서 126KB의 영역을 확보하려 한다면 시스템은 128KB 크기의 영역을 확보하게 된다.
flAllocationType
VirtualAlloc 함수를 이용해 메모리를 확보할 것인지 커밋할 것인지를 나타내며 각각 MEM_RESERVE, MEM_COMMIT의 값을 가진다. 시스템은 비어 있는 주소 중 어디에나 확보할 수 있기 때문에 차곡차곡 순서대로 영역이 확보될 것이라는 보장은 없다. 2000 계열에서는 MEM_TOP_DOWN이라는 플래그를 사용하면 가장 높은 주소부터 영역을 확보하게 된다.
flProtect
이 파라미터는 해당 영역에 할당할 보호 특성(protection attribute)을 지정한다. 어떤 페이지를 읽고 쓰기로 커밋하고 싶다면 사전에 PAGE_READWRITE 플래그를 주어 메모리를 확보해야 한다. 그외 페이지의 읽고 쓰기에 관한 권한이나 실행(execute)에 관한 여러 플래그가 있으니 MSDN을 참조한다. 98 계열에서는 이중 PAGE_NOACCESS, PAGE_READONLY, PAGE_READWRITE만 지원하고 있다. 주의할 것은 이 protection attribute가 확보된 영역에 해당되는 것이며 커밋된 물리적 메모리와는 무관하다는 것이다. 일단 메모리가 확보만 되고 커밋되지 않았다면 해당 주소에 대한 접근은 무조건 access violation을 일으키게 된다.

2.3.2 확보된 영역에서 특정 페이지를 commit

VirtualAlloc으로 커밋할 때는 flAllocationType 인자를 MEM_COMMIT으로 주면 된다. 물론 lpAddress 인자에 커밋을 원하는 메모리 시작 주소를, dwSize에 커밋할 메모리 크기를 주게 된다. 이 때 반드시 한번에 모든 영역을 커밋할 필요는 없다.
다음의 코드는 어떤 512KB 영역이 주소 6,553,600에서부터 확보되었을 때, 이로부터 2KB 후에서 6KB의 크기만큼 커밋하는 코드이다(x86 CPU 기준)
VirtualAlloc((PVOID)(6553600+2*1024), 6*1024, MEM_COMMIT, PAGE_READWRITE); 
이 경우 시스템은 페이지 크기의 배수로 커밋을 해야 하기 때문에 8KB의 메모리를 커밋하게 되며 커밋된 주소 공간은 6,553,600부터 6,561,791(6553600+8 KB-1)까지가 된다.

2.3.3 가상 메모리 해제free

프로세스가 더 이상 가상 메모리를 사용하지 않을 때는 이를 해제시켜 줘야 한다. 이때 사용하는 함수는 VirtualFree() 함수를 이용한다.
BOOL VirtualFree( 
  LPVOID lpAddress,  // address of region of committed pages 
  DWORD dwSize,      // size of region 
  DWORD dwFreeType   // type of free operation 
); 
커밋된 페이지를 해제할 때는 flFreeType 파라미터에 MEM_DECOMMIT 값을, 확보된 영역을 해제할 때는 MEM_RELEASE 값을 주게 된다. dwSize에 0을 주어 확보된 영역 전체를 모두 decommit하고 free한다.
VirtualFree(6553600, 0, MEM_DECOMMIT | MEM_RELEASE); 
만일 영역 전체가 커밋된 것이 아니라면 반드시 커밋된 페이지를 먼저 decommit 시키고 해제해야 한다.

3 Memory-Mapped File

가상 메모리와 유사하게 memory mapped file도 프로세스의 주소 공간에 특정 영역을 확보하고 이를 물리적 메모리에 맵핑할 수 있게 해준다. 다른 점은 가상 메모리에서는 물리적 메모리가 페이징 파일로 한정되는 반면, memory mapped file은 유저가 지정하는 임의의 파일 자체라는 것이다. 이렇게 파일이 매핑이 되고 나면 파일을 마치 메모리인 것처럼 액세스할 수 있게 되는 것이다.
memmap.jpg
<Memory-Mapped File과 가상 주소와의 관계>
memory mapped file은 보통 다음의 세 가지 경우에 사용된다.
  • EXE파일이나 DLL들을 로딩할 때 : 페이징 파일 크기를 줄이고 어플리케이션의 실행에 필요한 시간을 줄일 수 있다. 운영체제가 실행 파일을 읽어 오고 실행하는 내부적인 방법도 바로 memory-mapped file이다.
  • 디스크의 데이타 파일에 접근할 때 : 디스크의 I/O를 줄이고 파일을 메모리에 buffering할 수 있다. 이는 파일을 마치 메모리처럼 사용할 수 있으므로 간편한 파일 조작을 가능하게 한다.
  • 프로세스간 통신(IPC) : memory-mapped file은 win32에서 프로세스간 메모리를 공유하는 유일한 방법이기 때문에 시스템 상에서 수행중인 서로 다른 프로세스끼리 데이타를 주고 받을 때 사용한다. 6.
특히 IPC의 경우 윈도우즈에서는 RPC, COM, OLE, DDE, 윈도우 메시지(WM_COPYDATA), clipboard, mailslot, 파이프, 소켓과 같은 다양한 IPC 메커니즘을 제공하지만 실제로 하위 레벨에 가서는 이들 모두가 memory-mapped file로 구현되어 있다. 왜냐하면 memory-mapped file로 IPC를 구현하는 것이 성능대비 overhead가 적기 때문이다.

3.1 Memory-Mapped File과 주소 공간

CreateProcess()를 호출해서 EXE파일을 실행할 때 windows 운영체제가 어떤 일들을 하는지 step별로 살펴보자.
  1. CreateProcess()의 인자로 넘겨진 .exe 파일을 디스크에서 찾는다. 만약 찾지 못하면 FALSE를 리턴한다.
  2. windows는 새로운 Kernel Object를 생성한다.
  3. 새로운 프로세스를 위한 고유의 주소 공간(address space)를 생성한다.
  4. 새로운 프로세스를 로딩하기에 충분한 주소 공간 영역을 확보reserve 한다. windows에서는 .exe파일을 로딩하기 위한 base address의 기본값 으로 0x00400000 번지를 할당한다.7 만일 다른 주소를 base address로 지정하고자 한다면 linker에서 제공하는 옵션 /BASE로 지정할 수 있다.
  5. 시스템은 reserve된 물리적 메모리가 시스템의 paging file 대신 .exe파일에 있다는 것을 명시한다. 이 부분이 바로 Memory-Mapped File 형태로 동작하는 부분이다.
.exe파일이 프로세스 주소 공간에 매핑이 되면 시스템은 .exe에서 사용하는 dll을 로딩하기 위해 dll 파일들을 명시한 section을 읽어서 이 dll들을 LoadLibrary() 함수를 이용해 각각 메모리에 로딩하게 된다. LoadLibrary()가 호출하여 dll을 로딩할때의 동작은 4, 5번과 유사하다. 단지, 메모리에 dll을 로딩할 때의 base 주소값이 0x10000000 번지라는 것만 다르다. 이 역시 기본값으로 linker에서 /BASE 옵션으로 프로그래머가 재설정할 수 있다.
이렇게 실행 파일이 프로세스 주소 공간에 매핑 되고 dll까지 로딩이 끝났으면 운영체제는 .code section에 기록된 startup 코드를 실행하게 된다. 이후 시스템은 코드를 실행하면서 paging, buffering, caching을 모두 관리하는데 예를 들어 코드에서 아직 메모리에 로딩되지 않는 주소의 인스트럭션으로 점프하게 되면 page fault가 발생하고 시스템은 파일 이미지에서 해당 주소의 페이지를 RAM으로 가져와서 프로세스 주소 공간에 매핑한다. 그리고 마치 아무 일 없었다는 듯이 코드를 계속 실행시키게 된다. 이 과정은 RAM에 로딩되지 않는 코드 영역이나 데이타 영역을 액세스할 때마다 반복되는 것이다.
그럼 여기서 새로운 어플리케이션이 아닌, 이미 실행되고 있는 어플리케이션을 하나 더 실행시키면 어떻게 될까.
이때 는 실행 파일 이미지를 구분할 수 있는 memoy-mapped view를 하나 더 생성하고 새로운 process object를 만들게 된다. 여기에 새 process id와 thread id를 부여하고 memory-mapped file를 이용해서 가상 메모리에 로딩되어 있는 코드와 데이타를 공유하게 된다.
mmf1.jpg
<어플리케이션이 1개만 실행되고 있을 때 프로세스 주소 공간과 가상 메모리가 매핑되어 있는 모습>
mmf2.jpg
<같은 어플리케이션을 하나 더 실행했을 때. 시스템은 같은 파일을 메모리에 로딩하지 않고 이미 가상 메모리에 올라가 있는 코드와 데이터 영역 페이지를 매핑한다.>
만약 여기서 실행되고 있는 두개의 어플리케이션 인스턴스중 하나가 데이타 페이지에 있는 내용을 변경시키면 다른 인스턴스에까지 영향을 미치게 되는 문제가 발생하게 된다. 이를 예방하기 위해 시스템은 copy-on-write라 는 기법을 사용하고 있는데 이는 공유하고 있는 페이지중 하나를 어떤 인스턴스에서 변경하려고 하면 가상 메모리에 원래 페이지를 복사해서 같은 페이지를 생성한 후 새 페이지에 변경 내용을 반영하는 기법을 말한다. 아래 그림은 이렇게 변경된 주소 공간의 모습을 보여주고 있다.
mmf3.jpg
<First Instance에서 Data page 2이 내용을 변경했을 때 운영체제는 가상 메모리에 새로운 페이지를 생성해서 First Instance의 주소 공간에 매핑하게 된다. 그리고 New page의 내용에 변경된 값을 쓰게 된다.>

3.2 Memory-Mapped File의 이용

memory-mapped file을 이용하기 위해선 다음의 세 단계를 거쳐야 한다.
  1. 디스크에서 Memory-mapped file로 사용할 파일를 식별하기 위하여 파일 커널 객체(file kernel object)를 생성한다.
  2. 시스템에게 파일의 크기와 어떻게 파일에 접근할 것인지를 알리기 위해 파일 매핑 커널 객체(file-mapping kernel object)를 생성한다.
  3. 시스템에게 파일 매핑 커널 객체의 일부, 혹은 전부를 자신의 프로세스 주소 공간에 매핑시키도록 한다.
이렇게 memory-mapped file을 생성하여 사용을 끝마쳤을 때는 다음의 세 단계를 거쳐서 memory-mapped file을 해제시켜야 한다.
  1. 프로세스 주소 공간에 매핑되어 있는 파일 매핑 커널 객체(file-mapping kernel object)를 unmap한다.
  2. 파일 매핑 커널 객체를 close한다.
  3. 파일 커널 객체(file kernel object)를 close 한다.

3.2.1 File Kernel Object 생성

memory-mapped file을 위해 이미 존재하는 파일을 열거나 혹은 새로 생성하고자 한다면 CreateFile() 함수를 이용해서 새로운 파일 커널 객체를 생성하게 된다.
HANDLE CreateFile( 
  LPCTSTR lpFileName,          // pointer to name of the file 
  DWORD dwDesiredAccess,       // access (read-write) mode 
  DWORD dwShareMode,           // share mode 
  LPSECURITY_ATTRIBUTES lpSecurityAttributes, 
                               // pointer to security attributes 
  DWORD dwCreationDisposition,  // how to create 
  DWORD dwFlagsAndAttributes,  // file attributes 
  HANDLE hTemplateFile         // handle to file with attributes to  
                               // copy 
); 
각 인자에 대한 자세한 설명은 MSDN 라이브러리를 참고하고 여기서는 이 함수를 쓰는 간단한 예를 들어 보겠다.
예)
HANDLE hFile = CreateFile( pszPathName, 
                           GENERIC_WRITE | GENERIC_READ, 
                           FILE_SHARE_READ | FILE_SHARE_WRITE, 
                           NULL, 
                           CREATE_ALWAYS, 
                           FILE_ATTRIBUTE_NORMAL, 
                           NULL); 
pszPathName이라는 이름으로 파일을 읽고 쓰기 권한으로 열고 오픈한 파일의 공유 모드 역시 읽고 쓰기 모드로 공유하게 했다. 그리고 같은 이름의 파일의 존재 여부와 상관없이 새로운 파일을 생성시키는데 만약 기존에 같은 이름의 파일이 존재하면 덮어써버리게 된다.
함수가 성공적으로 수행이 되면 파일에 대한 핸들이 리턴되며 함수 실행이 제대로 수행되지 않았다면 INVALID_HANDLE_VALUE(-1)가 반환된다.
만약 프로세스간 공유를 목적으로 운영체제에서 사용하는 페이징 파일을 이용하고자 한다면 굳이 새로운 파일을 생성시킬 필요는 없다. 이때는 CreateFile함수를 이용해서 파일 커널 객체를 생성하는 이 과정은 생략이 된다.

3.2.2 File-Mapping Kernel Object 생성

시스템에게 CreateFile함수로 파일 매핑에 사용될 파일의 위치를 지정해 주었다면, 이번엔 파일 매핑 객체를 위해 얼마만큼의 공간을 잡을 것인지를 알려줘야 한다. 이때 사용하는 함수가 CreateFileMapping() 함수이다.
HANDLE CreateFileMapping( 
  HANDLE hFile,              // handle to file to map 
  LPSECURITY_ATTRIBUTES lpFileMappingAttributes, 
                             // optional security attributes 
  DWORD flProtect,           // protection for mapping object 
  DWORD dwMaximumSizeHigh,   // high-order 32 bits of object size 
  DWORD dwMaximumSizeLow,    // low-order 32 bits of object size 
  LPCTSTR lpName             // name of file-mapping object 
); 
역시 각 인자에 대한 설명은 MSDN 도움말을 참고한다.
예)
HANDLE hFileMap = CreateFileMapping(INVALID_HANDLE_VALUE, 
                                        NULL, 
                                        PAGE_READWRITE, 
                                        0, 
                                        0x1000, 
                                        “SharedMemoryFile” 
CreateFileMapping()의 첫번재 인자는 memory-mapped file로 이용할 파일 커널 객체의 핸들을 넘겨주게 되어 있다. 따라서 위의 CreateFile()을 호출해서 리턴되는 핸들을 이 인자로 전달하면 된다. 여기서의 예는 이 값을 INVALID_HANLDE_VALUE로 주어 특정 파일 대신 시스템의 페이징 파일을 이용할 수도 있다는 것을 보여준다.
그 리고 다음에는 파일 매핑 객체에 보호 특성(protection attribute)를 주도록 되어 있다. 위에서 설명했지만, memory-mapped file을 생성한다는 것도 결국 가상 메모리를 할당받는 것과 마찬가지로 프로세스의 주소 공간의 일부 영역을 확보reserve해서 그 영역을 실제 물리적 저장소에 커밋commit하는 것과 같다. 단지 memory-mapped file은 그 물리적 저장소를 시스템의 페이징 파일 대신에 디스크의 특정 파일로 지정해줄 수 있다는 차이뿐인 것이다. 여기서 파일 매핑 객체를 생성한다는 것이 막바로 시스템으로 하여금 주소 공간을 확보reserve해서 그 파일 영역을 그 주소로 매핑하도록 하는 것은 아니다. 그러나 파일 영역을 프로세스의 주소 공간에 매핑할 때 시스템은 물리적 저장소에 어떤 보호 특성을 지정해줘야 할지 알아야 하기 때문에 CreateFileMapping() 함수에서 보호 특성을 지정해 줄 수 있게끔 되어 있다.
이 예에서는 파일 매핑 공간에 대해 읽고 쓰기 특성을 부여하도록 했다.
보호 특성 다음의 두 인자 dwMaximumSizeHighdwMaximumSizeLow는 memory-mapped file로 사용할 파일의 크기를 전달하는 인자인데 각 32비트씩 상위 하위 바이트로 총 64비트, 즉 16EB 크기의 파일까지 지정해줄 수 있게 되어 있다. 그러나 대부분 32비트 윈도우 플랫폼이 사용되는 현재는 하나의 파일 크기가 4GB를 넘는 경우가 거의 드물기 때문에 dwMaximumSizeHigh 인자에는 0을 할당하고 dwMaximumSizeLow 인자에 memory-mapped file의 크기를 지정해 주면 된다.8 기존에 존재하는 파일일 경우는 GetFileSize()함 수로 파일의 크기를 가져와서 그 값을 전달해도 되고 이 예에서는 고정된 값 0x1000, 즉 4KB 만큼 주었다. 만약 파일 매핑 오브젝트의 크기를 오픈한 파일 사이즈 값을 그대로 반영시키고 오직 읽기 위해서 액세스를 한다면 두 인자 모두 0을 주면 된다. 그리고 memory-mapped file에 어떤 내용을 덧붙여서 파일 크기가 증가할 것 같으면 실제 파일 크기보다 더 여유있는 크기로 파일 매핑 오브젝트를 생성하도록 한다.
만약 memory-mapped file로 사용할 파일을 새로 생성해서 이 함수를 호출한 것이라면 이 함수가 불려진 후에는 실제로 디스크에 이 함수의 인자로 넘겨준 크기만큼의 파일이 생성이 될 것이다. 그러나 이 시점에서는 memory-mapped file로 사용할 파일을 생성시킨 것 뿐 실제로 프로세스의 메모리에 매핑된 것은 아니다.
마지막 인자 pszName 자리에 들어간 "SharedMemoryFile"은 파일 매핑 커널 객체(file-mapping kernel object)의 이름을 부여하는 것으로 주로 다른 프로세스에서 이 메모리 맵 파일을 공유할 때 사용된다. 만약 공유 목적이 아니라면 이 자리에 NULL을 주어도 상관없다.
이렇게 파일 매핑 커널 객체를 생성했으면 이제 이 객체object를 프로세스의 주소 공간에 매핑하는 일이 남았다.

3.2.3 File Data를 프로세스 주소 공간에 mapping

프로세스의 주소 공간의 영역을 확보reserve하고 그 영역에 매핑되는 물리적 주소를 파일 데이타로 커밋commit하는 함수가 MapViewOfFile() 함수이다.
LPVOID MapViewOfFile( 
  HANDLE hFileMappingObject,  // file-mapping object to map into  
                              // address space 
  DWORD dwDesiredAccess,      // access mode 
  DWORD dwFileOffsetHigh,     // high-order 32 bits of file offset 
  DWORD dwFileOffsetLow,      // low-order 32 bits of file offset 
  DWORD dwNumberOfBytesToMap  // number of bytes to map 
); 
여기까지 제대로 따라왔다면 여기에 넘겨지는 인자들의 내용은 그리 어렵지 않게 이해할 수 있을 것이다.
예)
lpMapVIew = MapViewOfFile(hFileMap, 
                          FILE_MAP_READ | FILE_MAP_WRITE, 
                          0, 0, 
                          0); 
첫번째 인자는 CreateFileMapping()함 수에서 리턴되는 파일 매핑 객체(file-mapping kernel object)의 핸들이다. 두 번째는 파일에서 매핑된 메모리의 접근 권한을 명시하며 세 번째와 네 번째는 메모리 맵 파일의 offset 값이다. 파일 매핑 객체를 생성했다고 해서 반드시 파일 전체 크기를 메모리로 매핑시킬 필요는 없고 파일의 일부를 메모리로 매핑시킬 수 있는데 이렇게 메모리로 매핑된 파일의 일부를 view라고 부르는 것이다. 윈도우즈에서는 memory-mapped file의 크기를 16EB까지 지원하므로 32비트 인자 2개를 써서 64비트의 offset 값을 사용한다. win32에서 프로그래밍하고 있다면 dwFileOffsetHigh는 0을 넣고 dwFileOffsetLow에 실제 파일의 offset 값을 넘겨주면 될 것이다. 이때 주의할 것은 파일의 offset 값 또한 위의 가상 메모리에서 설명한 시스템의 할당 세밀도(allocation granularity)의 배수로 지정해줘야 한다는 것이다.
다섯 번째 인자는 파일의 offset으로부터 얼마만큼의 크기를 메모리로 매핑시킬 것인지를 알려준다. 0을 넘겨주면 offset에서부터 파일의 전체를 메모리로 매핑시키겠다는 의미이다.9
만약 파일의 view를 프로세스 주소 공간의 특정 주소로 매핑시키고 싶다면 다음의 함수를 이용한다.
LPVOID MapViewOfFileEx( 
  HANDLE hFileMappingObject,  // file-mapping object to map into  
                              // address space 
  DWORD dwDesiredAccess,      // access mode 
  DWORD dwFileOffsetHigh,     // high-order 32 bits of file offset 
  DWORD dwFileOffsetLow,      // low-order 32 bits of file offset 
  DWORD dwNumberOfBytesToMap, // number of bytes to map 
  LPVOID lpBaseAddress        // suggested starting address for mapped  
                              // view 
); 
대부분의 인자는 MapViewOfFile()함수와 같고 마지막 인자 lpBaseAddress에서 매핑할 주소를 지정하도록 되어 있다. 이때 지정할 주소 역시 allocation graularity(64KB)의 경계에 맞추어 주어야 한다. 그렇지 않으면 에러가 발생하여 NULL을 리턴하게 된다.10 그외 매핑하려는 주소에서 파일 사이즈에 비해 빈 공간이 적어서 이미 확보된 다른 영역까지 overlap될 위험이 있을 경우 역시 NULL이 반환된다. 이 MapViewOfFileEx() 함수는 때때로 유용하게 쓰이는데 예를 들어 두 개 이상의 프로세스가 memory-mapped file을 공유하고 있고 공유하는 데이타 내용이 linked list인 경우 모든 프로세스가 linked list의 노드를 정상적으로 액세스하려면 동일한 주소로 매핑시켜야 할 것이다. 그렇지 않으면 노드 안에 있는 next node의 주소 값이 한 프로세스에게만 의미가 있고 나머지 프로세스들은 엉뚱한 주소로 액세스할테니 말이다.
MapViewOfFIle()에서 정상적으로 메모리가 commit이 되었다면 commit된 메모리의 base address가 void pointer 형태로 반환된다. 이후에는 이 값으로 메모리 포인터를 사용하듯 이용하면 되는 것이다.
만약 다른 프로세스에서 이 메모리 맵 파일을 공유하여 사용하려면 OpenFileMapping() 함수와 파일 매핑 커널 객체의 이름을 이용하면 된다.11
예)
hFileMapping = OpenFileMapping(FILE_MAP_WRITE, FALSE, "SharedMemMapFile"); 

3.2.4 Memory-Mapped File의 해제

파일 데이타를 더 이상 메모리로 매핑할 필요가 없어졌을 땐 UnmapViewOfFile() 함수로 메모리 영역을 release시켜줘야 한다.
BOOL UnmapViewOfFile( 
  LPCVOID lpBaseAddress   // address where mapped view begins 
); 
첫번째 인자 lpBaseAddressMapViewOfFile()에서 리턴된 메모리의 시작 번지이다.
memory-mapped file도 어쨌든 시스템으로부터 할당 받은 메모리로 간주되므로 이것을 release시켜주지 않으면 프로세스가 끝날 때까지 리소스를 쥐고 있게 된다. MapViewOfFile()이 호출될 때 마다 시스템은 매번 새로운 메모리 영역을 확보해서 리턴할 뿐 이전의 호출로 확보된 영역을 release해주지 않으므로 반드시 MapViewOfFile() 함수마다 UnmapViewOfFile()이 쌍을 이루어 호출되어야 한다. 이 함수가 불려지면 운영체제는 메모리로 매핑된 파일중 변경된 내용을 파일 이미지에 반영시키고 프로세스 주소 공간중 memory-mapped file에서 매핑된 영역을 release한다.12
파일 매핑 객체(file-mapping object)로 또다른 map view를 생성할게 아니라면, 지금까지 오픈했던 커널 객체들, 즉 파일 매핑 객체와 파일 객체(file object)를 시스템에 반환해줘야 한다. 이것은 CloseHandle() 함수를 사용해서 각각의 객체 핸들을 인자로 넘겨주면 된다. 이때 각 객체를 반환하는 순서는 상관이 없다.

see also

4 Heap

자, 이제 마지막으로 win32에서 제공하는 메모리 관리의 3번째 메커니즘은 힙heap이다.
heap은 내부적으로 프로세스 주소 공간에 확보served된 메모리 영역region을 의미한다. 즉, heap 영역은 reserve되어 있고 "heap 메모리를 할당받는다."는 것은 그 reseve된 공간을 실제 물리적 메모리 공간으로 커밋commit한다라는 의미인 것이다. 이렇게 commit되는 물리적 메모리 공간은 항상 시스템의 페이징 파일에서 할당 받게 되며 할당받은 메모리 블럭을 해제free하면 heap 관리자는 물리적 메모리에서 해당 블럭을 decomiit하게 된다.13
heap은 작은 크기의 블럭을 여러개 할당해야할 응용에 가장 적합한데 그 이유는 가상 메모리는 페이지 단위로 할당되기 때문에, 즉 몇 바이트를 쓰든 상관없이 항상 할당 세밀도(allocation granularity)로 할당 받게되며 페이지 경계(page bounary)도 신경을 써야하는 반면, heap은 어플리케이션에서 요구하는 만큼만 할당되므로 훨씬 경제적으로 이용할 수 있다는 장점이 있는 것이다. 예를 들어 링크드 리스트(linked list), 트리(binary tree)는 가상 메모리나 메모리 맵 파일로 관리하는 것보다는 힙으로 관리하는 것이 가장 적당할 것이다. 그리고 C++에서 new연산자등을 통해 class object를 동적으로 할당 받는 것에도 heap이 이용된다. 면 heap은 가상 메모리를 이용해서 구현된 높은 수준의 메모리 관리 메커니즘이므로 메모리를 할당받거나 해제할 때 다른 메모리 관리 메커니즘보다는 속도 측면에서 떨어질 수 밖에 없고 물리적 메모리를 commit하거나 decommit하는 등의 직접 제어가 불가능하다는 단점이 있다.

4.1 프로세스의 기본 Heap

운영체제에서 프로세스를 생성할 때 기본적으로 할당되는 힙 영역이 있는데 이를 프로세스의 기본 힙(Process`s Default Heap)이라고 한다. 프로그래머가 링커 옵션인 /HEAP으로 사이즈를 지정해주지 않는다면 프로세스 주소 공간에 기본적으로 1MB의 heap영역이 만들어 진다.14 물론 이 기본 heap은 run-time시에 크기를 키워나갈 수 있다.
프 로세스의 기본 heap은 윈도우즈 API에서 주로 사용하는데 ANSI 문자열을 UNICODE 문자열로 컨버전할 때, 혹은 API내에서 임시 메모리 블럭을 사용할 때 쓰이는 공간으로 유저가 맘대로 해제할 순 없다. 따라서 프로세스는 이 기본 heap 이외에 추가적인 heap을 할당해서 사용한다.
defaultheap.jpg

4.2 추가적인 heap의 용도

프로세스에서 추가적인 heap을 생성해서 사용하는 용도는 크게 5가지로 구분할 수 있다.
컴포넌트 보호(Component Protection)
예를 들어, 어떤 어플에서 링크드 리스트(linked list)와 트리(tree)와 같은 상이한 타입의 데이터 구조들을 운용한다고 생각해 보자. 그런데 이들 데이타 구조를 하나의 단일 heap에서 관리하고 있을 때 코드내에 숨어 있는 버그로 인해 heap의 consistency를 깨뜨릴 위험이 있다. 단일 heap에서 다음과 같이 linked list 노드와 tree의 branch 노드가 같이 할당되어 있다고 했을때,
heap1.jpg
linked list의 노드를 연산하는 함수에서 linked list의 2번 NODE를 연산하다가 실수로 노드의 크기를 over해서 연산해 버리면 BRANCH 2번에 덮어써 버리게 된다. 이런 위험을 방지하기 위해 linked list의 NODE와 tree의 BRANCH를 다른 heap으로 각각 전담시키게 하면 heap을 깨뜨릴 위험이 많이 줄어들 것이다.
효율적인 메모리 관리(More Efficient Memory Management)
heap은 같은 크기의 블럭 사이즈로 할당할 때 가장 효율적으로 관리가 된다. 위의 component 보호에서 예를 든 것처럼, 리스트와 트리를 단일 heap에서 운용할 때, NODE와 BRANCH를 다른 사이즈로 각각 24바이트, 32바이트씩 할당한다고 가정하자. NODE와 BRANCH 인스턴스를 heap에서 계속 할당하다가 전체 heap을 모두 할당했다고 했을 때, NODE 하나를 해제하면 24바이트의 메모리 공간이 생긴다. 그리고 다른 공간에 또다른 NODE를 해제해서 총 48바이트의 빈공간이 있어도 정작 32바이트의 BRANCH를 할당할 수 있는 공간이 없게 된다. 이런 상황을 fragmentation이라고 부르는데 만약 NODE와 BRANCH를 동일한 사이즈의 블럭으로 할당하면 이런 fragmentation을 방지하고 효율적으로 메모리를 관리할 수 있게 된다.
지역적 접근(Local Access)
시스템에서 page fault가 발생해서 RAM과 디스크의 페이징 파일(paging file) 사이에 스왑swap이 발생할 때마다 시스템 성능에 막대한 영향을 끼친다. 만약 어플에서 접근하는 메모리 영역을 일정 주소 영역으로 한정해서 지역화 시키면 이런 디스크와의 스와핑을 줄여서 시스템 성능을 높일 수가 있게 된다. 만약 다른 타입의 데이타 구조들을 단일 heap에 혼재해서 쓰다보면 구조체의 인스턴스들의 근접성이 떨어져서 page fault가 발생할 확률을 높이게 된다. 위에서 예로든 리스트와 트리의 응용에서 최악의 경우, 한 페이지에 NODE가 하나만 할당되고 나머진 BRANCH 인스턴스가 자리잡는다면, list의 NODE를 하나씩 traverse할 때마다 page fault가 발생하여 프로세스의 속도는 상당히 저하될 것이다. 이때 각각의 데이터 구조를 별개의 heap으로 관리해서 각 인스턴스를 인접시키면 성능을 향상시킬 수 있게 된다.
쓰레드 동기화 부담 해소(Avoiding Thread Synchronization Overhead)
기본적으로 heap은 순서화(serialization)를 보장한다. 이말은 프로세스에서 다중 쓰레드를 운용할 때 각 쓰레드가 동일한 heap에서 블럭을 할당하거나 해제하려고 한다면, 한번에 오직 하나의 쓰레드만 할당하거나 해제하는 기능을 수행할 수 있으며 나머지 쓰레드는 이전 쓰레드에서 할당, 해제 작업이 끝난 다음에서야 heap 블럭에 대한 할당, 해제에 들어갈 수 있다라는 의미이다. 그러나 이를 위해 당연히 heap의 메모리 할당, 해제 함수에서 serialization을 위한 추가적인 코드가 실행됨으로써 속도에 영향을 미치게 된다. 이때 추가적인 heap을 생성해서 각각의 heap에 하나의 프로세스만 접근하게 하고 serialization을 적용하지 않는다면 성능상의 이익을 얻을 수 있게 된다.
빠른 해제(Quick Free)
마지막으로 데이터 구조체에 전담으로 할당한 heap이 있다면, 해당 구조체를 더 이상 사용하지 않을 때 heap의 메모리 블럭을 해제하는 것보다 heap 자체를 해제해 버리는 것이 더 편리하고 성능 또한 높이게 된다.

4.3 추가적인 heap의 사용

4.3.1 Heap 생성

프로세스에서 heap을 추가하기 위해선 다음의 함수를 호출해서 사용하면 된다.
HANDLE HeapCreate( 
  DWORD flOptions,      // heap allocation flag 
  DWORD dwInitialSize,  // initial heap size 
  DWORD dwMaximumSize   // maximum heap size 
); 
이 함수는 가상 메모리에 유저가 지정해주는 크기의 heap object를 생성시키는 함수이다.
flOptions은 생성하는 heap의 특성을 지정해주는 플래그이다. 0, HEAP_GENERATE_EXCEPTIONS, HEAP_NO_SERIALIZE 셋 중의 하나의 값, 혹은 두 개 이상의 or 연산값을 넘겨줄 수 있다. 여기서 중요한 것은 HEAP_NO_SERIALIZE 플래그인데 heap은 기본적으로 동적 메모리를 할당하거나 해제할 때 순서화된 접근을 허용한다고 위에서 설명하였다. 이 옵션을 주면 새로 생성하는 heap에서는 순서화serialization 특성을 적용하지 않겠다라는 의미이다. 따라서 멀티 프로세스 및 멀티 쓰레드 환경에서 heap을 사용하는 응용이라면 이 옵션을 지정해주지 않는 것이 좋다. 이 옵션은 메모리 할당 및 해제시에 속도를 높여주긴 하므로 이 옵션은 단일 쓰레드나 단일 프로세스일 때, 다중 쓰레드이긴 하지만 오직 하나의 쓰레드만 heap에 접근할 때나 크리티컬 섹션(criticial section), 뮤텍스(mutax), 세마포(semaphor)등의 동기화 메커니즘을 사용해서 heap의 접근을 제어할 때만 사용하도록 한다.
HEAP_GENERATE_EXCEPTIONS 은 heap의 생성이 실패할 때 시스템에서 예외 시그널(exception)을 발생시키는 플래그로 어떤 이유로 함수의 리턴값으로 체크하는 것보다 exception을 체크하는 것이 더 나은 응용일 때 사용한다.
dwInitialSize은 heap의 초기 사이즈를 지정해주는 인자로 내부에서 페이지 크기로 올림하여 처리된다. dwMaximumSize는 heap에서 허용하는 최대 크기를 지정해주는 인자로 메모리를 할당 받을 때 총 할당량이 이 값 이상이 되어버리면 할당이 안된다. 0으로 지정해주면 시스템에서 남는 메모리가 있을 때까지 무제한으로 heap을 할당할 수 있게 된다.
heap이 정상적으로 생성이 되면 heap object에 대한 핸들이 리턴된다. 이후 이 heap에 대한 제어 함수에 이 핸들을 인자로 넘겨주게 된다.

4.3.2 Heap으로부터 메모리 블럭 할당

heap으로부터 메모리 블럭을 할당받을 때는 시스템 내에서 다음의 과정을 거치게된다.
  1. 메모리 관리자 내부에는 할당된 메모리 블럭과 free 메모리 블럭들이 링크드 리스트(linked list)로 관리되는데 이 링크드 리스트에 대해 순회traverse를 시작한다.
  2. free block의 node를 검색한다.
  3. 검색된 free block에 할당되었다는 마킹을 해서 새로운 메모리 블럭을 할당한다.
  4. 메모리 블럭에 새로운 entry를 추가한다.
이러한 과정을 거쳐서 새로운 메모리 블럭을 할당받게 하는 함수가 HeapAlloc()이다.
LPVOID HeapAlloc( 
  HANDLE hHeap,  // handle to the private heap block 
  DWORD dwFlags, // heap allocation control flags 
  DWORD dwBytes  // number of bytes to allocate 
); 
hHeap은 heap의 핸들을 받는 인자이고 dwFlags은 할당받는 메모리 블럭의 특성을 지정해주는 인자이다. 이 인자에는 현재 3개의 플래그만 지정해줄 수 있는데 HEAP_GENERATE_EXCEPTIONS, HEAP_ZERO_MEMORY, HEAP_NO_SERIALIZE 가 있다. HEAP_GENERATE_EXCEPTIONS는 위에서 설명했듯 함수가 실패할 때 exception을 발생시키는 플래그이고 HEAP_NO_SERIALIZE 또한 앞서와 마찬가지로 메모리 블럭을 할당할 때 serialization을 위한 코드를 실행하지 않도록 하는 플래그이다. 만약 HeapCreate()에서 이 플래그를 넘겨주었다면 이후 이 heap에서 할당받거나 해제할 때는 자동으로 serialization을 적용하지 않기 때문에 굳이 여기서 넘겨주지 않아도 된다. 그러나 HeapCreate()에서 이 플래그를 넣지 않았다면 이 플래그를 사용하는 개별 함수에서만 serialization을 적용하지 않게 된다. 다시한번 강조하지만, 멀티 쓰레드 환경이라면 이 플래그는 상당히 조심해서 적용해야 한다.
HEAP_ZERO_MEMORY는 메모리 블럭을 할당받을 때 0으로 초기화하도록 지정해주는 플래그이다.
heap에서 메모리가 정상적으로 할당되었다면 할당된 메모리 블럭이 주소가 리턴되고 함수가 실패하면 NULL이 리턴된다.
주 의할 것은 win98 계열에서 이 함수를 사용해서 256MB 이상의 메모리를 할당하고자 한다면 메모리 할당이 실패하고 NULL이 리턴된다. 할당받고자 하는 메모리가 1MB가 넘을 때는 heap보다는 가상 메모리를 이용해서 할당받는 것이 바람직하다.

4.3.3 블럭 크기 변경

HeapAlloc() 를 통해 할당 받은 메모리 블럭을 사용하다 보면 이 블럭의 크기를 더 키워야 한다거나 줄여야 하는등 블럭 크기를 재조정해야할 때가 있다. 이때는 다음의 함수를 사용해서 할당 받은 메모리 블럭의 사이즈를 재조정해서 할당받는다.
LPVOID HeapReAlloc( 
  HANDLE hHeap,  // handle to a heap block 
  DWORD dwFlags, // heap reallocation flags 
  LPVOID lpMem,  // pointer to the memory to reallocate 
  DWORD dwBytes  // number of bytes to reallocate 
); 
dwFlags에는 재할당 받는 메모리 블럭의 특성을 지정해주는데 4개의 플래그가 들어갈 수 있다. 이중 HEAP_ZERO_MEMORY는 재조정하는 메모리 블럭이 기존에 할당받은 메모리 블럭보다 클 경우, 늘어난 크기의 메모리 블럭을 0으로 초기화하라는 플래그이다. HEAP_REALLOC_IN_PLACE_ONLY 는 현재 메모리 위치에서 사이즈를 조정해서 리턴하도록 지시하는 옵션이다. 일반적으로 HeapReAlloc()는 유저가 지정하는 사이즈에 맞는 메모리 위치를 찾아서 이전 블럭의 내용을 옮기고 그 주소를 리턴하게 된다. 그런데 이 옵션을 넘겨주면 메모리 블럭 위치를 변경시키지 말고 현재 메모리 블럭의 사이즈를 조정하라는 옵션인 것이다. 주의할 것은 블럭 크기가 줄어드는 경우에는 상관없지만, 그 크기를 키우는 것이라면 인접한 메모리 공간이 늘어나는 크기만큼 비어있어야 한다. 그렇지 않고 인접한 메모리 공간이 점유되어 있으면 이 함수는 수행이 안되고 NULL이 리턴된다. 이 옵션을 사용하는 경우는, 예를 들어 링크드 리스트나 트리의 한 노드의 크기를 키워야 하는 경우에 사용된다. 만약 블럭 크기를 조정하는 노드의 주소가 바뀌면 그 노드를 포인트하는 인접 노드의 내용도 함께 바뀌어야 할 것이다.
lpMem은 재할당 받고자 하는 블럭의 주소를 넘겨주는 인자이고 dwBytes에는 재조정하고자 하는 크기를 지정해준다.
현재 할당된 메모리 블럭의 크기를 알고자 한다면 다음의 함수를 사용한다.
DWORD HeapSize( 
  HANDLE hHeap,  // handle to the heap 
  DWORD dwFlags, // heap size control flags 
  LPCVOID lpMem  // pointer to memory to return size for 
); 
lpMem에 크기를 알고자 하는 메모리 블럭의 주소를 넘겨준다.

4.3.4 Heap 메모리 블럭 해제 및 Heap 제거

할당받은 heap 메모리 블럭을 해제할 때는 HeapFree() 함수를 사용한다.
BOOL HeapFree( 
  HANDLE hHeap,  // handle to the heap 
  DWORD dwFlags, // heap freeing flags 
  LPVOID lpMem   // pointer to the memory to free 
); 
lpMem에 해제하고자 하는 메모리 블럭의 주소를 넘겨준다.
이렇게 메모리 블럭을 할당하다가 더 이상 heap을 사용하지 않는다면 heap object를 시스템에서 제거할 수 있다.
BOOL HeapDestroy( 
  HANDLE hHeap   // handle to the heap 
); 

4.4 Heap을 이용한 new, delete 구현

heap 메모리를 이용하는 응용중 대표적인 케이스는 C++에서 다음과 같이 new 연산자를 이용해서 클래스 객체를 할당받는 예일 것이다.
CSomeClass *pSomClass = new CSomClass; 
C++ 컴파일러는 이 문장을 만나게 되면 클래스에서 new 연산자가 정의되어 있는지 체크해서 만약 정의되어 있다면 클래스에서 정의한 new 연산자 함수가 실행되고 클래스내에서 정의되어 있지 않다면 표준 C++ 라이브러리에서 구현된 new 연산자 함수를 실행하게 된다.
이렇게 할당받은 클래스 객체의 사용이 끝나면 delete 연산자로 객체를 메모리에서 해제하게 된다.
delete pSomeClass; 
자, 그럼 new연산자와 delete 연산자를 각각 어떻게 구현할 수 있는지 살펴보도록 하겠다. 다음은 클래스를 정의한 헤더의 모습이다.
class CSomeClass { 
private: 
    static HANDLE s_hHeap; 
    static UINT s_uNumAllocsInHeap; 
               .......... 
public: 
    void *operator new(size_t size); 
    void operator delete(void *p); 
              .......... 
}; 
클래스의 멤버 변수인 s_hHeaps_uNumAllocsInHeap를 static으로 정의하였다. 이는 CSomeClass로 생성되는 모든 인스턴스는 같은 변수를 공유하도록 하기 위함이다. C++ 컴파일러는 CSomeClass 객체가 생성될 때마다 s_hHeaps_uNumAllocsInHeap 멤버 변수를 따로 할당하지 않게 된다.
다음은 각각 new와 delete의 구현 코드이다. 그리 어렵지 않으므로 코드내의 코멘트로 설명을 대신하도록 하겠다.
HANDLE CSomeClass::s_hHeap = NULL; 
UINT CSomeClass::s_uNumAllocsInHeap = 0; 
 
void *CSomeClass::operator new(size_t size) { 
    if (s_hHeap == NULL) {    
         // heap object가 존재하지 않으면 heap 생성 
        s_hHeap = HeapCreate(HEAP_NO_SERIALIZE, 0, 0); 
 
        if (s_hHeap == NULL) 
            return NULL; 
     } 
 
    // class 객체를 위해 heap 메모리 블럭 할당 
    void *p = HeapAlloc(s_hHeap, 0, size); 
 
    if (p != NULL) { 
         s_uNumAllocsInHeap++; 
    } 
 
    // 할당된 메모리 블럭의 주소를 리턴 
    return p; 
} 
void CSomeClass::operator delete(void *p) 
{ 
    if (HeapFree(s_hHeap, 0, p)) { 
         // 메모리 블럭이 성공적으로 해제되었다. 
         s_uNumAllocsInHeap--; 
    } 
 
    if (s_uNumAllocsInHeap == 0) { 
         // heap에 더 이상 할당된 메모리 블럭이 없으면 heap을 제거한다. 
         if (HeapDestroy(s_hHeap)) { 
             // heap을 제거하면 힙 핸들에 NULL을 할당한다. 
             s_hHeap == NULL; 
         } 
    } 
} 

5 References

  • Programming Applications for Microsoft Windows 4ed, Jeffrey Richter
  • MSDN Library, Microsof Visual Studio 6.0
  • 마이크로소프트 2003.11월호, "윈도우 이해하기3", 고동일
주석
____
  1 프 로세스란 일반적으로 실행되고 있는 프로그램의 인스턴스로 정의한다. win32에서 각 프로세스는 4GB의 가상 메모리 영역으로 구성된 프로세스의 주소영역(address space)를 갖는다. win32에서 프로세스는 코드를 실행하지 않는다. 다만 프로세스는 단순히 코드와 데이타를 포함하는 4GB의 주소 영역을 소유하고 있을 뿐이다. 주소영역과 함께 프로세스는 운영체제가 할당하는 파일, 파이프, 통신 포트 등의 시스템 자원을 저장한다.이들 시스템 자원은 프로세스가 종료될 때 소멸된다. 이와 함께 모든 프로세스는 적어도 하나의 쓰레드를 갖고 있어야 한다. 프로세스가 소유할 수 있는 쓰레드의 수는 메모리가 허용하는 한 제한이 없다.
  2 Alpha칩은 32/64비트 공히 페이지 크기를 8KB로 쓴다. IA64도 8KB 페이즈 크기를 가지는 것으로 알고 있다.
  3 그래서 옛날 DOS를 이용했던 유저들은 시스템에 물리적 메모리를 어떻게든 쥐어 짜내보려고 EMM 386등을 이용해 메모리를 확장(Extened)시키는 등의 눈물겨운(?) 노력을 했던 기억이 있을 것이다.
  4 thread 는 process 안에서 실행되는 코드의 실행 흐름이라고 할 수 있다. 쓰레드는 프로세스가 할당한 메모리 영역에서 실행되며 프로세스에 할당된 시스템 자원을 사용하게 된다. 프로세스가 생성되고 초기화될 때마다 운영체제는 기본쓰레드(primary thread)를 생성한다. 각 쓰레드는 명령 코드와 함께 CPU 레지스터 상태를 저장하는 context와 2개의 스택 영역이 포함된다. 2개의 스택 영역은 특권 프로세스 모드와 사용자 모드를 위한 스택이다.
  5 윈 도우즈에서 실행중인 파일이나 dll을 삭제하려고 할 때 실행중이므로 삭제할 수 없다라는 메시지까 뜨면서 삭제가 안되는 경우를 많이 보았을 것이다. 이는 실행한 파일이나 dll을 전부 메모리에 올리지 않고 필요한 부분만 메모리에 올려지고 나머지는 memory mapped file로 묶여져 있기 때문인 것이다.
  6 windows에서는 IPC를 위해 여러 메커니즘을 지원하는데 이들 모두는 하위 레벨에 가서는 결국 memory-mapped file로 구현되어 있다.
  7 base address 0x00400000번지는 win9x의 경우이다. win NT 및 win2000은 프로세스의 base address로 0x00100000 번지가 기본값이다. 실행파일이 메모리에 로딩되는 base address 값은 .exe파일의 header에 들어있다.
  8 32 비트 주소 공간에서 4GB 크기를 넘는 파일을 memory-mapped file로 사용하는 방법이 있긴 있다. 그것은 파일 전체를 한번에 매핑시키지 않고 액세스 가능한 크기로 나누어서 매핑시키고 다시 unmap하여 다른 부분을 매핑시키는 기법을 쓰는데 자세한 것은 Reference에 있는 Jeffrey의 책에 자세히 설명되어 있다.
  9 여 기서 win98과 win2000이 다른 점이 있는데 win98에서는 만약 MapVIewOfFile에서 전체 파일 크기만큼의 메모리 영역을 확보하지 못하면 dwNumberOfBytesToMap 값에 관계없이 NULL을 반환한다. 반면, win2000에서는 파일 전체 크기와 상관없이 dwNumberOfBytesToMap 인자로 요구한 크기 만큼의 메모리가 확보되면 정상적으로 commit이 된다.
 10 win2000 의 경우에는 에러 처리하여 NULL을 반환하는 반면, win98에서는 에러로 처리를 안하고 할당 세밀도의 경계에 맞게 반내림하여 처리한다. 그리고 주소값에 넣어줄 범위도 플랫폼에 따라 다른데 win98에서는 lpBaseAddress 값을 공유 영역인 0x80000000부터 0xBFFFFFFF 사이의 값을 넣어주어야 하고 win2000에서는 user-mode partition 내의 범위라면 어떤 값을 넣어줘도 상관없다.
 11 이 미 전술한 바 있지만 win98에서는 memory-mapped file의 위치가 공유 영역에 있다. 따라서 win98에서는 한 프로세스가 MapViewOfFile()을 호출해서 공유 영역을 확보했으면 그 리턴값을 다른 프로세스에게 전달만 해주면 다른 프로세스에서도 그 값을 이용해서 memory-mapped file을 맘껏 사용할 수 있는 것이다. 그러나 이렇게 프로그램을 짜버리면 win2000과 호환되지 않으며 첫번째 프로세스에서 UnpaViewOfFile()을 호출해서 그 영역을 release해 버리면 다른 프로세스에서는 그 영역을 접근할 때 access viloation이 발생하게 될 것이다.
 12 유 념해야할 점은 memory-mapped file이라고 해서 view에 작업한 내용들이 그 즉시 파일에 반영되는 것은 아니라는 것이다. 어플리케이션이 파일의 view에 작업하는 동안 운영체제는 퍼포먼스 문제 때문에 파일 데이타 페이지를 버퍼링한다. 따라서 memory-mapped file에 작업하는 동안 시스템이 불시에 멈춘다거나 재부팅되면 그 직전에 작업중이던 내용이 모두 날아갈 수 있다. 이것을 방지하려면 중간에 FlushViewOfFile()이라는 함수를 사용해서 작업중이던 내용을 파일 이미지에 쓰게할 수 있다.
 13 heap 메모리 블럭을 해제하는 룰은 win98과 win2000이 약간 다른데 win98은 메모리 사용률에 중점을 둬서 동적 메모리를 해제할 때마다 바로 물리적 메모리를 decommit하려는 반면, win2000은 메모리 함수의 속도를 중시해서 동적 메모리를 해제한다고 해서 바로 물리적 메모리를 decommit하지 않고 일정 기간 메모리 공간을 쥐고 있다가 해제하는 경향을 보인다. 그런데 이런 룰은 새로운 버전의 운영체제나 하드웨어가 등장하면 언제든 바뀔 수 있는 정책이라고 한다.
 14 dll은 heap을 가질 수 없기 때문에 dll을 만들때는 /HEAP 옵션을 사용할 수 없다.
: