01. Windows에서의 쓰레드 생성과 소멸
쓰레드의 생성
Windows에서 사용할 수 있는 가장 기본적인 쓰레드 생성 함수는 CreateThread이다.
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
lpThreadAttributes: 프로세스 생성 함수와 동일하게 핸들의 상속 여부를 결정한다.
dwStackSize: 쓰레드를 위한 개별적인 스택의 사이즈를 지정한다.
lpStartAddress: 쓰레드로 동작하기 위한 함수. 쓰레드의 main 역할을 하는 함수를 지정한다.
lpParameter: 쓰레드 함수에 전달할 인자를 지정한다.
dwCreationFlags: 쓰레드의 생성 및 실행을 조절한다.
lpThreadId: 쓰레드 ID를 전달받기 위한 변수의 주소값을 전달한다.
+ 함수 호출 성공 시 생성된 쓰레드의 핸들이 반환된다.
또한, Windows에서는 메모리가 허용하는 만큼 쓰레드를 생성 가능하다. 즉, 쓰레드가 생성될 때마다 독립된 스택을 할당해 주는데, 이를 메모리가 허용 가능한 범위만큼 쓰레드를 생성할 수 있다.
//
// CountThread.cpp
// 생성 가능한 쓰레드의 개수 측정
//
#include "stdio.h"
#include "tchar.h"
#include "windows.h"
#define MAX_THREADS (1024*10)
DWORD WINAPI ThreadProc(LPVOID lpParam) {
DWORD threadNum = (DWORD)lpParam;
while (1) {
_tprintf(_T("thread num : %d \n"), threadNum);
Sleep(5000);
}
return 0;
}
DWORD cntOfThread = 0;
int _tmain(int argc, TCHAR* argv[]) {
DWORD dwThreadID[MAX_THREADS];
HANDLE hThread[MAX_THREADS];
while (1) {
hThread[cntOfThread] =
CreateThread(
NULL, // 디폴트 보안 속성 지정
0, // 디폴트 스택사이즈
ThreadProc, // 쓰레드 함수
(LPVOID)cntOfThread, // 쓰레드 함수의 전달인자
0, // 디폴트 생성 flag 지정
&dwThreadID[cntOfThread] // 쓰레드 ID 반환
);
if (hThread[cntOfThread] == NULL) {
_tprintf(
_T("MAXIMUM THREAD NUMBER : %d \n "),
cntOfThread
);
break;
}
cntOfThread++;
}
for (DWORD i = 0; i < cntOfThread; i++) {
CloseHandle(hThread[i]);
}
return 0;
}
위 함수에서 중요하게 봐야 할 부분은 CreateThread 부분이다.
여기서 쓰레드를 생성하는데, 두 번째 전달인자가 0으로, 디폴트 크기의 쓰레드 스택이 할당된다.
또한, 세 번째 인자로 전달된 ThreadProc 함수를 호출하며, 이때가 프로그램 실행 흐름이 추가되는 시점이다.
main 함수는 CreateThread 함수 호출 이후 다음 줄을 실행하며, 생성된 쓰레드는 main 함수와 별개로 ThreadProc 함수를 호출하여 또 다른 프로그램의 흐름을 만든다.
이를 통해 알 수 있는 것은 main 쓰레드의 return 문은 프로세스의 종료로 이어지며, 실행 중에 생성된 쓰레드의 return은 해당 쓰레드의 종료를 의미한다.
해당 코드의 실행 결과를 보면, 위와 같이 순서가 섞인 부분이 있다. 이처럼, 쓰레드의 흐름은 시스템의 당시 상황에 따라 다르게 동작하기 때문에 예측할 수 없다. 물론 확률적으로, 리소스를 더 많이 필요로 하는 쪽이 먼저 호출된다는 추측 정도는 가능하다.
또한, CreateThread 함수의 전달인자로 0을 전달하여 디폴트 스택 사이즈(1024*1024 바이트)를 정해주었지만, 이를 1024*1024*10 바이트로 변경할 경우 생성된 쓰레드의 수가 1/10으로 줄어들게 되는 것을 확인할 수 있다.
반대로 디폴트 스택 사이즈보다 작게 설정할 경우엔, 스택의 수가 늘어나지 않는다. 이는 디폴트 스택 크기가 쓰레드에서 필요로 하는 최소 스택 크기이기 때문에 사이즈를 디폴트 사이즈로 지정하는 것이다.
이처럼 운영체제는 프로그래머에게 모든 것을 맡기지 않고, 우리의 요구사항 등을 적절하게 조절한다.
쓰레드의 소멸(쓰레드 생성에 대한 추가적인 이야기)
쓰레드는 쓰레드 함수 내에서 return 문을 통해 종료 및 소멸시키는 방법이 가장 이상적이다. 하지만 이러한 방법이 모든 상황에서 최선의 방법은 아니다. 따라서 쓰레드를 종료할 수 있는 추가적인 방법을 제시한다.
case 1: 쓰레드 종료 시 return을 이용하면 좋은 경우(대부분)
앞서 언급했듯이 가장 이상적이고 일반적인 경우이다.
다음 예제를 통해 1-10까지 3개의 쓰레드를 통해 나눠서 계산하는 코드를 볼 수 있다.
/*
ThreadAdderOne
쓰레드를 이용한 덧셈
*/
#include "stdio.h"
#include "tchar.h"
#include "windows.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) (¶mThread[0]),// 쓰레드 함수의 전달인자
0, // 디폴트 생성 flag 지정
&dwThreadID[0] // 쓰레드 ID 반환
);
//...
WaitForMultipleObjects(
3, // 총 3개의 커널 오브젝트를 관찰
hThread, // 커널 오브젝트 핸들의 배열 정보
TRUE, // 모든 커널 오브젝트가 signaled 상태가 되는 것을
INFINITE // 무한히 기다림
);
GetExitCodeThread(hThread[0], &result);
total += result;
//...
_tprintf(_T("total : %d"), total);
CloseHandle(hThread[0]);
//...
return 0;
}
코드 요약:
먼저, 쓰레드 함수에서는 포인터 인자를 받아 해당 주소의 값부터 다음 주소에 있는 값까지 더하여 total 값을 반환한다.
메인 함수에서는 쓰레드를 생성하여, 쓰레드 함수 및 전달 인자를 전달하며, WaitForMultipleObjects 함수를 통해 쓰레드가 종료(Signaled 상태) 되기를 기다린다. 최종적으로 GetExitCodeThread 함수를 통해 종료 코드를 반환받고, 이를 total에 더하여 출력한다.
다음은 GetExitCodeThread의 정의이다.
BOOL GetExitCodeThread(
HANDLE hThread,
LPDWORD lpExitCode
);
hThread: 종료 코드를 얻기 위한 쓰레드의 핸들을 전달한다.
lpExitCode: 얻게 되는 종료코드를 저장할 메모리의 주소값을 전달한다.
위처럼 쓰레드 기반으로 구현할 경우, 프로세스 기반으로 했을 때보다 속도적인 측면에서 훨씬 유리하다.
case 2: 쓰레드 종료 시 ExitThread 함수 호출이 유용한 경우(특정 위치에서 쓰레드의 실행을 종료시키고자 하는 경우)
ExitThread 함수는 현재 실행 중인 쓰레드를 종료하고자 할 때 호출하는 함수로, return 방식의 쓰레드 종료만큼 선호된다. 다음은 ExitThread 함수의 정의이다.
VOID ExitThread(
DWORD dwExitCode
);
dwExitCode: 커널 오브젝트에 등록되는 쓰레드 종료코드(Exit Code)를 지정한다.
이때 등록되는 종료코드는 앞서 소개한 GetExitCodeThread 함수를 통해 얻을 수 있다.
이 함수의 장점은 언제 어디서나 쓰레드를 종료시킬 수 있고, return 문을 사용하는 것보다 코드를 이해하기 좋다는 점이다. return을 사용한 쓰레드 종료의 경우, 만약 쓰레드 함수에서 A 함수를, A 함수가 B 함수를, B 함수가 다시 C 함수를 호출할 때, 모든 함수가 return 될 때까지 기다려야만 한다.
하지만, C++을 이용한 프로그래밍에서는 A 함수와 B 함수의 스택 프레임에 C++ 객체가 존재한다고 가정하면, ExitThread 함수가 중간에 호출될 경우, 객체의 소멸자가 호출되지 않아 메모리 유출이 발생할 수 있다.
case 3: 쓰레드 종료 시 TerminateThread 함수 호출이 유용한 경우(외부에서 쓰레드를 종료시키고자 하는 경우)
main 함수 내에서 쓰레드를 생성할 경우 해당 쓰레드의 핸들을 얻게 된다. 이 핸들을 이용해서 쓰레드를 강제 종료시킬 수 있다. 함수의 정의는 다음과 같다.
BOOL TerminateThread(
HANDLE hThread,
DWORD dwExitCode
);
hThread: 종료할 쓰레드의 핸들
dwExitCode: 종료할 쓰레드의 종료코드를 인자로 전달한다. 이 종료코드는 해당 쓰레드의 커널 오브젝트에 등록된다.
위 함수의 문제점은 강제 종료라는 점이다. 이는 종료에 필요한 여러 가지 일들(메모리 혹은 할당된 리소스 해제 등)을 처리하지 못하고 바로 종료된다.
02. 쓰레드의 성격과 특성
힙, 데이터 영역, 그리고 코드 영역의 공유에 대한 검증
쓰레드는 메모리를 공유한다. 특히 전역변수가 할당되는 데이터 영역과, 메모리가 동적으로 할당되는 힙 영역을 공유한다. 따라서 이전 예제와 같이 불필요하게 복잡한 구조로 프로그램을 구현할 필요가 없다. 아래와 같이 total이라는 변수를 전역 변수로 선언한 뒤, 공유하는 total을 가지고 덧셈 연산을 진행하면 간단해진다.
하지만 이러한 방식은 한 가지 문제점을 가지고 있다.
동시접근에 있어서의 문제점
쓰레드는 동시에 실행되는 것처럼 보이지만 사실은 돌아가면서 실행되는 것이다. 따라서 A 쓰레드가 계산을 마치고 메모리에 결과를 저장하려는 찰나에 B 쓰레드로 순서가 넘어가고, B 쓰레드가 아직 업데이트되지 않은 total 값으로 연산을 진행해 버리면, 온전한 결과를 얻을 수 없게 된다. 이러한 현상을 쓰레드의 race condition이라고도 한다.
이처럼 같은 메모리 영역을 동시에 참조하는 것은 문제를 일으킬 가능성이 매우 높다.
프로세스로부터의 쓰레드 분리
프로세스는 쓰레드를 담는 상자 역할을 한다고 하였다. 하지만 핸들 테이블은 여전히 프로세스 소유이다. 즉, 하나의 프로세스에 하나의 핸들 테이블이 존재한다. 따라서, 핸들 값은 핸들 테이블에 정보가 등록된 이후에, 이 핸들 테이블의 소유자에 해당하는 프로세스에게만 의미를 지닌다.
만약, 프로세스 A의 핸들 테이블에 핸들 204에 대한 정보가 등록되어 있다면, 이 정보를 통해 커널 오브젝트와 리소스 C에 접근이 가능하다. 하지만, 프로세스 B의 핸들 테이블에는 204에 대한 정보가 없고, 따라서 접근이 불가능하다.
이 경우, 프로세스 A 내에서 생성된 쓰레드들에게도 핸들 204가 의미를 지닌다. 왜냐하면 같은 프로세스 내에서 생성된 모든 쓰레드들은 스택 이외의 모든 것을 공유하기 때문이다. 즉, 핸들 테이블까지도 공유한다.
이어서 Usage Count에 대한 이야기를 하자면, 쓰레드 역시 생성과 동시에 Usage Count는 2가 된다. 하나는 쓰레드 종료 시 감소하고, 나머지 하나는 쓰레드 핸들을 인자로 CloseHandel 함수가 호출될 때 감소한다. 따라서 이전에 자식 프로세스의 커널 오브젝트 소멸과 관련된 문제가 동일하게 쓰레드에서도 발생할 수 있다.
이러한 문제를 막기 위해, 쓰레드 생성 시 반환된 핸들 값을 인자로 전달하면서 CloseHandle 함수를 곧바로 호출한다. 이렇게 되면 쓰레드의 UsageCount는 1이 되고, 쓰레드가 종료함과 동시에 UsageCount는 0이 되어 모든 메모리를 반환하게 된다. 이러한 CloseHandle 함수를 가리켜 다음과 같이 표현한다.
“프로세스로부터 쓰레드를 분리한다.”
ANSI 표준 C 라이브러리와 쓰레드
초기에 표준 C 라이브러리가 구현될 당시에 쓰레드에 관한 고려가 전혀 이뤄지지 않았기 때문에, 멀티 쓰레드 기반을 프로그램을 구현하게 되면, 동일한 메모리 영역을 동시에 접근하는 문제가 발생할 수 있다. 대표적인 예시로 문자열을 특정 기준에 따라 나누는 strtok 함수이다.
#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 have two pennies to rub together.");
TCHAR seps[] = _T(" ,.!");
// 토근 분리 조건 , 문자열 설정 및 첫 번째 토큰 반환
TCHAR* token = _tcstok(string, seps);
// 계속해서 토근을 반환 및 출력
while (token != NULL)
{
_tprintf(_T(" %s\n"), token);
token = _tcstok(NULL, seps);
}
return 0;
}
+strtok 함수는 전달된 기준에 따라 나눠지는 한 단어씩 반환하며, 다음 단어를 반환받고 싶다면, 첫 번째 인자에 NULL을 전달한다. 즉, 함수가 처음 호출되면서 등록된 문자열이 어딘가에 저장된다는 것이다.
여기에서 우리는 전역, 혹은 static으로 선언된 배열에 문자열에 저장되어 있음을 예측할 수 있다. 즉, 멀티 쓰레드 기반으로 프로그램 구현 시 ANSI 표준 함수의 호출은 메모리의 동시 참조 문제가 발생할 수 있는 것이다. (여러 개의 쓰레드가 동시에 NULL을 입력하여 원래의 문자열에 접근할 경우에 발생)
이는 MS에서 제공하는 멀티 쓰레드에 안전한 ANSI 표준 라이브러리를 사용하여 해결할 수 있다. 이는 프로젝트 설정에서 설정 가능하다. 또한, CreateThread가 함수가 아닌 _beginthreadex 함수를 통해 쓰레드를 생성해야만 한다. _beginthreadex 함수도 내부적으로는 CreateThread 함수를 호출하지만, 쓰레드를 생성하기에 앞서 쓰레드를 위해 독립적인 메모리 블록을 할당한다. 또한, 이는 전달인자의 순서와 의미가 CreatThread 함수와 동일하다. 다만, 선언된 매개변수 자료형과 반환형에 차이가 있어, 쓰레드 함수 선언의 변경과 약간의 형변환이 요구된다.
추가적으로, 쓰레드 종료 시 ExitThread 함수를 활용하고자 할 경우에는 _endthreadex 함수를 호출해야 한다. 이는 _beginthreadex 함수를 통해 할당된 독립적인 메모리 블록을 반환한다. 또한 내부적으로 ExitThread 함수를 호출한다.
03. 쓰레드의 상태 컨트롤
경우에 따라서는 쓰레드의 상태를 프로그래머가 임의로 변경시켜야만 하는 경우가 있다. 특정 쓰레드의 실행을 멈추기 위해 Blocked 상태로 만들거나, 다시 실행을 재개하기 위해 Ready 상태로 둔다거나 하는 경우이다.
쓰레드의 상태 변화
Windows에서는 상태가 변화하는 주체가 프로세스가 아니라 쓰레드이다. 이는 앞선 장에서의 프로세스의 상태 변화와 동일하므로, 그림만 보고 자세한 설명은 생략한다.
Suspend & Resume
특정 쓰레드를 지목해서 그 쓰레드를 Blocked 상태로 이동시킬 수 있고, 또다시 Ready 상태로 옮겨놓을 수도 있다. 이때 사용하는 두 가지 함수는 다음과 같다.
DWORD SuspendThread(
HANDLE hThread
);
DWORD ResumeThread(
HANDLE hThread
);
각각 hThread 인자에 상태를 변경하고자 하는 쓰레드의 핸들을 전달한다.
첫 번째 함수는 쓰레드를 Blocked 상태에 두는 함수이고, 두 번째 함수는 Blocked 상태에 있는 함수를 Ready 상태에 두기 위한 함수이다. 그런데, 쓰레드의 커널 오브젝트에는 SuspendThread 함수의 호출 빈도수를 기록하기 위한 서스펜드 카운트(Suspend Count)라 불리는 멤버가 존재하는데, 현재 실행 중인 쓰레드의 서스펜드 카운트는 0이다.
그러나, SuspendThread 함수 호출 시, 서스펜드 카운트는 1이 되고, 쓰레드는 Blocked 상태가 된다. 다시 SuspendThread 함수 호출 시, 서스펜드 카운트는 2가 된다. 즉, SuspendThread 함수는 서스펜드 카운트 값을 하나 증가시킨다.
반대로, ResumeThread 함수는 서스펜드 카운트를 하나 감소시키는 역할을 하며, 이러한 상황에서는 두 번 호출되어야 Ready 상태에 놓이게 된다.
04. 쓰레드의 우선순위 컨트롤
앞서 9장에서는 프로세스의 우선순위를 설명하였다. 하지만, 프로세스는 실행의 주체가 아닌 쓰레드를 담는 그릇에 지나지 않는다. 따라서 Windows는 프로세스가 우선순위를 갖는 것이 아니라, 프로세스 안에서 동작하는 쓰레드가 우선순위를 갖는다.
9장에서 말한 프로세스의 우선순위를 가리켜 기준 우선순위라 한다.
그리고, 쓰레드는 다음과 같이 상대적 우선순위를 갖는다.
최종적인 쓰레드의 우선순위는 프로세스의 기준 우선순위와 쓰레드의 상대적 우선순위의 조합으로 결정된다. 즉, 프로세스의 기준 우선순위를 기준으로 해서 상대적 우선순위에 해당하는 값을 더하거나 빼면 쓰레드의 실질적인 우선순위를 계산할 수 있다.
ex) 기준 우선순위가 NORMAL_PRIORITY_CLASS(9)인 프로세스 내부에 상대 우선순위가 -2, 0인 두 쓰레드가 A, B가 존재한다면, A의 우선순위는 7(9-2)이고, B의 우선순위는 9(9+0)가 된다.
프로세스 내에서 생성되는 모든 쓰레드의 상대적 우선순위는 THREAD_PRIORITY_NORMAL이다. 즉, 프로세스의 기준 우선순위를 그대로 사용하는 것이다. 이를 변경하거나 참조할 때에는 다음과 같은 두 함수를 사용한다.
참고 자료:
윤성우. 『뇌를 자극하는 윈도우즈 시스템 프로그래밍』.한빛미디어, 2007.
'독서 > [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ]' 카테고리의 다른 글
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 14. 쓰레드 동기화 기법 2 (2) | 2023.11.06 |
---|---|
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 13. 쓰레드 동기화 기법 1 (2) | 2023.11.05 |
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 11. 쓰레드의 이해 (3) | 2023.10.27 |
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 10. 컴퓨터 구조에 대한 세 번째 이야기 (3) | 2023.10.08 |
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 09. 스케줄링 알고리즘과 우선순위 (3) | 2023.10.03 |