2012/02/24 10:28
Chapter 06 스레드의 기본

스레드는 프로세스와 유사하게 아래의 두 가지 요소로 구성되어 있다.

  • 운영체제가 스레드를 다루기 위해 사용하는 스레드 커널 오브젝트. 스레드 커널 오브젝트는 시스템이 스레드에 대한 통계 정보를 저장하는 공간이기도 하다.
  • 스레드가 코드를 수행할 때 함수의 매개변수와 지역변수를 저장하기 위한 스레드 스택

프로세스는 스스로 어떤 것도 수행할 수 없으며 단순히 생각한다면 스레드의 저장소로 볼 수도 있다. 스레드는 항상 프로세스의 컨텍스트 내에 생성되며, 프로세스 안에만 살아 있을 수 있다. 즉, 스레드는 프로세스의 주소 공간 내에 있는 코드를 수행하고 데이터를 다룬다.



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 함수를 사용해서 스레드를 생성해야 한다.

[ HANDLE WINAPI CreateThread(
    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 함수 ]

[ VOID ExitThread(DWORD dwExitCode) ]
: 이 함수는 스레드를 강제로 종료하고 운영체제가 스레드에서 사용했던 모든 운영체제 리소스를 정리하도록 한다. 하지만 C/C++ 리소스는 정리되지 않으므로 주의해야 한다. dwExitCode 매개변수는 스레드의 종료코드로 설정된다. 이 함수는 반환되지 않는 함수이기 때문에 이후에 나오는 코드는 수행되지 않는다.

만일 C/C++로 코드를 작성하는 경우라면 ExitThread 함수 대신 _endthreadex 함수를 사용하는 것을 강력히 추천한다. 이유는 이후에 설명한다.

[ 3. TerminateThread 함수 ]

[ BOOL TerminateThread(HANDLE hThread, DWORD dwExitCode) ]
: ExitThread 함수가 이 함수를 호출하는 스레드를 종료하는 것과는 다르게 TerminateThread 함수는 어떠한 스레드라도 종료시킬 수 있다. hThread 매개변수는 종료할 스레드의 핸들을 가리킨다.

이 함수를 호출하면 종료될 스레드는 자신이 종료될 것이라는 사실을 전달받지 못하기 때문에 적절한 정리 작업을 수행할 수도 없고, 종료 자체를 회피할 수 있는 방법도 없다.

ExitThread 함수를 호출하면 스레드가 사용하던 스택이 정상적으로 정리되지만, TerminateThread 함수를 사용하면 시스템은 종료된 스레드를 소유하고 있던 프로세스가 살아 있는 동안 그 스레드가 사용하였던 스택을 정리하지 않는다(다른 스레드가 강제적으로 종료된 스레드의 스택을 참조할 수도 있기 때문).

[ 4. 프로세스가 종료되면 ]

ExitProcess 와 TerminateProcess 함수를 호출하는 경우에도 스레드는 종료된다. 차이점이라면 이러한 함수들을 호출하면 프로세스가 소유하고 있던 모든 스레드가 종료된다는 것이다. 프로세스가 사용하던 리소스들이 모두 정리되므로 스레드들이 사용하던 스택도 정리가 되지만, C++ 오브젝트들은 정리되지 못한다.

[ 5. 스레드가 종료되면 ]

스레드가 종료되면 아래와 같은 작업들이 수행된다.

  • 스레드가 소유하고 있던 모든 유저 오브젝트 핸들이 삭제된다. 윈도우에서는 대부분의 오브젝트들이 스레드에 의해 생성되지만 프로세스에 의해 소유된다. 그런데 윈도우와 윈도우 훅 두 개의 사용자 오브젝트는 스레드에 의해 소유된다. 스레드가 종료되면 시스템은 자동적으로 해당 스레드가 생성한 윈도우를 파괴하고, 설치한 윈도우 훅을 제거한다. 다른 형태의 오브젝트들은 모두 소유하고 있는 프로세스가 종료되는 시점에 파괴된다.
  • 스레드의 종료 코드는 STILL_ACTIVE 에서 ExitThread 나 TerminateThread 에서 지정한 종료 코드로 변경된다.
  • 스레드 커널 오브젝트의 상태가 시그널 상태로 변경된다.
  • 종료되는 스레드가 프로세스 내의 마지막 활성 스레드라면 시스템은 프로세스도 같이 종료되어야 하는 것으로 간주한다.
  • 스레드 커널 오브젝트의 사용 카운트가 1만큼 감소한다.

스레드가 종료된다 하더라도 스레드 커널 오브젝트는 핸들을 삭제하지 않는 이상 자동적으로 파괴되지 않는다.

[ BOOL GetExitCodeThread(HANDLE hThread, PDWORD pdwExitCode) ]
: 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++ 런타임 라이브러리가 제공하는 스레드 함수들을 호출해야만 한다.

[ uintptr_t _beginthreadex(
   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. 자신의 구분자 얻기

[ HANDLE WINAPI GetCurrentProcess(void) ]
: 현재 프로세스의 허위 핸들(pseudo handle)을 가져온다.

[ HANDLE WINAPI GetCurrentThread(void) ]
: 현재 스레드의 허위 핸들을 가져온다.

이 함수들은 프로세스의 커널 오브젝트 핸들 테이블에 어떠한 새로운 핸들도 생성하지 않기 때문에, 프로세스나 스레드 커널 오브젝트의 사용 카운트에도 영향을 미치지 않는다. 만일 이러한 허위 핸들을 CloseHandle 함수의 매개변수로 전달하면 호출자체를 무시하고 FALSE를 반환한다. 허위 핸들은 프로세스나 스레드의 핸들을 필요로 하는 함수들(GetProcessTimes같은)에 사용할 수 있다.

[ DWORD WINAPI GetCurrentProcessId(void) ]
: 이 함수를 호출한 프로세스의 프로세스 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/한빛미디어)
Posted by mobilism
TAGc++
2012/02/21 09:42
Chapter 05 잡

윈도우는 잡 커널 오브젝트를 이용하여 프로세스들을 하나의 그룹으로 묶어서 다루거나 샌드박스(sandbox)를 만들어서 프로세스들이 수행하는 작업에 제한을 가할 수 있다. 잡 오브젝트는 프로세스의 컨테이너 역할을 수행한다. 하나의 프로세스만 담고 있는 잡 오브젝트를 생성할 수도 있는데, 이를 통해 프로세스의 일부 기능을 제한하는 것과 같이 일반적으로 하기 힘든 작업을 수행할 수 있다는 장점이 있다.

잡 내에 프로세스를 배치하여 프로세스의 일부 기능을 제한하고자 할 때, 아래의 순서로 이루어진다. 자세한 내용은 책 p.184~186의 소스코드를 참고하도록 하자.

  • 프로세스가 다른 잡 내에 포함되어 있는지 확인. 만일 프로세스가 다른 잡 내에 포함되어 있는 경우라면 이 프로세를 다른 잡으로 옮길 수 없다 : IsProcessInJob
  • 잡 커널 오브젝트를 생성한다 : CreateJobObject
  • 잡 내의 프로세스 들에 대해 알맞은 제한사항을 설정한다 : SetInformationJobObject
  • 새로운 프로세스를 생성. 이때 생성되는 프로세스의 주 스레드가 멈추게 하기 위해 CREATE_SUSPENDED 플래그를 사용해서 CreateProcess를 호출해야 한다
  • 프로세스를 잡 내에 배치한다 : AssignProcessToJobObject
  • 프로세스의 스레드를 실행시킨 후 스레드의 핸들 삭제 : ResumeThread, CloseHandle
  • 프로세스와 잡 기능을 수행
  • 프로세스와 잡 정리

[ BOOL IsProcessInJob(HANDLE hProcess, HANDLE hJob, HANDLE pbInJob) ]
: 주어진 프로세스가 잡 내에 포함되어 있는지를 확인한다. 현재 프로세스가 어떠한 잡 내에 포함되었는지 알아보려면 hJob에 NULL을 전달하면 된다. 다른 잡 내에 포함되어 있는 프로세스는 다른 잡으로 옮길 수 없으며, 자식 프로세스도 동일하게 적용된다. 이것은 잡의 제약사항 내에서 수행되던 프로세스가 임의로 잡으로부터 빠져나오는 것을 막기 위한 보안 제약사항 중 하나이다.

[ HANDLE CreateJobObject(PSECURITY_ATTRIBUTES psa, PCTSTR pszName) ]
: 새로운 잡 커널 오브젝트를 생성한다. 첫 번째 매개변수에는 다른 커널 오브젝트들과 마찬가지로 보안 정보를 전달하며 두 번째 매개변수에는 잡 오브젝트의 이름을 전달한다. 기본적으로 윈도우 탐색기에서 수행된 프로세스는 자동적으로 "PCA" 로 시작하는 잡 내에 배치된다. 잡 내의 프로세스가 종료되는 순간 잡은 통지 이벤트를 받을 수 있는데, 이러한 특성을 이용하여 탐색기는 애플리케이션이 정상적으로 수행되지 않을 경우 프로그램 호환성 지원자가 수행되도록 작성되었다. 애플리케이션 매니페스트를 작성하면 탐색기가 프로세스를 "PCA" 로 시작하는 잡 내에 배치하지 않도록 할 수도 있다.

CloseHandle 함수를 호출해서 잡 오브젝트의 핸들을 삭제한다해도 잡 내의 모든 프로세스가 종료되는 것은 아니다. 잡 오브젝트를 삭제하거나 파괴하도록 명령을 주어도 잡 내에 프로세스가 존재할 경우 실제로 잡 오브젝트를 파괴하지 않고 앞으로 파괴될 것이라는 표시만 해 둔다. 이후 잡 내의 모든 프로세스가 종료되는 그때 비로소 잡 오브젝트가 파괴된다. 잡 핸들을 제거하면 실제로 잡 오브젝트가 존재하더라도 잡 오브젝트에 접근할 수 없다 (OpenJobObject와 같은 함수 호출이 실패한다).



Section 01. 잡 내의 프로세스에 대한 제한사항 설정

일반적으로 잡을 생성하고 나면 잡 내에서 수행될 프로세스들에 대한 샌드박스(제한사항의 집합)를 설정하고 싶을 것이다. 몇 가지의 제한사항 형태가 있다.

  • 기본 제한 사항과 확장 제한 사항은 잡 내의 프로세스가 시스템 리소스를 독점적으로 사용하지 못하도록 한다.
  • 기본 UI 제한 사항은 잡 내의 프로세스가 사용자 인터페이스를 사용하지 못하도록 한다.
  • 보안 제안 사항은 잡 내의 프로세스가 보안 자원(파일, 레지스트리 서브키 등)에 접근하지 못하도록 한다.


아래의 함수를 호출하여 제한사항을 설정할 수 있다.

[ BOOL WINAPI SetInformationJobObject(
  __in  HANDLE hJob,
  __in  JOBOBJECTINFOCLASS JobObjectInfoClass,
  __in  LPVOID lpJobObjectInfo,
  __in  DWORD cbJobObjectInfoLength
) ]
: 잡의 제한 사항을 설정한다. 첫 번째 매개변수는 설정하고자 하는 잡의 핸들 값, 두 번째는 어떤 형태의 제한 사항인지를, 세 번째는 제한사항을 담고 있는 구조체의 주소 값, 네 번째는 구조체의 크기를 의미한다.

주로 사용되는 제한 사항의 종류와 사용되는 구조체는 아래와 같으며, 더 많은 제한 사항의 종류와 자세한 내용은 는 함수 설명을 참조한다.

 제한 사항 형태  두 번째 매개변수의 값  세 번째 매개변수의 구조체
 기본 제한사항  JobObjectBasicLimitInformation  JOBOBJECT_BASIC_LIMIT_INFORMATION
 확장 제한사항  JobObjectExtendedLimitInformation  JOBOBJECT_EXTENDED_LIMIT_INFORMATION
 기본 UI 제한사항  JobObjectBasicUIRestrictions  JOBOBJECT_BASIC_UI_RESTRICTIONS
 보안 제한사항  JobObjectSecurityLimitInformation  JOBOBJECT_SECURITY_LIMIT_INFORMATION

잡에 제한사항을 설정하기에 앞서 어떠한 제한사항이 설정되어 있는지 알고 싶다면 아래의 함수를 호출한다.

[ BOOL WINAPI QueryInformationJobObject(
  __in_opt   HANDLE hJob,
  __in       JOBOBJECTINFOCLASS JobObjectInfoClass,
  __out      LPVOID lpJobObjectInfo,
  __in       DWORD cbJobObjectInfoLength,
  __out_opt  LPDWORD lpReturnLength
) ]
: 잡의 제한사항 정보를 가져온다. JobObjectInfoClass 매개변수에 어떤 형태의 정보를 획득하고자 하는지 알려주면 된다. 잡 내의 프로세스는 자신이 속한 잡의 제한사항을 가져오려 하는 경우 잡에 대한 핸들 값으로 NULL을 지정하여 호출하면 된다.



Section 02. 잡 내에 프로세스 배치하기

잡에 몇 가지 제한사항을 설정한 후 잡에 배치하기 위한 프로세스를 생성할 때 CreateProcess 함수를 CREATE_SUSPENDED 플래그를 사용해서 호출하는 이유는, 새로 자식 프로세스가 생성하였을 경우 자동으로 잡에 배치되지 않으므로 자식 프로세스가 코드를 수행하면 잡을 통해 부여한 제한사항이 적용되지 않기 때문이다. 따라서 새로 프로세스를 생성한 경우 코드를 수행하기 전에 반드시 잡 내에 배치되어야 한다.

[ BOOL WINAPI AssignProcessToJobObject(
  __in  HANDLE hJob,
  __in  HANDLE hProcess
) ]
: 주어진 프로세스를 잡 내부에 배치한다. 이때 매개변수로 전달된 프로세스가 다른 잡 내에 포함되어 있지 않은 경우에만 성공한다.

전달된 프로세스가 이미 잡 내에 포함된 프로세스라면 다른 잡으로 프로세스를 옮기거나 잡과 연관성이 없도록 프로세스를 분리하는 것은 불가능하다. 이미 잡 내에 포함된 프로세스가 새로운 프로세스를 생성하면 생성된 프로세스는 자동적으로 부모 프로세스가 포함된 잡 내에 배치된다. 하지만, 다음과 같이 자식 프로세스를 잡과 분리하여 수행할 수도 있다.

  • JOBOBJECT_BASIC_LIMIT_INFORMATION 의 LimitFlags 멤버를 JOB_OBJECT_LIMIT_BREAKAWAY_OK 플래그로 설정하고, CreateProcess를 호출할 때 CREATE_BREAKAWAY_FROM_JOB 플래그를 이용하면 잡과 분리된 프로세스가 생성된다.
  • JOBOBJECT_BASIC_LIMIT_INFORMATION 의 LimitFlags 멤버를 JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK 플래그로 설정하고,  CreateProcess를 호출한다.



Section 03. 잡 내에 모든 프로세스 종료하기

잡 내의 모든 프로세스를 종료하는 것은 잡을 이용해 할 수 있는 가장 유용한 작업 중의 하나이다.

[ BOOL WINAPI TerminateJobObject(
  __in  HANDLE hJob,
  __in  UINT uExitCode
) ]
: 잡과 연관된 모든 프로세스를 종료한다. 이 함수는 잡 내의 모든 프로세스에 대해 TerminateProcess를 호출하는 것과 유사하다. 프로세스의 모든 종료 코드는 uExitCode로 전달한 값으로 설정된다.

[ 1. 잡의 통계 정보 조회 ]

QueryInformationJobObject 함수를 이용하면 현재 잡의 제한사항 이외에 잡의 통계 정보도 얻어올 수 있다.

  • 기본 어카운팅 정보
    • 두 번째 매개변수 : JobObjectBasicAccountingInformation
    • 세 번째 매개변수 : JOBOBJECT_BASIC_ACCOUNTING_INFORMATION 구조체의 주소
      • 이 구조체에는 잡 내의 프로세스들이 사용한 CPU시간이나 잡 내의 프로세스 개수, 페이지 폴트 횟수 등의 정보가 포함되어 있다.
  • 기본 어카운팅 정보 + I/O 통계 정보
    • 두 번째 매개변수 : JobObjectBasicAndIoAccountingInformation
    • 세 번째 매개변수 : JOBOBJECT_BASIC_AND_IO_ACCOUNTING_INFORMATION 구조체의 주소
      • 이 구조체에는 JOBOBJECT_BASIC_ACCOUNTING_INFORMATION 구조체와 IO_COUNTERS 구조체를 동시에 포함하고 있는데 IO_COUNTERS 구조체를 통해 잡 내의 프로세스가 수행한 읽기, 쓰기, 기타 동작의 수행 횟수를 가져올 수 있다.
      • 만일 잡 내에 포함되어 있지 않은 프로세스에 대해 이러한 통계를 얻어오려면 GetProcessIoCounters 함수를 사용하면 된다.
  • 잡 내에서 현재 수행중인 프로세스들의 ID
    • 두 번째 매개변수 : JobObjectBasicProcessIdList
    • 세 번째 매개변수 : JOBOBJECT_BASIC_PROCESS_ID_LIST 구조체의 주소
      • 실제 예제는 책 p.200 을 참고



Section 04. 잡 통지

잡 내의 모든 프로세스가 종료되거나 허락된 CPU 시간을 모두 사용하였거나 잡 내에 새로운 프로세스가 생성되었을 때, 이러한 상황에 대해 통지를 받고 싶다면 아래와 같은 추가 작업이 필요하다.

허락된 CPU 시간이 모두 사용되었는지의 여부를 확인해야 하는 경우 잡 통지 기능을 이용하면 쉽게 확인할 수 있다. 잡 오브젝트는 잡 내의 프로세스가 허락된 CPU 시간을 모두 사용하지 않았을 경우 논시그널(nonsignal) 상태를 유지하다가 CPU 시간이 모두 사용되는 순간 잡 내의 프로세스들을 강제로 종료함과 동시에 시그널(signal) 상태로 변경된다. 이러한 상태 변경은 WaitForSingleObject 함수(혹은 이와 유사한 함수)를 호출하여 쉽게 확인할 수 있다. 잡 오브젝트를 다시 논시그널 상태로 변경하려면 SetInformationJobObject 함수를 호출하면 된다.

MS는 잡의 시그널 상태를 일종의 에러 통지로 다루고 싶었기 때문에 허락된 CPU 시간이 모두 사용되었을 때 잡을 시그널 상태로 변경하도록 하였다. 대부분의 잡들은 부모 프로세스에 의해 생성되고, 부모 프로세스는 자식 프로세스들의 작업이 종료될 때까지 기다리는 것이 일반적이기 때문에, 모든 잡들이 종료되었는지의 여부를 확인하기 위해서는 단순히 부모 프로세스 오브젝트가 시그널 상태가 될 때까지 기다리기만 하면 된다. 이 방법은 책 p.184~p.186 의 StartRestrictedProcess 함수 구현을 참고하도록 한다.

향상된 통지는 I/O 콤플리션 포트 커널 오브젝트를 생성하고, 그것을 잡 오브젝트나 다른 오브젝트들과 연결해야 한다. 이후 하나 이상의 스레드를 새로 생성해서 잡 통지를 기다리고 있다가, 잡 통지가 발생하면 앞서 생성해 둔 스레드 중 하나가 특정 작업을 수행하도록 해야 한다.
JOBOBJECT_ASSOCIATE_COMPLETION_PORT joacp;
joacp.CompletionKey = 1;  // 잡을 구분할 수 있는 임의의 고유값
joacp.CompletionPort = hIOCP; // 통지를 수신하기 위한 I/O 콤플리션 포트의 핸들
SetInformationJobObject(hJob, JobObjectAssociateCompletionPortInformation, &joacp, sizeof(joacp));

위와 같은 코드를 수생하면 시스템은 잡의 상태를 감시하고 있다가 잡 통지가 발생하면 I/O 콤플리션 포트를 통해 그 사실을 알려주게 된다. 새로 생성된 스레드는 I/O 콤플리션 포트로부터 정보를 가져오기 위해 GetQueuedCompletionStatus 함수를 호출해야 한다. 이 함수는 잡으로부터 통지가 발생하면, 발생된 통지를 가져온다. 이때 주의해야할 사항은 기본적으로 잡 오브젝트 내의 프로세스들이 허락된 CPU 시간은 모두 사용했을 경우 잡 내의 모든 프로세스는 자동으로 종료되며,  JOB_OBJECT_MSG_END_OF_JOB_TIME 통지는 전달되지 않는다는 것이다. 만일 잡 오브젝트가 프로세스들을 종료시키지 않고 CPU 시간을 초과하였음을 알리는 통지만 받고 싶다면 아래와 같은 코드를 작성한다.
JOBOBJECT_END_OF_JOB_TIME_INFORMATION joeojti;
joeojti.EndOfJobTimeAction = JOB_OBJECT_POST_AT_END_OF_JOB;
// 잡 오브젝트에게 허락된 CPU 시간을 초과하였을 경우 어떤 작업이 수행되길 원하는지 설정한다.
SetInformationJobObject(hJob, JobObjectEndOfJobTimeInformation, &joeojti, sizeof(joeojti));

잡 시간 사용 완료 이후에 수행할 동작으로 지정할 수 있는 유일한 값으로는 JOB_OBJECT_TERNINATE_AT_END_OF_JOB 이 있으며, 이는 잡이 생성되었을 때의 기본 동작이다.

Windows via C/C++ (Microsoft Press/한빛미디어)
Posted by mobilism
TAGc++
2012/02/16 10:18
Chapter 04 프로세스



Section 02. CreateProcess 함수

BOOL WINAPI CreateProcess(
  __in_opt      LPCTSTR lpApplicationName,
  __inout_opt  LPTSTR lpCommandLine,
  __in_opt      LPSECURITY_ATTRIBUTES lpProcessAttributes,
  __in_opt      LPSECURITY_ATTRIBUTES lpThreadAttributes,
  __in           BOOL bInheritHandles,
  __in           DWORD dwCreationFlags,
  __in_opt     LPVOID lpEnvironment,
  __in_opt     LPCTSTR lpCurrentDirectory,
  __in           LPSTARTUPINFO lpStartupInfo,
  __out         LPPROCESS_INFORMATION lpProcessInformation
);

스레드가 CreateProcess를 호출하면
  1. 시스템은 프로세스 커널 오브젝트 생성 - 프로세스 커널 오브젝트는 프로세스 자체는 아니며 운영체제가 프로세스를 관리하기 위한 목적으로 생성하는 조그마한 데이터 구조체이다. 사용 카운트는 1.
  2. 새로운 프로세스를 위한 가상 주소 공간을 생성
  3. 실행 파일의 코드와 데이터 및 실행에 필요한 추가적인 DLL 파일들을 프로세스의 주소 공간에 로드
  4. 새로 생성된 프로세스의 주 스레드를 위한 스레드 커널 오브젝트 생성 - 사용 카운트는 1.
  5. 주 스레드는 링커에 의해 진입점으로 지정된 C/C++ 런타임 시작 함수를 실행
  6. 시작 함수는 사용자가 작성한  WinMain 함수를 호출

CreateProcess 함수는 새로 생성된 프로세스가 완전히 초기화되기 전에 TRUE를 반환한다. 만일 초기화가 실패한다면 생성된 프로세스는 곧바로 종료되지만 CreateProcess는 이미 TRUE를 반환했을 수도 있기때문에, 이 경우 부모 프로세스는 자식 프로세스에 어떤 문제가 발생했는지 알 수 없다.

[ 1. pszApplicationName과 pszCommandLine ]

pszCommandLine은 CreateProcess 함수에 변경될 수 있는 형태로 전달되어야 한다. CreateProcess는 내부적으로 전달된 명령행 문자열에 변경 작업을 수행한 후 반환 직전에 원래의 값으로 돌려 놓는다. 때문에, TEXT("NOTEPAD")처럼 문자열 상수를 pszCommandLine으로 전달하게 되면 MS C/C++ 컴파일러는 이 문자열을 읽기 전용의 메모리에 배치하기 때문에 접근 위반을 유발하게 된다.

MS C/C++ 컴파일러는 중복 문자열을 제거하고 이를 읽기 전용 섹션에 배치시킬 수 있도록 /Gf와 /GF 컴파일러 스위치를 제공한다 (편집&계속 기능을 사용하기 위해 /ZI 컴파일러 스위치를 켜면 /GF 스위치를 수반하게 된다). 이러한 문제를 해결하는 방법은 /GF 스위치를 사용하되 임시버퍼를 이용해서 문자열을 잠시 보관하는 것이다.

pszApplicationName이 NULL일 경우(대부분 NULL) pszCommandLine의 첫 번째 토큰을 실행 파일명으로 간주하여, 확장자가 없으면 .exe로 간주한다. CreateProcess는 실행 파일을 찾기 위해 다음과 같이 순차적으로 검색을 진행한다.
  1. 생성할 프로세스의 실행 파일명에 포함된 디렉터리
  2. 생성할 프로세스의 현재 디렉터리
  3. 윈도우 시스템 디렉터리. 즉, GetSystemDirectory가 반환하는 System32 서브폴더
  4. 윈도우 디렉터리
  5. PATH 환경변수에 포함된 디렉터리들

만약 생성할 프로세스의 파일명이 전체 경로를 포함하고 있는 경우라면 이를 이용해 실행 파일을 찾게 되고 나머지 디렉터리에서는 검색을 수행하지 않는다. 시스템이 실행파일을 찾으면 실행파일의 코드와 데이터는 새로운 프로세스의 주소 공간에 매핑된다.

pszApplicationName에 실행 파일명을 지정할 경우 반드시 확장자를 지정해 주어야 한다. 이 경우 CreateProcess는 파일명에 경로명이 없다면 현재 디렉터리 상에 있을 것이라고 가정하고, 만약 파일이 현재 디렉터리에 없다면 다른 디렉터리를 검색하지 않고 실패를 반환한다.

pszApplicationName과 pszCommandLine 모두에 실행 파일명을 지정할 수 있도록 된 이유는 CreateProcess가 윈도우의 POSIX 서브시스템을 지원하도록 하기 위해 포함시킨 것이다.

[ 2. psaProcess, psaThread, 그리고 bInheritHandles ]

새로운 프로세스를 생성하기 위해 시스템은 프로세스 커널 오브젝트와 스레드 커널 오브젝트를 생성해야 한다. 이 오브젝트들은 모두 커널 오브젝트이므로 부모 프로세스는 각각에 대해 보안 특성을 지정할 수 있어야한다. 프로세스의 기본 보안 디스크립터를 사용하길 원한다면 psaProcess, psaThread 를 NULL로 지정하면 되고, 그렇지 않을 경우라면 SECURITY_ATTRIBUTES 구조체를 설정하여 적절한 보안 권한을 설정하면 된다.

이들 매개변수의 SECURITY_ATTRIBUTES 구조체와 bInheritHandles을 통해 프로세스나 스레드의 커널 오브젝트 핸들은 상속 가능하도록 할 수 있다. 예제는 p.141 ~ p.142 참조

[ 3. fdwCreate ]

fdwCreate 매개변수는 새로운 프로세스를 어떻게 생성할지를 결정하며, 여러 개의 플래그 값을 비트 OR 연산으로 결합하여 전달할 수 있다. 자세한 내용은 아래의 표를 참고하도록 하자.
 플래그 값  설명
 DEBUG_PROCESS  부모 프로세스가 자식 프로세스와 자식 프로세스가 생성하는 모든 프로세스를 디버깅하려 한다는 사실을 시스템에 알려준다. 자식 프로세스(디버기)에서 특수 이벤트가 발생한 경우 부모 프로세스(디버거)에게 그 사실을 통보해 준다.
 DEBUG_ONLY_THIS_
 PROCESS
 부모 프로세스가 직접 생성한 자식 프로세스에 대해서만 특수 이벤트 통보를 받는다.
 CREATE_SUSPENDED  새로운 프로세스가 생성된 후 프로세스의 주 스레드를 정지 상태로 만든다.
 DETACHED_PROCESS  CUI 기반의 자식 프로세스가 자신의 부모 프로세스의 콘솔 윈도우에 접근하는 것을 막는다.
 CREATE_NEW_
 CONSOLE
 프로세스 생성 시 새로운 콘솔 윈도우를 생성한다. DETACHED_PROCESS와 같이 지정하면 에러 발생
 CREATE_NO_
 WINDOW
 애플리케이션의 콘솔 윈도우를 생성하지 않는다.
 CREATE_NEW_
 PROCESS_GROUP
 사용자가 Ctrl+C나 Ctrl+Break를 눌렀을 때 통보할 프로세스들의 목록을 수정한다.
 CREATE_DEFAULT_
 ERROR_MODE
 부모 프로세스가 사용하는 에러 모드를 새로 생성하는 프로세스가 상속받지 않도록 한다.
 CREATE_SEPERATE_
 WOW_VDM
 윈도우에서 16비트 애플리케이션을 수행하고자 할 때만 사용된다. 이 플래그는 운영체제가 가상 도스 머신(virtual dos machine : VDM)을 생성하고 그 안에서 16비트 윈도우 애플리케이션을 수행하도록 해 준다.
 CREATE_SHARED_
 WOW_VDM
 윈도우에서 16비트 애플리케이션을 수행하고자 할 때만 사용된다. 단일 VDM 내에서 애플리케이션을 수행한다.
 CREATE_UNICODE_
 ENVIRONMENT
 자식 프로세스의 환경블록 내에 유니코드 문자들이 사용되도록 한다. 기본적은 ANSI 문자열을 사용.
 CREATE_FORCEDOS  MS-DOS 애플리케이션이 16비트 OS/2 애플리케이션 내에서 수행되도록 한다.
 CREATE_BREAKWAY_
 FROM_JOB
 잡 내의 프로세스가 해당 잡과 연결되지 않는 프로세스를 생성한다.
 EXTENDED_
 STARTUPINFO_
 PRESENT
 psiStartInfo 매개변수를 통해 STARTUPINFOEX를 전달할 것임을 운영체제에게 알려준다.

fdwCreate 매개변수를 이용하여 우선순위 클래스(priority class)를 지정할 수 있지만, 이러한 방법은 사용하지 않는것이 좋다.

[ 4. pvEnvironment ]

pvEnvironment 매개변수는 새로운 프로세스가 사용할 환경변수 문자열을 포함하고 있는 메모리 블록을 가리키는 포인터를 지정한다. 대부분 자식 프로세스가 부모 프로세스의 환경블록을 상속받을 수 있도록 하기 위해 NULL로 설정한다.

[ 5. pszCurDir ]

pszCurDir 매개변수는 부모 프로세스가 자식 프로세스의 현재 드라이브와 디렉터리를 설정할 수 있도록 한다. NULL로 전달하면 새로 생성되는 프로세스는 현재 디렉터리는 새로운 프로세스를 생성하는 애플리케이션의 현재 디렉터리와 동일하게 설정된다.

[ 6. psiStartInfo ]

이 매개변수에는 STARTUPINFOSTARTUPINFOEX 구조체를 가리키는 포인터가 지정된다. 윈도우는 새로운 프로세스를 생성할 때 이 구조체의 멤버들을 사용한다. 대부분의 애플리케이션들은 기본 값을 사용하여 프로세스를 생성하는데, 이 경우에도 최소한 구조체의 모든 멤버를 0으로 초기화하고 cb 멤버를 구조체의 크기로 설정하는 작업을 수행해야 한다.

이 구조체의 내용은 대부분 화면에 보여지는 윈도우를 어떻게 나타낼 것 인지를 나타내고 있다.

[ 7. ppiProcInfo ]

ppiProcInfo 매개 변수는 PROCESS_INFORMATION 구조체를 가리키는 포인터로 지정되며, 함수 호출에 앞서 반드시 메모리를 할당해야 한다. 새로운 프로세스를 만들면 시스템은 새로운 프로세스 커널 오브젝트와 스레드 커널 오브젝트를 생성한 후 사용 카운트를 1로 초기화한다. CreateProcess 함수가 반환되기 직전에 프로세스 커널 오브젝트와 스레드 커널 오브젝트를 최대 접근 권한으로 한 번 더 열게 되고, PROCESS_INFORMATION 구조체 내의 hProcess와 hThread 멤버에 이 함수를 호출한 프로세스에서 사용할 수 있는 커널 오브젝트 핸들 값을 할당하게 된다. 이처럼 CreateProcess 내부에서 오브젝트를 다시 한 번 열기 때문에 사용카운트는 2가 된다.

따라서 프로세스 커널 오브젝트가 파괴되려면 프로세스가 종료되고(사용카운트 1감소) 부모 프로세스가 CloseHandle을 호출해야만 한다(사용카운트 1감소). 마찬가지로 스레드 커널 오브젝트가 파괴되려면 스레드가 종료되고 부모 프로세스가 스레드 커널 오브젝트를 핸들을 삭제해야만 한다.

개발자들은 프로세스나 핸들을 삭제하면 프로세스나 스레드가 종료될 것이라 생각하지만, 핸들을 삭제하는 것은 단순히 프로세스와 스레드에 관심이 없다는 것을 시스템에 알려주는 것 뿐이기 때문에 자체적으로 종료될 때까지 계속해서 수행된다.

프로세스 커널 오브젝트나 스레드 커널 오브젝트는 고유의 ID를 부여받는데, 이 ID는 시스템 내에서 유일한 ID 이기 때문에 동일한 ID값을 가지는 경우는 없다. 이러한 ID값은 재사용될 수 있기 때문에, ID를 이용해 프로세스와 스레드를 추적해서는 안된다. 예를 들어 124라는 ID를 가지는 프로세스가 종료되고, 동일한 ID를 가지는 새로운 프로세스가 생성될 수도 있다.

프로세스와 스레드의 ID 를 얻기 위한 함수들은 아래와 같다.
  • GetCurrentProcessId, GetCurrentThreadId
  • GetProcessId, GetThreadId
  • GetProcessIdOfThread

시스템은 각 프로세스의 부모 프로세스 ID를 기록해 둔다. 하지만 ID는 재사용될 수 있기 때문에 ID를 이용해 부모 프로세스를 추적하기 보다는 커널 오브젝트나 윈도우 핸들 등을 이용하는 것이 좋다. 프로세스나 스레드 ID가 재사용되는 것을 막는 유일한 방법은 프로세스나 스레드 커널 오브젝트가 파괴되지 않도록 하는 것이다.



Section 03. 프로세스의 종료

프로세스는 다음과 같이 4가지 방법으로 종료될 수 있다.

  • 주 스레드의 진입점 함수가 반환된다. (강력 추천!)
  • 프로세스 내의 어떤 스레드가 ExitProcess 함수를 호출한다. (비추천)
  • 다른 프로세스의 스레드가 TerminateProcess 함수를 호출한다. (비추천)
  • 프로세스 내의 모든 스레드가 각자 종료된다. (가끔 발생)

[ 1. 주 스레드 진입점 함수의 반환 ]

프로세스가 종료되어야 할 때에는 항상 주 스레드의 진입 함수(entry-point function)가 반환하도록 하는 것이 좋다. 이 방법만이 유일하게 주 스레드의 리소스들이 적절히 해제되는 것을 보장 할 수 있다. 주 스레드의 진입점 함수가 반환되면 다음과 같은 작업을 수행한다.

  • 주 스레드에 의해 생성된 C++ 오브젝트들이 파괴자를 이용하여 적절하게 파괴된다.
  • 운영체제는 스레드 스택의 용도로 할당한 메모리 공간을 적절히 해제한다.
  • 시스템은 진입점 함수의 반환 값으로 프로세스의 종료 코드(프로세스 커널 오브젝트 내에 포함되어 있는)를 설정한다.
  • 시스템은 프로세스 커널 오브젝트의 사용 카운트를 감소시킨다.

[ 2. ExitProcess 함수 ]

프로세스 내의 스레드가 ExitProcess를 호출하면 프로세스는 종료된다.

[ VOID ExitProcess(UINT fuExitCode) ]
: 프로세스를 종료하고 fuExitCode로 프로세스의 종료 코드를 설정한다. ExitProcess는 어떠한 값도 반환하지 못하는데, 이 함수를 호출하면 프로세스가 바로 종료되어 버리기 때문이다. ExitProcess를 호출하는 코드 뒤쪽에 있는 코드는 절대 수행되지 않는다.

윈도우 플랫폼 SDK 문서에는 프로세스 내의 모든 스레드가 종료되기 전까지 프로세스가 종료되지 않는다고 나와있지만, C/C++ 런타임 시작 코드는 애플리케이션의 주 스레드가 진입점 함수로부터 반환되면 프로세스 내의 다른 스레드의 수행 여부와 상관없이 ExitProcess를 호출하여 프로세스를 종료한다. 만약, 진입점 함수 내에서 ExitProcess대신 ExitThread 함수를 호출하게 되면, 프로세스 내에 수행 중인 다른 프로세스가 있는 한 프로세스가 종료되지 않을 것이다.

ExitProcess나 ExitThread를 호출하면 프로세스나 스레드가 함수 내에서 바로 종료되어 버린다. 이렇게 되면 C/C++ 런타임이 관리하는 리소스에 대한 정리작업(전역 객체의 소멸자 호출 등의)이나 프로세스를 대신하여 수행하는 많은 일들이 정상적으로 정리되지 않기 때문에 가능한한 호출하지 않는 것이 좋다.

[ 3. TernimateProcess 함수 ]

[ BOOL TernimateProcess(HANDLE hProcess, UINT fuExitCode) ]
: hProcess로 주어진 프로세스를 종료시킨다. 프로세스가 종료되면 fuExitCode에 전달한 값이 종료 코드로 설정된다. TernimateProcess를 호출하면 프로세스 종료와 관련된 어떠한 통지도 받지 못하기 때문에 애플리케이션은 적절한 정리 작업을 수행할 수가 없다. 다른 방법으로 프로세스를 종료시킬 수 없을 경우에만 TernimateProcess를 호출하도록 하자.

이 함수를 호출하면 프로세스는 자신을 정리할 만한 기회를 가지 못하겠지만, 프로세스가 사용하던 모든 리소스는 완벽하게 정리된다. 프로세스가 종료되면 운영체제는 프로세스가 사용하던 리소스를 남김없이 제거할 것임을 보장한다.

이 함수는 비동기 함수이므로 반환시점에 맞춰 프로세스가 종료될 것이라는 것을 보장할 수 없다. 만일 프로세스가 종료되는 시점을 정확히 알고 싶다면 WaitForSingleObject나 이와 유사한 함수에 프로세스의 핸들을 전달하면 된다.

[ 4. 프로세스 내의 모든 스레드가 종료되면 ]

만일 프로세스 내의 모든 스레드가 종료되면 운영체제는 더 이상 프로세스의 주소 공간을 유지할 이유가 없다고 판단하고 프로세스를 종료하며, 프로세스의 종료 코드는 마지막으로 종료된 스레드의 종료 코드와 동일하게 설정된다.

[ 5. 프로세스가 종료되면 ]

프로세스가 종료되면 다음과 같은 작업이 이루어진다.

  1. 프로세스 내에 남아 있는 스레드가 종료된다.
  2. 프로세스에 의해 할당되었던 모든 사용자 오브젝트와 GDI 오브젝트가 삭제되며, 모든 커널 오브젝트는 파괴된다. (다른 프로세스가 해당 커널 오브젝트에 대한 핸들을 소유하고 있지 않은 경우에만 커널 오브젝트가 파괴되며, 그렇지 않은 경우 커널 오브젝트는 파괴되지 않는다.)
  3. 프로세스의 종료 코드는 STILL_ACTIVE에서 ExitProcess나 TerminateProcess 호출 시 설정한 종료 코드로 변경된다.
  4. 프로세스 커널 오브젝트의 상태가 시그널 상태로 변경된다. 이것은 시스템에서 수행되는 다른 스레드가 프로세스 종료 시까지 대기할 수 있도록 하기 위함이다.
  5. 프로세스 커널 오브젝트의 사용 카운트가 1만큼 감소한다.

프로세스 커널 오브젝트의 사용 카운트가 0 이 되지 않았다는 것은 다른 프로세스가 현재 종료중인 프로세스의 커널 오브젝트에 대한 핸들을 소유하고 있다는 의미이다. 보통 부모 프로세스는 자식 프로세스를 생성하고 나서 자식 프로세스의 커널 오브젝트 핸들을 삭제하지 않는데, 이를 이용해 프로세스의 통계정보나 GetExitCodeProcess를 호출해서 자식 프로세스의 종료 코드를 얻는 등의 작업을 수행할 수 있기 때문이다.

[ BOOL GetExitCodeProcess(HANDLE hProcess, PDWORD pdwExitCode) ]
: 프로세스 커널 오브젝트의 내부를 확인하여 구조체 내의 프로세스 종료 코드를 담고 있는 멤버의 값을 가져온다. 프로세스가 종료되기 전에 이 함수를 호출하면 STILL_ACTIVE값이 pdsExitCode에 반환된다. 프로세스가 종료되었는지의 여부를 확인하기 위해 이 함수를 계속 호출하는 것은 좋은 방법이 아니다. 프로세스가 언제 종료되었는지를 확인하는 방법은 다음절에서 알아볼 것이다.

프로세스의 통계 정보 등에 관심이 없다면 CloseHandle을 호출하여 시스템에게 그 사실을 알려주어야 한다. 만일 프로세스가 이미 종료되었다면 CloseHandle은 커널 오브젝트에 대한 사용 카운트를 감소시키고, 이를 파괴한다.



Section 04. 차일드 프로세스

애플리케이션에서 특정 코드 블록을 수행해야 할 경우 함수나 서브루틴, 스레드 등으로도 작성할 수 있지만, 별도의 자식 프로세스를 만들어 수행할 수도 있다.  자식 프로세스에게 필요한 자료를 넘겨주기 위해서 DDE(Dynamic Data Exchange), OLE, 파이프(pipe), 메일슬롯(mailslot) 등과 같은 방법을 사용할 수 있다. 가장 편리한 방법은 메모리 맵 파일을 이용하여 자료를 공유하는 것이다.

만일 새로운 프로세스를 생성하여 작업을 수행하도록 하고, 그 결과를 기다리는 코드를 작성하려 한다면 아래와 비슷한 형태로 코드를 작성할 수 있을 것이다.
PROCESS_INFORMATION pi;
DWORD dwExitCode;

// 차일드 프로세스를 생성한다.
BOOL fSuccess = CreateProcess(..., &pi);
if (fSuccess)
{
    // 스레드 핸들이 더 이상 필요 없어지면 바로 삭제한다.
    CloseHandle(pi.hThread);

    // 자식 프로세스가 종료될 때까지 대기한다.
    WaitForSingleObject(pi.hProcess, INFINITE);

    // 자식 프로세스가 종료되면 종료 코드를 가져온다.
    GetExitCodeProcess(pi.hProcess, &dwExitCode);

    // 프로세스 핸들이 더 이상 필요 없어지면 바로 삭제한다.
    CloseHandle(pi.hProcess);
}

위의 코드에서 CreateProcess 함수가 반환되는 즉시 자식 프로세스의 주 스레드 커널 오브젝트 핸들을 바로 삭제하는데, 이것은 자식 프로세스의 주 스레드를 종료시키는 것이 아니라 단순히 핸들을 사용하지 않는 것 뿐이다. 이런 방식이 좋은 이유는 자식 프로세스의 주 스레드가 다른 스레드를 생성하고 자신이 종료되는 경우, 부모 프로세스가 자식 프로세스의 주 스레드 커널 오브젝트 핸들을 미리 삭제하였다면 시스템은 자식 프로세스의 주 스레드 커널 오브젝트를 메모리로부터 바로 삭제할 수 있을 것이다.

[ 1. 차일드 프로세스의 독립적인 수행 ]

대부분의 경우 애플리케이션이 다른 프로세스를 생성할 때에는 독립적인 프로세스로 생성한다. 새로운 프로세스가 생성되고 수행 중인 동안 서로 통신을 하거나 새로운 프로세스의 작업 완료를 기다릴 필요도 없을 것이다. 다음 코드는 새로운 프로세스를 생성한 후 어떻게 이를 완전히 분리시키는지를 보여주고 있다.
PROCESS_INFORMATION pi;

// 자식 프로세스를 생성한다.
BOOL fSuccess = CreateProcess(..., &pi);
if (fSuccess)
{
    // 시스템이 자식 프로세스가 종료되는 즉시 프로세스와 스레드 커널 오브젝트를 종료할 수 있도록 해 준다.
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
}



Section 05. 관리자가 표준 사용자로 수행되는 경우

이번 섹션을 윈도우 비스타에 추가된 사용자 게정 컨트롤(User Account Control : UAC)를 다루는데, 모든 윈도우의 공통 사항이 아니므로 건너뛴다. 책 p.163~p.181 을 참고하도록 한다.

Windows via C/C++ (Microsoft Press/한빛미디어)
Posted by mobilism
TAGc++