출처 블로그 > comeOn&tearMeDown
원본 http://blog.naver.com/phoelan/140019953126
윈도우의 프로세스와 스레드

1.1 시분할 멀티태스킹 (Time slicing Multitasking)

운영체제는 동시에 많은 프로그램을 수행하고 있는 것처럼 보이지만, 실제로는 아주 짧은 하나의 실행 단위 시간동안 운영체제의 일부와 조합된 특정 코드가 시스템 전체를 장악하고 있으며, 사람이 느끼지 못할 정도의 짧은 시간이 계속 전환되면서 마치 시스템 사용자들의 입장에서는 동시에 많은 작업이 수행되는 것처럼 착각하게 된다. 이러한 방식을 시분할 멀티태스킹이라고 한다.

보통 CPU의 사용 점유율을 이야기할 때, 시분할 멀티태스킹의 개념을 사용하게 되며, 따라서 특정 프로그램이 CPU를 10% 점유하고 있다는 의미는 1 시간동안 컴퓨터를 사용한다면 이 중 0.1시간은 특정 프로그램 수행을 위해 사용하고 있다는 의미가 된다.

시분할 멀티태스킹은 협업(cooperative, 비선점형)과 선점형(preemptive) 멀티태스킹으로 구분한다. 비선점형 멀티태스킹은 운영체제가 강제로 작업 제어권을 획득할 수 없으며, 각종 자원의 점유 문제로 인해 시스템에서 불필요하게 소모되는 시간이 많다. 현재 운영되고 있는 실제 운영 체제는 선점형 멀티태스킹을 사용하고 있다.

선점형 멀티태스킹은 운영체제에 의해 프로그램 수행 여부가 결정되며, 운영체제의 동작 역시 백그라운드 (윈도우 시스템에서 백그라운드/포그라운드의 개념은 유닉스에서의 의미가 아니다)로 동작한다.

운영체제에 의해 시스템 자원이 배분되고 프로세스의 동작이 결정되기 때문에 특정 프로세스의 자원 점유로 인한 시스템의 안정도 하락 문제가 거의 발생하지 않는다.

1.2 스케줄링(Scheduling)

시분할로 멀티태스킹을 한다면 일정 시간 동안 어떤 순서로 프로그램을 수행시킬 것인가가 문제로 대두된다. 윈도우 시스템에서는 이 단위를 퀀텀(Quantum)이라고 하며 근본적으로 20ms의 수행 시간 간극을 갖고 있다. 또한, 스레드 자체도 위계 질서를 갖고 있으며, 항상 높은 위계 질서의 스레드가 수행 우선권(priority)을 갖고 있다. 수행 우선권이 가장 높은 작업이 먼저 수행된다.

윈도우에서는 스케줄링 퀀텀이 스레드에게 배분된다. 이것은 스케줄의 기본 단위가 스레드이며, 프로세스 단위로 스케줄링이 배분되는 유닉스 시스템과는 차이를 갖게 된다. 스케줄의 기본 단위가 프로세스가 아닌 스레드라는 것은 모든 프로세스는 기본적으로 스레드를 갖게 된다는 의미이다.


2.1 프로세스

프로세스는 애플리케이션에서 사용되는 리소스를 관리하는 가장 큰 체계이다. 프로세스는 일정 고유 영역의 메모리를 할당받고 운영되며, 하나의 프로세스는 전역변수와 환경변수 및 힙과 스레드 스택 및 코드를 갖고 있다. 스레드는 프로세스의 일부분에 속한 개념이다.

윈도우 프로세스는 유닉스 개념의 프로세스와 그렇게 큰 차이는 없다. 다만 프로세스 자체가 포괄하는 개념적 측면이 더 많기 때문에 근본적으로 그 규모가 더 크게 보이며, 윈도우 시스템이 멀티스레딩을 기준으로 설계되어 있기 때문에 다량의 프로세스가 발생할 때 시스템의 속도가 저하되는 문제점을 맞이할 수 있다.

프로세스 단위로 멀티태스킹이 수행되는 것은 전통적인 윈도우 방식이지만, 이는 몇 가지 한계점을 갖고 있다. 먼저 프로세스 상호간 통신 방법상의 한계점이 가장 크다. 각각의 프로세스는 모드 다른 사용자 권한을 갖고 있으며, 구분되는 메모리 영역을 갖고 있기 때문에 별도의 프로그램적 과정이 필요하다. 보통 파이프(Pipe)라고 불리우는 프로세스 상호간의 통신 수단과 IPC(Interprocess Call)를 사용할 수 있게 코드가 작성되어야 프로세스 간의 데이터 이송을 할 수 있다. 전통적으로 프로세스 사이에서 통신할 수 있는 여러 방법론들이 제시되었다.

IPC가 프로세스 상호간의 통신을 담당하지만, 실제 IPC를 쓰는 것은 DCE/RPC를 사용할 경우 외에는 그렇게 많이 존재하지 않는다. 로컬 컴퓨터에서 멀티태스킹으로 애플리케이션이 수행될 경우 멀티스레딩으로 처리하기 때문에 프로세스 상호간의 변수 전달과 같은 문제들이 발생하지 않기 때문이다.

2.2 스레드

스레드는 프로세스 내의 실행부 코드이다. 유닉스에서는 굳이 스레드를 작성하지 않으면 스레드가 만들어지지 않지만, 윈도우 시스템의 경우에는 스레드를 작성하지 않아도 스케쥴링의 문제 때문에 최소한 하나의 스레드가 동작하게 된다. 예를 들어 다음과 같은 간단한 프로그램을 컴파일하더라도 하나의 스레드가 발생된다.

#include <iostream.h>

void main()

  char a;
  cin >> a;
  cout << a << endl; 
}

현재 운영되고 있는 스레드는 작업 관리자에서 확인할 수 있다. 작업 관리자에는 현재 운영중인 모든 프로세스에 할당된 스레드를 볼 수 있게 나타나 있으며, 이렇게 스레드를 별도로 제작하지 않은 상태에서 발생되는 스레드를 주 스레드(Primary Thread) 라고 일컫는다. 주 스레드는 인스턴스 번호가 0 번으로 나타난다.

 

스레드는 보편적으로 함수 하나 정도의 규모이며, 메모리나 자원의 할당과 같은 문제들을 생각하지 않아도 되고 스레드는 모두 프로세스의 전역 변수 등을 공용으로 사용하고 있기 때문에 인수 전달에서의 장점을 갖게 된다. 예를 들어, 하나의 프로그램에서 인쇄 작업을 별도의 스레드로 제작하였을 경우 사용자들은 인쇄 작업을 수행하면서 다른 작업을 같이 영위할 수 있다. 이 때, 인쇄 명령을 내리고 스풀러에 데이터가 전달되기 전에 해당 스레드를 호출한 프로세스를 닫아 버리면 인쇄가 중단되어 버리는 사태를 맞게 된다. 스레드가 참조하는 프로세스의 자원이 모조리 사라져 버리기 때문이다.

또한 프로세스 간 데이터를 전달하기 위해서 별도의 작업이 필요하지 않다. 하나의 프로세스 내에서 모든 인수를 갖고 있으므로, 전역 변수의 경우나 할당된 자원은 모든 스레드가 기본적으로 같이 사용하게 된다.

인쇄와 같은 작업이 프로세스 사이에서 이루어진다면 상대적으로 데이터를 설계하기 힘들고, DLL을 참조하여 통합 코드를 사용한 인쇄 등이 어렵게 된다. 물론 굳이 멀티스레딩으로 제작하지 않고, 단일 프로세스에서 DLL 내의 인쇄 루틴을 호출하는 방법도 생각해 볼 수 있지만, 그런 경우 인쇄 명령을 내리면 인쇄 작업이 끝나기 전까지 (정확하게는 스풀러에 들어가기 전까지) 다른 작업을 수행할 수 없을 것이다. (스레드는 프로세스와는 달리 경량으로 제작되므로 동시에 많은 스레드가 동작하더라도 시스템에 부하를 그렇게 많이 주지 않는다. 반면 윈도우 시스템에서 프로세스가 여럿 동작하면 시스템은 상대적으로 심각한 부하를 받게 된다 이런 이유로 웹 서버 성능 테스트 및 상호 성능 비교를 유닉스 계열과 윈도우 계열에서 동일한 방식으로 할 수 없다. 실행 프로세스 기반의 CGI를 사용하면 윈도우 시스템이 백전백패할 것이 명백하기 때문이다).

2.3 컨텍스트

많은 디버깅 서적, 혹은 프로그래밍 서적에서 갑자기 프로그래밍 도중 "문맥"과 같은 엉뚱한 말이 등장하는 것을 볼 수 있다. 그 말은 이 컨텍스트라는 단어를 우리말로 직역하다 생긴 용어이다.

컨텍스트는 “문맥”이라고 번역되는 것보다는 “정황”으로 번역되는 것이 훨씬 더 적절하다. 윈도우 프로그램에서의 컨텍스트는 운영중인 시스템에서 스레드가 동작하기 위한 정황 정보, 즉 멀티태스킹 환경에서는 시간에 따라 자원을 점유하는 스레드가 달라지는데, 미처 자신의 작업을 마무리하지 못하고 전환 시간을 맞게 되었을 경우 스레드가 다시 자신이 동작할 수 있는 시간으로 돌아왔을 때, 원래 동작하던 내용과 완벽히 일치하도록 상황을 저장해 놓은 다음 복귀해야 한다. 이 때 저장되는 값들, 스레드가 원점으로 되돌아왔을 경우 원래 동작하던 내용을 그대로 저장해 놓은 것을 컨텍스트라고 한다. 따라서, 레지스터의 내용, 스택 포인트, 스레드 주소 공간, 운영 변수 등이 이 컨텍스트이며, 마무리되지 못한 스레드가 원래 동작하던 정확한 지점으로 복귀될 수 있는 최소한의 필요 정보가 삽입된다. 따라서, 컨텍스트는 “정황”으로 번역하여 사용하는 것이 더욱 정확하지만, 여기서는 그냥 컨텍스트로 사용하도록 하겠다.

컨텍스트 전환(Switching)은 시분할 멀티태스킹에 따라 현재 실행중인 스레드가 시간에 따라 자꾸 변하면서 관련된 정확한 컨텍스트를 연결해 주는 작업이다. 다시 말하면 스레드의 전환 그 자체라고 생각해도 크게 다르지 않다.

컨텍스트는 Win32에서 사용하는 구조체 중 CPU에 종속적인 부분이다. 따라서, 윈도우 멀티스레딩 방식의 프로그램을 다른 컴퓨터로 이전할 경우 컨텍스트와 관계된 여러 문제가 발생할 수 있다.

2.4 멀티스레드

스레드는 독자적으로 행동하는 프로세스와 성질이 크게 다르지 않다. 그런데 시작과 종료가 항상 스레드를 발생시킨 프로세스에게 종속되어 있다. 이 말은 스레드가 종료되기 전 프로세스가 종료되면 스레드는 모두 허공에 사라져 버리는 결과를 낳는다는 것이다. 대부분의 윈도우 프로그램은 메시지 큐 기반의 무한 루프를 타는 방식을 사용하므로 이러한 문제가 민감하게 다가오지 않겠지만, 경우에 따라 순차적인 프로그램을 멀티스레드로 운용하는 수가 있다. 일정 작업을 한 뒤 종료하는 순차적 프로그램일 경우에는 모 프로세스가 항상 스레드보다 먼저 종료하지 않도록 주의하는 것이 매우 중요하다.

스레드는 프로세스와 자원을 공유하는 장점이 있지만, 이러한 문제 때문에 자원의 점유 경쟁이 벌어진다. 자원의 점유 경쟁은 스레드 상호간에 이루어지며, 스레드를 발생시킨 프로세스 역시 운영의 핵심은 주 스레드이기 때문에 하나의 스레드로 취급된다. 자원의 경쟁 때문에 실제로 프로그램이 중단되는 일은 잘 벌어지지 않지만, 예상하지 못한 결과가 종종 발생하게 된다. 따라서, 멀티스레드를 사용하는 프로그램에서는 현재 원하는 변수 혹은 입출력을 다른 스레드가 쓰고 있을 확률이 없는지 면밀히 검토해야 한다.


3.1 Win32 API 함수로 스레드 생성

스레드는 Win32 API에서 생성하는 방법과 MFC의 CWinThread 클래스를 통하는 두 가지 방법이 있다. Win32 API는 다시 CreateThread() 함수를 사용하는 API 함수와 _beginthread() 런타임 라이브러리 함수 호출이 존재한다. 런타임 라이브러리에서 호출하는 것은 근본적으로 Win32 래퍼를 통한 것이다.

당연히 API 단계의 스레드 생성 호출이 저급 호출로 분류된다. 따라서, MFC를 사용하여 멀티스레딩을 구현한다면 근본적으로 CWinThread 클래스를 사용하여 다른 MFC 코드와 조화를 맞추어 사용하도록 처리하는 것이 좋다. MFC가 거의 사용되지 않는 시스템 레벨의 프로그래밍에서는 CreateThread() 함수 정도로 족하다.

스레드는 함수를 독립적으로 실행시키는 것 이상의 개념은 아니다. 따라서, 스레드로 수행되는 함수는 기존 C/C++ 프로그램에서 사용하는 함수와 생긴 모양이 크게 다르지 않다. 다만, 함수형이 DWORD 형으로 고정되고, 입력값으로 VOID 형 포인터를 받아들이면 된다. 적절한 값을 포인터로 전달하고, 해당 값을 필요에 따라 형변환하여 사용하면 된다.

다음 소스는 hello, there를 스레드를 사용하여 인쇄하는 예제이다.

#include <windows.h> 
#include <iostream> 
using namespace std; 
DWORD WINAPI myfunction(LPVOID); 
int main()
{
  HANDLE hThread = NULL; 
  DWORD threadId = NULL; BOOL bOK; 
  char *hello = "hello, there";
  cout << "몇 개의 Hello, there를 인쇄할까요?(-1:끝냄)" << endl;
  int i,j;
  
  do
  {
    cin << i; 

    if (i==-1)
      return -1;
    
    for(j=0;j<i;j++)
    {
      hThread = CreateThread (NULL, // ACL 제어 여부, NULL:안함 
                0// 할당될 스택 공간, 0:초기상속
                &myfunction,// 참조값이 아니라도 참조값으로 인식
                (LPVOID)hello,// 인수에 대한 포인터 0: VOID 
                0// 생성 플래그
                &threadId); // 반환값=스레드 ID
      if(hThread == NULL) 
      { 
        cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; 
        return -1
      } 
      bOK=CloseHandle(hThread);
    } 
  }
  while(1); 
  return 0


DWORD WINAPI myfunction(LPVOID p)
{
  cout << (char*)p << endl; 
  return 0
}

이 프로그램은 사용자로부터 숫자를 입력받아 해당 숫자만큼 루프를 수행하며 hello, there를 인쇄하는 기능을 수행한다. 주 스레드는 스레드를 호출하는 역할만을 수행하며, 만일 주 스레드에서 루프문에 다른 메시지를 출력하기 위해 cout을 하나 추가할 때마다 생성된 스레드의 출력값과 주 스레드의 출력값이 엉키는 결과가 발생된다.

루프문 중간에 cin을 둔 이유는 프로그램을 잠시 중단하기 위한 까닭이 크다. 이 순차적 프로그램은 스레드 생성과는 별개로 종료 시점을 판단하기 때문에 메인 루틴이 종료하면, 즉 주 스레드가 종료하면 생성한 스레드의 생존 여부와 관계 없이 모든 스레드는 소멸되어 버린다. 따라서, 컴퓨터가 무척 느리지 않다면 결과를 확인하기 위해 잠시 주 스레드를 중단시켜 놓는 루틴을 삽입해 주어야 한다. 그렇지 않다면 이 프로그램의 결과 아무 것도 출력되지 않을 것이다.

프로그램의 전체 구조는 그다지 이해하기 어렵지 않을 것이다. 함수를 호출할 때 CreateThread() 함수를 사용하여 스레드로 실행할 함수명과 인수 포인터를 삽입한다. CreateThread() 함수의 반환값은 스레드 핸들로 주어지며, 이 핸들이 하는 역할은 단지 스레드에서 정보를 수집하거나 스레드를 종료시키는 것 뿐이다. CreateThread() 함수가 성공적으로 수행되었으면 이제 스레드는 독립적인 하나의 객체로 생명을 얻게 된다.

CreateThread 함수의 ACL은 윈도우 9x 버전에서는 별로 의미가 없다. 하지만 윈도우 NT, 2000, XP 등에 적용된 보안 구조체가 삽입된다. 이는 이 스레드의 보안성을 판별하며, 다음과 같은 구조체의 상속 지점을 정의한다.

typedef struct _SECURITY_ATTRIBUTES
{
  DWORD nLength; 
  LPVOID lpSecurityDescriptor;
  BOOL bInheritHandle;
} SECURITY_ATTRIBUTES

상속 지점을 NULL로 입력하면 특별히 스레드 핸들에 대한 보안성을 지정하지 않고, 기존에 갖고 있던 보안성 개체(즉 모 프로세스의 보안 개체)가 상속된다. 윈도우 9x 버전에서는 어쩔 수 없이 NULL을 입력해야 할 것이며, 이는 윈도우 NT, 2000, XP 등의 커널과 9x 시스템의 근원적인 차이가 된다. 9x 커널에서는 NULL 외에 입력할 수 있는 옵션이 존재하지 않는다.

ACL을 상속받지 않고 직접 입력이 가능하다는 것을 일단 기억해 두기 바라며, 필요한 경우 사용하도록 한다. 보편적으로는 NULL로도 만족할 만한 프로그램을 작성할 수 있다.

할당될 스택 공간에 0을 입력하게 되면 초기 스택은 원래 지정받아야 될 크기로 입력되며, 0이외의 값을 삽입하면 필요에 따라 다른 스레드 스택 크기를 제공할 수 있다. 보편적으로는 건드리지 않는 값이며, 필요할 경우 DEF 파일을 조정하여 STACKSIZE 구문으로 스택의 크기를 조정할 수 있다. 별도로 필요한 옵션이 존재하지 않는다면 NULL을 사용하면 된다.

정의한 함수명은 스레드로 실행될 DWORD 반환 함수를 사용하면 되겠지만 함수의 인수가 삽입되는 방식은 VOID 형 포인터이기 때문에, 입력과 출력이 생각보다 형변환이라는 문제에 걸려 신경이 많이 쓰이게 된다. 따라서, 실제로 Win32 API 프로그램을 사용할 경우 이런 문제를 직접 컨트롤해야 한다. MFC에서 CWinThread 클래스를 사용할 경우는 조금 더 깔끔한 코드를 얻을 수 있으며, 포그라운드 애플리케이션을 제작할 경우 주로 MFC를 사용하므로, 백그라운드 프로그램을 주로 작성할 것이 아니라면 가급적 MFC의 CWinThread로 통일하는 것이 좋다.

생성된 스레드는 결과값을 반환함과 동시에 사라진다. 따라서, 별도의 명령을 내리지 않아도 운영체제는 해당 스레드를 참조하는 참조 수를 내리거나, 핸들을 없애 버린다. 하지만, 이것은 어디까지나 운영체제의 이론적 동작일 뿐이며, 실제 구현상의 문제 때문에 사용이 끝난 핸들이 남아 있는 경우가 생긴다. 이를 막기 위해 명시적으로 핸들을 닫아 줄 필요가 있다. 핸들과 관계된 함수는 공통으로 사용할 수 있으며, CloseHandle() 함수를 사용하면 된다. 이는 핸들의 확인사살이며, 실제로는 운영체제에게 해당 스레드의 참조 수를 내리게 되는 역할을 한다. 그러므로, 스레드를 확인사살하지 않는다. 스레드는 프로세스와 성격이 비슷하여 굳이 별도의 작업을 하지 않아도 리턴값을 반환하면 사라지게 된다.

스레드의 종료 역시 함수의 리턴값으로 처리할 수 있지만, 경우에 따라 일반적인 exit() 함수와 비슷한 역할을 요구할 때가 있다. 이 경우에는 결과값을 되돌리지 않고 스레드를 종료하며 ExitThread() 함수를 사용하여 스레드 중간에서 동작을 중지시킬 수 있다. 스레드 외부에서는 TerminateThread() 함수를 사용하여 주 스레드로부터 생성된 스레드를 삭제할 수 있다.

3.2 스레드 상태 구하기

크게 사용되지는 않겠지만 스레드의 상태를 구하는 몇 가지 함수들이 존재하고 가장 대표적인 예가 GetExitCodeThread() 함수이다. 이 함수는 스레드의 종료 상태를 반환하며, BOOL 형이기 때문에 종료되었는지 실행중인지 두 가지 경우 중 하나만 등장하게 된다. 스레드는 기본적으로 운영체제에 의해 소멸이 결정되므로 실제 스레드를 종료시키는 명령을 사용하는 것과 스레드가 자연히 소멸하는 것은 약간의 시간 간극이 존재한다. 실제로 스레드의 종료 상태 등을 구하는 함수의 용도는 그렇게 많지 않다.

#include <windows.h>
#include <iostream>
using namespace std; 

DWORD WINAPI myfunction(LPVOID);
int main()

  HANDLE hThread = NULL; 
  DWORD threadId = NULL; 
  BOOL bOK; 
  FILETIME CreationTime; 
  FILETIME ExitTime; 
  FILETIME KernelTime; 
  FILETIME UserTime; 
  SYSTEMTIME Csystem; 
  SYSTEMTIME Esystem; 
  SYSTEMTIME Usystem; 
  DWORD retCode=0
  hThread = CreateThread(NULL, // ACL 제어 여부, NULL:안함 
               0// 할당될 스택 공간, 0:초기상속 
               myfunction, // 정의한 함수
               NULL,// 인수에 대한 포인터 
               0// 생성 플래그 
               &threadId); // 반환값=스레드 ID 
  if(hThread == NULL) 
  { 
    cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; 
    return -1
  } 
  WaitForSingleObject(hThread, INFINITE); //스레드 상태가 변하는 것을 기다린다 
  GetExitCodeThread(hThread, &retCode); 
  if(retCode!=STILL_ACTIVE)
  { 
    GetThreadTimes( hThread, //스레드 
            &CreationTime, //시작시간 
            &ExitTime, //종료시간 
            &KernelTime, //커널모드 동작시간 
            &UserTime); //사용자모드 동작시간 
    FileTimeToSystemTime(&CreationTime, &Csystem); 
    FileTimeToSystemTime(&ExitTime, &Esystem); 
    FileTimeToSystemTime(&UserTime, &Usystem); 
    cout << "스레드 ID" << threadId << endl; 

    cout << "스레드 생성 시각 " << Csystem.wYear 
       << "년" << Csystem.wMonth 
       << "월 " << Csystem.wDay 
       << "일 " << Csystem.wHour 
       << "시 " << Csystem.wMinute 
       << "분 " << Csystem.wSecond 
       << "초" << endl;
    if (Esystem.wYear=1601)
      cout << "종료시각이 정의되지 않았습니다." << endl;
    else
      cout << "스레드 종료 시각 " << Esystem.wYear 
         << "년" << Esystem.wMonth 
         << "월 " << Esystem.wDay 
         << "일 " << Esystem.wHour 
         << "시 " << Esystem.wMinute 
         << "분 " << Esystem.wSecond 
         << "초" << endl; 
    
    cout << "사용자모드 동작 시간 " << Usystem.wMinute 
       << "분 " << Usystem.wSecond 
       << "초 " << Usystem.wMilliseconds 
       << "밀리초" << endl; 
    
    bOK=CloseHandle(hThread); 
    exit(0); 
  } 
  return 0


DWORD WINAPI myfunction(LPVOID p)

  long j,k; k=0
  
  for(j=1;j<1000000000;j++) //클럭이 높은 요즘 PC는 많이 돌아야 초단위가 나온다. 
  
  k=j+k; 
  ExitThread(0); 
  return 0
}



위에서 제시된 코드는 스레드의 여러 상태 정보를 출력하는 것이다. 실제로 이 코드는 아주 성능이 엉망이며, 가급적 종료 상태를 확인하기 위해 루프문을 사용하는 일은 하지 않기 바란다. 가장 간략한 예를 들기 위해 필자는 루프문을 사용한 것 뿐이다.

프로그램에서 스레드의 생성 시간과 소멸 시간은 모두 GMT 시간으로 표현된다. 따라서, 현재 시간과 맞지 않다고 해서 시계가 틀렸다고 생각하지 않기 바란다. 시스템 시간 함수에 대해서는 시스템을 다루는 부분에서 언급하게 될 것이다.

스레드는 여러 다양한 상태를 가지며, 이 부분은 사실 운영체제의 소관이다. 따라서, API 레벨에서는 크게 언급할 부분은 아니다. 다만 상태 변화에 따른 정보를 프로그래머가 받아 처리할 수 있다. 여기서 사용한 WaitForSingleObject() 함수는 커널 객체의 상태가 변경되기 전까지 기다리는 DWORD 형의 함수이다. 스레드의 상태가 변한 뒤 주 스레드에서 생성된 스레드의 정보를 획득하도록 처리하는 것이므로, 쓸데없는 루프문을 생성하지 않고 시스템의 효율성을 위해 삽입하였다. 커널 객체는 상태가 변할 때 메시지를 보내야 할 상황이 정의되어 있으며, 이러한 커널 객체 함수들을 사용하면 CPU 시간을 낭비하지 않고도 원하는 작업을 수행할 수 있다. 뒤에서 언급하게 될 세마포어와 뮤텍스는 모두 커널 객체이므로 이들의 상태 변화에 따른 추가적인 작업을 수행할 수 있도록 응용할 수 있다.

제시된 코드를 컴파일하면 약간의 시간 지연 후에 스레드와 관계된 정보가 나타난다. 시간 지연은 CPU 소모를 거의 하지 않으면서 일어나기 때문에 시스템에 별다른 무리를 주지 않는다. 같은 작업을 do-while 문으로 처리한 뒤 작업 관리자를 사용하여 CPU 소모량을 한 번 조사해 보기 바란다.

3.3 C 런타임 라이브러리의 사용

C 런타임 라이브러리는 Win32 API와 병행하여 사용될 수 있으며, 스레드 핸들을 다루는 형태의 깔끔한 윈도우 프로그램이 아닌 다소 투박한 모습으로 나타난다. 사용되는 것은 _beginthread(), _beginthreeadex(), _endthread(), endthreadex() 총 네 개의 함수로, 최초 간략한 사용을 위해 _beginthread() 함수가 정의되었지만 현재는 주로 _beginthreadex() 함수를 사용하고 있다.

C 런타임 라이브러리를 멀티스레드에 사용할 경우 프로젝트 설정을 다소 바꾸어야 할 필요가 있다. VC++을 사용한다면 다음 그림과 같은 설정으로 바꾸어 줄 수 있다.

 

프로그램 자체는 호출 함수형을 Win32형이 아닌 표준 C/C++ 형으로 처리한 것 이외에는 별다른 차이점이 존재하는 것은 아니다. 그러나 런타임 함수만으로 모든 것이 처리되지 않으므로 Win32 API를 전혀 사용할 수 없도록 코드를 작성하는 것은 금한다. 따라서, 전체적인 코드는 다음과 같은 형태로 설정된다.

 



C 런타임 라이브러리와 API 함수의 가장 큰 차이는 하위 호환성이다. 가령 C로 작성된 기존 애플리케이션을 가급적 최소한의 수정으로 멀티스레드 프로그램으로 제작할 경우 C 런타임 라이브러리를 사용하는 것을 권장한다. 또한 부동 소수점 연산이 매개된다면 가급적 런타임 라이브러리를 권장한다. 그 이외의 경우라면 API 함수를 사용하는 것이 훨씬 프로그램 작성상의 통일성을 기할 수 있을 것이다.

멀티프로세스 프로그램은 동시에 여러 작업을 수행할 수 있도록 하는 장점이 있지만, 이에 따른 단점도 같이 존재한다. 가장 먼저 대두되는 내용은, 스레드가 시분할로 나뉘어 있어서 각각의 시간대 별로 전환되는 컨텍스트 관리를 위해 시스템은 여분의 작업을 해 주어야 하고 이는 전체 성능의 측면에 좋은 영향을 끼칠 리 만무하다.

그러나 성능 문제보다 더 심각한 문제는 여러 스레드가 공통의 전역변수나 공용 리소스를 사용하고 있다는 점이다. 따라서, 컨텍스트 전환이 일어날 경우, 변수가 다른 형태로 예측할 수 없이 변경되는 일들이 벌어진다.

가령 주 스레드에 A 라는 변수가 정의되어 있고, 이 스레드에서 파생된 ThreadA, ThreadB 두 개의 파생 스레드가 있다고 가정하자. 주 스레드를 제외한 파생 스레드가 동시에 동작하고 있고 컨텍스트 전환을 계속 반복하고 있으며, A라는 전역 변수를 사용한다고 할 때, 컨텍스트 전환이 한 번 일어날 때마다 전역 변수를 건드리는 스레드가 달라진다. 따라서 변수의 값이 제멋대로 변하는 결과가 벌어지거나, 변수를 사용하지 못하는 결과가 벌어질 수도 있다.

4.1 경쟁상태(Race Condition)

하나의 전역 변수를 다수의 스레드가 동시에 사용할 경우, 경쟁 상태라는 문제가 벌어진다. 이는 변수를 여러 스레드가 같이 사용하다 보니 해당 변수가 계속 엉뚱한 값으로 변경된다는 것이다. 또한 한 번에 입출력이 이루어져야 하는 여러 장비와 관계된 것도 마찬가지다. 가령 텍스트 콘솔로 출력되는 프로그램을 멀티스레드로 작성한 경우 콘솔 출력 화면은 각각의 스레드가 출력하는 메시지로 뒤덮이는 사태가 벌어진다.

따라서, 스레드는 컨텍스트 전환이 일어날 때마다 자신이 변수를 갖고 있는 유일한 스레드라고 착각하게 되며, 공용 변수나 공용 장치 등을 장악하는 스레드는 멋대로 공용 자원을 처리해 버리게 된다. 따라서, 순차적 프로그램과 같은 방법으로 공용 변수를 주었다고 한다면, 실제 실행은 순차적 프로그램처럼 호출된 스레드 순서대로 일어나는 것이 아니라, 동시에 전체 발생된 스레드 및 주 스레드가 동시에 달리기(Race)를 시작하게 된다. 결국 자원을 먼저 점유한 측에서 먼저 자기 멋대로 자원을 사용할 권한이 주어지고, 프로그램이나 출력 결과는 전혀 예상한 대로 나타나지 않고, 프로그램을 실행할 때마다 결과가 달라지는 일이 벌어지게 된다. 다음 제시되는 프로그램의 실행 결과를 예측해 본 다음 한 번 실행해 보라. 실행할 때마다 다른 결과가 나타날 것이다. 이 프로그램은 k가 증가함에 따라 I가 동반증가하므로 결코 중단됨이 없어야 한다. 하지만, 프로그램이 돌아가면서 경쟁 상태를 맞게 되므로 프로그램이 어떤 순간에 죽어 버리고, 그 시점은 아무도 예측할 수 없다.

#include <windows.h> #include <iostream> using namespace std; DWORD WINAPI myfunction(LPVOID); int i=1; int main(){ HANDLE hThread = NULL; DWORD threadId = NULL; BOOL bOK; int j; for(j=0;j<5;j++){ hThread = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } bOK=CloseHandle(hThread); } Sleep(3000); return 0; } DWORD WINAPI myfunction(LPVOID p){ int j,k; j=(int)p; for(k=0;k<i;k++){ cout << k << endl; i=i+j; } return 0; } 

경쟁 상태를 막기 위해서는 하나의 스레드가 자원을 점유하고 있을 때, 해당 자원을 그 스레드가 점유하고 있다는 사실을 다른 스레드에게 알려 주는 것이 중요하다. 따라서 일단 점유된 자원에 대한 사용 금지 조처가 필요하며, 이러한 방법으로 사용하는 것이 크리티컬 섹션(Critical Section)이다. 그러나, 크리티컬 섹션만으로는 다음 제시되는 교착 상태와 관계된 문제를 해결할 수 없다.

4.2 철학자의 만찬

다음 그림은 아주 유명한 “철학자의 만찬(The Dining-Philosophers Problem)"에 관한 것이다. 운영체제론을 다루는 데 있어서 아주 고전적인 문제이다.

 

철학자들은 하는 일이라고는 생각하고 밥을 먹는 일밖에 없으며, 다섯 명의 철학자들이 식탁에 둘러앉아 있다. 이들은 항상 밥을 먹는 시간과 양이 동일하지만, 언제 밥을 먹고 싶은지는 아무도 모른다. 철학자들은 자기가 내킬 때마다 식탁 위의 포크를 들어 밥을 먹게 된다. 철학자들은 좌우의 포크 중 임의로 하나를 사용할 수 있다. 일단 밥을 먹기 시작했으면 끝까지 밥을 먹어야 한다.

문제는 동시에 몇 사람의 철학자가 밥을 먹고 싶을 때 종종 발생한다. 포크를 한 개만 사용할 경우, 그림에서 플라톤과 니체, 헤겔이 동시에 밥을 먹고 싶은 상황을 한 번 가정해 보자. 이 경우, 플라톤은 자신의 오른쪽 포크를 사용하고 헤겔은 자신의 왼쪽 포크를 집었다고 할 때 니체는 갑자기 밥을 먹을 포크가 사라져 버린 결과를 낳게 된다. 그 결과 니체는 무척 배가 고플 것이다.

포크를 두 개를 사용할 때를 한 번 가정해 보자. 모든 사람은 자신의 좌우 포크를 다 사용할 수 있다. 플라톤과 니체가 동시에 배가 고플 경우, 플라톤은 모든 포크를 다 들고, 니체는 오른쪽 포크만을 들고 있는 상황을 가정해 볼 수 있다. 니체는 배가 고프기는 하겠지만 플라톤이 밥을 다 먹고 난 뒤 왼쪽 포크를 확보할 수 있을 것이다.

자 그런데, 모든 철학자가 동시에 배가 고파 자신의 왼쪽 포크를 들었다는 가정을 해 보자. 이 때, 철학자들은 모두 오른쪽 포크 사용이 끝나기를 기다리게 된다. 그러나, 오른쪽 포크는 모든 왼쪽 포크가 사용중인 까닭에 하나도 사용 가능 상태로 돌아오지 않고, 결국 철학자들은 배고픔을 견디다 못해 굶어 죽어 버린다. 이러한 상황을 교착 상태(Deadlock)라고 일컫는다.


5.1 스레드 동기 방법

스레드 상호간 리소스를 적절하게 분배하기 위해서 사용하는 이 작업은 크게 몇 가지가 나뉜다. 가장 많이 사용하는 것은 크리티컬 섹션과 세마포어, 모니터, 이벤트 등이다. 다음은 Win32에서 사용하고 있는 동기 요소이다. 동기 요소들은 커널 객체 (Kernel Object)인 경우와 그렇지 않은 경우가 있다. 커널 객체인 경우 핸들을 사용하고 참조 숫자를 지정할 수 있는 특성이 있다.

  1. 크리티컬 섹션(Critical Section): 스레드로부터 리소스의 일부를 보호하는 기법으로, 하나의 스레드가 어떤 리소tm를 점유하겠다고 예약하는 기법이다. 예약된 리소스는 예약한 스레드가 놓아 주지 않는 한 다른 스레드가 점유하지 못한다.
  2. 뮤텍스(Mutexes): 커널 객체로 운영되기 때문에 핸들을 사용할 수 있다. 크리티컬 섹션이 가지는 교착 상태 문제를 해결할 수 있는 방법이다. 다른 멀티스레딩을 지원하는 언어(Java, C# 등)에서 언급하는 모니터는 윈도우에서 뮤텍스의 일종이다.
  3. 세마포어(Semaphore): 역시 커널 객체이며, 뮤텍스가 단일 스레드를 기준으로 한다면, 세마포어는 스레드의 수를 컨트롤할 수 있는 방법이다.
  4. 이벤트(Event): 범용적으로 쓰일 수 있는 방법. 신호에 의존하게 된다.
  5. 대기 타이머(Waitable Timer): 스레드가 특정 시간에 도달하였는지 여부 파악. 엄밀하게 말해 동기 기능은 거의 존재하지 않는다고 보아도 좋다.

크리티컬 섹션은 커널 객체가 아니므로 수행성능이 좋을 수밖에 없다. 커널 객체를 사용하는 뮤텍스나 세마포어는 객체 관리자(Object Manager)와 연관지어 움직이기 때문에 커널이 참조 숫자를 일일이 관리하게 된다. 따라서, 전반적으로 오버헤드가 걸리게 된다. 같은 프로그램을 크리티컬 섹션으로 처리할 것인지 뮤텍스나 세마포어로 처리할 것인지의 판단은 전적으로 교착 상태가 발생할 가능성이 있는지에 달려 있다. 만일 수백만 분의 1의 확률로 교착 상태가 발생할 수 있다면 크리티컬 섹션은 사용할 수 없다.


5.2 크리티컬 섹션

크리티컬 섹션은 열차의 화장실과 비슷하다. 열차 화장실은 누가 들어가면 화장실 사용중이라는 등이 켜지고, 그 등이 꺼지기 전까지는 아무도 화장실에 들어가지 않는다. 이와 같이 화장실용 전등을 지칭하는 구조체 하나만을 더 만들어 주면 된다. 크리티컬 섹션이 삽입되었다고 하더라도 프로그램의 구조가 아주 많이 변하는 것은 아니다. 열차 화장실에 등을 달기 위해서는 문고리에 스위치를, 외부에 등을 달면 된다. 다만, 화장실에 누가 들어간 것을 감지해야 하므로, 들어간 즉시 문에 있는 스위치를 건드리도록 조정해야 한다.

크리티컬 섹션은 하나의 구조체 CRITICAL_SECTION을 사용하며, 다행히도 이 내부 사정을 별로 알아야 될 필요성은 없다. 이 구조체의 주소는 다른 스레드로부터 보호되는 것들이 삽입되어 있다는 사실을 알려 준다. 커널 오브젝트가 아니라서 핸들에 의해 구현되는 것이 아니라 직접 메모리 주소로 구분하게 된다. 앞서 경쟁 상태에 빠졌던 소스를 한 번 사용해 보도록 하자.

#include <windows.h> #include <iostream> using namespace std; DWORD WINAPI myfunction(LPVOID); CRITICAL_SECTION critic; int i=1; int main(){ HANDLE hThread = NULL; DWORD threadId = NULL; BOOL bOK; int j; 
 InitializeCriticalSection(&critic); for(j=0;j<5;j++){ hThread = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForSingleObject(hThread, INFINITE); // 스레드보다 섹션이 먼저 삭제될 수 없음. 여러 개의 스레드가 생성되므로 // 정상적으로 하자면 WaitForMultipleObject를 사용해야 한다. DeleteCriticalSection(&critic); bOK=CloseHandle(hThread); 
return 0; } DWORD WINAPI myfunction(LPVOID p){
 EnterCriticalSection(&critic); int j,k; j=(int)p; for(k=0;k<i;k++){ cout << k << endl; i=i+j; } LeaveCriticalSection(&critic); 
return 0; }

경쟁 상태가 일어난 소스 일부분을 크리티컬 섹션으로 가렸다. 따라서, 이 프로그램은 원래 예측한 대로 무한 루프를 타야 정상일 것이며, 실제로도 중지되지 않고 계속 실행되어 버린다. WaitForSingleObject()와 같은 커널 객체를 다루는 함수는 이와 같은 순차적 프로그램, 혹은 스레드를 호출한 이후 스레드를 호출했던 모 프로세스가 중단되는 경우 크리티컬 섹션의 시작 메모리 영역이 강제로 삭제되는 사태가 벌어진다. 시스템 입장에서는 이를 예외로 받아들일 수밖에 없으며, 따라서 메모리 침범 오류를 받을 수밖에 없다.

크리티컬 섹션은 하나의 구조체를 정의하는 것으로부터 시작한다. 그런데 프로그래머들이 이 구조체를 직접 다루는 일은 거의 발생하지 않으며 이 구조체는 커널과 연관지어 작업하는 경우에만 의미를 지니게 된다. 구조체는 보호되는 영역의 시작 주소와만 관계하며 다른 작업과는 연관이 없다.

크리티컬 섹션이 시작하는 부분은 여러 스레드가 경쟁하는 초입부에서 정의해 주고, 초기화시킨다. InitializeCriticalSection() 함수로부터 크리티컬 섹션이 출발하며, 각각의 스레드에 크리티컬 섹션으로 진입하는 EnterCriticalSection() 함수를 사용한다. 이렇게 설정되면 가장 먼저 크리티컬 섹션으로 진입하는 스레드가 우선권을 갖고 다른 작업을 수행하게 된다.

위에서 제시된 소스는 총 5개의 스레드를 형성한다. 그렇다면, 제일 먼저 진입된 스레드를 제외한 나머지 스레드는 어떤 상태로 추정되는가? 나머지 4개의 스레드는 현재 활성화된 상태이기는 하지만, 자신의 큐에서 아무런 작업도 하지 못하고, 크리티컬 섹션으로 먼저 들어간 스레드가 나오기만을 기다리고 있다. 따라서, 이 프로세스의 스레드를 측정해 보면 총 5개의 스레드가 검출될 것이다. 성능 모니터로 확인해 볼 수 있을 것이다.

 

크리티컬 섹션이 경쟁 상태를 해소했다고 볼 수 있지만, 이렇게 동작한다면 잘못하면 새로운 문제를 야기할 수 있다. 앞선 열차 화장실 문제에서 이미 드러난 것처럼, 누군가가 화장실을 점유하고 있다면 열차 내에 있는 사람은 아무도 화장실에 가지 못하는 일이 벌어진다. 즉, 보편적으로 멀티스레드 프로그램은 효율성을 나타내기 위해 운영되지만, 크리티컬 섹션을 잘못 사용하면 프로그램이 정지하여 아무 일도 하지 못하는 사태가 벌어질 수 있다. 이것은 일종의 교착 상태이다. 이는 다음 두 가지 경우로 나눌 수 있다.

  • 프로그램 수행이 너무 길어지는 경우, 혹은 프로그램이 제 3의 문제로 종료하지 않는 경우
  • 스레드가 중간에 예외를 발생시켜 미처 크리티컬 섹션을 빠져나오지 못한 상황에서 스레드가 종료되는 바람에, 시스템은 계속 해당 자원이 점유되고 있다고 착각하는 경우.

첫 번째의 경우는 크리티컬 섹션으로 사실 해결할 마땅한 방법이 없다. 따라서, 뮤텍스나 세마포어를 도입한다. 뮤텍스의 경우에는 해당 자원을 사용하기 위해서 보호되는 영역으로 진입하려고 얼마나 기다려야 적절한지 시간 계산을 할 수 있다. 따라서, 열차 화장실과 같은 케이스로 해결할 수 있다. 일정 시간을 기다려서 열차 화장실에 아무런 반응이 없으면 누군가 화장실에서 쓰러졌을 가능성이 높으므로, 119에 연락하면 된다. 뮤텍스는 크리티컬 섹션이 갖지 못하는 이러한 기능을 갖고 있다.

세마포어를 도입하는 경우는 버스 승강장과 같은 경우이다. 출근 시간에 버스 승강장은 매우 혼잡하지만, 승강장이 하나밖에 없다고 가정할 때, 가장 좋은 해결책은 승강장을 몇 개 더 만드는 것이다. 승강장이 많아지게 되면 이런 문제는 효율적으로 끝난다.

물론, 세마포어가 승강장을 무턱대고 많이 만든다고 해서 효율적인 것은 아니다. 세마포어는 스레드 동기를 위한 커널 객체 중 가장 느린 상황이며, 예외적인 상황이기는 하지만, 전체 세마포어가 모두 시간지연이 벌어지는 경우가 많다. 식사중에 이 책을 읽고 있다면 실례겠지만, 공중화장실의 모든 변기가 가득 차 있는데, 줄도 무척 긴 상황을 맞이할 수 있다. 멀티스레드 프로그램은 이러한 다양한 변수가 있기 때문에 실제로 프로그래머들이 생각하는 것처럼 쉽게 많은 문제들이 해결되지 않는다. 이러한 문제가 단지 공상이라고 생각되는 독자 중 혹시 웹서버를 운영하는 독자가 있다면 지금 즉시 데이터베이스 서버를 사용 불능 상태로 놓아 두고, 스레드에 할당되는 대기열을 조사해 보면 된다. 일반적으로 데이터베이스 연결 풀링은 세마포어를 사용하는 경우가 많고, 데이터베이스를 중단하면 해당 데이터베이스를 사용하는 스레드가 모조리 타임아웃 상태가 날 때까지 자신의 자원을 반환하지 않는다. 크리티컬 섹션으로 만약 제작하였다면? 타임아웃조차도 나오지 않기 때문에 뒷일을 책임질 수 없다.

크리티컬 섹션의 취약점은 스레드가 중간에 죽어버린 경우에 종종 발생한다. 시스템 예외 상황을 맞아 스레드만 종료된 경우, 시스템은 해당 스레드가 계속 리소스를 점유중이라고 생각하고 다른 스레드의 진입을 막는다. 언제까지 막을 것인가가 관건인데, 크리티컬 섹션의 경우는 프로세스를 중단하기 전까지 영원히 막아버린다. 이것이 두 번째 케이스이다. 따라서, 크리티컬 섹션으로 진입하는 경우 반드시 예외 처리를 해 주어야 한다. 다음과 같은 케이스면 좋다.

DWORD WINAPI myfunction(LPVOID p){ __try{ EnterCriticalSection(&critic); int j,k; j=(int)p; for(k=0;k 

따라서, 이렇게 예외 처리를 하는 것을 습관처럼 삼아야 한다. 크리티컬 섹션 류의 동기 방법론은 잘못 설계되면 시스템의 성능을 형편없이 떨어뜨리는 주범이 되므로 주의하여야 한다.


5.3 뮤텍스

뮤텍스와 크리티컬 섹션의 가장 큰 차이점은 뮤텍스는 커널 객체라는 점이다. 따라서, 크리티컬 섹션이 하나의 스레드의 일정 영역에 한정되었다면, 뮤텍스는 그 자체로 하나의 객체로 다루어지기 때문에, 다른 스레드가 핸들을 획득하여 접근이 가능하다. 따라서, 크리티컬 섹션과는 달리 자원 자체를 다루는 것이 하나의 별도 객체로 생성되는 셈이다. 결국 프로세스나 스레드를 형성하는 것과 비슷한 루틴이 작용하는 것을 생각해 볼 수 있다.

뮤텍스는 CreateMutex() 함수를 사용하여 생성된다. 그런데, CreateMutex() 함수는 매번 생성될 때 뮤텍스의 이름을 사용자로부터 요구하며, 이 이름은 정형화되어 있지 않다. 다시 말하면, 사용자가 임의로 뮤텍스의 이름을 지정하도록 처리하는 셈이다. 이런 측면들은 GUID나 기타 정형적이면서도 고유한 이름 체계가 사용되기 이전에 제작된 운영체제의 단점이 계승된 것이다. 따라서, 어떤 뮤텍스를 생성할 때, 혹은 생성된 뮤텍스를 사용할 때 같은 문제점들이 계속 발생하게 된다.

뮤텍스는 이 이름을 사용하여 핸들을 얻을 수 있다. 따라서, 다른 프로세스로부터 뮤텍스 핸들을 얻기 위해서는 어떠한 이름을 공유해야 한다. 만일, 뮤텍스가 공유된다면 많은 작업자 사이에서 뮤텍스 이름이 겹칠 가능성이 충분히 존재한다. 결국, 멀티프로세스 프로그램 설계자들은 프로그램을 설계하기 전 뮤텍스 이름에 대해서 “나름대로 표준적인” 체계를 지정해 줄 필요가 있다.

뮤텍스는 다음 루틴으로 적용된다.

  1. . 뮤텍스 생성: CreateMutex()
  2. . 생성된 뮤텍스로부터 핸들 획득: OpenMutex()
  3. . 핸들 제거: CloseHandle()
  4. . 뮤텍스 제거: ReleaseMutex()

뮤텍스는 다른 커널 객체와 마찬가지로 상태가 변할 때 자신의 상태를 객체 관리자에게 송신한다. 따라서, WaitForSingleObject()문을 사용하거나 WaitForMultipleObject() 함수를 사용하여 해당 뮤텍스가 상태 변경 신호를 보냈을 때를 기준으로 다른 작업을 실행하면 된다. 계속 언급하고 있지만, 상태 변화 신호를 보낸다는 것은 그만큼 시스템이 느려짐을 의미하게 된다.

WaitForSingleObject() 등의 함수를 사용하기 때문에 뮤텍스는 일정 시간 이상 반응이 나타나지 않으면 프로그램은 뮤텍스를 기다리지 않고 독자적으로 다른 작업을 수행할 수 있다. 즉, 앞서 말한 화장실 대기열의 경우, 화장실 내에 있는 사람이 이상을 감지하는 것이 아니라, 이상 상태가 존재하면 그것은 외부의 시간으로부터 감지되며, 객체가 별도의 신호를 관리자에게 보내지 않는다면 프로그램이 나름대로 시간을 잰 뒤, 문제가 있으면 다음 동작을 취할 수 있는 것이다. 따라서, 대기열의 문제는 뮤텍스의 특성이 아니라, 커널 객체이기 때문에 동작할 수 있는 방법론일 따름이다.

뮤텍스는 다른 프로세스에 의해 점유되거나 점유되지 않는 두 가지 상태를 갖고 있다. 뮤텍스는 한 번에 하나의 스레드만이 사용할 수 있기 때문에 앞에서 언급한 참조 숫자가 나타나는 것이 아니라 점유/비점유 두 가지 상태를 나타낼 뿐이며, 뮤텍스는 점유 해제가 일어날 경우 객체 관리자에게 자신이 비점유 상태라는 것을 알린다. 뮤텍스를 사용하고자 하는 스레드는 커널 관리자에서 뮤텍스가 비점유 상태라는 것을 알아낸 이후부터 사용이 가능하다.

따라서, 크리티컬 섹션에서 사용한 코드를 여기에 적용하면 다음과 같다. 앞에서 언급한 WaitForMultipleObjects() 함수를 사용한 예를 들어 보았다.

#include <windows.h> #include <iostream> using namespace std; DWORD WINAPI myfunction(LPVOID); HANDLE hMutex; int i=5; int main(){ HANDLE hThread[5]; DWORD threadId = NULL; BOOL bOK; int j; hMutex=CreateMutex(NULL, // ACL FALSE, // 현재 이 스레드가 점유 여부 "MyMutex"); // 뮤텍스 이름 for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); bOK=CloseHandle(hMutex); for(j=0;j<5;j++){ bOK=CloseHandle(hThread[j]); } return 0; } DWORD WINAPI myfunction(LPVOID p){ __try{ WaitForSingleObject(hMutex, INFINITE); int j,k; j=(int)p; for(k=0;k 

스레드에서는 이제 뮤텍스의 상태 변화에 따라 해당 스레드가 뮤텍스를 사용하고 있는지 여부를 파악하게 된다. CreateMutex() 함수에서는 최초 ACL과 해당 뮤텍스를 지금 현재 스레드가 점유할 것인지 여부를 파악하게 된다. 그 다음 적절한 뮤텍스 이름을 생성해 주면 된다. 이미 생성된 뮤텍스를 사용하기 위해서는 OpenMutex() 함수를 사용할 수 있고, 이미 생성된 뮤텍스라고 할지라도 CreateMutex() 함수로 해당 뮤텍스 핸들을 받아올 수 있다. 전자의 경우는 이미 생성되지 않은 뮤텍스일 경우 오류값을 반환할 것이지만, 후자의 경우는 오류값을 반환하지 않고 새로운 뮤텍스를 생성한다. 두 함수의 차이점을 명백히 인식하고 필요한 함수를 사용할 것을 권한다.

CreateMutex() 함수에서 두 번째 인수를 TRUE로 변경하면, 현재 이 스레드가 일차적으로 점유하는 뮤텍스가 형성된다. 이는 뮤텍스 자체가 컨텍스트 전환이 일어나면서 엉뚱한 프로세스에 의해 점유되는 결과가 발생할 수 있다.

뮤텍스는 커널 객체이기 때문에 다음과 같은 방법으로 현재 이 뮤텍스가 다른 스레드에 의해 점유중인지 파악할 수 있다. 만일 특정 시간 내에 점유가 풀리지 않는다면 다른 방법을 강구해 보거나, 코드 수정을 할 필요가 있을 것이다.

DWORD state; state=WaitForSingleObject(hMutex, n); if (state != WAIT_TIMEOUT) { // Job } else cout << "timeout" << endl; 

뮤텍스는 WaitForMultipleObjects()를 사용할 수 있도록 설정되었으며, 이는 필연적으로 다중의 뮤텍스의 상태 변화가 있을 때까지 스레드의 자원 사용을 허가하지 않는 형태의 코드를 작성할 수 있다. 이는 교착 상태를 해소하는 데 조금 쉬운 방법을 제공하며, 각각의 리소스를 건드리는 뮤텍스를 순차적으로 나열한 뒤, 가장 늦게 종료되는 뮤텍스가 상태를 반환하였을 경우 프로그램을 재개하는 방법을 생각해 볼 수 있다.


5.4 OOP로 동기화

지금까지는 클래스를 사용하지 않은 C++로만 프로그램을 계속 작성하였다. 그러나, 이렇게 작성한 것은 뒤에서 언급하게 될 모니터를 예로 들기에는 그다지 좋은 솔루션이라 보기 어렵다. 70년대 초반 등장한 개념인 모니터는 일종의 구조체이지만 거의 클래스에 가깝게 작성되므로, 아예 뮤텍스를 클래스로 운영하는 방법에 대해서 생각해 보기로 하겠다. 가독성을 높이기 위해서 뮤텍스를 전부 하나의 파일로 가정하고 작성하였다. 독자들이 실제로 사용할 때는 함수 선언부와 함수 실체, 그리고 메인 코드를 모두 분리하여 사용할 것을 권한다. 지금부터는 사용하던 코드를 약간 변형하여 i+j 구문을 넣지 않는다. 이는 가독성을 높이기 위함이며, 만일 경쟁 상태가 발생한다면 모니터에 출력되는 내용이 엉켜 나타나게 될 것이다.

#include <windows.h> #include <iostream> using namespace std; // 클래스 정의 class Mutex { protected: HANDLE hMutex; public: Mutex(); ~Mutex(); BOOL getMutex(LPSECURITY_ATTRIBUTES lpsec, BOOL bOwn=FALSE, LPCTSTR pName=NULL); BOOL startMutex(); BOOL endMutex(); }; // 멤버 함수 정의 Mutex::Mutex(){ hMutex=NULL; } Mutex::~Mutex(){ if(hMutex!=NULL) { ::CloseHandle(hMutex); hMutex=NULL; } } BOOL Mutex::getMutex(LPSECURITY_ATTRIBUTES lpsec, BOOL bOwn, LPCTSTR pName){ hMutex=::CreateMutex(lpsec, bOwn, pName); if (hMutex == NULL){ throw "뮤텍스가 형성되지 않았습니다."; return FALSE; } else return TRUE; } BOOL Mutex::startMutex(){ DWORD dwResult; dwResult = WaitForSingleObject(hMutex, INFINITE); if (dwResult != WAIT_TIMEOUT) return TRUE; else return FALSE; } BOOL Mutex::endMutex(){ if (::ReleaseMutex(hMutex)) return TRUE; else return FALSE; } // 소스 메인 부분 DWORD WINAPI myfunction(LPVOID); Mutex myMutex; int i=100; int main(){ HANDLE hThread[5]; DWORD threadId = NULL; BOOL bOK; int j; myMutex.getMutex(NULL, FALSE, "myMutex"); for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); for(j=0;j<5;j++){ bOK=CloseHandle(hThread[j]); } return 0; } DWORD WINAPI myfunction(LPVOID p){ myMutex.startMutex(); int j,k; j=(int)p; for(k=0;k<i;k++) cout << k << endl; myMutex.endMutex(); return 0; } 

클래스로 정의하면 명백하게 생성과 소멸 과정을 거치므로, C에서의 __try를 사용한 예외 처리는 클래스 내에 삽입된 것으로 생각하면 된다. 클래스로 코드를 나타내면 메인 코드의 가독성이 높아지며, 쓰레기 값 처리를 위해서 들어가는 노력이 줄어든다. 핸들을 직접 다루지 않기 때문에 오류 가능성이 줄어들기도 하지만, 속도 측면에서 큰 이득을 볼 수는 없을 것이다. 뮤텍스 핸들은 외부에서 접근하지 않도록 할 것이므로 보호 영역에 삽입한다. 뮤텍스를 생성하는 생성자는 NULL로 초기화된 하나의 뮤텍스를 생성하며, 소멸자는 현재 핸들이 NULL상태가 아니면 핸들을 없애 버리는 과정을 거친다. 따라서, 전체 소스에서는 뮤텍스의 핸들링과 관계된 코드는 들어가지 않는다.


5.5 교착 상태와 모니터

1970년대에 정의된 모니터는 주로 구조체 형태로 정의되었다. 모니터는 데이터 구조체와 함수가 한번에 실행되는 것으로, OOP 개념이 일반화되지 않은 시점에서는 매우 작성하기 어려웠다. 앞서 철학자의 만찬 문제로 다시 돌아가 보기로 하자.

철학자의 만찬에서는 무작위로 철학자들이 밥을 먹는다. 하지만 모니터를 사용할 경우, 철학자들은 갑자기 훈련 잘 받은 “개”가 되어 버린다. 개를 조련시킬 때 가장 먼저 하는 명령 중의 하나가 “기다려” 그리고 “먹어”다. 개는 주인이 주는 음식 외에는 먹어서도 안 되며, 그것도 아무 때나 밥상에 달려들지 않도록 조련해야 한다. 철학자의 문제에서도 마찬가지로, 현재가 먹어야 할 시점인지 기다려야 할 시점인지 판단한다면, 교착 상태가 일어나지 않도록 설정된다.

동시에 모든 철학자들은 아무런 판단 없이 식사를 하는 것이 아니라, 누군가가 먹어야 할 시점과 먹지 말아야 할 시점을 구분한다. 그 다음, 누가 먹기 시작했을 때는 식기가 또 한 사람이 먹기 충분한지 판단하는 시간을 갖는다. 이것은 항상 순차적으로 일어나야 하고, 실제로 컴퓨터는 시분할 멀티태스킹으로 항상 시간 우선 순위를 갖는다. 누군가 빠른 시간에 작업을 수행했고, 리소스가 충분하지 않다면 늦은 시간은 항상 우선 “기다려” 상태로 가게 된다. 강아지 훈련과 철학자의 만찬은 결국 같은 문제로 귀결된다.

 

모니터란, 멀티스레드를 사용하는 클래스(혹은 구조체)에 이와 같은 기능을 미리 설계해 넣은 것을 의미한다. 아주 간단히 제작하려면 클래스로 작성된 뮤텍스를 해당 클래스에 삽입하면 된다. 모니터는 이렇게 코드 자체에 감시 기능이 존재하는 상태이며, 앞에서 다룬 코드들을 모두 모니터 클래스 객체로 변형하면 다음과 같은 코드를 얻을 수 있다.

class Printit { private: int i; Mutex myMutex; public: Printit(); void writeit(int j); }; Printit::Printit(){ i=100; } void Printit::writeit(int j){ myMutex.startMutex(); int k; for(k=0;k 

먼저 선언된 Mutex 클래스를 사용하여, 특정 작업을 수행할 하나의 클래스를 형성하며, 해당 작업을 클래스의 멤버 함수로 삽입하였다. 그 다음 작업의 시작과 끝을 모두 뮤텍스로 둘러쌌다. 이렇게 정의하면 멤버 함수가 호출될 때 필요한 리소스를 잡고 수정 작업을 할 수 있다.

이제 메인 프로그램은 더 간단히 처리되며, 정의된 모든 클래스를 헤더 함수에 삽입하든지 하나의 C++ 파일에 전부 집어넣든지 관계없이 다음과 같은 최종 코드를 볼 수 있을 것이다.

DWORD WINAPI myfunction(LPVOID); Printit Ptest; int main(){ HANDLE hThread[5]; DWORD threadId = NULL; BOOL bOK; int j; for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); for(j=0;j<5;j++){ bOK=CloseHandle(hThread[j]); } return 0; } DWORD WINAPI myfunction(LPVOID p){ int j=(int)p; Ptest.writeit(j); return 0; } 

모니터를 설계할 때 주의할 점은 일단 실행이 이루어졌으면 모든 작업이 끝날 때까지 다른 자원이 침범하지 못하도록 해 주어야 한다는 것이다. 따라서, 중간에 컨텍스트 전환이 일어나지 않도록 작업이 뮤텍스로 적절히 둘러싸 주어야 한다.

모니터는 가장 전통적인 멀티프로세스 또는 멀티스레드 프로그램에서 일어나는 경쟁 및 교착 상태의 해결책이기는 하지만, 각각의 객체 단위로 설계가 이루어져야 하기 때문에 상대적으로 설계 시간이 다소 소모되는 단점이 있다.


5.6 세마포어

세마포어는 다익스트라(Dijkstra)에 의해 제시된 개념으로, 철학자들이 굶어죽는 문제를 처음 제기한 이가 다익스트라이다. 세마포어는 병렬적인 리소스와 그 리소스를 차지하는 스레드 사이의 다대다 관계로 해석하면 된다. 세마포어는 철도의 신호기에서 온 것으로 하나의 철길을 두 대 이상의 열차가 사용하기 위해 신호를 따른다는 의미이다.

뮤텍스가 한 번에 단 하나의 보호되는 자원을 설정할 수 있음에 반해, 세마포어는 해당 자원을 몇 개의 스레드가 동시에 사용할 수 있을지 정의한다. 일반적으로 뮤텍스를 하나의 스레드만이 접근할 수 있는 까닭에 바이너리 세마포어라고 일컫기도 하지만, 윈도우 시스템에서는 최대 하나의 스레드만이 접근할 수 있는 세마포어와 뮤텍스는 완전히 다른 개체로 인정되므로, 처음부터 뮤텍스로 설정한 것과 세마포어로 설정한 것은 차이점을 갖는다는 사실을 잊어서는 안 된다.

뮤텍스나 세마포어는 스레드를 순서대로 동작시키는 것에 주안점을 맞추므로, 실제 CPU의 활용 상황에서 그렇게 최적화되지 못하는 경우가 많다. 하나의 리소스를 잠금 설정을 했을 경우, 해당 리소스에 복수 접근이 가능하더라도 최대 접근 수는 항상 1로 설정되기 때문에 결국 멀티스레드 프로그램에서 나타나는 복수 스레드가 특정 자원을 뮤텍스로 감쌌을 경우 여러 스레드 사이에서 대기 상태가 계속 나타나게 된다. 이는, 컨텍스트 전환 이후 아이들링 상태의 CPU 동작을 빈번히 가져오게 되며 결국 프로그램은 멀티스레드의 이점을 퇴대한 활용할 수 없다.

세마포어는 하나의 자원, 혹은 루틴에 최대 접근 숫자를 정의해 놓고, 접근 숫자가 모두 차면 여분의 접근 숫자를 0으로 설정한다. 이후 여분 접근 숫자가 0이 아니면 그만큼의 스레드의 요청을 받아들일 수 있다. 따라서, 잘못 설계하게 되면 세마포어는 멀티스레드가 갖는 문제를 그대로 나타내게 된다. 단지 동시에 동작할 수 있는 스레드의 수만 한정해 놓는 셈이다. 따라서, 상호 간섭이 나타나는 리소스에는 세마포어를 사용할 수 없다. 가령, 열차 화장실 문제로 다시 복귀해서, 화장실이 모자라서 하나를 증설하는 경우 뮤텍스나 크리티컬 섹션으로는 동작하지 않고 세마포어로 동작하게 된다. 그러나, 화장실로 가는 스레드가 동시에 두 개까지 허용된다는 것은 실제 화장실이 두 개인 것을 의미한다. 따라서, 화장실이 하나임에도 불구하고 스레드를 두 개 만들면 어쩔 수 없이 경쟁 상태를 맞게 된다.

세마포어가 가장 효율적으로 사용되는 것은 데이터베이스의 커넥션 등의 작업이다. 보통 데이터베이스 접근과 같은 작업들을 멀티스레딩으로 처리하는 경우가 많은데, 실제로 데이터베이스 연결은 라이선스, 혹은 성능에 의해 최대 연결 가능 숫자가 지정되어 있다. 따라서, 정의한 숫자 이내의 연결을 위해서 리소스를 사용하는 스레드를 컨트롤해야 한다. 단 하나의 리소스만을 사용할 경우에는 뮤텍스를 쓸 수 있겠지만, 리소스가 두 개 이상이 될 경우에는 효율적인 시스템 운영을 위해서 세마포어를 사용할 수밖에 없다.

세마포어는 뮤텍스와 약간 다른 방법으로 동작한다. 뮤텍스가 전역 변수로 설정된 것에 반해 세마포어는 지역 변수로 설정된다. 세마포어는 동시에 다중 참조가 가능한 커널 객체이므로, 세마포어 이름을 알고 있다면 일단 생성되어 있는 세마포어를 열어 사용할 수 있기 때문이다. 따라서, 주 스레드에서 세마포어를 연 뒤 파생되는 스레드에서 세마포어를 사용할 수 있는 체계가 확립되는 셈이다.

이제 뮤텍스를 사용했던 예제를 세마포어까지 확대시켜 보자.

#include <windows.h> #include <iostream> using namespace std; DWORD WINAPI myfunction(LPVOID); int i=100; int main(){ HANDLE hThread[5]; HANDLE hSemaphore; DWORD threadId = NULL; BOOL bOK; int j; hSemaphore=CreateSemaphore(NULL, // ACL 1, // Initial Locking Count 1, // Maximum Locking Count "MySemaphore"); // 세마포어명 for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); //스레드 죽기 전에 세마포어가 삭제되면 안됨 bOK=CloseHandle(hSemaphore); bOK=CloseHandle(hThread); return 0; } DWORD WINAPI myfunction(LPVOID p){ HANDLE hSemaphore=OpenSemaphore(SEMAPHORE_ALL_ACCESS, FALSE, "MySemaphore"); WaitForSingleObject(hSemaphore, INFINITE); int j,k; j=(int)p; for(k=0;k<i;k++){ cout << k << endl; } long lCount; ReleaseSemaphore(hSemaphore, 1, &lCount); CloseHandle(hSemaphore); return 0; } 

전체적인 구성에 있어서는 그다지 차이를 나타내지 않는다. 세마포어는 참조가 가능한 형태로 운영되는 커널 객체이므로 일단 주 스레드에서 세마포어를 선언하고, 해당 CreateSemaphre() 함수를 사용하여 세마포어를 생성하였다. 세마포어는 참조 숫자를 갖고 있기 때문에 현재 자신이 얼마나 참조되고 있는지 ReleaseSemaphore() 함수로 확인할 수 있다. 다만 주의할 점은 ReleaseSemaphore() 함수를 사용하고 난 뒤에는 세마포어가 보호하는 루틴으로부터 빠져 나온 것이기 때문에 컨텍스트 전환의 문제를 맞이할 수 있다는 것이다. 따라서 이 참조 숫자를 사용할 필요가 있을 때 역시 컨텍스트 전환을 방지할 수 있는 방법론을 도입할 필요가 있다.

OpenSemaphore() 함수는 세마포어를 어떻게 접근할 것인지 접근 옵션을 지정하고 있으며, 이는 일반적인 커널 객체의 경우 거의 비슷한 방법으로 접근이 가능하다. 세마포어의 경우에는 SEMAPHORE_ALL_ACCESS, SEMAPHORE_MODIFY_STATE, SYNCHRONIZE 모두 세 가지가 지원이 가능하며, 모든 접근 가능, 카운터 변경 가능, 대기 함수에서 핸들 사용 가능의 옵션이다.


5.7 이벤트

이벤트는 지금까지 언급했던 동기화 객체들과는 다소 차이가 있다. 지금까지 말한 커널 객체나 크리티컬 섹션은 운영체제에 의해 영위되는 전자동식 운영이었다면 이벤트는 자동이 아니라 수동으로 운영된다. 즉 프로그래머의 의도에 의한 컨텍스트 전환이 이루어진다.

이벤트는 일종의 전기 스위치로 볼 수 있다. 이 전기 스위치는 두 가지가 있으며, 일반적으로 보통 볼 수 있는 ON/OFF 스위치와, 작업이 끝나면 자동으로 꺼지는 스위치가 있다. 윈도우에서는 전자를 수동 재설정 이벤트(Manual Reset Event)라고 하며 후자를 자동 이벤트(Automatic Event)라고 한다. 이 두 가지의 차이점은 아주 명백하여, 수동 재설정 이벤트는 명시적으로 켜거나 끄지 않으면 항상 원래 있던 상태를 유지하고 있으며, 자동 이벤트는 항상 꺼져 있는 상태로 유지되다가, 스위치를 누르면 특정 작업을 수행하는 순간만 켜져 있고, 작업이 끝나면 도로 꺼져버리는 특성이 있다.

이벤트 역시 핸들러로 지원되는 커널 객체이다. 따라서, 여지껏 해온 문맥대로라면 CreateEvent() 함수로 설정하고 OpenEvent() 함수로 이벤트를 열 것이다. CreateEvent() 함수에서 이벤트가 어떤 종류(수동 이벤트, 자동 이벤트)로 설정될 것인지 나타낼 수 있으며, 이벤트를 다루는 것은 다음 세 가지 추가 함수를 사용한다.

  • SetEvent()
  • ResetEvent()
  • PulseEvent()

SetEvent() 함수는 이벤트를 켜는 명령어이다. 수동 이벤트라면 계속 켜져 있을 것이며, 자동 이벤트라면 단일 스레드가 작동한 뒤 다시 꺼질 것이다. ResetEvent() 명령은 이벤트를 끄는 것으로, 자동 이벤트의 경우에는 별다른 영향을 주지 못한다. PulseEvent 명령어는 자동 이벤트의 경우 SetEvent()와 동일한 효과를 나타내며, 수동 이벤트는 대기열에 있는 스레드를 동작시키게 된다.

이벤트는 프로세스 사이, 혹은 스레드 사이에서 통신할 수 있으며, 프로세스 사이에서 통신하는 것도 스레드와 동일한 코드를 사용하게 된다. 이제 질리도록 친근해진 소스를 이벤트를 사용하는 방법으로 변형해 보기로 하자.

#include <windows.h> #include <iostream> using namespace std; DWORD WINAPI myfunction(LPVOID); int i=100; int main(){ HANDLE hThread[5]; HANDLE hEvent; DWORD threadId = NULL; BOOL bOK; int j; hEvent=CreateEvent(NULL, // ACL FALSE, // 자동 이벤트 TRUE, // 초기 상태: ON "MyEvent"); // 이벤트명 for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); bOK=CloseHandle(hEvent); bOK=CloseHandle(hThread); return 0; } DWORD WINAPI myfunction(LPVOID p){ HANDLE hEvent=OpenEvent(EVENT_ALL_ACCESS, FALSE, "MyEvent"); WaitForSingleObject(hEvent, INFINITE); int j,k; j=(int)p; for(k=0;k<i;k++){ cout << k << endl; } SetEvent(hEvent); CloseHandle(hEvent); return 0; } 

WaitForSingleObject()는 Event의 경우 ON 상태에서 감지된다. 따라서, 자동 이벤트를 사용하였으므로 일단 이벤트가 켜진 상태로 프로그램을 설정한다. 그러면 제일 처음 스레드가 실행되면서 이벤트가 자동으로 꺼지게 된다. 이후 발생되는 스레드는 모두 대기 상태로 들어갈 수밖에 없다. 처음 스레드가 종료되어야 SetEvent() 함수가 실행되어 다시 스레드를 원래 상태로 돌려놓을 것이기 때문이다.

이벤트는 이처럼 특정 이벤트 객체의 상태를 파악하여 모든 작업을 제어한다. 따라서, 상대적으로 단순한 구조를 갖고 있다. 또한 ACL을 올바르게 설정하였다면 다른 스레드는 물론 프로세스에서도 해당 이벤트를 같은 이름으로 받을 수 있다. 다만, 프로세스 사이에서 통신이 이루어질 경우에는 이벤트 이름 형식을 정확히 정의해 주어야 한다. 가령 다음과 같이 하면 될 것이다.

   HANDLE hEvent=OpenEvent(EVENT_ALL_ACCESS, FALSE, _T("MyEvent"));

5.8 대기 타이머(Waitable Timer) 및 타이머 큐(Timer Queue)

지금부터 언급하는 내용들은 근본적으로 위에서 언급된 루틴과는 다르다. 아예 Sleep()과 같은 함수와 동일한 개념으로 동작한다고 생각하면 된다. 즉, 일정 시간의 유예를 둔 뒤 해당 시간이 지나면 자동으로 해당 작업을 멈추어 버리는 방법이다. 타이머는 뮤텍스나 세마포어처럼 명시적으로 스레드 동기화에 관여하지 않으므로, 실제로는 동기 함수라기 보다는 특별한 경우에만 주로 사용된다. 이는 Sleep() 함수보다 더 시스템을 효율적으로 다룬다는 특징을 지닐 뿐이다. 따라서 Sleep()이 필요한 시점에서 대기 타이머나 타이머 큐를 사용하는 것을 권한다.

타이머 큐는 단순히 일정 시간을 기다리는 콜백 함수이며, 대기 타이머는 단순히 기다리는 것이 아니라, 이벤트와 같이 ON/OFF 등을 구분하는 상태를 나타낼 수도 있고, 특정 시간이 지나면 주기적으로 활성화되는 형태를 나타내기도 한다. 대기 타이머 역시 이벤트와 비슷하게 수동 타이머(Manual Reset Timer)와 동기 타이머(Synchronization Timer)가 존재한다. 동기 타이머가 이벤트의 자동 타이머와 비슷한 구실을 하며, 그 외에 주기 타이머(Periodic Timer)가 있다. 타이머 역시 생성과 소멸은 비슷한 문법을 가지므로, CreateWaitableTimer() 함수로 생성된다. 문법은 거의 이벤트와 흡사하며, 동기화에서 그렇게 많이 사용되지 않으므로 더 자세한 내용을 원하는 이들은 MSDN에서 사용법을 참고할 것을 권한다.


5.9 TLS

스레드에서 전역 변수를 사용할 경우 모든 스레드가 해당 변수를 건드리기 때문에, 다른 스레드가 전혀 동작하지 않는다고 하더라도, 컨텍스트 전환의 결과로 특정 데이터를 엉망으로 만들어 버릴 가능성이 높다. 이 경우 스레드 로컬 저장소를 사용하면 문제가 깔끔히 해결된다. 스레드 로컬 저장 영역은 32비트 블록으로 지정되며, 각각의 스레드별로 사용 공간을 할당할 수 있다. 포인터로 값을 전달하기 좋아하는 C 계열의 프로그래머들이 일반적인 멀티스레드 프로그램을 작성할 때, 스레드와 해당 스레드가 호출하는 함수 사이에서 인수 전달이 포인터로 이루어진다면 여러 문제가 발생할 수 있다.

제일 큰 문제는 포인터가 전역 변수로 지정되어야 한다는 것이다. 이 말은 컨텍스트 스위칭이 일어나지 않더라도, 각각의 멀티스레드 프로그램이 포인터로 지정된 변수를 일그러뜨릴 가능성이 매우 높다는 것이다. 가령, 항상 초기값이 0으로 세팅되어 있어야 특정 스레드와 그 연관 함수가 만족하지만, 스레드 수행 결과 그 포인터 변수는 초기값이 스레드의 결과값으로 치환된 이후 컨텍스트 전환이 일어난 경우 다른 스레드는 엉뚱한 초기값을 받게 된다. 물론, 이러한 부분은 뮤텍스나 세마포어 등을 사용하여 초기값을 항상 원래대로 복구하는 것으로 해결할 수 있다.

그렇지만, 뮤텍스나 세마포어는 비교적 수행 속도가 느리고, 경쟁 상태나 교착 상태의 가능성이 없이 단순히 변수의 변화를 막기 위해 뮤텍스나 세마포어를 사용하는 것은 비효율적이다. 또한 스레드 동기화는 근본적으로 컨텍스트 전환은 계속 수행하고 있는 상황이기 때문에 CPU의 시간을 계속 소모하면서 대기하는 방법을 사용하게 된다. 따라서, 스레드와 그 스레드가 호출하는 함수 사이에서만 사용할 수 있는 포인터 변수를 도입하면 메모리 측면에서는 모든 참조형 변수의 사본을 만드는 손해를 볼 수 있지만, 각각의 스레드를 병렬적으로 수행하면서 컨텍스트 전환에 의해 발생하는 문제들을 근원적으로 해결할 수 있다. 경쟁 상태가 나타나지 않는, 단순히 멀티스레드의 이점을 최대한 활용하기 위해 이러한 방법론을 사용한다. 물론 저장 영역을 손해보고, 메모리 연산을 빈번히 사용하기 때문에 복잡해지는 경우 오류 가능성이 더욱 커질 수 있다.

TLS는 각 스레드를 위한 전용 주소 공간을 제공하며, 각 주소 공간의 인덱스로 해당 공간을 접근한다. 이는 마치 지하철 물품보관소와 같이 각자가 물품보관소 열쇠를 갖고 있고, 해당 열쇠를 가진 사람만이 물건을 넣고 꺼낼 수 있는 형태로 운영된다. 각 스레드는 호출되는 함수를 통해 자신이 삽입한 변수를 공유할 수 있다. TLS는 정적, 혹은 동적으로 할당될 수 있으며, 마치 일반적인 C++ 프로그램의 메모리 할당과 비슷한 방법으로 동작하게 된다. 다음 그림은 TLS와 스레드의 관계를 나타낸 것이다.

 

각각의 스레드는 TLS 영역에 보관함을 가질 수 있으며, 이 스레드는 자신이 호출한 함수, 혹은 자신 내에서만 이 TLS 영역에 접근할 수 있다. 빈번히 사용되는 동적 TLS는 TlsAlloc() 함수로 저장 공간을 설정하며, TlsFree() 함수로 저장 공간을 해제한다. 저장 공간에 값을 입력할 때는 TlsSetValue() 함수로 값을 입력하고, 출력은 TlsGetValue() 함수로 처리한다.

다음 코드는 TLS를 동적으로 사용한 예제이다.

#include <windows.h> #include <iostream> #include <cstdio> using namespace std; DWORD WINAPI myfunction(LPVOID); VOID CommonFunc(VOID); DWORD dwIndex; int main(){ HANDLE hThread[5]; DWORD threadId = NULL; BOOL bOK; int j; dwIndex=TlsAlloc(); //Tls 영역 설정 for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); bOK=CloseHandle(hThread); TlsFree(dwIndex); //Tls 영역 해제 return 0; } DWORD WINAPI myfunction(LPVOID p){ int j; j=(int)p; int *m=new int; *m=j+1; TlsSetValue(dwIndex, (LPVOID)m); //변수를 TLS Index를 받고 TLS 영역에 삽입한다. CommonFunc(); return 0; } // 스레드와 함수 사이에서는 *m을 공유하지만, 이는 각각 스레드별로 독립적으로 행동한다. VOID CommonFunc(VOID) { int i, *m, k; m=(int *)TlsGetValue(dwIndex); //호출된 for(i=0;i<*m;i++) k=*m+i; cout << "k=" << k << "(Thread:" << GetCurrentThreadId() << ")" << endl; } 

이 코드는 스레드 동기화를 위해 어떠한 방법론도 사용하지 않고 있다. 출력 화면에서 경쟁 상태가 나타나기는 하겠지만, 실제로 프로그램의 수행 결과는 예측대로 나타나게 된다.

정적 TLS는 조금 더 다루기 쉽다. 동적 TLS가 전역 메모리 할당과 흡사하게 동작한다고 하면 정적 TLS는 전역 변수 선언과 비슷하게 선언된다. 앞선 동적 할당이 주로 포인터 변수를 처리하기 위한 방법으로 사용된다면, 정적 TLS는 전역변수가 항상 동일한 초기값을 갖도록 처리할 수 있다. 정적 TLS를 사용하는 방법은 단지 전역 변수를 선언할 때 __declspec(thread)키워드를 사용하면 된다. 다음 코드를 한 번 살펴보자.

#include <windows.h> #include <iostream> #include <cstdio> using namespace std; DWORD WINAPI myfunction(LPVOID); VOID CommonFunc(VOID); DWORD dwIndex; __declspec(thread) int m=1; // TLS 전역 변수 사용 int main(){ HANDLE hThread[5]; DWORD threadId = NULL; BOOL bOK; int j; for(j=0;j<5;j++){ hThread[j] = CreateThread(NULL, 0, &myfunction, (LPVOID)j, 0,&threadId); if(hThread[j] == NULL) { cerr << "스레드 생성을 하지 못했습니다. :" << GetLastError() << endl; return -1; } } WaitForMultipleObjects(5, hThread, TRUE, INFINITE); bOK=CloseHandle(hThread); return 0; } DWORD WINAPI myfunction(LPVOID p){ int j; j=(int)p; CommonFunc(); m=m+j; // 만약 일반적인 전역 변수라면 이 구문이 호출된 이후 변수 자체가 변할 것이다. return 0; } // 스레드와 함수, 그리고 전체 코드는 m을 공유하지만, m은 생성된 스레드에 따라 다른 변수로 // 취급받는다. VOID CommonFunc(VOID) { int i, k; for(i=0;i<m;i++) k=m+i; cout << "k=" << k << "(Thread:" << GetCurrentThreadId() << ")" << endl; } 

이 프로그램이 초기 선언된 m 값을 일반적인 변수로 선언했다면 스레드에서 m 값을 계속 변경시키기 때문에 전역 변수는 각각의 스레드마다 다른 초기값을 받게 되며, 출력 결과는 예측할 수 없도록 변할 것이다. 그러나, 정적 TLS 변수로 선언되었기 때문에 모든 변수는 스레드 내에서 같은 초기값을 갖게 된다.

정적 TLS를 사용할 때 TLS 인덱스는 명시적으로 생성되거나 파괴되지 않는다. 스레드가 생성될 때 TLS 저장 영역이 생성되며, 스레드가 파괴될 때 같이 파괴된다. 주로 멀티스레드 프로그램을 수행하면서 정적 초기 변수가 변경될 가능성이 있지만 이러한 변수들을 제외하고는 교착 상태나 경쟁 상태가 벌어지지 않을 경우 TLS는 유용하다.


6.1 스레드 우선권

윈도우 시스템은 스레드를 기준으로 다중 작업이 이루어지는 멀티스레딩 프로그램이라고 앞에서 언급하였다. 스레드는 각각 일정 시간의 실행 시분할을 갖게 되며, 일단 동시에 모든 요청 스레드를 다 처리할 수 없으므로, 순서대로 처리하는 스레드 큐(Thread Queue)가 형성되게 된다. 그런데, 이 큐가 한 줄로 늘어선 것이 아니라, 우선권이라는 개념을 가진 상태에서 병렬적인 큐로 형성되어 있다. 우선권은 숫자가 커지면 커질수록 우선적인 개념을 갖고 있고 이는 시스템이 빨리 처리해야 하는 스레드이다.

가령, 은행 객장에 우대고객 전용 창구가 따로 있고, 아무리 은행 줄이 길어도 이 우대고객 전용 창구에 줄을 선 우대고객은 가장 먼저 은행일을 볼 수 있는 것과 같은 개념이다. 윈도우는 이 우선권이 31번까지 있다. 따라서, 스레드가 어떤 큐에 들어가느냐에 따라 처리되는 속도가 달라지게 된다. 다음 그림은 스레드가 각각의 큐에 들어가 있는 모습이다.

 

우선권이 높은 큐를 할당받는 스레드는 주로 I/O 등이나 시스템과 관계된 중요한 시스템들이다. 엄밀하게 말해서 우선권이 높은 큐는 그만큼 많은 시간 할당을 받게 된다. 시분할 멀티태스킹이기 때문이다.

윈도우 시스템은 동적으로 우선권을 관리한다. 이는, 프로그램이 실행되는 동안에도 계속 우선권이 변경될 수 있으며, 시스템은 항상 우선권을 적절한 방법으로 통제하고 관리한다. 가령, 윈도우 NT 이상의 시스템에서 나타나는 성능 옵션에서, 포그라운드 서비스와 백그라운드 서비스의 응용 프로그램 응답 옵션을 바꿀 수 있다. 참고로 윈도우 시스템은 유닉스 시스템과 포그라운드 서비스와 백그라운드 서비스의 개념상의 차이가 존재한다. 윈도우에서는 로그온 여부와 프로세스의 생존이 바로 직결되어 있으므로 로그오프 이후에도 운영되는 프로그램은 존재하지 않는다. 어떤 방법이든 사용자 로그온이 처리되어 있어야 프로세스가 생존할 수 있으며, 이렇게 따진다면 유닉스 개념의 포그라운드와 백그라운드가 나타나지 않는다.

윈도우 시스템에서 백그라운드 서비스는 현재 활성화되지 않은 모든 다른 프로세스를 의미한다. 즉, 로컬 로그온이 처리되어 창의 상단의 바가 파란색으로 바뀌지 않은 다른 모든 프로세스는 백그라운드 서비스이다. 이 경우, 성능 옵션에서의 다음 조정은 결국 우선권의 조정일 수밖에 없다.

 

실제로 위와 같은 성능 옵션의 응용프로그램 최적화는 원래 이 응용프로그램이 갖도록 정의되었던 스레드의 우선권보다 다소 높은 우선권을 동적으로 설정하라는 것이다. 우선권의 개념은 작업 관리자에서도 볼 수 있으며, 작업 관리자에서는 우선권 클래스에 해당하는 다섯 가지 개념이 나타나게 된다. 이 우선권들은 일차적으로 프로세스가 형성될 경우 지정된다. IDLE(4), NORMAL(7,9 또는 7+1=8, 9+1=10), HIGH(13), REALTIME(24)가 일차적으로 부여되며, 이 값에 가감을 하여 우선권 적용이 이루어진다. 가감은 +4~-3까지 가능하며, 이 조합으로 전체 우선권을 만들어 낼 수 있다. 따라서 우선권은 총 32개가 있기는 하지만 여기서 만들어 낼 수 있는 것은 총 21개가 된다. 이 중 가감이 될 수 없는 절대값(1, 15, 31)이 존재하며, 포그라운드 성능 최적화를 한 경우 초기부터 9-10 사이의 우선권이 주어진 채로 출발하게 된다. 음영을 넣은 부분은 실시간(Real Time) 우선권에 해당하는 부분이며, 일반적으로 사용자 응용프로그램의 우선권이 15 이상으로 올라가는 경우는 거의 없다. 만일 사용자 프로그램의 우선권이 15 이상으로 올라간다면 이는 프로그램상의 버그이거나 프로그래머가 의도적으로 실시간 큐에 삽입한 것이며, 이런 상황에서 프로그램의 응답이 지연되는 경우 전체 시스템이 같이 느려지는 현상이 벌어진다. 이는 상위 큐에 시간 할당이 더 많이 되어 있으며, 별다른 이유 없이(콜백 함수나 객체 상태 함수를 사용하지 않고) 시스템이 느려진다면 CPU 자원을 계속 잡아먹고 있는 셈이 된다. 15 미만에서 시작한 스레드의 우선권 상한도는 15까지이며, 15 이상에서 시작한 우선권이 상한선 31을 갖게 된다. 실제로 이렇게 높은 우선권을 갖고 있는 경우는 찾기 어렵다. 윈도우 CE의 경우 우선권은 32가지를 모두 사용하며, 이러한 측면들은 윈도우 소프트웨어가 발전할수록 계속 변하는 부분이다. 다음 표는 윈도우 스레드 우선권을 전부 나열한 것이며, 어떤 우선권이 있으며, 어떻게 우선권 변경이 되는지 확인해 볼 수 있다. 15, 31 은 절대값이며, 더 이상 가감되지 않는다.

6.2 우선권 가감

스레드의 우선권 가감 연산은 누적하여 수행할 수 없다. 운영체제는 하나의 스레드에 대한 기저 우선권(프로세스가 시작되는 시점에서의 우선권, Base Priority)을 기억하고 있으며, 스레드의 우선권 조정 함수는 이 기저 우선권에 대해서만 가감을 한다. 즉, 원래 기저 우선권이 10인 스레드에 우선권 조정을 몇 번 반복하더라도 우선권은 12 또는 15로밖에는 설정할 수 없다는 것이다. 따라서, 우선권은 사용할 수 있는 범위 한계가 있다. 다음과 같이 프로그램을 작성하고 컴파일해 보자.

#include <windows.h> #include <iostream> using namespace std; int main(){ HANDLE hThread; char a; hThread=GetCurrentThread(); cin >> a; // 잠시 정지를 위함 SetThreadPriority(hThread, THREAD_PRIORITY_HIGHEST); cin >> a; SetThreadPriority(hThread, THREAD_PRIORITY_HIGHEST); cin >> a; SetThreadPriority(hThread, THREAD_PRIORITY_HIGHEST); cin >> a; return 0; } 

이 프로그램은 현재 동작하는 스레드(주 스레드)로부터 핸들을 얻어, 자신 스레드의 우선 권을 변경하는 프로그램이다. GetCurrentThread() 함수는 현재 동작하는 스레드의 핸들을 반환하며, SetThreadPriority() 함수는 현재 스레드의 우선권을 변경한다. 초기 기저 우선권은 프로세스가 형성될 때 생성되며, 별다른 옵션 없이 프로세스를 생성하였다면 기저 우선권은 운영체제가 적절하게 정의한다.

이 프로그램은 기저 우선권에 우선권을 계속 더하는 기능을 하고 있다. THREAD_PRIORITY_NORMAL은 +2를 하는 키워드이며, 이 프로그램을 수행하면서 성능 모니터를 같이 띄워 스레드의 우선권이 어떻게 변하는지 한 번 확인해 보기 바란다. 만일 기저 우선권이 8이었다면 10, 12, 14 이렇게 계속 증가할 것인지, 아니면 8+2 만 계속 반복하여 결과는 항상 10이 나오는지 측정해 보면 된다.

각자의 컴퓨터는 모두 다 다른 설정을 지니므로, 기저 우선권은 1~2 정도 오차가 있을 수 있다. 따라서, 프로그램의 수행 결과 초기값에 비해 어느 정도의 변경이 있었는지를 알아내는 것이 중요하며, 컴파일해서 실행해보면 알겠지만, 기저 우선권에 비해 결코 2 이상의 값이 올라가지 않는다. 시스템은 해당 스레드의 기저 우선권을 기억하고 있고, 해당 명령을 계속 수행하면 기저 우선권에 계속 2를 더하게 되는 까닭이다.

우선권의 변경은 일반적으로는 운영체제의 필요에 의해 이루어진다. 이 말은 운영체제의 필요가 아닌 경우라면 우선권의 변경은 프로그램적으로는 거의 이루어지지 않는다는 것이다. 간혹 인터넷 익스플로러 등의 프로그램이 작업 관리자의 종료 명령도 무시하고 계속 수행되는 경우를 볼 수 있으며, 이런 현상은 프로그램의 버그 등에 의해 작업 관리자 보다 해당 프로그램의 스레드 우선권이 높은 경우에 나타나게 된다.


7.1 윈도우와 프로세스

유닉스에서는 여러 개의 프로세스를 동시에 운영하는 것이 보편적이었다. 분기(fork)라는 형태로 하나의 프로세스에서는 다른 프로세스를 형성하고, 그 기저에는 셸 프로세스가 존재한다. 일차적으로 셸 프로세스가 생성되고 그 위에서 프로세스는 계속 시스템 분기를 사용하여 프로세스를 형성해 나간다. 만들어진 프로세스는 이전 프로세스의 거의 대부분의 속성을 계승하며, 특히 보안성과 관련되어서는 출발한 프로세스의 대부분의 속성을 갖고 있다.

프로세스는 해당 프로그램에서 필요한 주소 공간을 확보하고 있으며, 관계 리소스를 모두 보유하고 있다. 유닉스에서는 fork() 함수를 사용하여 시스템 호출로 새로운 프로세스를 만들어 내며, 프로세스 자체가 차지하는 오버헤드가 그렇게 크지 않기 때문에 멀티프로세스 프로그램이 힘들지 않게 운영된다.

윈도우 시스템에서는 런타임 라이브러리 레벨의 _spawn() 혹은 _exec() 함수를 사용하거나, Win32 API 레벨의 CreateProcess() 함수를 사용할 수 있다. CreateProcess() 함수는 상당히 많은 인수를 갖고 있으며 그 대부분은 프로세스에 대한 미세 조정이다.

주의할 점은 윈도우에서는 프로세스를 여러 개 생성시키는 형태의 작업은 거의 이루어지지 않으며, 필요에 의해 프로세스를 생성한다고 할지라도 최소한의 프로세스 작업으로 처리해 주어야 한다. 윈도우 프로세스는 새로 프로세스를 형성하였을 경우에는 기존 프로세스와 완전히 별개인 하나의 프로세스가 형성된다. 실제로 윈도우 시스템에서 새로운 프로세스를 형성하여 작업을 시도하는 경우는 그렇게 많지 않다. 동시에 많은 창이 뜨는 윈도우 프로그램은 거의 다 새로운 창은 하나의 스레드로 동작하도록 설정한다. 프로세스와 셸을 가득 띄워 놓은 유닉스 프로그램에 익숙하다면 이렇게 동작하는 방법론이 다소 답답할 수도 있을 것이다.

작업 관리자를 사용하면 현재 운영되고 있는 프로세스와 프로세스가 사용하고 있는 CPU의 시간 점유율을 볼 수 있다. 프로세스가 사용하는 시간 점유율은 그 프로세스가 형성한 모든 스레드의 동작 시간의 총합으로 정의된다. 스레드를 전혀 생성하지 않도록 프로그램을 설계했다고 하더라도 최소 하나의 스레드가 정의된다고 앞에서 이미 언급하였다.

실제로 프로세스를 생성하는 것은 아주 다양하고 복잡한 개념을 포괄하고 있다. 기본적으로 윈도우 2000은 보안성이 강한 운영체제이며, 이에 따라 어떤 스테이션, 어떤 세션, 그리고 어떤 ACL을 갖고 있는지 프로세스 자체에 명시되어야 한다. 일상적으로 이러한 작업들은 유닉스와 비슷하게 모 프로세스의 사본을 사용하는 경우가 많지만, 윈도우에서는 근본적으로 이런 설정들을 아예 처음부터 “새로” 만들 수 있다는 사실을 주목하자. 프로세스와 스레드 자체는 윈도우 보안성과 아주 밀접히 관련되어 있으며, 이런 일들은 시스템에 누가 들어와 있는지 판별하는 과정과 아주 밀접하게 관련된다.

7.2 프로세스 생성

프로세스 생성을 위해서는 BOOL 형의 CreateProcess() 함수가 사용된다. 이 함수는 생각보다 인수가 많으며, 각각의 인수들 중 일부는 구조체로 정의되어 있어 전부를 이해하는 것이 그렇게 생각보다 쉽지는 않다. 또한 보안성이라는 것이 같이 맞물려 있어, 해당 프로세스를 어떻게 형성할 것인지에 대해 정의할 수 있다.

윈도우는 다양한 종류의 프로세스가 존재한다. 여기서 Win32 API에 한정한다면 16비트 코드를 해석할 수 있는 WOW(Windows On Win32) VDM(Virtual DOS Machine) 내에 프로세스를 한정시킬 것인지, WOW VDM도 공유 VDM을 쓸 것인지 전용 VDM을 쓸 것인지 고려해야 하는 문제가 벌어진다. 하나의 프로세스에서 다른 프로세스를 형성할 때 해당 프로세스가 혹시 Win16 코드를 갖고 있지 않는지 한 번쯤 살펴볼 필요가 있다. 윈도우 XP가 나오는 이 시점에서 Win16 코드를 논하는 것이 시대착오적인 것 같지만 실제로 중소규모 소프트웨어 회사에서 제작한 아주 많은 애플리케이션은 여전히 Win16 코드를 일부 갖고 있다.

다음은 아주 간단한 프로세스 생성의 예이다. 이 코드는 인터넷 익스플로러를 실행시키고 모 프로세스는 중단된다.

#include <windows.h> #include <iostream> using namespace std; int main(){ STARTUPINFO stInfo; PROCESS_INFORMATION pInfo; BOOL bOk; LPTSTR strPath="C:\\Program Files\\Internet Explorer\\iexplore.exe"; ZeroMemory(&pInfo, sizeof(PROCESS_INFORMATION)); ZeroMemory(&stInfo, sizeof(STARTUPINFO)); stInfo.cb=sizeof(STARTUPINFO); bOk = CreateProcess(0, strPath, NULL, NULL, FALSE, CREATE_DEFAULT_ERROR_MODE, NULL, NULL, &stInfo, &pInfo); if(!bOk){ cout << "프로세스 생성 오류:" << GetLastError() << endl; return -1; } WaitForSingleObject(pInfo.hProcess, INFINITE); // 생성한 프로세스가 종료될 때까지 기다린다. CloseHandle(pInfo.hProcess); CloseHandle(pInfo.hThread); return 0; } 

CreateProcess() 함수는 꽤 많은 인수를 갖고 있으며, MSDN에 이에 대해서는 아주 상세히 설명되어 있으므로 MSDN을 참조하기 바란다. 프로세스를 생성하는 것은 크게 네 가지 중요 정보를 제공해 준다. 일단 CreateProcess()가 받아들이는 인수는 다음과 같다.

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

보안 옵션은 사용자그룹 보안 분과에서 해당 내용을 찾아볼 수 있을 것이다. 생성 플래그는 몇 가지 방법론을 제공하며, 이 플래그를 어떻게 조작하느냐에 따라 16비트 VDM 혹은 디버그 이벤트 받음 여부 등을 설정할 수 있다. 또한 생성된 프로세스의 주 스레드를 대기 모드(suspended state)로 생성할 수도 있다. 프로세스를 생성하기 위한 환경 변수로 STARTUPINFO 구조체를 사용하며, 프로세스가 생성한 스레드 및 프로세스의 핸들을 받기 위해서 PROCESS_INFORMATION 구조체를 사용하게 된다. 환경 변수는 초기 실행이 고정되지 않은 프로세스를 대상으로 프로세스의 시작 위치, 윈도우 크기 등을 지정할 수 있으며, 프로세스 정보 구조체에서는 생성된 프로세스와 주 스레드의 ID 및 핸들을 얻을 수 있다.

뒤에서 다루겠지만, 윈도우에서 프로세스가 새 프로세스를 생성할 때는 기존 프로세스의 속성을 계승할 수 있도록 설정하기도 하고, 계승하지 않도록 설정하기도 한다. 다음 제시되는 함수는 CreateProcess() 함수와 생긴 모양은 흡사하지만, 기존 프로세스와는 다른 사용자 계정에서 운영되는 새 프로세스를 형성한다. 이것은 4장에서 상세히 다루어질 것이다.

BOOL CreateProcessAsUser( HANDLE hToken, LPCTSTR lpApplicationName, LPTSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCTSTR lpCurrentDirectory, LPSTARTUPINFO lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation ); 

7.3 프로세스 종료 및 정보 얻기

유닉스에서는 전통적으로 kill 명령어를 사용하여 프로세스를 강제로 종료하였다. 윈도우 시스템에는 작업 관리자가 존재하지만 작업 관리자는 반드시 GUI로 실행해 주어야만 되는 단점이 존재하고 있다. 그런데 윈도우에서 프로세스를 종료하는 것은 그다지 어려운 작업이 아니다.

프로세스 역시 커널 객체이므로, 이제 커널 객체에 익숙해졌다면 생성된 프로세스 ID로 프로세스 핸들을 받아 와서 해당 핸들을 가진 프로세스를 종료하면 된다는 등식이 머릿속에 도식적으로 나타날 것이다.

그런데 윈도우 프로세스는 일반 객체가 아니라 보안성까지 체크되는 보안 객체이다. 이러한 까닭에 단순히 프로세스 번호로 해당 프로세스를 종료시키는 것이 아니라, 해당 프로세스의 ACL 혹은 시스템 프로세스 여부 등을 파악하여 최종적으로 종료시킬 수 있는지를 판단한다. 사용자가 충분한 권한이 있으며, 해당 프로세스를 종료시킬 수 있다면 얻어낸 핸들값을 사용하여 프로세스를 완전히 종료시킬 수 있다.

시스템 프로세스의 경우 작업 관리자를 통해서도 액세스 거부가 나타나며, 이는 보안 분과에서 이유를 알아보도록 하라. 작업 관리자 등을 사용하여 알아낸 특정 프로세스를 종료하기 위한 코드는 다음과 같다.

#include <windows.h> #include <iostream> using namespace std; int main(int argc, char* argv[]){ if (argc !=2) { cout << "사용법: kill Pid" << endl; return -1; } int processId=atoi(argv[1]); unsigned uExitCode=0; HANDLE hProcess; hProcess=OpenProcess(PROCESS_TERMINATE, FALSE, processId); if(TerminateProcess(hProcess, uExitCode)) cout << "프로세스 " << processId << "번을 종료했습니다." << endl; else cout << "종료하지 못했습니다." << endl; return 0; } 

파일이나 기타 핸들을 사용하는 여러 구문들은 전부 이와 비슷한 형태로 동작하게 된다. 만일 프로세스를 종료하지 못했다면 프로세스가 시스템에 소속된 것이거나, ACL이 맞지 않아 종료할 수 없는 것일 가능성이 높다.

프로세스를 중단하기 위해서 TernminateProcess() 함수를 주로 사용하지만, 간혹 프로세스가 자살할 필요가 있다. 보편적으로 윈도우 프로그램은 명시적으로 프로세스를 종료하라는 말이 없을 경우에는 프로세스를 종료하지 않으므로 ExitProcess() 함수를 사용하여 종료할 수 있다. 이 함수는 자살을 위해 사용하므로 void 형이면 충분하다.

ExitProcess() 함수는 사용중인 DLL에 대한 참조값을 제거하고 발생시킨 모든 스레드의 참조값 역시 제거한다. 그리고 스스로 죽고 싶다는 메시지를 객체 관리자에게 송신한다. 그러나, 객체 관리자가 “죽여” 주기 전까지는 실제로 프로세스 객체가 사라지는 것은 아니다. 이는 앞에서 언급하였다.


8.1 파이버

파이버는 스레드보다 더 작은 단위의 일을 의미한다. 보통 SQL 서버를 운영하면서 파이버라는 단어를 처음 볼 수 있다. 다음과 같은 정보 창에서 윈도우 파이버를 이용하는 체크박스를 한 번쯤 보았을 것이다.

 

멀티 스레딩 프로그램에서 동시에 엄청난 다중 작업이 들어왔을 경우, 시스템은 컨텍스트 전환 오버헤드가 걸리게 된다. 특히 다중 프로세서를 운영하는 중앙 데이터 서버와 같은 경우는 초당 수천 건의 컨텍스트 전환과 100%에 육박하는 CPU 점유율을 보이는 경우가 다반사이다. 이러한 경우에 특화된 해결책이 파이버이다.

스레드(Thread)가 실을 의미하고 있기 때문에 실의 원료가 되는 섬유(fiber)로 이름을 붙인 것으로 생각된다. 파이버는 스레드와 달리 컨텍스트 전환을 시스템의 스케쥴에 따르지 않고 수동으로 해 주어야 한다. 따라서, 컨텍스트 전환에 따른 오버헤드가 발생하지 않는다. 그런데, 컨텍스트 전환을 수동으로 하는 것은 결국 멀티스레딩의 강점을 상당 부분 잃어버리는 것을 의미한다. 따라서, 파이버로 모든 코드를 작성하는 경우는 존재할 수 없다. 이는 프로세스 단위에서 항상 싱글스레딩을 영위하고 있는 결과를 낳게 된다.

만일 파이버가 스레드에서 파생될 수 있다면 어떤 의미를 지닐까? 이는 컨텍스트 전환은 스레드 단위에서 이루어지고, 업무는 파이버 단위에서 이루어지는 것을 의미한다. 가령 100개의 스레드를 사용하는 응용프로그램이 존재한다고 할 때, 스레드 100개를 영위하기 위해서는 100가지의 서로 다른 상태를 저장하고, 이들 사이에서 전환이 이루어져야 하지만, 50개의 스레드와 2개의 파이버를 사용한다면 컨텍스트 전환에 따르는 오버헤드를 다소 줄일 수 있다 시스템에서 이루어지는 컨텍스트 전환은 스레드 단위이기 때문에 각각의 스레드는 어차피 할 컨텍스트 전환이 반으로 줄어드는 셈이 된다. 그리고, 파이버 상호간에 이루어지는 컨텍스트 전환은 그 오버헤드도 작고 수동으로 이루어지므로 많은 장점이 존재한다.

그러나 스레드 대신 파이버를 사용하는 것은 성능 계산을 정확하게 해야 하는 단점이 존재한다. 따라서, 대용량 멀티스레드 프로그램에서 주로 채용되며, 현재까지 가장 효과적으로 사용되고 있는 것은 멀티프로세서가 장착된 MS-SQL 데이터베이스 서비스이다. 파이버를 사용할 경우 가장 큰 약점은 자기 자신의 프로세스가 전체 CPU를 대부분 장악하는 상황이 아니라면 컨텍스트 스위칭에서 후순위로 밀려버리는 상황이 벌어질 수 있다는 것이다. 즉, 스레드 숫자가 적기 때문에 할당에 따른 상대적 차별을 당할 수 있다. 따라서, SQL Server의 경우 CPU 점유량이 많지 않다면 파이버로 스레딩을 대신하는 것은 아무런 득이 되지 못할 가능성이 높다.

다음은 파이버를 사용하는 예제 코드이다.

파이버는 컨텍스트 전환에 따른 동기 문제가 발생하지 않기 때문에 동기 관계 객체들이나 이벤트 등을 사용할 필요가 전혀 없다. 다만 생성된 파이버 상호간 수동으로 전환하는 코드를 삽입하여야 한다. 스레드가 DWORD형의 함수를 갖는 데 반해 파이버는 VOID 형이라는 차이가 있다. 일단 전역 파이버를 선언한다. 그리고, 각각의 파이버를 생성한다. 다른 객체와 마찬가지로 파이버를 생성하는 것도 CreateObject 형식의 CreateFiber() 함수를 사용한다. CreateFiber()함수는 파이버에 할당될 초기 스택 크기와 함수 포인터, 그리고 전달 인수를 받아들인다. 파이버 당 할당될 수 있는 초기 최대 스택 크기는 따로 정의하지 않는 한 1MB이며, 이 인수를 제외하고는 스레드를 사용하는 것과 큰 차이가 나지 않는다.

파이버는 상호 컨텍스트 전환을 수동으로 해 주어야 하며 이 경우 주 스레드 역시 파이버로 변경되어야 컨텍스트 전환이 가능할 것이다. 따라서, 주 스레드를 파이버로 변경하는 ConvertThreadToFiber() 함수를 따로 제공하고 있다. 이 함수를 호출하게 되면 주 스레드가 파이버로 인식되며 (사실은 모든 파이버를 포함한 전체 주 스레드가 하나의 스레드로 시스템 상에서는 간주되는 것이지만) 파이버 상호간 컨텍스트를 수동으로 전환하는 SwitchToFiber() 함수를 사용하여 다른 파이버로 실행 권한을 넘기게 된다. 이 경우 제어권을 넘길 파이버는 어떤 파이버로 자신의 제어권을 넘길 것인지를 명시해 주어야 하며, 선언된 VOID형의 파이버 이름을 인수로 전달한다.

위 프로그램을 컴파일하여 실행하면 지금까지 보았던 예와 동일한 결과를 얻을 수 있을 것이다. 그렇지만 파이버로 작성한 프로그램은 프로세스 내에서 스케쥴 퀀텀을 받을 수 있는 기회가 줄어든다는 것을 잊어서는 안 된다.

8.2 작업 객체

작업 객체는 프로세스와 스레드를 다루는 객체 단위 중 가장 큰 것으로, 일련의 프로세스를 하나의 작업(job)으로 묶어 놓은 것이다.


본내용은 회원으로 가입하고있는 server관련 웹모임의 운영진에서 집필된 것임으로 인용시 출처를 밝혀주시기 바랍니다.


+ Recent posts