책정리/윈도우 API 정복2

41장 멀티 스레드

GONII 2019. 4. 16. 21:34
01 스레드
. 고전적인 다중 작업
보통 CPU 하나뿐인 컴퓨터는 번에 하나의 일만 있다. 동시에 작업 수행이 되는 것처럼 보이게 하기 위하여 어떤 방법들이 사용되었는지 알아보고 장단점을 논해본다.
작업은 시간을 잘게 잘라서 시간마다 조금씩 나누어서 해야한다. WM_TIMER 메시지를 사용할 있다.
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
static BYTE blue;
HBRUSH hBrush, hOldBrush;
 
switch (iMessage)
{
case WM_CREATE:
SetTimer(hWnd, 1, 20, NULL);
break;
case WM_TIMER:
blue += 5;
InvalidateRect(hWnd, NULL, FALSE);
break;
case WM_PAINT:
hdc = BeginPaint(hWnd, &ps);
hBrush = CreateSolidBrush(RGB(0, 0, blue));
hOldBrush = (HBRUSH)SelectObject(hdc, hBrush);
Rectangle(hdc, 10, 10, 400, 200);
SelectObject(hdc, hOldBrush);
DeleteObject(hBrush);
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
KillTimer(hWnd, 1);
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
타이머 메시지를 받았을 조금씩 작업을 하면서 다른 메시지를 받을 수도 있고, 사용자의 입력을 받거나 다른 작업을 같이 진행할 있다.
동시 작업이 필요한 프로그램들은 고전적으로 이런 방법을 많이 사용했었다. 하지만 방법도 문제점이 있다. 우선 WM_TIMER 메시지는 1초에 최대 18.2(NT 100)밖에 실행되지 않으므로 고속 처리가 필요할 때는 사용할 없다. 또한 타이머 메시지에서 화면을 그리는 동안은 다른 메시지를 곧바로 처리할 없으므로 반응성이 좋지 않다. 만약 100초가 걸리는 작업을 1초씩 쪼개 100번을 나누어 한다고 최악의 경우 1초동안은 사용자가 메뉴를 선택해도 아무 반응이 없을 것이다.
그래서 이런 방법을 쓸려면 작업을 아주 잘게 쪼개야 한다는 제약이 있다. 그나마도 분할이 가능한 작업이라면 얼마든지 작업을 쪼개서 점진적으로 진행할 있지만 파일 저장처럼 분할하기 힘들거나 분할할 속도가 너무 느려지는 경우는 방법을 쓰기 어렵다.
타이머를 쓰는 방법 외에 작업중에 루프를 돌리면서 전달되는 메시지를 우선적으로 처리하는 메시지 펌핑과 메시지 루프를 조작하여 처리할 메시지가 없는 아이들 타임을 찾아 활용하는 방법도 있다. 메시지 펌핑은 작업을 분할하기 힘든 경우에 사용하며 주기적으로 처리해야 하는 작업에는 아이들 타임이 적합하다.
. 스레드를 이용한 다중작업
멀티스레드는 Win32 API 핵심적인 부분이며 어렵기는 하지만 아주 흥미로운 주제이다.
스레드(Thread) 프로세스 내에 존재하는 실행 경로, 일련의 실행코드이다. WinMain 메시지 루프, WndProc, 일반 함수들을 오가며 메시지를 처리하는 일련의 코드들을 스레드라고 한다. 프로세스는 단지 존재하기만 하는 껍데기일 뿐이며 실제 작업은 스레드가 담당한다. 프로세스 생성시 하나의 스레드(Primary Thread) 생성되며 대부분의 경우 스레드가 모든 작업을 처리하고 스레드가 종료되면 프로세스도 같이 종료된다.


만약 동시에 가지 작업을 해야 한다면 스레드에서 추가로 스레드를 만들 있다. 이렇게 되면 프로세스는 개의 실행 흐름을 가지게 되며 스레드와 스레드2 CPU 시간을 우선 순위에 따라 적절하게 분배하여 동시에 실행된다. 운영체제는 스레드별로 골고루 CPU 시간을 배분하므로 스레드가 시간을 지나치게 오래 끌더라도 다른 스레드가 이에 영향을 받지 않고 실행된다. 방법은 타이머나 PeekMessage 사용하는 방법보다 반응성이 훨씬 좋으며 일부러 작업을 잘게 쪼갤 필요도 없다.
하나의 운영체제에 여러 개의 프로세스가 동시에 실행되는 환경을 멀티 태스킹이라고 한다. 멀티 스레드란 하나의 프로세스에서 여러 개의 스레드가 동시에 실행되는 환경을 의미한다. 운영체제 차원에서 지원되며 일부 하드웨어의 지원까지 받으므로 아주 부드럽게 실행되며 신뢰성이 있다.
DWORD WINAPI ThreadFunc(LPVOID temp)
{
HDC hdc;
BYTE blue;
HBRUSH hBrush, hOldBrush;
 
hdc = GetDC(hWndMain);
while (true)
{
blue += 5;
Sleep(20);
hBrush = CreateSolidBrush(RGB(0, 0, blue));
hOldBrush = (HBRUSH)SelectObject(hdc, hBrush);
Rectangle(hdc, 10, 10, 400, 200);
SelectObject(hdc, hOldBrush);
DeleteObject(hBrush);
}
ReleaseDC(hWndMain, hdc);
return 0;
}
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
DWORD dwThreadID;
HANDLE hThread;
 
switch (iMessage)
{
case WM_CREATE:
hWndMain = hWnd;
hThread = CreateThread(NULL, 0, ThreadFunc, NULL, 0, &dwThreadID);
CloseHandle(hThread);
return TRUE;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
스레드를 만들 때는 다음 함수를 사용한다.
HANDLE CreateThread(LPSECURITY_ATTRIBUTES lpThreadAttributes, DWORD dwStackSize, LPTHREAD_START_ROUTINE lpStartAddress, LPVOID lpParameter, DWORD dwCreationFlags, LPDWORD lpThreadId);
lpThreadAttributes
스레드의 보안 속성을 지정
dwStackSize
스레드의 스택 크기를 지정
0으로 지정하면 스레드와 같은 크기로 설정
(1M예약, 그중 페이지 보통 4K 확정되어 있음)
lpStartAddress
스레드의 시작 함수를 지정
lpParameter
스레드 함수는 개의 인자를 받을 있다
스레드로 전달할 작업 내용이되 없을 경우 NULL
dwCreationFlags
보통 0
CREATE_SUSPENDED 플래그를 지정하면 스레드를 만들기만 하고 실행하지 않는다. 만들어 놓고 원하는 조건이 되었을 실행하고자 경우 플래그를 사용
중지된 스레드를 실행할 때는 ResumeThread함수를 호출
lpThreadId
스레드 ID 리턴
 
다음 예제는 4개의 스레드를 만들며 스레드까지 동시에 5개의 스레드가 실행된다.
DWORD WINAPI ThreadFunc(LPVOID temp)
{
HDC hdc;
BYTE blue;
HBRUSH hBrush, hOldBrush;
 
hdc = GetDC(hWndMain);
while (true)
{
blue += 5;
Sleep(20);
hBrush = CreateSolidBrush(RGB(0, 0, blue));
hOldBrush = (HBRUSH)SelectObject(hdc, hBrush);
Rectangle(hdc, 10, 10, 400, 200);
SelectObject(hdc, hOldBrush);
DeleteObject(hBrush);
}
ReleaseDC(hWndMain, hdc);
return 0;
}
 
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
DWORD dwThreadID;
static RECT arRect[] = {
{100,100,200,200}, {300,100,400,200},
{100,300,200,400}, {300,300,400,400},
};
int i;
 
switch (iMessage)
{
case WM_CREATE:
hWndMain = hWnd;
for (i = 0; i < 4; i++) {
CloseHandle(CreateThread(NULL, 0, ThreadFunc, &arRect[i], 0, &dwThreadID));
}
return TRUE;
case WM_LBUTTONDOWN:
hdc = GetDC(hWnd);
Ellipse(hdc, LOWORD(lParam) - 10, HIWORD(lParam) - 10, LOWORD(lParam) + 10, HIWORD(lParam) + 10);
ReleaseDC(hWnd, hdc);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
같은 프로세스 내의 스레드끼리는 주소공간, 전역변수, 코드를 공유하므로 같은 시작함수를 사용해도 상관없다. 시작 함수가 같더라도 전달되는 인수가 다르면 다른 동작을 한다.
. 스레드 관리
실제 스레드는 일정한 백그라운드 작업을 맡아 처리하고 작업이 끝나면 종료되는 것이 보통이다. 예를 들어 시간이 오래 걸리는 인쇄 작업이나 정렬, 다운로드 작업 등에 스레드가 사용된다. 작업이 종료되면 스레드의 시작함수가 종료되며 이렇게 되면 스레드도 이상 필요 없으므로 파괴된다.


작업 스레드가 백그라운드 작업을 주스레드는 작업 스레드를 만들기만 하고 종료 상태에는 별로 관심을 두지 않는 것이 보통이다. 특별한 경우를 제외하고 스레드는 서로 독립적으로 실행될 뿐이다. 스레드는 적어도 작업 스레드가 종료되었는지의 여부는 주기적으로 조사해봐야 하는데 이때는 다음 함수가 사용된다.
BOOL GetExitCodeThread(HANDLE hThread, LPDWORD lpExitCode);
hThread
관심의 대상인 스레드의 핸들을 넘긴다
lpExitCode

스레드의 종료 코드를 조사해 리턴한다.
실행중이라면 STILL_ACTIVE 리턴되며 종료되었다면 스레드의 시작함수가 리턴한 값이나 ExitThread 함수의 인수가 리턴된다.
 
모든 스레드는 프로세스가 종료되면 강제로 종료되므로 무한루프를 도는 스레드를 만들어도 상관없다. 스레드가 종료되면 프로세스가 종료될 것이고 따라서 작업 스레드도 같이 종료된다.
백그라운드 작업을 하는 작업 스레드는 정해진 작업을 순서대로 처리한 자연 종료되지만 때로는 작업 중간에 스레드를 종료해야 경우도 있다. 예를 들어 다운로드를받는 스레드를 만들었는데 중간에 사용자가 다운로드를 취소했다면 이상 스레드는 존재할 필요가 없다. 스레드를 강제 종료할 때는 다음 함수가 사용된다.
VOID ExitThread(DWORD dwExitCode);
BOOL TerminateThread(HANDLE hThread, DWORD dwExitCode);
ExitThread 스레드가 스스로 종료할 사용하는데 인수로 종료 코드를 전달한다. 종료 코드는 스레드에서 GetExitCodeThread함수로 조사할 있다. 스레드가 ExitThread 호출하면 자신의 스택을 해제하고 연결된 DLL 모두 분리한 스스로 파괴된다.
TerminateThread 스레드 핸들을 인수로 전달받아 해당 스레드를 강제로 종료한다. 함수는 스레드와 연결된 DLL에게 어떠한 통보도 하지 않으므로 DLL들이 제대로 종료 처리를 하지 못할 수도 있으며 할당된 자원들이 제대로 해제되지 않을 수도 있다. 함수 외에 다른 방법이 없을 위급한 상황에만 사용되어야 하며 스레드가 어떤 작업을 하고 있는지, 종료 어떤 일이 벌어질지를 정확히 알고 있을 때만 사용해야 한다. 스레드를 중간에 종료할 때는 전역변수나 기타 다른 방법을 통해 스레드가 종료 사실을 있게 하여 스레드가 스스로 종료하도록 하는 것이 가장 좋다.
ExitThread C++ 객체의 소멸자가 호출되지 않고 C런타임이 만든 고유의 데이터 블록이 해제되지않는 문제가 있다.
가장 바람직한 종료는 스레드가 작업을 무사히 마치고 return 문으로 스레드 시작함수를 종료하는 것이다.
스레드의 동작을 잠시 중지시킬 수도 있는데 이때는 다음 함수를 사용한다.
DWORD SuspendThread(HANDLE hThread); // 동작 중지
DWORD ResumeThread(HANDLE hThread); // 동작 개시
스레드는 내부적으로 중지 카운트를 유지하는데 카운트는 SuspendThread함수가 호출되면 증가하고, ResumeThread함수가 호출되면 감소하여 카운트가 0이면 스레드는 재개된다. 그래서 SuspendThread 호출했다면 ResumeThread 호출해야 스레드가 동작한다.
. 배너
. UI 스레드
스레드는 보통 백그라운드에서 작업을 하며 사용자 눈에는 보이지 않는다. 이처럼 내부적인 계산만 하는 스레드를 작업 스레드(Worker Thread)라고 하는데, 대부분의 스레드는 작업 스레드이다. 이에 비해 UI 스레드는 윈도우를 만들고 메시지 큐와 메시지 루프를 가진다. 메시지 큐는 스레드별로 생성되는데 UI스레드는 스레드와는 다른 메시지 큐를 가지는 스레드이다. 윈도우를 가지고 메시지를 처리할 있다는 말은 사용자와 상호 작용을 있다는 뜻이다.
다음 예제는 백그라운드에서 압축을 푸는 스레드를 생성하는데 압축 해제 경과 표시와 중지, 재개를 위해 스레드가 별도의 윈도우를 만든다. 윈도우를 만들면 사용자 눈에 윈도우가 보일 것이고 사용자는 윈도우를 조작할 있다. 따라서 윈도우로 전달되는 메시지를 관리하기 위해 스레드가 메시지 루프를 가져야 하며 메시지 처리 함수도 만들어야 한다.
LRESULT CALLBACK WndProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
HDC hdc;
PAINTSTRUCT ps;
TCHAR* msg = "왼쪽 버튼을 누르면 압축 해제 스레드 생성";
HANDLE hThread;
DWORD threadID;
 
switch (iMessage)
{
case WM_LBUTTONDOWN:
hThread = CreateThread(NULL, 0, ThreadFunc, NULL, 0, &threadID);
CloseHandle(hThread);
return 0;
case WM_PAINT:
hdc = BeginPaint(hWnd, &ps);
TextOut(hdc, 10, 10, msg, lstrlen(msg));
EndPaint(hWnd, &ps);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
 
DWORD WINAPI ThreadFunc(LPVOID temp)
{
HWND hWnd;
MSG message;
 
hWnd = CreateWindow("DecompWnd", "압축해제중", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 400, 150, hWndMain, (HMENU)NULL, g_hInst, NULL);
ShowWindow(hWnd, SW_SHOW);
 
while (GetMessage(&Message, NULL, 0, 0)) {
TranslateMessage(&message);
DispatchMessage(&message);
}
return message.wParam;
}
 
LRESULT CALLBACK DeCompProc(HWND hWnd, UINT iMessage, WPARAM wParam, LPARAM lParam)
{
TCHAR cap[256];
int value;
 
switch (iMessage)
{
case WM_CREATE:
CreateWindow("button", "시작", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 50, 80, 90, 25, hWnd, (HMENU)0, g_hInst, NULL);
CreateWindow("button", "닫기", WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON, 250, 80, 90, 25, hWnd, (HMENU)1, g_hInst, NULL);
value = 0;
SetProp(hWnd, "VALUE", (HMENU)1, g_hInst, NULL);
SendMessage(hWnd, WM_COMMAND, MAKEWPARAM(0, BN_CLICKED), (LPARAM)0);
return 0;
case WM_TIMER:
value = (int)GetProp(hWnd, "VALUE");
value++;
wsprintf(cap, "압축 해제중:%d", value);
SetWindowText(hWnd, cap);
SetProp(hWnd, "VALUE", (HANDLE)value);
if (value == 100) {
SetWindowText(hWnd, "압축 해제 완료");
KillTimer(hWnd, 1);
EnableWindow(GetDlgItem(hWnd, 0), FALSE);
}
return 0;
case WM_COMMAND:
switch (LOWORD(wParam)) {
case 0:
GetDlgItemText(hWnd, 0, cap, 256);
if (lstrcmp(cap, "시작") == 0) {
SetDlgItemText(hWnd, 0, "중지");
SetTimer(hWnd, 1, 200, NULL);
}
else {
SetDlgItemText(hWnd, 0, "시작");
KillTimer(hWnd, 1);
}
break;
case 1:
DestroyWindow(hWnd);
break;
}
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return (DefWindowProc(hWnd, iMessage, wParam, lParam));
}
스레드는 메시지 큐를 따로 가지는데 운영체제는 윈도우를 생성하는 스레드에 대해 별도의 메시지 큐를 생성한다. 큐는 스레드에 소속된 윈도우로 전달되는 모든 메시지를 저장하며 스레드의 메시지 루프에서 큐에 있는 메시지를 꺼내 윈도우의 메시지 처리 함수로 전달한다. DeCompProc 전달되는 메시지를 처리하며 압축 해제 작업을 시뮬레이션 한다.
시작, 닫기 개의 차일드 버튼을 생성하고 버튼들의 WM_COMMAND 처리하며 타이머를 설치하여 압축이 풀리는 것처럼 흉내를 내기도 한다. 물론 진짜 압축을 푼다면 타이머 대신 압축 해제 코드를 작성해야 할것이다. 압축을 푸는 중에 윈도우를 움직일 수도 있고 버튼을 눌러 중지하거나 취소할 수도 있다. WM_DESTROY에서는 PostQuitMessage 호출하여 스레드의 메시지 루프를 종료시켜 스레드 자체를 종료한다.
02 스케줄링
. 스레드 컨텍스트
멀티 스레드란 복수 개의 스레드가 동시에 실행되는 시스템이다. CPU 하나인 시스템에서는 멀티 스레드는 동시에 실행되는 것처럼 흉내내는 방법으로 구현된다. 스레드를 어떤 순서로 얼마만큼의 간격으로 실행할 것인가를 결정하는 정책을 스케줄링이라고 한다. CPU 여러 있다고 하더라도 스레드는 그보다 많기 때문에 이런 시스템에서도 여전히 스케줄링이 필요하다.
운영체제는 CPU 실행시간을 아주 잘게 쪼개어 스레드를 조금씩 순서대로 실행함으로써 동시에 실행되는 것처럼 보이게 만든다. 시간을 얼마나 잘게 쪼갤 것인가는 운영체제 버전과 목적, 설정 상태에 따라 달라지는데 대략 0.02 정도로 시간을 분할하며 이때 분할된 시간 조각 하나를 퀀텀(Quantum)이라고 한다. 멀티 스레딩은 사실 동시에 실행되는 것이 아니라 굉장히 빠른 속도로 스레드를 번갈아 가며 실행하는 것이다. 이런 방식을 라운드 로빈(Round Robin) 방식이라 한다.


CPU 스레드 A 1퀀텀동안 실행한다. 1퀀텀이 경과하면 CPU A 실행을 잠시 중지하고 B 실행(스위칭)하며 이런 식으로 순서대로 C,D,E 실행한다. 그리고 E CPU 시간을 쓰면 다시 A 실행하는데 빠른 속도로 스레드를 순서대로 실행하면 모든 스레드가 동시에 실행되는 것처럼 보인다. 선점형 멀티 태스킹 환경에서 스위칭은 강제로 발생하며 운영체제는 언제든지 스레드로부터 제어권을 뺏어 스케줄링할 있다. 그래서 스케줄링이 아주 공평하게 수행된다. 이에 비해 비선점형 환경에서는 한쪽에서 양보하지 않으면 운영체제가 강제로 제어권을 뺏을 없어 멀티 태스킹이 부드럽지 못하다.
스케줄러가 관심을 가지는 대상은 코드를 가지고 있는 스레드이며 스레드를 번갈아 가며 실행한다. 스케줄링 대상은 프로세스가 아니라 스레드이다. 그림에서 A B 프로세스 소속일 수도 있고 아닐 수도 있는데 스케줄러는 어떤 프로세스 소속인가는 관심을 가지지 않는다.
A에서 B 작업이 스위칭될 때를 보면, CPU A 실행시킬 중지한 작업을 정확한 위치에서 다시 시작하기 위해 A 작업 상태를 어딘가에 저장해 두어야 한다. 그렇지 않으면 A 다음번 실행될 어디까지 작업을 하다 말았는지 없을 것이다. 스레드 실행 상태에 대한 정보를 스레드 컨텐스트(Thread Context)라고 하는데 스레드가 하던 작업의 상태에 관한 정보는 굉장히 복잡할 같지만 기계 차원에서 보면 단순한 레지스터값과 기타 가지 값의 집합일 뿐이다.
CPU 레지스터에는 다음 실행될 명령의 포인터, 스택 포인터, 중간 연산 결과값 등이 모두 저장되어 있으므로 정보들만 다시 불러와 실행을 재개하면 된다. 일종의 구조체인데 CPU 따라 레지스터 구성이 판이하게 다르며 작업 상태에 대한 정보들도완전히 달라지므로 컨텍스트 정보는 CPU 별로 따로 정의되어 있다. winnt.h 헤더 파일에는 윈도우즈가 지원하는 플랫폼별로 CONTEXT 구조체가 조건부로 정의되어 있는데 가장 일반적인 인텔 계열 x86 시스템 컨텍스트는 다음과 같다.
typedef struct _CONTEXT {
    DWORD ContextFlags;
 
    //CONTEXT_DEBUG_REGISTERS
    DWORD   Dr0;
    DWORD   Dr1;
    DWORD   Dr2;
    DWORD   Dr3;
    DWORD   Dr6;
    DWORD   Dr7;
 
    // CONTEXT_FLOATING_POINT.
    FLOATING_SAVE_AREA FloatSave;
 
    // CONTEXT_SEGMENTS.
    DWORD   SegGs;
    DWORD   SegFs;
    DWORD   SegEs;
    DWORD   SegDs;
 
    //CONTEXT_INTEGER.
    DWORD   Edi;
    DWORD   Esi;
    DWORD   Ebx;
    DWORD   Edx;
    DWORD   Ecx;
    DWORD   Eax;
 
    //CONTEXT_CONTROL.
    DWORD   Ebp;
    DWORD   Eip;
    DWORD   SegCs;              // MUST BE SANITIZED
    DWORD   EFlags;             // MUST BE SANITIZED
    DWORD   Esp;
    DWORD   SegSs;
 
    //CONTEXT_EXTENDED_REGISTERS.
    BYTE    ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
레지스터 그룹에 따라 개의 섹션으로 구분되는데 이진수만 다루는 CPU 구조가 간단해서 보다시피 크기가 별로 크지 않다. 구조체에 스레드가 무엇을 하고 있었는지에 대한 정보가 완벽하게 저장될 있다. 스케줄러는 A 작업을 중지하기 전에 A 컨텍스트 정보를 먼저 저장한다. 그리고 B 컨텍스트 정보를 읽어와 작업 B 재개하며 B 중지하기 전에 B 컨텍스트 정보를 저장한다. 다음번 A 실행될 때는 저장해둔 컨텍스트 정보를 복원하여 정확하게 중지한 지점부터 재개된다.
스레드간 작업을 전환하는 이런 작업을 컨텍스트 스위칭이라고 한다.
다음 함수는 특정 스레드의 컨텍스트 정보를 구하거나 변경한다.
BOOL GetThreadContext(HANDLE hThread, LPCONTEXT lpContext);
BOOL SetThreadContext(HANDLE hThread, const CONTEXT* lpContext);
함수를 사용하면 스레드를 저수준에서 다룰 있지만 컨텍스트 정보는 단순한 레지스터 값의 집합일 뿐이며 실행 번지를 있다 하더라도 기계어 수준에서 코드를 해석해야 하므로 정보를 읽어 스레드가 무슨 작업을 하고 있었는지를 알기는 굉장히 어렵다. 컨텍스트 정보를 변경하여 중지된 스레드의 동작을 바꾸는 것도 가능하기만 하지만 역시 쉽지 않은 일일 것이다. 함수는 디버거를 위해 제공되며 일반 응용 프로그램이 함수로 스레드를 임의 조작하는 것은 바람직하지 않다.
. 우선순위
스레드의 우선 순위는 우선 순위 클래스(Priority Class), 우선 순위 레벨(Priority Value) 가지 값의 조합으로 결정된다. 우선 순위 클래스는 스레드를 소유한 프로세스의 우선 순위이며 CreateProcess 함수로 프로세스를 생성할 dwCreationFlag 지정한 값이다. 디폴트는 NORMAL_PRIORITY_CLASS 보통 우선순위를 가진다.
우선순위 클래스는 다음 함수로 조사 변경한다.

DWORD GetPriorityClass(HANDLE hProcess);
BOOL SetPriorityClass(HANDLE hProcess, DWORD dwPriorityClass);
쉘은 항상 보통 우선순위로 프로세스를 띄우며 프로세스가 스스로 자신의 우선 순위를 결정한다. 우선순위 클래스는 다음 여섯 가지가 있다.
우선순위 클래스
설명
REALTIME_PRIORITY_CLASS
가장 높은 우선순위이며 심지어 운영체제보다 높다. 극히 짧은 시간의 긴급한 프로세스에만 사용해야 한다.
HIGH_PRIORITY_CLASS
즉시 수행되어야 하는 긴급한 작업에 사용한다. 작업 관리자 정도의 프로세스가 클래스를 쓴다.
ABOVE_NORMAL_PRIORITY_CLASS
보통 우선 순위보다 조금 높은 우선순위
NORMAL_PRIORITY_CLASS
특별한 처리가 필요없는 보통 우선 순위
BELOW_NORMAL_PRIORITY_CLASS
보통 우선순위보다 조금 낮은 우선 순위
IDLE_PRIORITY_CLASS
시스템이 아주 한가할때만 실행되는 클래스이다. 스크린 세이버처럼 긴급하지 않은 프로세스가 클래스를 사용한다.
 
우선순위 레벨은 프로세스 내에서 스레드의 우선순위를 지정하며 다음 두함수로 설정하거나 읽을 있다. CreateThread 항상 보통 우선 순위로 스레드를 생성한다.
int GetThreadPriority(HANDLE hThread);
BOOL SetThreadPriority(HANDLE hThread, int nPriority);
7가지 하나로 지정 가능하다.
THREAD_PRIORITY_TIME_CRITICAL
THREAD_PRIORITY_HIGHEST
THREAD_PRIORITY_ABOVE_NORMAL
THREAD_PRIORITY_NORMAL
THREAD_PRIORITY_BELOW_NORMAL
THREAD_PRIORITY_LOWEST
THREAD_PRIORITY_IDLE
우선순위 클래스와 우선순위 레벨값으로 조합된 값을 기본 우선순위(Base Priority)라고 하며 스레드의 실제 우선순위를 지정하는 값이 된다.
 
이렇게 결정된 기본 순위는 스케줄러가 참조한다. 스케줄러는 스레드의 현재 상태와 기본 순위를 참조하여 다음 CPU 시간을 받을 스레드를 결정한다.
스케줄러의 기본 원칙은 무조건 순위가 높은 스레드를 우선 실행한다. 우선순위가 높은 스레드가 메시지를 처리중이면 낮은 순위의 스레드는 CPU 시간을 전혀 받지 못한다. 높은 순위의 스레드가 메시지를 꺼내지 못할때 다음 순위의 스레드가 실행된다.
극단적인 경우 최상위 순위인 31 스레드 하나가 실행중이라면 아래의 모든 스레드는 아무도 CPU 시간을 받을 없으며 상태를 기아(Starvation) 상태라고 한다. 그러나 실제로 이런 상황은 발생하지 않는데 우선 순위가 높을수록 작업 시간은 짧아서 메시지를 처리하고도 남는 시간이 많기 때문에 아래쪽 순위의 스레드에게도 시간이 골고루 돌아간다.
스레드의 상태를 보고 스케줄 대상을 결정하는데 서스펜드된 스레드, Sleep중인 스레드, GetMessage 멧지ㅣ를 꺼내지 못해 처리할 메시지가 없는 스레드 등에게는 CPU 시간을 주지 않는다. 이런 스레드는 순위가 아무리 높아도 시간을 필요가 없는 것이다. 사실 실행중인 대부분의 스레드는 스케줄 대상이 아미녀 사용자를 대면하는 서너 개의 스레드만 스케줄링 된다.
. 동적 우선 순위
프로세스와 스레드의 우선순위에 의해 기본 우선순위가 결정되더라도 실제로 적용되는 우선순위는 실행중에 시스템에 의해 계속 변한다. 이를 동적 우선순위(dynamic priority)라고 하며 스케줄러가 스레드를 실행할 실제로 적용하는 값이다. 시스템은 스레드의 반응성을 높이면서도 다른 스레드의 실행 시간을 지나치게 뺏지 않도록 가장 합리적이고 효율적인 방법으로 스레드의 우선순위를 높이거나 낮추는데 이를 우선순위 부스트(Priority Boost)라고 한다. 과정은 기반 우선순위 0~15 사이의 스레드에만 적용되며 16~31사이의 원래부터 우선순위가 높은 스레드에는 적용되지 않는다. 또한 부스팅은 낮은 스레드에게 우선 순위를 높이는 역할만 뿐이지 기본 우선 순위 이하로 내리지는 않는다.
 
다른 스레드에게 실행 시간을 양보할 때는 Sleep 함수로 일정 시간동안 대기하거나 SwitchToThread 함수를 사용한다. Sleep 자신에게 주어진 나머지 퀀텀을 양보하는 정도의 소극적인 양보를 하지만 SwitchToThread 함수는 자신보다 우선 순위가 낮은 스레드에게도 시간을 양보한다는 면에서 양보의 정도가 높다.
03 스레드의 함정
. 작업 복사본
하나의 주소 공간에 동시에 이상의 실행 흐름이 있다는 자체가 근본적인 문제점이다. 마치 16비트 환경에서 여러 개의 프로세스가 같은 주소 공간에서 바람직하지 못한 영향을 미칠 있는 것처럼 스레드끼리 공유 자원을 놓고 경쟁하거나 무한 대기하는 상태가 있다.
// 요약
여러 스레드가 하나의 메모리에 접근하면 원하는 값을 얻을 없기때문에 스레드마다 각각의 메모리를 만들어 값을 복사한 원하는 작업을 처리하도록 한다.
arData[num++] = rand()%100;
int* arCopy = (int*)malloc(sizeof(arData));
memcpy(arCopy,arData, sizeof(arData));
CreateThread(NULL, 0, CalcThread, arCopy, 0, &threadID);
. 스레드의 호출 순서
스레드는 일단 생성되면 CPU 의해 스케줄링되므로 언제 실행되고 언제 종료될지 예측할 없다. 그래서 응용 프로그램은 스레드의 실해애 순서나 실행 시간에 대해 어떠한 가정도 해서는 안된다.
스레드의 작업 시간이 얼마나 걸릴지는 실제로 돌려보기 전에는 없다. 마찬가지로 스레드가 동시에 실행될 어떤 스레드가 먼저 시작하거나 끝날 것이라는 것도 가정해서는 안된다. 스레드는 완전히 독립적인 작업을 해야 하므로 순서가 있는 작업은 스레드로 분리해서는 안되며 만약 필요하다면 동기화해야 한다.
. 재진입 가능성
스레드는 항상 재진입(ReEntrant) 가능하다는 것을 염두에 두어야한다.
멀티 스레드 환경에서 정적 변수(공유 자원) 사용할 경우 여러 개의 스레드가 변수를 같이 사용하게 되므로 문제가 되는데 일종의 경쟁 상태가 되는 것이다. 문제가 없도록 지역 변수나 다른 방법으로 바꿔야 것이다.
지역 변수는 스레드별로 생성되며 여러 개의 스레드로부터 호출되더라도 아무 문제가 없다. 아니면 어려운 방법으로 TLS(Thread Local Storage) 사용할 있다.
__declspec(thread) static int m=0;
TLS 지정하고 싶은 변수 앞에 __declspec(thread) 붙이면 변수는 스레드별로 생성되므로 스레드의 지역 변수가 된다.
스레드에서 전역, 정적 변수를 참조하는 것은 피해야 하는데 재진입되었을 똑같은 대상을 여러 개의 스레드가 참조하기 때문이다.
. C 런타임 라이브러리
C언어는 멀티 스레드 개념이 도입되기 이전의 언어이다. 그래서 멀티 스레드 환경과는 어울리지 못하는 문제가 있는데 C라이브러리 전체가 공유하는 전역 변수와 몇몇 함수들이 사용하는 정적 변수들이 문제가 된다.
C라이브러리 함수 중에 정적 변수를 사용하는 대표적인 예는 문자열을 토큰으로 분할하는 strtok인데 함수는 검색중인 토큰의 위치를 자신의 정적 변수에 저장한다. 스레드만 함수를 호출하면 문제가 없지만 여러 개의 스레드가 함수를 호출할 경우 정적 변수가 값을 제대로 유지하지 못하며 포인터를 잃어 버린 함수가 제대로 동작할 리가 없다.
컴파일러 제작사는 멀티 스레드용으로 사용할 있는 별도의 C라이브러리르 제공하므로 프로젝트 설정 대화상자에서 ㅁ러티 스레드용의 라이브러리로 바꾸기만 하면된다. Project/Settings 선택한 C/C++탭의 Code Generation에서 Use run-time Library 옵션을 보자.


디폴트값은 Single-threaded이며 C런타임 라이브러리로 LIBC.LIB 사용한다. 라이브러리에는 C함수들이 포함되어 있다.
옵션으 Debug Multithreaded 수정하고 릴리즈 모드는 Multithreaded 변경해야 한다. 그러면 /MT 스위치가 추가되며 멀티 스레드에서 안정적으로 동작하도록 수정된 LIBCMT.LIB 대신 사용된다.
C라이브러리의 전역, 정적 변수 문제를 완전히 해결하려면 라이브러리의 함수만 수정해서는 안되며 함수들이 참조할 있는 전역, 정적 변수를 스레드마다 생성해야 한다. , 스레드를 만드는 시점에도 여분의 처리가 필요하다는 뜻인데 스레드 생성문인 CreateThread 직접 사용해서는 안되며 C라이브러리가 제공하는 다음 함수로 스레드를 생성해야 한다.
unsigned long _beginthreadex(void* security, unsigned stack_size, unsigned (__stdcall)* start_address)(void*), void* arglist, unsigned initflag, unsigned* threadaddr);
함수는 스레드를 만들기 전에 스레드가 정적 변수의 집합을 따로 할당하고 스레드에서 변수를 찾을 있도록 스레드 지역 저장소(TLS) 변수들을 배치한다. 그리고 CreateThread 함수를 호출하여 스레드를 생성하는데 이후 스레드가 호출하는 C 런타임 함수들은 TLS 저장된 자신만의 정적 변수를 참조할 것이다. 이후 스레드가 종료되어 리턴하면 할당한 변수들을 회수한다.
종료할 때도 C라이브러리가 제공하는 별도의 함수를 사용해야 한다.
void _endthreadex(unsigned retval);
함수는 스레드에 할당된 지역 저장소를 해제하고 ExitThread 호출하여 스레드를 파괴한다.
원칙적으로 따지자면 C언어로 스레드르 프로그래밍을 때는 _beginethreadex함수를 쓰는 것이 옳고 안전하다.
04 TLS
. 스레드 지역 저장소
. TLS 예제
. DLL TLS
반응형

'책정리 > 윈도우 API 정복2' 카테고리의 다른 글

51장 서비스  (0) 2020.03.26
42장 동기화  (0) 2019.05.07
39장 메모리  (0) 2019.04.04
40장 프로세스  (0) 2019.04.04
39장 메모리  (0) 2019.04.03