'Development/C / C++'에 해당되는 글 14건

  1. 2009.06.03 [VisualStudio] C -> CPP, CPP -> C 로 컴파일 하기
  2. 2008.10.01 시스템이 지정된 프로그램을 실행할 수 없습니다
  3. 2008.08.14 Run-Time Check Failure #2
  4. 2008.03.25 Name mangling in C++
  5. 2007.04.28 Error LNK2005 : _DllMain@12 already defined
  6. 2007.04.24 The Function Pointer Tutorials
  7. 2007.02.13 애플리케이션 개발시의 메모리 디버깅 : 메모리 누수 발견 기법
  8. 2007.01.02 TCP/IP를 기반으로 한 온라인 게임 제작 : 크리티컬섹션과 세마포어 , 뮤텍스
  9. 2006.12.27 TryEnterCriticalSection
  10. 2006.12.27 '_beginthreadex': identifier not found, even with argument-dependent lookup


[VisualStudio] C -> CPP, CPP -> C 로 컴파일 하기

Development/C / C++ 2009. 6. 3. 17:36
Visual Studio 6.0에서 C 확장자로 된 파일을 CPP로 컴파일 하거나 CPP 확장자로 된 파일을 C 파일로 컴파일 하기 위해서는
아래와 같은 옵션을 사용하면 가능하다.

/TC : CPP 확장자를 가진 파일을 C 확장자로 컴파일
/TP : C확장자를 가진 파일을 CPP 확장자로 컴파일


아래는 MSDN의 내용
/Tc, /Tp, /TC, /TP   (Specify Source File Type)

Syntax

/Tcfilename
/Tpfilename
/TC
/TP

The /Tc option specifies that filename is a C source file, even if it doesn’t have a .C extension. The /Tp option specifies that filename is a C++ source file, even if it doesn’t have a .CPP or .CXX extension. A space between the option and filename is optional. Each option specifies one file; to specify additional files, repeat the option.

/TC and /TP are "global" variants of /Tc and /Tp. They specify to the compiler to treat all files named on the command line as C source files (/TC) or C++ source files (/TP), without regard to location on the command line in relation to the option. These global options can be overridden on a single file via /Tc or /Tp.

By default, CL assumes that files with the .C extension are C source files and files with the .CPP or the .CXX extension are C++ source files.

Examples

The following CL command line specifies that MAIN.C, TEST.PRG, and COLLATE.PRG are all C source files. CL will not recognize PRINT.PRG.

CL MAIN.C /TcTEST.PRG /TcCOLLATE.PRG PRINT.PRG

The following CL command line specifies that FOO1.C, FOO2.CXX, FOO3.HUH, and FOO4.O are compiled as C++ files, and FOO5.Z is compiled as a C file.

CL FOO1.C FOO2.CXX FOO3.HUH FOO4.O /Tc FOO5.Z /TP

*Visual Studio 2005에서는 Compile As 라는 옵션으로 설정이 가능하다.
:

시스템이 지정된 프로그램을 실행할 수 없습니다

Development/C / C++ 2008. 10. 1. 15:25
참고 : http://msdn.microsoft.com/ko-kr/library/ms235342(VS.80).aspx

현상 :
1.ShellExecute를 이용해서 외부 응용프로그램을 실행했을 때 정상적으로 실행되지 않음.
2.Return value를 확인한 결과 SE_ERR_ACCESSDENIED 로 나옴.
3.해당 에러의 메시지는 "The operating system denied access to the specified file."

원인 :
처음에는 응용프로그램의 권한에 관련된 문제인 줄 알았으나 확인 결과 해당 응용프로그램이 Visual Studio 2005에서 빌드되었는데 실행하는 호스트에는 VC 2005의 CRT가 설치되어 있지 않았음. (또는 VC2005가 설치되어 있어도 설치된 .NET 라이브러리의 버전이 틀리면 발생할 수 있음.)

해결 :
해당 응용프로그램을 VC.Net 2003에서 빌드하거나 호스트에 Visual Studio 2005를 설치.(상위 버젼의 닷넷 라이브러리를 설치)



:

Run-Time Check Failure #2

Development/C / C++ 2008. 8. 14. 09:56

Visual Studio 2005에서 디버깅 시에 발생하는 오류

Run-Time Check Failure #2 - Stack around the variable 'xxxxxxx' was corrupted.

VS.NET VC++ 에서 사용하던 소스인데 VS2005 VC++로 컨버젼하여 사용중이던 소스.

xxxxxxx 변수에 들어갈 사이즈를 초과할 경우에 발생한다. char[] 타입일 경우 사이즈를 늘려서 해결.




:

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




:

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로 바꾸어 주면 된다.

사용자 삽입 이미지

프로젝트 속성 설정화면






: