시스템 프로그래밍 12장 - 쓰레드의 생성과소멸

김주현·2021년 10월 14일
0

시스템 프로그래밍

목록 보기
12/21

01 . Windows에서의 쓰레드 생성과 소멸

-쓰레드의 생성

Windows에서 사용할 수 있는 가장 기본적인 쓰레드 생성 함수는 CreateThread이다. 이보다 더 이름이 명확할 수 있겠는가? 이 함수를 이용해서 쓰레드를 생성해 보도록 하겠다.

HANDLE CreateThread(

LPSECURITY_ATTRIBUTES lpThreadAttributes // 프로세스를 생성하는 함수에도 동일한 매개변수 존재,이 매개변수를 통해서 핸들의 상속 여부를 결정. 여기서도 동일.NULL이 전달되면 생성되는 핸들은 프로세스 생성 시 상속 대상에서 제외된다.

SIZE_T dwStackSize //쓰레드 새성 시 해당 쓰레드를 위한 스택이 별도로 생성된다.이때 생성되는 쓰레드의 스택 크기를 지정하기 위한 매개변수다. 만약, 0이 전달되면 디폴트 스택크기인 1MB가 적용됨

lpStartAddress //쓰레드로 동작하기 위한 함수. 다시 말해서 쓰레드의 main 역할을 하는 함수를 지정하는 전달인자이다. 인자 타입이 LPTHREAD_START_ROUTINE 인데 이는 다음과 같이 정의 되어 있는 함수 포인터이다.

따라서 반환 타입이 DWORD이고 매개변수 타입은 LPVOID(void *)인 형태로 함수가 정의되어야 한다.

LPVOID lpParameter // 쓰레드 함수에 전달할 인자를 지정하는 용도로 사용한다. 매개변수 lpStartAddress가 가리키는 함수 호출 시 전달할 인자를 지정하는 것이다. main 함수에서 argv로 문자열이 전달 되는 것과 유사하다.

DWORD dwCreationFlags // 쓰레드의 생성 및 실행을 조절하기 위해 사용되는 전달인자다.
인자로 CREATE_SUSPENDED가 전달되면, 쓰레드는 생성과 동시에 Blocked 상태에 놓이게 된다.
그리고 함수 ResumeThread가 호출되면 실행을 시작한다.
그리고 Windows XP이상에서는 인자로 STACK_SIZE_PARAM_IS_A_RESERVATION를 전달할 수 있는데, 이 경우 commit 메모리 크기를 의미하게 된다

LPDWORD lpThreadID // 쓰레드 ID를 전달받기 위한 변수의 주소값을 전달한다. 굳이 필요 없다면 NULL을 전달해도 좋다.단! Windows ME 이하 버전에서는 NULL을 전달할 수 없기 때문에 범용적인 사용을 위해서라도 주소값을 전달하는 것이 안전하다.

마지막으로 함수 호출이 성공하면 생성된 쓰레드의 핸들이 반환된다.

);
윈도우즈에서 생성 가능한 최대 쓰레드 개수가는 메모리가 허용하는 만큼이다(MS에서 명시적으로 지정해놓지 않았음)

쓰레드가 생성될 때마다 독립된 스택을 할당해 줘야만 한다. 다시 말해서 스택을 할당할 수 있을때까지 쓰레드의 생성을 허용한다.

위 그림에서 main 쓰레드의 return문은 프로세스 종료라고 표시해 두었고, 쓰레드 함수의 return문은 쓰레드 종료라고 표시해 두었다. main쓰레드나,main 쓰레드가 생성한 또 하나의 쓰레드나,쓰레드라는 점에서는 동일하다 그러나 main 쓰레드의 return문은 다른 의미를 지닌다.

main 쓰레드는 프로세스 전체를 대표한다.

따라서 main 쓰레드의 return문은 프로세스 종료로 이어진다.

쓰레드의 return문은 return을 실행한 쓰레드의 종료를 의미한다,여러 가지 쓰레드 종료방법이 있지만 return을 통한 쓰레드의 종료가 가장 권장하는 방법이다.

  1. CreateThread()
  2. printf()
  3. ...

다음과 같은 문장이 있어서 순차적으로 실행된다고 가정했을때 1번문장이 먼저호출될지 2번 문장이 먼저 호출될지는 "아무도 알수가 없다"
물론 확률적으로는 쓰레드 생성이 시스템 리소스를 더 많이 필요로 하므로 printf 함수가 먼저 호출될 것으로 예측할 수 있지만, 이는 어디까지나 예측일 뿐이다.
멀티 쓰레드 기반 프로그래밍을 하면서 초보 프로그래머가 범하는 과오 중에 하나가 쓰레드의 흐름을 예측하려고 한다는 것이다. 흐름을 예측한다는것은 불가능하다. 시스템의 당시 상황에 따라서 다르게 동작하기 때문에 예측 자체가 의미가 없다.

쓰레드의 스택사이즈를 0으로 줄경우 디폴트값인 1M바이트가 넘어가고 2024개의 쓰레드가 생성됨
10MB를 인자로 넘길시 202개 그럼 0.5M넘기면? -> 똑같이 2024개
이유 : 운영체제는 프로그래머에게 모든 것을 맡기지 않는다. 우리의 요구사항이 불합리 하거나 시스템이 가지고 있는 제한사항 때문에 문제가 될 수 있을 경우 이를 적절한 수위로 조절을 한다.

VOID Sleep(
DWORD dwMilliseconds // 쓰레드의 실행을 멈추기 위한 시간 정보를 밀레세컨드 단위로 지정한다. 0을 인자로 전달할 경우 자신에게 현재 할당된 타임 슬라이스를 포기하고 우선순위가 같은 다른 쓰레드에게 실행의 기회를 양보한다. 만약에 우선순위가 같은 쓰레드가 존재하지 않는다면 바로 이어서 실행을 재개한다.
);

  • 쓰레드의 소멸

Case 1 : 쓰레드 종료 시 return을 이용하면 좋은 경우(거의 대부분의 경우)

1부터10 까지 더하는 작업을 하려고할때 입출력 작업이 많이요구된다면 Blocked 상태에 자주 놓이게 될것 이때 세 개의 쓰레드를 생성해서 일의 부담을 각각 나눠준다면

정해진 시간 동안에 CPU에게 보다 많은 일을 시킬 수 있고,Blocked 상태에 놓이는 상황도 세 개의 쓰레드가 나눠서 감당하기 때문에 속도가 높아질 확률이 높다.

두 개의 숫자를 전달해주면 그사이에 있는 값들의 합을 계산해주는 함수를 디자인하자

"쓰레드 함수가 반환한 결과값(쓰레드의 종료코드)은 프로세스의 종료코드처럼 커널 오브젝트에 그 결과가 저장된다" 문제는 쓰레드의 종료코드를 어떻게 main 쓰레드가 확인하느냐에 있다.

GetExitCodeThread 함수는 쓰레드의 종료코드를 얻기위해 사용되는 함수이다.

BOOL GetExitCodeThread(
HANDLE hThread // 종료코드를 얻기 위한 쓰레드의 핸들을 인자로 전달한다
LPDWORD lpExitCode // 얻게 되는 종료코드를 저장할 메모리의 주소값을 전달한다.
);

Case2 : 쓰레드 종료 시 ExitThread 함수 호출이 유용한 경우(특정 위치에서 쓰레드의 실행을종료시키고자 하는 경우

ExitThread 함수는현재 실행 중인 쓰레드를 종료하고자 할 때 호출하는 함수이다.

VOID ExitTHread (
DWORD dwExitCode //커널 오브젝트에 등록되는 쓰레드 종료코드를 지정한다.
);

여기서 등록되는 종료코드는 GetExitCodeThread 함수를 통해 얻을 수 있다. 이함수의 장점은 언제 어디서나 쓰레드를 종료 시킬수 있다는 점이다
-> return문은 언제 어디서나 쓰레드를 종료시킬수 없다.

이상황에서 ExitThread를 하면 바로 종료되지만 return에 의한 종료를 하고자 한다면 thread function까지 return을 해야만 종료가 가능해진다.

하지만 한 가지 주의할 사항이있다. C++을 이용해서 프로그래밍 할 때에는 이 함수의 호출에 주의해야 한다. 위 그림에서 A,B 함수의 스택 프레임에 C++ 객체 (생성자 호출을 통해서 만들어지는 객체)가 존재한다고 할때. C 함수에서 ExitThread 함수가 호출되면. 이러한 경우 a,b함수의 스택 프레임에 존재하는 객체의 소멸자는 호출되지 않는다. 따라서 메모리 유출 현상이 발생할 수도 있다.

c,c++ 구분없이 가장 좋은 방법은 역시 return문에 의한 쓰레드 종료이다.

case 3 : 쓰레드 종료 시 TerminateThread 함수 호출이 유용한 경우(외부에서 쓰레드를 종료시키고자 하는 경우)

main 함수 내에서 쓰레드를 생성할 경우 해당 쓰레드의 핸들을 얻게 된다. 이 핸들을 이용해서 쓰레드를 강제 종료시킬 수 있다. 순전히 외부에 의한 강제 종료이다. 쓰레드 스스로가 종료시점을 결정짓는 상황이 아니다.

BOOL TerminateThread(
HANDLE hThread // 강제 종료할 쓰레드의 핸들
DWORD dwExitCode // 종료할 쓰레드의 종료코드를 인자로 전달한다. 이 종료코드는 해당 쓰레드의 커널 오브젝트에 등록된다.

이 함수의 문제점을 강제종료이기때문에 대상이되는 쓰레드는 종료되는 사실을 인식하지못하고 종료에 필요한 여러 가지일들(대표적으로 메모리 혹은 할당받은 리소스 해제)를 처리하지 못하고 바로 종료된다.

02 . 쓰레드의 성격과 특성

  • 힙, 데이터 영역, 그리고 코드 영역의 공유에 대한 검증

쓰레드는 메모리를 공유한다 특히 전역변수가 할당되는 데이터 영역과, 메모리가 동적으로 할당되는 데이터 영역과, 메모리가 동적으로 할당되는 힙영역을 공유한다.

위 그림에서 보면 total이라는 변수를 전역변수로 선언하고 있다. 때문에 생성된 모든 쓰레드는 자신만의 total을 가지고 덧셈 연산을 진행하는 것이 아니라, 공유하는 total을 가지고 덧셈 연산을 진행한다. 그리고 그 최종 결괴를 main 쓰레드가 참조하는 형태로 전개된다.

  • 동시접근에 있어서의 문제점

위 예제의 문제점은 아무런 안전장치도 없이 전역변수 total에 둘 이상의 쓰레드가 동시접근한다는데 있다.

위 그림은 덧셈이 이뤄지는 과정을 간략히 세 단계로 나눠서 설명하고 있다. 연산이 이뤄지기 위해서는 메모리에 저장된 데이터를 레지스터로 이동해야 한다. 그리고 ALU에 의해 실질적인 덧셈 연산이 진행되고,그 결과가 다시 메묄에 저장되는 구조로 진행이 된다.

컨텍스트 스위칭은 함수가 호출중이여도 일어나기 때문에 둘 이상의 쓰레드가 같은 메모리 영역을 동시에 참조하는 것은 문제를 일으킬 가능성이 매우 높다.

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

프로세스는 쓰레드를 담는 상자 역할을 한다고 했는데 그렇다면 8장에서 언급한 "프로세스 핸들 테이블"도 사실을 "쓰레드 핸들 테이블"이 되어야 옳은 표현이 아닐까? 즉 각각의 쓰레드별로 핸들 테이블이 존재하는 것은 아닐까?

그렇지 않다. 핸들 테이블은 여전히 프로세스 소유이다. 즉 하나의 프로세스에 하나의 핸들 테이블이 존재할 뿐이다.

핸들값은 핸들 테이블에 정보가 등록된 이후에,이 핸들 테이블의 소유자에 해당하는 프로세스에게만 의미를 지닌다.


위 그림을 보면 프로세스 A의 핸들 테이블에 핸들 204에 대한 정보가 등록되어있고, 이 정보를 통해서 커널 오브젝트와 리소스 C에 접근이 가능하다. 그러나 프로세스 B의 핸들 테이블에는 핸들 204에 대한 정보가 존재하지 않는다. 따라서 접근이 불가능하다.

그렇다면 쓰레드의 관점에서는 어떨까? 예를 들어서 프로세스 A내에서 생성된 쓰레드들에게는 핸들 204가 의미를 지닐까? 지닌다! 왜냐하면 같은 프로세스 내에서 생성된 모든 쓰레드들은 스택 이외의 모든 것을 공유하기 때문이다. 즉 핸들 테이블까지도 공유를한다.

"Usage Count는 무엇이며, 자식 프로세스는 생성되지마자 Usage Count가 어떻게 되는가?"

Usage Count는 커널 오브젝트의 소멸 타이밍을 결정하기 위해서 필요하다고 앞서 설명하였다. 자식 프로세스의 경우, 생성과 동시에 Usage Count는 2가 된다.그래서 하나는 자식 프로세스가 종료될 때 감소하고, 또 하나는 부모 프로세스가 자식 프로세스의 핸들을 인자로 CloseHandle 함수를 호출할 때 감소한다고 하였다.

이는 쓰레드도 마찬가지다. 쓰레드 역시 생성과 동시에 Usage Count는 2가된다, 하나는 쓰레드 종료 시 감소하고, 나머지 하나는 쓰레드 핸들을 인자로 CloseHandle 함수가 호출될 때 감소한다. 따라서 이전에 자식 프로세스의 커널 오브젝트 소멸과 관련된 문제가 동일하게 쓰레드에서 발생할 수 있다.

그래서 이러한 실수를 막기 위해, 쓰레드 생성 시 반환된 핸들값을 인자로 전달하면서 CloseHandle 함수를 곧바로 호출한다. 이렇게 되면, 쓰레드의 UsageCount는 1이 되고, 쓰레드가 종료함과 동시에 UsageCount는 0이 되어 모든 메모리를 반환하게 된다. 이러한 CloseHandle 함수 호출을 가리켜 다음과 같이 표현한다.

"프로세스로부터 쓰레드를 분리한다"

-ANSI 표준 C 라이브러리와 쓰레드

우리가 C 언어를 배울 때 처음 접하는 것이 printf라는 이름의 표준 C 라이브러리 함수이다. 뿐만 아니라 대부분의 프로그램에서는 표준 C 라이브러리를 활용한다. 특히 문자열 처리와 입.출력에 관련해서는 표준 C 라이브러리 의존도가 높은 편이다.

문제는 초기에 표준 C라이브러가 구현될 당시만해도 쓰레드에 대한 고려가 전혀 이뤄지지 않았다는 점이다. 따라서 멀티 쓰레드 기반으로 프로그램을 구현한게 되면 , 동일한 메모리 영역을 동시 접근하는 불상사가 발생할수 있다.

strtok 함수를 사용할때 우리는 전역, 혹은 static으로 선언된 배열에 문자열이 저장되어 있음을 예측할수있다.

해결책은 마이크로소프트에서 제공하는 멀티 쓰레드에 안전한 ANSI 표준 라이브러리를 사용하는것

추가로 쓰레드를 생설할때 CreateThread함수가아닌 _beginThreadex 함수를 사용해야한다. 이 함수는 쓰레드를 위해 독립적인 메모리 블록을 할당한다.

"Multi-"로 시작하는 이름의 표준 C 라이브러리 함수는 이렇게 할당된 쓰레드 각각의 메모리 블록을 기반으로 연산을 한다. 이로써 멀티 쓰레드 기반에서 안정성이 확보되는 것이다.

또한 만약에 ExitThread 함수를 활요하고자 할 경우에는 이함수를 대신애 _endthreadex 함수를 호출해야한다.

_beginthreadex 함수 호출 시 각각의 쓰레들 위해서 메모리를 할당한다고 하였다. 따라서 쓰레드 종료 시에는 할당한 메모리를 반환해야만 한다. 이 역할을 하는 함수가 바로 _endthreadex이다. 이 함수는 내부적으로 쓰레드에 할당된 메모리를 해제하고 ExitTHread 함수를 호출한다.

return 문을 이용해서 종료할경우 _endthreadex 함수가 자동으로 호출됨

  1. 쓰레드의 상태 컨트롤

-쓰레드의 상태 변화

Windows에서는 상태가 변화하는 주체가 프로세스가 아니라 쓰레드 이다.

[상황 1 & 2]

생성시 Ready 상태. 다음 스케줄러에 의해서 선택될 경우 Running 상태가 되면서 실제 실행이 이뤄짐. 따라서 Ready 상태에 놓이는 쓰레드는 여럿이 될 수 있지만 Running 상태에 놓이는 쓰레드는 하나

[상황 3]
실행 중인 쓰레드에게 할당된 타임 슬라이스가 모두 소비되어서 Running 상태에서 Ready 상태로의 이동

[상황 4 & 5]

Running 상태에 있는 쓰레드가 입출력 연산을 하거나 Sleep 함수 호출로 인해서 잠시 실행이 중단된 경우 Blocked 상태로 이동을 하면서 다른 쓰레드의 실행을 도모하게 된다. Blocked 상태로 이동시킨 원인이 해결되면 다시 Ready 상태로 돌아가서 실행되기만을 기다린다.

-Suspend & Resume

쓰레드를 Blocked 상태에 두는 함수

DWORD SuspendThread(
HANDLE hThread // Blocked 상태에 두고자 하는 쓰레드의 핸들을 인자로 전달한다.
);

쓰레드를 Blocked 상태에서 Ready로 두는 함수

DWORD ResumeThread(
HANDLE hThread // Ready 상태에 두고자 하는 쓰레드의 핸들을 인자로 전달한다.
);

여기서 쓰레드의 커널 오브젝트에는 SuspendThread 함수의 호출 빈도수를 기록하기 위한 "서스펜드 카운트"라 불리는 멤버가 존재하는데, 현재 실행 중인 쓰레드의 서스펜드 카운튼 0이다. 그러나 이 쓰레드의 핸들을 인자로 SuspenThread 함수가 호출이 되면, 서스펜드 카운트는 1이되고 쓰레드는 Blocked 상태에 놓이게된다. 그리고 다시 한번 SuspendThread 함수가 호출되면 서스펜드 카운트는 2가된다. 즉 SuspendThread 함수는 서스펜드 카운트값을 하나 증가시키는 역할을 한다.

이렇게 서스펜드 카운트가 2인 상황에서는 한번의 ResumeThread 호출로 바로 Ready 상태가 되지 않는다. ResumeThread 함수의 호출은 서스펜드 카운트를 하나 감소시키는 역할을 한다. 따라서 이러한 상황에서는 두번 호출해야 Ready 상태에 놓이게된다.

함수의 반환값을 통해서 서스펜드 카운트의 변화를 확인하는 것이 가능하다. 위 두 함수 모두 함수 호출이 성공하면 변경되기 이전에 저장되었던 서스펜드 카운트를 반환하기 때문이다.

  1. 쓰레드의 우선순위 컨트롤

프로세스는 실행으 주체가 아닌 쓰레드를 담는 그릇에 지나지 안흔다. 따라서 Windows에서는 프로세스가 우선순위를 갖는 것이 아니라, 프로세스 안에서 동작하는 쓰레드가 우선순위를 갖는다.

쓰레드의 우선순위는 프로세스의 기존 우선순위와 쓰레드의 상대적 우선순위의 조합으로 결정된다. 예를 들어 기준 우선순위가 NORMAL_PRIORITY_CLASS(9)dls 프로세스 안에 두 개의 쓰레드가 존재하는데 각각 상대적 우선순위가 THREAD_PRIORITY_LOWEST(-2),THREAD_PRIORITY_NORMAL(0)이다 그렇다면 각쓰레드의 최중 우선순위는 7(9-2),9(9-0)로 결저된다. 즉 프로세스의 기준 우선순위를 기준으로 해서 상대적 우선순위에 해당하는 값을 더하거나 빼면 쓰레드의 실지적인 우선순위를 계산해 낼 수 있다.

프로세스 내에서 생성되는 모든 쓰레드의 상대적 우선순위는 THREAD_PRIORITY_NORMAL이다. 즉 프로세스의 기존 우선순위를 그대로 수용하는 것이다. 이를 변경하거나 참조할 때에는 다음 두 함수를 쓴다.

BOOL SetThreadPriority (
HANDLE hThread,
int nPriority
);

int GetThreadPriority (
HANDLE hThread
);

SetThreadPriority 함수의 첫 번쨰 인자는 우선순위를 변경할 쓰레드의 핸들이고, 두 번째 전달인자는 위 표에 정의되어있는 상수값이다.

이것만은 알고 갑시다

1.CreateThread 함수와 _beginthreadex 함수의 차이점

_beginthreadex 함수를 기반으로 쓰레드를 생성할 경우 쓰레드별로 독립적인 메모리 공간을 할당 받는다. 그리고 이 메모리 공간은 ANSI 표준 함수를 호출하는과정에서 사용한다. 이는 둘 이상의 쓰레드가 하나의 메모리 공간에 동시접근하는 문제점을 막기 위함이다.

  1. 둘 이상의 쓰레드가 동시접근하는 메모리 공간의 문제점

실제로는 둘 이상의 쓰레드가 동시에 실행되지 않으므로, 메모리에 동시접근 이라는 문제점은 발생하지 않는다고 생각할 수 있다. 따라서 이 문제점은 함수 단위가 아닌 CPU의 연산 단위에서 이해해야 한다.

  1. 쓰레드의 상태 변화

Windows의 실행의 주체는 프로세스가 아닌 쓰레드이다. 따라서 상태를 지니는 실질적인 대상도 프로세스가 아닌 쓰레드이다. 쓰레드가 Ready,Blocked,Running 상태를 지니게 되며, 이들 상태간의 이동이 발생하는 시기는 앞서 설명한 프로세스의 상태 이동이 발생하는 이유와 동일하다.

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

프로세스로부터 쓰레드를 분리한다는 것은 프로세스가 소유하는 쓰레드의 핸들을 반환함으로써 쓰레드의 Usage Count를 1로 두겠다는 뜻이다. 이는 쓰레드가 종료되는 시점에 쓰레드의 리소르르 반환하기 위함이다.

0개의 댓글