12장. 쓰레드의 생성과 소멸

유니야·2023년 1월 12일

✏️ Windows에서의 쓰레드 생성과 소멸

💻 쓰레드의 생성

쓰레드를 생성할 때는 해당 쓰레드의 작업 영역 범위를 명확히 구분지어야 함.

  • CreateThread() 함수를 통해 쓰레드 생성
    • dwStackSize: 쓰레드는 독립적인 스택 공간을 할당받는데, 해당 스택의 사이즈.
    • lpStartAddress: 함수 포인터. 쓰레드의 main함수
      • LPVOID: 원하는 자료형으로 자료형을 변환해서 전달해달라는 것. 그러나 쓰레드의 생성은 OS의 일이기 때문에 사용자가 전달할 수 없고, 아래 lpParameter을 통해 전달 가능.
    • lpParameter: 해당 매개 변수는 쓰레드의 메인 함수 호출 시 전달됨
    • lpThreadId: 쓰레드의 ID를 리턴받을 수 있음
HANDLE CreateThread(
    LPSECURITY_ATTRIBUTES   lpThreadAttributes, // 보안관리자. 핸들 테이블의 상속 여부 결정 가능.
    SIZE_T                  dwStackSize,        // initialize stack size.
    LPTHREAD_START_ROUTINE  lpStartAddress,     // thread function
    __drv_aliasesMem LPVOID lpParameter,        // thread function's argument.
    DWORD                   dwCreationFlags,    // creation option
    LPDWORD                 lpThreadId          // thread identifier
);
  • Handle과 ID의 차이
    헨들은 프로세스 내에서 다른 커널 오브젝트에 접근하기 위해 사용됨.
    반면 ID는 범위가 좀 더 넓음. 여러 프로세스를 생성하고 그 안에서 수많은 쓰레드를 만들었을 경우 각 쓰레드의 ID는 고유한 값. ID가 사용되는 경우가 있고, 핸들이 사용되는 경우가 있음. 윈도우즈에서는 보통 핸들을 사용됨.

  • 생성 가능한 쓰레드의 개수?

    OS 디자인에 따라 최대 쓰레드 개수를 정할 수도 있지만, 보통 최대치까지 허용.
    쓰레드를 생성할 때 새롭게 스택 영역이 할당됨. 일단 32비트 시스템 기준 2GB는 OS가 차지.
    나머지 2GB 내에서 프로세스의 스택과는 별도로 쓰레드 별로 스택을 할당하다가. 더 이상 할당할 수 없을 때 까지 쓰레드를 생성할 수 있다.

아래 이미지를 보면 프로세스가 실행될 때 main() 함수를 호출하는 main 쓰레드가 생성됨.
그리고 main쓰레드가 CreateThread()를 통해 새로운 쓰레드 생성 + 별도의 흐름을 갖게 됨. 이렇게 생성된 쓰레드들도 CreateThread() 함수를 통해 새로운 쓰레드를 생성할 수 있다.

💻 쓰레드의 소멸

  • 쓰레드 종료 시 return을 이용하면 좋은 경우 => 모든 경우에 해당
    쓰레드는 main() 함수가 리턴되면 종료됨. 이는 CreateThread() 함수를 호출할 때 인자로 전달하는 함수 포인터가 가리키는 함수.
    메인 쓰레드가 쓰레드 A, B를 생성했을 경우, 해당 쓰레드의 종료는 메인 쓰레드 내에서 결정지어야 함.
    쓰레드는 전역 함수이기 때문에 언제 어디서나 호출이 가능. 쓰레드 A, B는 단지 해당 기능을 쓰기 위해 호출된 것. 소멸 주기는 해당 쓰레드를 생성한 메인 쓰레드가 담당하는 것이 가장 안정적이다.

프로세스 종료 시 반환 코드 => 커널 오브젝트에 저장
쓰레드 종료 시 반환 코드 => 마찬가지로 쓰레드의 커널 오브젝트에 저장
GetExitCodeThread() 함수를 통해 해당 값을 얻어올 수 있음.

/*
    ThreadAdderOne.cpp
    프로그램 설명: 프로세스 유사형태 쓰레드 생성.
*/

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

// 쓰레드 메인 함수는 아래와 같이 간단명료한게 최고임.
DWORD WINAPI ThreadProc( LPVOID lpParam ) 
{ 
    DWORD * nPtr = (DWORD *) lpParam;

    // 아래는 전달된 인자를 파싱하는 과정.
    DWORD numOne = *nPtr;
    DWORD numTwo = *(nPtr+1);

    DWORD total = 0;

    for(DWORD i=numOne; i<=numTwo; i++)
    {
        total += i;
    }

    return total; 
}


int _tmain(int argc, TCHAR* argv[])
{
    DWORD dwThreadID[3];
    HANDLE hThread[3];

    DWORD paramThread[] = {1, 3, 4, 7, 8, 10};
    DWORD total = 0;
    DWORD result = 0;

    hThread[0] = 
        CreateThread ( 
                NULL, 0,
                ThreadProc,
                (LPVOID)(&paramThread[0]),
                0, &dwThreadID[0]
            );			                
 
    hThread[1] = 
        CreateThread ( 
                NULL, 0,
                ThreadProc,
                (LPVOID)(&paramThread[2]),
                0, &dwThreadID[1]
            );			                
 
    hThread[2] = 
        CreateThread ( 
                NULL, 0,
                ThreadProc,
                (LPVOID)(&paramThread[4]),
                0, &dwThreadID[2]
            );
 

    if(hThread[0] == NULL || hThread[1] == NULL || hThread[2] == NULL)
    {
        _tprintf(_T("Thread creation fault! \n"));
        return -1;
    }

	// 메인 쓰레드가 종료되면 프로세스가 종료되버림.
    // 그래서 쓰레드들이 종료될 때까지 메인 쓰레드가 대기하고 있는것.
    WaitForMultipleObjects(3, hThread, TRUE, INFINITE);
    // 이 함수 뒤로는 세 쓰레드가 모두 종료되었고, 반환 코드가 커널 오브젝트에 적혔단 뜻.

    GetExitCodeThread(hThread[0] , &result);
    total += result;

    GetExitCodeThread(hThread[1] , &result);
    total += result;

    GetExitCodeThread(hThread[2] , &result);
    total += result;

    _tprintf(_T("total (1 ~ 10): %d \n"), total);

    CloseHandle(hThread[0]);
    CloseHandle(hThread[1]);
    CloseHandle(hThread[2]);

    return 0;
}

✏️ 쓰레드의 성격과 특성

  • 힙, 데이터, 코드 영역의 공유 검증

    종료 코드를 통해 반환하지 않고, 데이터 영역에 선언된 전역 변수에 결과 값을 저장하는 형식의 예제
/*
    ThreadAdderTwo.cpp
    프로그램 설명: 전역변수를 이용한 쓰레드 기반 Adder.
*/

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

static int total = 0;

DWORD WINAPI ThreadProc( LPVOID lpParam ) // 모든 쓰레드가 ThreadProc() 전역함수를 공유함 즉 Code 영역이 공유되고 있단 뜻.
{ 
    DWORD * nPtr = (DWORD *) lpParam;

    DWORD numOne = *nPtr;
    DWORD numTwo = *(nPtr+1);

    for(DWORD i=numOne; i<=numTwo; i++)
    {
        total += i;
    }

    return 0; // 정상적 종료.
}


int _tmain(int argc, TCHAR* argv[])
{
    DWORD dwThreadID[3];
    HANDLE hThread[3];

    DWORD paramThread[] = {1, 3, 4, 7, 8, 10};

    hThread[0] = 
        CreateThread ( 
                NULL, 0,
                ThreadProc,
                (LPVOID)(&paramThread[0]),
                0, &dwThreadID[0]
            );			                
 
    hThread[1] = 
        CreateThread ( 
                NULL, 0,
                ThreadProc,
                (LPVOID)(&paramThread[2]),
                0, &dwThreadID[1]
            );			                
 
    hThread[2] = 
        CreateThread ( 
                NULL, 0,
                ThreadProc,
                (LPVOID)(&paramThread[4]),
                0, &dwThreadID[2]
            );
 

    if(hThread[0] == NULL || hThread[1] == NULL || hThread[2] == NULL)
    {
        _tprintf(_T("Thread creation fault! \n"));
        return -1;
    }

    WaitForMultipleObjects(3, hThread, TRUE, INFINITE);

    _tprintf(_T("total (1 ~ 10): %d \n"), total);

    CloseHandle(hThread[0]);
    CloseHandle(hThread[1]);
    CloseHandle(hThread[2]);

    return 0;
}

💻 동시 접근의 문제점

total이라는 변수에 둘 이상의 쓰레드가 동시에 접근하는 상황.
동시 접근 -> 실행 중에 다른 쓰레드가 접근한다는 것.
동시 접근을 하더라도 사실상 스케줄러에 의해 시간을 나눠서 번갈아 실행되기 때문에 진짜로 동시에 접근하지는 않으나 문제가 생길 수 있음.

1) A 쓰레드는 total + 6, B 쓰레드는 total + 9
2) A 쓰레드 작업 시작. 레지스터->ALU를 거쳐 데이터 A 쓰레드의 연산 결과를 다시 레지스터에 저장하려는 순간 컨텍스트 스위칭이 일어날 경우, 레지스터는 실행 대상의 값으로 채워져야 하기 때문에 기존 값을 메모리에 잠시 저장.
3) B 쓰레드의 작업 시작. 작업이 끝난 후 멈춰놨던 A 쓰레드의 작업을 다시 시작
4) 레지스터에 있는 값을 저장하고 복원하는 위 과정에서 메모리 동시 접근이 문제가 됨.

이를 막기 위해서 A 쓰레드가 메모리에 저장해둔 값에 접근해서 작업하는 동안 B 쓰레드의 접근을 막아야 함. 작업이 끝난 후 접근해야 한다.

임계 영역: 둘 이상의 쓰레드가 동시에 접근하면 안 되는 코드 블럭 (여기서는 메모리에 저장된 total값)

💻 프로세스로부터의 쓰레드 분리

  1. 쓰레드 Usage Count: 생성과 동시에 2.
    자기 자신의 실행 + 쓰레드를 생성할 경우, 쓰레드를 생성한 프로세스에게 반환할 핸들 정보
    따라서 쓰레드의 커널 오브젝트에 접근할 수 있는 대상은 2.
  2. 하나는 쓰레드 종료 시 감소, 다른 하나는 CloseHandle() 함수 호출 시 감소.
    어떤 프로세스가 A 쓰레드를 생성, A 쓰레드가 자신의 작업을 마친 뒤 종료됐을 경우 US는 1. 쓰레드의 커널 오브젝트는 쓰레드가 종료되어도 남아 있기 때문에 이런 형태로 쓰레드를 계속 생성한다면 쓰레드의 커널 오브젝트가 계속 메모리에 남아있는 문제가 생길 수 있음.
  3. 쓰레드 생성과 동시에 CloseHandle() 함수 호출: 쓰레드 분리.
    위와 같은 문제를 막기 위해서는 쓰레드 생성과 동시에 쓰레드의 커널 오브젝트 삭제를 위해 프로세스는 CloseHandle() 함수를 호출해 A 쓰레드의 US를 1 감소시켜줘야 함.
    주의: 쓰레드의 종료 코드를 커널 오브젝트에 저장했다 추후 읽어들이고 싶을 경우, CloseHandle() 함수를 호출해버리면 안 됨.
  • ANSI 표준 C 라이브러리와 쓰레드
#include <stdio.h>
#include <tchar.h>
#include <windows.h>

int _tmain(int argc, TCHAR* argv[])
{
	TCHAR string[] = _T("Hey, get a life!") _T("You don't even ... ...together.");
	TCHAR seps[] = _T(",.!");

	// 토큰 분리 조건, 문자열 설정 및 첫 번째 토큰 반환.
	TCHAR* token = _tcstok(string, seps);
	while (NULL != token)
	{
		_tprintf(_T(" %s\n"), token);
		token = _tcstok(NULL, seps);
	}
    /*
    - strtok() 함수는 결국 어딘가에 정적 변수로건 전역 변수로건 문자열을 저장해두어야 함.
      그래야만 반환가능 하기 때문.
    - 그렇단 소리는 결국 strtok() 함수를 쓰레드 메인 함수로 하는 쓰레드들이 있을때
      임계 영역이 발생할 수 있단 소리.
    - 그래서 예전의 ANSI 표준 C 라이브러리는 쓰레드 개념에서 취약한 부분들이 있어서,
      멀티 쓰레드에 안전한 라이브러리의 사용이 필요함.
    - 최신 버전 VS를 사용한다면 자동으로 링크가 걸려 있긴 함.
      이때부터 CreateThread() and ExitThread() -> _beginthreadex, _endthreadex로 바꿔 써야 함.
    - 다만 ExitThread()은 안쓸거라, _endthreadex도 사용할 일이 없음.
    - 또 단순하게 생각해보면, CreateThread() 함수가 안좋을거 같지만,
      _beginthreadex()도 내부적으로는 CreateThread() 함수를 호출함.
      그와동시에 _beginthreadex()로 생성된 쓰레드들은 각자의 메모리 공간을 갖게끔 함.
      그래서 같은 메모리에 동시 접근 안해도 되게끔 함.
    - 마찬가지로 _endthreadex()는 ExitThread()와 하는 일은 같음
      그러나 _beginthreadex()에서 만들어진 각자의 메모리 공간을 해제 작업도 함.
      그래도 return을 통해서 하면 자동으로 메모리 공간 해제도 이뤄지기에 return이 최고임.
    */

	return 0;
}

✏️ 쓰레드의 상태 컨트롤 및 쓰레드의 우선순위 컨트롤

💻 쓰레드의 상태 컨트롤

윈도우즈에서는 스케줄러가 프로세스가 아닌 쓰레드를 관리하고, 쓰레드가 상태를 지닌다.

  • SuspendThread(): Running 상태의 쓰레드를 잠시 Blokced 상태로 멈추는 함수.

  • ResumeThread(): Ready에서 Runnning으로 바꾸는 함수

  • 쓰레드의 우선 순위 결정 요소

실제로 우선 순위를 갖는 것 또한 프로세스가 아닌 쓰레드.
먼저 프로세스 단위에서 우선 순위 클래스를 가지고, 해당 클래스 안에서 프로세스 우선 순위를 기준으로 쓰레드의 우선 순위를 가진다.
ex) 프로세스의 우선순위가 13인 클래스가 있다면 해당 계층 쓰레드의 우선 순위는 모두 13 + N

0개의 댓글