스레드는 프로세스와 유사하게 아래의 두 가지 요소로 구성되어 있다.
- 운영체제가 스레드를 다루기 위해 사용하는 스레드 커널 오브젝트. 스레드 커널 오브젝트는 시스템이 스레드에 대한 통계 정보를 저장하는 공간이기도 하다.
- 스레드가 코드를 수행할 때 함수의 매개변수와 지역변수를 저장하기 위한 스레드 스택
프로세스는 스스로 어떤 것도 수행할 수 없으며 단순히 생각한다면 스레드의 저장소로 볼 수도 있다. 스레드는 항상 프로세스의 컨텍스트 내에 생성되며, 프로세스 안에만 살아 있을 수 있다. 즉, 스레드는 프로세스의 주소 공간 내에 있는 코드를 수행하고 데이터를 다룬다.
Section 01. 스레드를 생성해야 하는 경우
대다수의 애플리케이션은 작업을 수행하는데 주 스레드 하나면 충분하다. 하지만 프로세스는 작업을 수행하는 데 도움이 된다면 추가적인 스레드를 생성할 수도 있다. 아래에 몇 가지 예를 들어 보았다.
- 윈도우 운영체제의 인덱스 서비스는 낮은 우선순위로 생성되어 주기적으로 디스크 드라이브에 있는 파일에 대해 내용을 기반으로 인덱싱을 수행한다.
- 디스크 조각 모음 같은 경우 낮은 우선순위의 스레드를 이용하면 유틸리티를 백그라운드로 수생하고 시스템이 유휴 상태일 때에만 조각 모음을 수행할 수 있다.
- Visual Studio IDE 는 사용자가 입력을 멈추었을 때 C# 코드를 자동으로 컴파일한다.
- 스프레드시트는 백그라운드로 재계산을 수행할 수 있다.
- 워드프로세서는 철자와 문법 검사, 프린팅 같은 작업을 백그라운드로 수행할 수 있다.
이러한 다양한 예에서 볼 수 있듯이 멀티스레딩을 사용하면 사용자 인터페이스를 좀 더 단순화 시킬 수 있다. 또한, 각 스레드 별로 전용 CPU를 할당하는 것이 가능한데, CPU개수만큼 스레드를 운용한다면 그 만큼 효율이과 확장성이 늘어날 것 이다.
Section 02. 스레드를 생성하지 말아야 하는 경우
새로운 스레드를 만드는 것은 또 다른 문제를 발생시킬 수가 있기 때문에 신중히 고려해야 한다. 예를 들어, 프린트시 편집을 계속할 것 인가? 프린트하는 동안 임시 작업본에서 수행할 것 인가? 등의 문제가 있을 수 있다. 사용자 인터페이스는 반드시 단일 스레드 내에서 처리되는 것이 좋다. 계산이나 I/O 위주의 작업은 윈도우를 만들지 않으므로 별도의 워커스레드로 만들 수 있다.
Section 03. 처음으로 작성하는 스레드 함수
모든 스레드는 수행을 시작할 진입점 함수(entry-point function)를 반드시 가져야 한다. 진입점 함수는 아래의 형태를 가진다.
DWORD WINAPI ThreadFunc(PVOID pvParam)
{
DWORD dwResult = 0;
...
return (dwResult);
}
스레드 함수에 대해 몇 가지 중요한 점을 짚어보자.
- 스레드 함수는 어떠한 이름이라도 사용될 수 있다. 애플리케이션에서 여러 개의 스레드 함수가 필요하다면 각각은 서로 다른 이름으로 명명되어야만 한다.
- 스레드 함수는 하나의 매개변수만 전달받을 수 있고, 사용자에 의해 의미가 정의되기 때문에 ANSI나 유니코드 버전을 각기 구성할 필요는 없다.
- 스레드 함수는 반드시 값을 반환해야 한다. 이 값은 나중에 스레드 종료 코드가 된다.
- 스레드 함수는 가능한 한 매개변수와 지역변수만을 사용하는 것이 좋다. 정적변수나 전역변수를 사용하게 되면 다수의 스레드가 동시에 접근할 수 있게 되며, 이는 변수의 값이 잘못 변경되는 원인이 되기도 한다.
Section 04. CreateThread 함수
윈도우가 CreateThread 함수를 제공하고는 있지만, MS C/C++ 로 코드를 작성한다면 CreateThread 함수를 사용하면 안되고 반드시 _beginthreadex 함수를 사용해서 스레드를 생성해야 한다.
PSECURITY_ATTRIBUTES psa,
DWORD cbStackSize,
PTHREAD_START_ROUTINE pfnStartAddr,
PVOID pvParam,
DWORD dwCreateFlags,
PDWORD pdwThreadID
) ]
: 이 함수를 호출한 프로세스의 가상 주소 공간에서 실행되는 스레드를 생성한다.
CreateThread가 호출되면 시스템은 스레드 커널 오브젝트를 생성한다. 이 스레드 커널 오브젝트는 스레드 자체는 아니며, 운영체제가 스레드를 다루기 위한 조그만 데이터 구조체에 불과하다. 스레드 커널 오브젝트를 스레드에 대한 통계 정보를 담고 있는 작은 데이터 구조체 정도로 생각할 수도 있다.
다음으로 시스템은 스레드가 사용할 스택을 확보한다. 새로운 스레드는 스레드를 생성한 프로세스와 동일한 컨텍스트 내에서 수행된다. 따라서 프로세스에 있는 모든 메모리뿐만 아니라 같은 프로세스에 있는 다른 스레드의 스택에 조차 접근이 가능하다. 때문에, 동일 프로세스 내의 스레드들은 손쉽게 상호 통신을 할 수 있다.
[ 1. psa ]
SECURITY_ATTRIBUTES 구조체를 가리키는 포인터. 스레드 커널 오브젝트에 대해 기본 보안 특성을 사용할 것 이라면 이 매개변수로 NULL을 전달하면 된다. 만일 자식 프로세스로 해당 스레드 커널 오브젝트의 핸들을 상속하도록 하려면 bInheritHandle 멤버를 TRUE로 전달한다.
[ 2. cbStackSize ]
이 매개변수에는 스레드가 스택을 위해 얼마만큼의 주소 공간을 사용할지를 지정한다. CreateProcess를 호출하여 프로세스가 시작되면 내부적으로 CreateThread 함수를 호출하여 프로세스의 주 스레드를 초기화하는데, 이때 CreateProcess는 실행 파일 내부에 저장되어 있는 값을 이용하여 cbStackSize 매개변수의 값을 결정한다. /STACK 링커 스위치를 사용해서 실행파일에 저장되어 있는 스택 크기값을 설정할 수 있다.
/STACK : [reserve] [, commit]
reserve 인자는 시스템이 스레드 스택을 위해 지정된 크기만큼의 주소 공간을 예약하게 된다. 기본값은 1MB이다. commit 인자는 스택으로 예약된 주소 공간에 커밋된 물리적 저장소의 초기 크기를 나타낸다. 기본값은 한 페이지 크기이다. 커밋된 공간이 부족하면 스택 오버플로우 예외가 발생하고 시스템은 추가적인 페이지를 예약된 주소 공간상에 커밋해 준다.
cbStackSize에 0 이외의 값을 저달하면 스레드 스택을 확보하기 위해 cbStackSize로 지정된 크기의 메모리를 예약하고 커밋까지 수행한다. 모든 공간이 미리 커밋되기 때문에 지정된 크기만큼의 스택을 항시 사용할 수 있다. 스택에서 사용할 예약된 영역은 /STACK 링커 스위치와 cbStackSize 중 큰 값을 이용한다. 커밋할 저장소의 크기는 항시 cbStackSize값을 따른다. cbStackSize에 0 을 전달하면 CreateThread는 /STACK 링커 스위치를 이용하여 실행 파일내에 포함된 커밋된 물리적 저장소의 초기 크기를 따르게 된다.
[ 3. pfnStartAddr 과 pvParam ]
pfnStartAddr 매개변수는 새로이 생성되는 스레드가 호출할 스레드 함수의 주소이며, pvParam 은 스레드 함수로 그대로 전달되는 매개변수이다. 다수의 스레드가 동작할 때 자식 스레드가 종료되기 전에 자식 스레드로 전달된 pvParam 매개변수를 삭제하지 않도록 주의해야 한다.
[ 4. dwCreateFlags ]
스레드를 생성할 때 세부적인 제어를 수행하기 위한 추가적인 플래그를 지정한다. 이 값을 0으로 지정하면 스레드는 생성되는 즉시 CPU에 의해 스케줄 가능하게 된다. 만일 CREATE_SUSPENDED를 전달하면 시스템은 스레드를 생성하고 초기화를 완료한 이후 CPU에 의해 바로 스케쥴 되지 않도록 일시 정지 상태를 유지하게 된다.
[ 5. pdwThreadID ]
새로운 스레드에 할당되는 스레드 ID 이 저장된다. 이 매개변수는 NULL을 전달할 수도 있는데(보통 그렇게 한다), 이렇게 하면 스레드의 ID에 대해서는 관심이 없다고 함수에게 알려주게 된다.
Section 05. 스레드의 종료
스레드는 4가지 방법으로 종료될 수 있다.
- 스레드 함수가 반환된다. (강력 추천!)
- 스레드 함수 내에서 ExitThread 함수를 호출한다. (비추천!)
- 동일한 프로세스나 다른 프로세스에서 TerminateThread 함수를 호출한다. (비추천!)
- 스레드가 포함된 프로세스가 종료된다. (비추천!)
[ 1. 스레드 함수 반환 ]
스레드를 종료하려는 경우, 스레드가 사용한 자원을 정리할 수 있도록 항상 스레드 함수가 반환되도록 설계하는 것이 좋다. 스레드 함수가 반환되면 다음과 같은 작업이 수행된다.
- 스레드 함수 내에서 생성한 모든 C++ 오브젝트들은 파괴자를 통해 적절히 제거된다.
- 운영체제는 스레드 스택으로 사용하였던 메모리를 반환한다.
- 시스템은 스레드의 종료 코드를 스레드 함수의 반환 값으로 설정한다. 이 값은 스레드 커널 오브젝트내에 저장된다.
- 시스템은 스레드 커널 오브젝트의 사용 카운트를 감소시킨다.
[ 2. ExitThread 함수 ]
: 이 함수는 스레드를 강제로 종료하고 운영체제가 스레드에서 사용했던 모든 운영체제 리소스를 정리하도록 한다. 하지만 C/C++ 리소스는 정리되지 않으므로 주의해야 한다. dwExitCode 매개변수는 스레드의 종료코드로 설정된다. 이 함수는 반환되지 않는 함수이기 때문에 이후에 나오는 코드는 수행되지 않는다.
만일 C/C++로 코드를 작성하는 경우라면 ExitThread 함수 대신 _endthreadex 함수를 사용하는 것을 강력히 추천한다. 이유는 이후에 설명한다.
[ 3. TerminateThread 함수 ]
: ExitThread 함수가 이 함수를 호출하는 스레드를 종료하는 것과는 다르게 TerminateThread 함수는 어떠한 스레드라도 종료시킬 수 있다. hThread 매개변수는 종료할 스레드의 핸들을 가리킨다.
이 함수를 호출하면 종료될 스레드는 자신이 종료될 것이라는 사실을 전달받지 못하기 때문에 적절한 정리 작업을 수행할 수도 없고, 종료 자체를 회피할 수 있는 방법도 없다.
ExitThread 함수를 호출하면 스레드가 사용하던 스택이 정상적으로 정리되지만, TerminateThread 함수를 사용하면 시스템은 종료된 스레드를 소유하고 있던 프로세스가 살아 있는 동안 그 스레드가 사용하였던 스택을 정리하지 않는다(다른 스레드가 강제적으로 종료된 스레드의 스택을 참조할 수도 있기 때문).
[ 4. 프로세스가 종료되면 ]
ExitProcess 와 TerminateProcess 함수를 호출하는 경우에도 스레드는 종료된다. 차이점이라면 이러한 함수들을 호출하면 프로세스가 소유하고 있던 모든 스레드가 종료된다는 것이다. 프로세스가 사용하던 리소스들이 모두 정리되므로 스레드들이 사용하던 스택도 정리가 되지만, C++ 오브젝트들은 정리되지 못한다.
[ 5. 스레드가 종료되면 ]
스레드가 종료되면 아래와 같은 작업들이 수행된다.
- 스레드가 소유하고 있던 모든 유저 오브젝트 핸들이 삭제된다. 윈도우에서는 대부분의 오브젝트들이 스레드에 의해 생성되지만 프로세스에 의해 소유된다. 그런데 윈도우와 윈도우 훅 두 개의 사용자 오브젝트는 스레드에 의해 소유된다. 스레드가 종료되면 시스템은 자동적으로 해당 스레드가 생성한 윈도우를 파괴하고, 설치한 윈도우 훅을 제거한다. 다른 형태의 오브젝트들은 모두 소유하고 있는 프로세스가 종료되는 시점에 파괴된다.
- 스레드의 종료 코드는 STILL_ACTIVE 에서 ExitThread 나 TerminateThread 에서 지정한 종료 코드로 변경된다.
- 스레드 커널 오브젝트의 상태가 시그널 상태로 변경된다.
- 종료되는 스레드가 프로세스 내의 마지막 활성 스레드라면 시스템은 프로세스도 같이 종료되어야 하는 것으로 간주한다.
- 스레드 커널 오브젝트의 사용 카운트가 1만큼 감소한다.
스레드가 종료된다 하더라도 스레드 커널 오브젝트는 핸들을 삭제하지 않는 이상 자동적으로 파괴되지 않는다.
: hThread 로 주어진 스레드의 종료 코드를 가져온다. 아직 종료되지 않은 스레드라면 pdwExitCode 값이 STILL_ACTIVE 값으로 설정된다. 함수가 성공적으로 호출되면 TRUE 반환.
Section 06. 스레드의 내부
아래의 그림은 시스템이 스레드를 생성하고 초기화하기 위해 어떤 작업을 수행하는지를 보여주고 있다 (이번장은 전체가 중요한 내용이므로 반드시 면밀히 살펴보도록 하자).
어떤 작업들이 수행되는지를 정확하게 이해하기 위해 위 그림을 면밀하게 들여다보자.
- CreateThread 함수가 호출되면 시스템은 스레드 커널 오브젝트를 생성하고 아래처럼 각각 초기화 한다.
- 초기 사용 카운트 = 2 (스레드 1, 스레드를 생성한 곳이 보유한 핸들 1)
- 정지 카운트 = 1
- 종료 코드 = STILL_ACTIVE
- 오브젝트 상태 = 논시그널
- 스레드 커널 오브젝트가 생성되면 시스템은 스레드 스택으로 활용할 메모리 공간을 할당
- 스레드는 자신만의 가상 메모리 공간을 가지지 않으므로 프로세스의 주소 공간에서 할당받음
- 이 후 스레드 스택의 가장 상위에 두 개의 값을 기록
- 첫 번째 값 : CreateThread 함수 호출시 전달된 pvParam 값
- 두 번째 값 : CreateThread 함수 호출시 전달된 pfnStartAddr 값
- 각 스레드는 자신만의 CPU 레지스터 세트를 가지는데, 이를 스레드 컨텍스트라고 부르며, 마지막으로 수행되었을 당시의 스레드의 레지스터 값을 가지고 있다.
- IP : Instruction Pointer
- IP는 NTDLL.dll 모듈이 익스포트 하고 있는 RTLUserThreadStart 라는 문서화되지 않은 함수의 주소를 가리키도록 설정된다.
- SP : Stack Pointer
- 스레드 커널 오브젝트가 초기화되면 스택 포인터는 pfnStartAddr를 저장하고 있는 스레드 스택의 주소로 설정된다.
- 스레드 초기화가 완료되면 시스템은 CreateThread 함수 호출시 CREATE_SUSPENDED 플래그가 전달되었는지 확인한다. 만일 이 플래그가 없다면 시스템은 스레드의 정지 카운트를 0으로 감소시켜 스레드가 프로세서에 스케줄될 수 있도록 한다.
- 스레드가 CPU 시간을 얻으면 시스템은 스레드 컨텍스트에 마지막으로 저장된 값을 CPU 레지스터에 로드한다.
VOID RtlUserThreadStart(PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam)
{
__try
{
ExitThread( (pfnStartAddr)(pvParam) );
}
__except( UnhandledExceptionFilter(GetExceptionInformation()) )
{
ExitProcess(GetExceptioncode());
}
// 주의 : 이 부분은 수행되지 않는다.
}
새로운 스레드의 IP가 RTLUserThreadStart 로 설정되어 있기 때문에, 이 함수는 스레드가 실질적으로 수행하는 최초 위치가 된다. 이 함수는 다른 코드로부터 호출되는 것이 아니라 운영체제가 임의로 호출하는 것이다. 이 함수로 전달되는 두 개의 매개변수도 운영체제가 임의로 스레드 스택에 삽입한 것이다.
새로운 스레드가 RTLUserThreadStart 함수를 호출하면 다음과 같은 작업이 수행된다.
- 스레드 함수 내에서 예외가 발생했을 경우 시스템이 제공하는 기본적인 예외 처리 코드를 수행할 수 있도록 구조적 예외 처리 프레임이 설정된다.
- 시스템은 CreateThread 함수 호출 시에 전달한 pvParam 매개변수로 스레드 함수를 호출한다.
- 스레드 함수가 반환되면 RTLUserThreadStart 함수는 스레드 함수가 반환한 값을 인자로 ExitThread 함수를 호출한다. 스레드 커널 오브젝트의 사용 카운트는 감소되고, 스레드는 수행을 종료한다.
- 만일 스레드가 예외를 유발하고 이러한 예외가 처리되지 않으면 RTLUserThreadStart 함수가 설정한 SEH 프레임이 예외를 처리하게 된다. 이때 사용자에게 메시지 박스를 출력하는데, 사용자가 프로그램 닫기를 선택하면 ExitProcess를 호출하여 전체 프로세스를 종료시켜 버린다.
RTLUserThreadStart 내에서 ExitThread 나 ExitProcess 가 호출되므로, 결국 스레드는 RTLUserThreadStart 로부터 반환되지 못하고 내부적으로 종료된다.
프로세스의 주 스레드가 초기화되면 IP는 RTLUserThreadStart 라는 문서화되지 않은 함수를 가리키도록 초기화된다. RTLUserThreadStart 함수는 C/C++ 런타임 라이브러리의 시작 코드를 호출하여 각종 초기화를 진행하고, WinMain 같은 진입점 함수를 호출한다. 진입점 함수가 반환되면 C/C++ 런타임 라이브러리 시작 코드는 ExitProcess를 호출한다. 따라서 C/C++ 애플리케이션의 주 스레드는 RTLUserThreadStart 함수로 절대 반환되지 않는다.
Section 07. C/C++ 런타임 라이브러리에 대한 고찰
멀리스레드 기반의 C/C++ 프로그램이 정상적으로 동작하려면 C/C++ 런타임 라이브러리 함수들을 사용하는 각 스레드별로 적절한 구조의 데이터 블록을 생성해야 한다. 또한 C/C++ 런타임 라이브러리 함수는 다른 스레드들로부터 영향을 받지 않도록 자신을 호출한 스레드의 데이터 블록에만 접근가능해야 한다. 하지만, 운영체제는 이러한 C/C++ 런타임 라이브러리의 동작에는 관여할수가 없으므로 C/C++ 런타임 라이브러리가 제공하는 스레드 함수들을 호출해야만 한다.
void *psa,
unsigned cbStackSize,
unsigned ( __stdcall * pfnStartAddr )( void * ),
void * pvParam,
unsigned dwCreateFlags,
unsigned * pdwThreadID
) ]
MS는 C/C++ 런타임 라이브러리의 소스 코드를 Visual Studio와 같이 배포하기 때문에 _beginthreadex 함수가 어떤 작업을 수행하는지 정확히 알 수 있다. 아래는 중요한 부분만을 발췌하여 의사코드(pseudo code) 형태로 옮겨본 것이다.
uintptr_t _beginthreadex(
void *psa,
unsigned cbStackSize,
unsigned ( __stdcall * pfnStartAddr )( void * ),
void * pvParam,
unsigned dwCreateFlags,
unsigned * pdwThreadID )
{
_ptiddata ptd; // 스레드의 데이터 블록을 가리키는 포인터
uintptr_t thdl; // 스레드 핸들
// 새로운 스레드에서 사용할 데이터 블록 할당
if ((ptd = (_ptiddata)_calloc_crt(1, sizeof(struct _tiddata))) == NULL)
goto error_return;
// 데이터 블록 초기화
initptd(ptd);
// 사용자가 지정한 스레드 함수와 스레드 함수에 전달할 매개변수를 해당 스레드의 데이터 블록 내에 저장한다.
ptd->_initaddr = (void*)pfnStartAddr;
ptd->_initarg = pvParam;
ptd->_thandle = (uintptr_t)(-1);
// 새로운 스레드를 생성한다.
thdl = (uintptr_t) CreateThread((LPSECUTIRY_ATTRIBUTES)psa, cbStackSize,
_threadstartex, (PVOID)ptd, dwCreateFlags, pdwThreadID);
if (thdl == 0)
{
// 스레드가 생성되지 않았다면 정리 작업을 수행하고 에러를 반환한다.
goto error_return;
}
// 스레드를 생성하는데 성공하였으며, 스레드 핸들 값을 unsigned long 타입으로 반환한다.
return (thdl);
error_return :
// 에러 : 데이터 블록이나 스레드가 생성되지 못했다.
// 만일 CreateThread 를 호출하는 과정에서 무엇인가 잘못되었다면 GetLastError()는 errno에 저장된 값을 가리키게 된다.
_free_crt(ptd);
return ((uintptr_t) 0L);
}
_beginthreadex 구현부에서 주목해야 할 부분은 아래와 같다.
- 각 스레드는 C/C++ 런타임 라이브러리 힙에 _tiddata 메모리 블록을 가진다.
- _beginthreadex 함수에 전달된 스레드 함수의 주소는 _tiddata 메모리 블록 내에 저장된다. 스레드 함수에 전달한 매개변수 또한 _tiddata 메모리 블록에 저장된다.
- _beginthreadex 는 내부적으로 CreateThread를 호출한다. 이것은 운영체제에게 새로운 스레드를 생성하도록 명령하는 유일한 방법이다.
- CreateThread가 호출되면 _beginthreadex의 pfnStartAddr 매개변수로 전달된 스레드 함수가 아니라 _threadstartex 라는 함수가 수행하게 된다. 또한 스레드 함수로 전달할 매개변수도 _beginthreadex에 전달한 pvParam이 아니라 _tiddata 구조체이다.
- 정상적인 경우 _beginthreadex는 CreateThread와 동일하게 스레드 핸들을 반환한다. 만일 문제가 발생하면 0을 반환한다.
이제 _beginthreadex 함수에서 할당한 _tiddata 구조체가 각 스레드와 어떻게 연결되는지 알아보자. _threadstartex 함수의 의사코드는 아래와 같다.
static unsigned long WINAPI _startthreadex(void *ptd)
{
// 주의 : ptd는 스레드의 _tiddata 블록의 주소를 가지고 있다.
// 이 스레드와 연관된 _tiddata는 _callthreadstartex 내에서 _getptd() 함수를 호출하여 얻을 수 있다.
TlsSetValue(__tlsindex, ptd);
// _tiddata 블록 내에 현재 스레드의 ID를 저장한다.
((_ptiddata)ptd)->_tid = GetCurrentThreadId();
// 헬퍼 함수를 호출하여 플로팅 포인트에 대한 초기화를 수행한다.
// (코드에는 나타나 있지 않다.)
_callthreadstartex();
// 아래 문장은 절대 실행되지 않는다. 스레드는 _callthreadstartex 내에서 종료된다.
return (0L);
}
static unsigned long WINAPI _callthreadstartex()
{
_ptidata ptd; // 스레드의 _tiddata 구조체를 가리키는 포인터
// TLS로부터 스레드 데이터를 가리키는 포인터를 얻어온다.
ptd = _getptd();
// 런타임 에러와 signal 함수를 처리할 수 있도록 SEH 프레임으로
// 사용자가 지정한 스레드 함수의 호출부를 둘러싼다.
__try
{
// 사용자가 전달한 매개변수로 사용자 정의 스레드 함수를 호출한다.
// 스레드 반환값으로 _endthreadex를 호출하여 스레드 종료 코드를 설정한다.
_endthreadex( (unsigned (WINAPI*)(void*)) ( ((_ptidata)ptd)->_initaddr) ) ( ((_ptidata)ptd)->_initarg );
}
__except (_XcptFilter(GetExceptionCode(), GetExceptionInformation()))
{
// C 런타임 예외 처리기는 런타임 에러와 signal 함수를 처리한다.
// 이 부분은 호출되지 않도록 하는 것이 좋다.
_exit(GetExceptionCode());
}
}
아래에 _threadstartex 에서 주목해야 할 부분을 나타내었다.
- 새로 생성된 스레드 RtlUserThreadStart (NTDLL.dll 내에 있는)를 호출하고 곧 _threadstartex로 진입한다.
- _threadstartex로는 새로 생성된 스레드의 _tiddata 블록을 가리키는 주소가 매개변수가 전달된다.
- TlsSetValue는 이 함수를 호출하는 스레드와 매개변수로 전달되는 값을 연계시키는 운영체제 함수다. 이러한 값이 저장되는 공간을 스레드 지역 저장소(thread local storage : TLS)라고 한다. _threadstartex 함수는 새로 생성된 스레드와 _tiddata 블록을 연계시킨다.
- 아무런 인자도 전달받지 않는 _callthreadstartex 함수 내에서는 사용자가 지정한 스레드 함수의 호출부를 둘러싸는 SEH 프레임을 구성한다. 이 프레임은 런타임 라이브러리와 관련된 많은 작업들을 수행하는데, 예를 들어 런타임 에러(처리되지 않은 C++ 예외)와 C/C++ 런타임 라이브러리의 signal 함수가 정상 동작하도록 작업을 수행한다. 이 부분은 매우 중요한데, 만일 CreateThread 함수를 이용하여 스레드를 생성한 후 C/C++ 런타임 라이브러리가 제공하는 signal 함수를 호출하게 되면 이 함수는 정상 동작하지 않는다.
- 사용자 정의 스레드 함수가 사용자가 전달한 매개변수 값으로 호출된다. 스레드 함수의 주소와 매개변수 값은 _beginthreadex 함수 내에서 TLS에 저장하였던 _tiddata 블록을 _callthreadstartex 함수내에서 가져와서 사용한다.
- 사용자가 지정한 스레드 함수의 반환 값은 스레드의 종료 코드가 된다. _tiddata 메모리 블록이 누수되는 것을 막지 위해 _endthreadex 라는 C/C++ 런타임 라이브러리 함수를 호출한다.
마지막으로 _endthreadex 함수에 대해 알아보자.
void __cdecl _endthreadex(unsigned retcode)
{
_ptiddata ptd; // 스레드 데이터 블록을 가리키는 포인터
// 플로팅 포인트 지원에 대한 정리를 수행한다. (코드는 나타내지 않았다)
// 이 스레드의 _tiddata 블록의 주소를 가져온다.
ptd = _getptd_noexit();
// _tiddata 블록을 해제한다.
if (ptd != NULL)
_freeptd(ptd);
// 스레드를 종료한다.
ExitThread(retcode);
}
아래에 _endthreadex 함수 에서 주목해야 할 부분을 나타내었다.
- C/C++ 런타임 라이브러리 함수인 _getptd_noexit 함수는 이 함수를 호출하는 스레드의 _tiddata 메모리 블록을 가져오기 위해 내부적으로 운영체제의 TlsGetValue 함수를 호출한다.
- _tiddata 블록이 삭제되고 운영체제의 ExitThread 함수가 호출되어 스레드를 실제로 파괴한다. 물론 이 과정에서 종료 코드가 전달되고 올바르게 설정된다.
ExitThread 함수를 직접적으로 호출하면 우리가 생성한 C++ 오브젝트의 파괴자 호출되지 못하거나 _tiddata 블록이 삭제되지 못하므로 반드시 _endthreadex 함수를 호출해야 한다.
C/C++ 런타임 라이브러리는 새로운 스레드가 생성될 때마다 각기 분리된 데이터 블록을 할당하고 스레드와 연계시킨다. 이렇게 연계된 데이터 블록에 접근하려면 TlsGetValue 함수를 호출하면 된다. 실제 errno를 사용하면 아래에서 볼 수 있듯이 C/C++ 런타임 함수인 _errno가 호출된다.
_CRTIMP extern int * __cdecl _errno(void);
#define errno (*_errno())
int* __cdecl _errno(void)
{
_ptiddata ptd = _getptd_noexit();
if (!ptd)
return &ErrorNoMem;
else
return (&ptd->_terrno);
}
[ 1. 이런! 실수로 _beginthreadex 대신 CreateThread를 호출했다 ]
만일 CreateThread 함수를 호출했다면 _tiddata 구조체를 할당하지 않았다는 문제점이 생긴다 (대부분의 C/C++ 런타임 라이브러리 함수들은 스레드 안전하며 _tiddata 구조체가 필요하지 않다). 만약 어떤 C/C++ 런타임 함수 호출시 스레드와 연계된 _tiddata 블록이 없다면, 이 시점에서 _tiddata 블록을 새로 할당하고 초기화한다.
하지만 이때에도 아래의 문제가 발생한다.
- signal 함수를 사용하는 경우 SEH 프레임이 준비되지 않았기 때문에 signal 함수 호출시 프로세스가 종료된다.
- 스레드가 _endthreadex를 호출하지 않고 종료되기 때문에, _tiddata 블록이 삭제되지 않는다. (CreateThread를 호출한 사람이 _endthreadex를 호출하겠는가?)
C/C++ 런타임 라이브러리의 DLL 버전을 링크하면, 해당 라이브러리는 스레드가 종료되었을 때 DLL_THREAD_DETACH 통지를 받게 되고, _tiddata 블록을 제거하게된다.
[ 2. 절대 호출하지 말아야 하는 C/C++ 런타임 라이브러리 함수 ]
C/C++ 런타임 라이브러리에 있는 이전의 스레드 생성함수인 _beginthread 와 _endthread 함수는 사용하지 말자.
_beginthread 함수는 보안 특성을 가진 스레드를 생성할 수 없으며, 일시 정지된 상태의 스레드도 생성할 수 없고, 스레드의 ID값을 얻을 수도 없다.
_endthread 함수는 어떠한 매개변수도 가지지 않기 때문에 스레드의 종료 코드가 항상 0이 된다. 또한 _endthread 함수는 내부에서 ExitThread 함수를 호출하기 직전에 CloseHandle 을 호출해서 스레드 핸들을 삭제하는데, 이는 스레드 종료 후 GetExitCodeThread 함수등을 사용할 때 버그를 일으킬 수도 있다.
Section 08. 자신의 구분자 얻기
: 현재 프로세스의 허위 핸들(pseudo handle)을 가져온다.
[ HANDLE WINAPI GetCurrentThread(void) ]
: 현재 스레드의 허위 핸들을 가져온다.
이 함수들은 프로세스의 커널 오브젝트 핸들 테이블에 어떠한 새로운 핸들도 생성하지 않기 때문에, 프로세스나 스레드 커널 오브젝트의 사용 카운트에도 영향을 미치지 않는다. 만일 이러한 허위 핸들을 CloseHandle 함수의 매개변수로 전달하면 호출자체를 무시하고 FALSE를 반환한다. 허위 핸들은 프로세스나 스레드의 핸들을 필요로 하는 함수들(GetProcessTimes같은)에 사용할 수 있다.
: 이 함수를 호출한 프로세스의 프로세스 ID를 반환한다.
[ DWORD WINAPI GetCurrentThreadId(void) ]
: 이 함수를 호출한 스레드의 스레드 ID를 반환한다.
이 함수들은 특정 프로세스나 스레드를 구분하기 위해 시스템 전체에서 고유한 ID 값을 사용하는 경우 사용할 수 있다.
[ 1. 허위 핸들을 실제 핸들로 변경하기 ]
실제핸들이란 특정 스레드를 대표할 수 있는 고유의 핸들 값을 의미한다. 허위 핸들은 무조건 그 핸들을 사용하는 스레드의 핸들로 인식되는데, 예를 들어 부모 스레드에서 넘겨받은 허위 핸들을 자식 스레드에서 사용하면 자식 스레드의 핸들로 인식된다. 때문에 이런 경우에는 허위 핸들을 실제 핸들로 변경해야 한다.
아래와 같이 DuplicateHandle 함수를 이용하여 프로세스에 대한 허위 핸들을 실제 프로세스 핸들로 변경할 수 있다. DuplicateHandle 을 사용하는 경우 커널 오브젝트의 사용 카운트를 증가시키기 때문에, 핸들 사용후에는 반드시 CloseHandle을 호출하여야 한다.
HANDLE hProcess;
DuplicateHandle(
GetCurrentProcess(), // 현재 프로세스의 허위 핸들
GetCurrentProcess(), // 변경할 프로세스의 허위 핸들 (스레드의 경우에는 GetCurrentThread())
GetCurrentProcess(), // 실제 프로세스 핸들을 생성할 프로세스 핸들
&hProcess, // 실제 핸들 값이 반환된
0, // DUPLICATE_SAME_ACCESS 가 지정되는 경우 무시됨
FALSE, // 새로 생성된 프로세스 핸들은 상속 불가능함
DUPLICATE_SAME_ACCESS ); // 새로 생성된 프로세스 핸들은 이전의 허위 핸들과 동일한 접근 권한을 가짐
Windows via C/C++ (Microsoft Press/한빛미디어)'C++ > Windows via C/C++' 카테고리의 다른 글
| Windows via C/C++ 요점 정리 (Chapter 6) (0) | 2012/02/24 |
|---|---|
| Windows via C/C++ 요점 정리 (Chapter 5) (0) | 2012/02/21 |
| Windows via C/C++ 요점 정리 (Chapter 4 - Section 2 ~ 5) (0) | 2012/02/16 |
| Windows via C/C++ 요점 정리 (Chapter 4 - Section 1) (0) | 2012/02/14 |
| Windows via C/C++ 요점 정리 (Chapter 3) (0) | 2012/02/13 |
| Windows via C/C++ 요점 정리 (Chapter 2) (0) | 2012/02/11 |