스레드의 생성과 소멸
Windows에서의 스레드 생성과 소멸
windows에서 사용할 수 있는 가장 기본적인 스레드 생성 함수는 CreateThread이다.
- dwStackSize
- 스레드 생성과 동시에 독립적인 스택을 할당받는데 이때 스택의 사이즈 결정짓는 요소다.
- lpStartAddress
- 함수를 가리킬 수 있는 포인터이다.
- 스레드 동작하기 위한 함수. 다시 말해서 스레드의 main 역할을 하는 함수를 지정하는 전달인자이다.
- lpParameter
- 스레드 함수에 전달할 인자를 지정하는 용도로 사용한다.
- 매개변수 lpStartAddress가 가리키는 함수 호출 시 전달할 인자를 지정하는 것이다.
- main 함수에서 argv로 문자열이 전달되는 것과 유사하다.
생성가능한 스레드 개수는?
“메모리가 허용하는 만큼”
스레드가 생성될 때마다 독립된 스택을 할당해 줘야만 한다.
다시 말해서 스택을 할당할 수 있을 때까지 스레드의 생성을 허용한다.
스레드의 생성과 흐름의 진행과정
- 프로세스가 실행이 되면 main 함수를 호출하는 main thread가 형성이 돼서 main 함수를 실행한다.
- main thread가 CreateThread함수를 호출해서 thread A를 형성했다. 그러면 별도의 실행흐름이 만들어진다.
- main thread가 CreateThread함수를 호출해서 또 다른 thread A를 형성했다
- thread A도 별도의 스레드를 생성 가능하다.
main 스레드의 return문은 다른 의미를 지닌다. main 스레드는 프로세스 전체를 대표한다.
따라서 main 스레드의 return문은 프로세스 종료로 이어진다.
스레드들을 담고 있는 집에 해당하는 프로세스가 사라지니 그 안에 있는 스레드들은 작업을 마치기도 전에 사라져 버리고 만다.
반면에 실행 중에 생성된 스레드의 return문은 return을 실행한 스레드의 종료를 의미한다.
여러 가지 스레드 종료방법이 있지만 return을 통한 스레드의 종료가 가장 권장하는 방법이다.
스레드의 소멸
case1 : 스레드 종료 시 return 이용하면 좋은 경우
- 사실상 모든 경우
일반적으로 안정적인 프로그램은 main 스레드가 자신의 생성과 소멸을 본인이 결정짓는 게 안정적이다.
- main 스레드에서 일을 나눠준다.
- 스레드는 자신의 일을 다 끝내면 리턴한다.
case2 : 스레드 종료 시 ExitThread 함수 호출이 유용한 경우
- 특정 위치에서 스레드의 실행을 종료시키고자 하는 경우
- 이런 방식은 쓰지 말자
case3: 스레드 종료 시 TerminateTgread 함수 호출이 유용한 경우
- 외부에서 스레드를 종료시키고자 하는 경우
- 이런 방식은 쓰지 말자
스레드의 성격과 특성
힙, 데이터 영역, 그리고 코드 영역의 공유에 대한 검증
ThreadAdderOne.cpp는 너무 복잡하다.
종료코드를 연산결과 반환용으로 사용하고, 또 이를 얻기 위해서 흔히 쓰이지 않는 함수도 사용하고 있다.
스레드는 메모리를 공유한다. 특히 전역변수가 할당되는 데이터 영역과 메모리가 동적으로 할당되는 힙 영역을 공유한다.
따라서 이전 예제와 같이 불필요하게 복잡한 구조로 프로그램을 구현할 필요가 없다.
다음 그림은 ThreadAdderOne.cpp 보다 간결하게 개선시킨 모델이다.
위 그림에서 보면 total이라는 변수를 전역변수로 선언하고 있다. 때문에 생성된 모든 스레드는 자신만의 total을 가지고 덧셈 연산을 진행하는 것이 아니라, 공유하는 total을 가지고 덧셈 연산을 진행한다. 그리고 그 최종결과를 main 스레드가 참조하는 형태로 전개된다. 즉 main을 포함한 A, B, C 스레드가 Data 영역의 total 변수를 공유해서 사용하는 상황이다. 그러면 위 그림의 형태로 프로그램을 수정해 보자.
ThreadAdderTwo.cpp
#include "stdio.h"
#include "tchar.h"
#include "windows.h"
static int total = 0;
DWORD WINAPI ThreadProc(LPVOID lpParam)
{
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) (¶mThread[0]),
0 , &dwThreadID[0]
);
hThread[1] =
CreateThread(
NULL , 0 ,
ThreadProc ,
(LPVOID) (¶mThread[2]),
0 , &dwThreadID[1]
);
hThread[2] =
CreateThread(
NULL , 0 ,
ThreadProc ,
(LPVOID) (¶mThread[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;
}
- static int total = 0; 이라 해서 전역변수 total을 선언하고 있다. 따라서 이 변수는 어디에서나 접근 가능하다. 스레드 함수도 물론 접근 가능하다.
- total += i; 부분에서 지금 사용하고 있는 모습을 확인할 수 있다.
동시 접근의 문제점
동시 접근이라는건 실행 중에 둘 이상의 스레드가 계속 접근을 한다는 의미다.
스레드가 아무리 동시접근을 해도 그것은 실질적인 동시 접근이 아니라 시간이 남아서 접근을 하는 거니까 별 문제가 생기지 않지 않냐?
위 그림은 덧셈이 이뤄지는 과정을 간략히 세 단계로 나눠서 설명하고 있다.
현재 변수 total의 값은 10이고 두 개의 스레드가 각각 6과 9를 더하려는 상황이라고 가정해 보자.
6을 더하려는 스레드를 A 스레드라 하고 9를 더하려는 스레드는 B 스레드라 하겠다.
CPU는 현재 A 스레드를 실행 중에 있다.
따라서 total에 저장된 값을 레지스터로 이동한 다음, 정수 6에 대한 덧셈까지 끝이 난 상황이다.
현재 연산결과는 레지스터에 저장되어 있다.
그런데 이 찰나에 스케줄러에 의해서 실행의 대상이 스레드 A에서 스레드 B로 이동하였다.
따라서 현재까지의 상황을 메모리에 저장해야만 한다. 저장하고 나서 스레드 B는 연산을 시작한다.
위 그림이 보여주는 중요한 사실은 스레드 A의 연산결과가 사용되지 않고 기존에 존재하던 값이 다시 한번 ALU로 들어갔다는 점이다.
정수 9를 더하는 스레드 B는 모든 연산을 마치고 total 값을 19로 증가시킨다.
스레드 B가 종료를 하였기 때문에 실행의 기회는 다시 스레드 A로 이동한다.
따라서 기존에 저장된 데이터를 복원하고 진행하지 못했던 나머지 절차를 진행한다.
결과를 확인해보면 스레드 A와 B가 각각 6과 9를 증가시켰으므로 최종결과는 25가 되어야 하는데 결과가 16이다.
문제의 이슈가 되는 위치는 레지스터다. 레지스터는 데이터를 저장하고 보관하는 과정에서 빈번한 컨텍스트 스위칭을 통해서 공유하고 있는 영역에 접근하면 위와 같은 문제가 발생할 수 있다.
이러한 문제를 해결하기 위해서는 A 스레드가 total을 가지고 작업을 할 동안은 B 스레드가 total에 접근을 하면 안 된다. 반대로 B 스레드가 total을 작업할 때는 A 스레드가 접근을 하면 안 된다. 즉 둘 중에 하나만 작업을 하고 끝나면 다른 스레드가 작업을 진행하면 된다. total이라는 변수에 접근하는 코드가 문제다. 정확히는 total이라는 변수에 둘 이상의 스레드가 동시에 실행할 수 있는 부분이 문제이다. 이러한 코드 블럭을 가리켜 임계영역이라 한다. 임계영역은 둘 이상의 스레드가 동시에 실행되면 안 되는 코드 블록을 말한다.
프로세스로부터 스레드 분리
스레드는 생성과 동시에 UC 값이 2이다 이유는 스레드 본인이 가리키고 스레드를 생성한 프로세스가 가리켜서 2이다.
하나의 스레드가 종료하면 UC 값은 1 줄어든다. 하지만 프로세스는 계속 종료하지 않는다면 종료된 스레드의 커널 오브젝트가 남아있다. 이러한 커널 오브젝트가 많아진다면 메모리에 계속 누적되는 문제가 발생한다. 그렇기 때문에 이상적으로 스레드가 종료되자마자 커널 오브젝트가 종료되기 위해서는 프로세스가 CloseHandle 함수를 호출해서 스레드의 UC 값을 감소시켜야 한다. 따라서 프로세스는 스레드 생성과 동시에 CloseHandle을 호출해서 프로세스로부터 스레드를 분리해야 한다. 이렇게 해야 스레드가 종료 시 본인의 커널 오브젝트 역시 소멸시킬 수 있다.
ANSI 표준 C 라이브러리와 스레드
우리가 C 언어를 배울 때 처음 접하는 것이 printf라는 이름의 표준 C 라이브러리 함수이다. 뿐만 아니라 대부분의 프로그램에서는 표준 C 라이브러리를 활용한다. 특히 문자열 처리와 입 출력에 관련해서는 C 라이브러리 의존도가 높은 편이다. 문제는 초기에 표준 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 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;
}
스레드의 상태 컨트롤
윈도우에서는 프로세스가 아니라 스레드가 상태를 지닌다 스레드의 상태는 프로그램이 실행되는 과정에서 수도 없이 변경된다. 입력 및 출력 연산을 시작하거나 종료하는 경우에도 변경되고, 새로운 스레드의 생성에 의해서도 변경될 수 있다. 이는 상황에 따라서, 그리고 운영체제의 관리방법에 따른 것으로써 프로그래머가 상태를 직접적으로 컨트롤하는 것은 아니다. 그러나 경우에 따라서는 스레드의 상태를 프로그래머가 임의로 변경시켜야만 하는 경우도 있을 수 있다. 특정 스레드를 지목하면서, 지목한 스레드의 실행을 잠시 멈추기 위해서 Blocked 상태로 만든다거나, 다시 실행을 재개시키기 위해서 Ready 상태로 둔다거나 하는 일이 경우에 따라서는 필요할 수도 있다. 즉 상태를 명시적으로 바꿀 수 있다. SuspendThread를 사용하면 해당 Running 상태의 스레드를 Blocked 상태로 바꿀 수 있다. Blocked 상태의 스레드가 Ready 상태로 다시 돌이키는 함수는 ResumeThread이다.
스레드의 상태 변화
상황 1 & 2
생성하자마자 Ready 상태에 놓이게 된다. 그다음 스케줄러에 의해서 선택될 경우 Running 상태가 되면서 실제 실행이 이뤄진다. 따라서 Ready 상태에 놓이는 스레드는 여럿이 될 수 있지만, Running 상태에 놓이는 스레드는 하나밖에 될 수 없다.
상황 3
실행 중인 스레드에게 할당된 타임 슬라이스가 모두 소비되어서, 다른 스레드에게 실행의 기회를 넘겨야 할 때, Running 상태에서 Ready 상태로의 이동이 이뤄진다. 간혹 Running 상태에서 Blocked 상태로 이동하는 것으로 오해하는 경우가 있는데, 실행의 기회를 넘겼다 하더라도 언제든지 다시 실행되어야 하기 때문에 Ready 상태로 이동하는 것이 맞다.
상황 4 & 5
Running 상태에 있는 스레드가 입/출력 연산을 하거나 , Sleep 함수 호출로 인해서 잠시 실행이 중단된 경우 Blocked 상태로 이동을 하면서 다른 스레드의 실행을 도모하게 된다. Blocked 상태로 이동시킨 원인이 해결되면 다시 Ready 상태로 돌아가서 실행되기 만을 기다린다.
모든 상황은 프로세스와 상태 변화와 100% 동일하다.
Suspend & Resume
스레드의 커널 오브젝트에는 SuspendThread 함수의 호출 빈도수를 기록하기 위한 서스펜드 카운트라 불리는 멤버가 존재하는데, 현재 실행 중인 스레드의 서스펜드 카운트는 0이된다. 그러나 이 스레드의 핸들을 인자로 SuspendThread 함수가 호출이 되면 서스펜드 카운트는 1이 되고 스레드는 Blocked 상태에 놓이게 된다. 그리고 다시 한번 SuspendThread 함수가 호출되면 서스펜드 카운트는 2가 된다. 즉, SuspendThread 함수는 서스펜드 카운트값을 하나 증가 시키는 역할을 한다.
이렇게 서스팬드 카운트가 2인 상황에서는 한 번의 ResumeThread 호출로 바로 Ready 상태가 되지 않는다. ResumeThread 함수의 호출은 서스펜드 카운트를 하나 감소시키는 역할을 한다. 따라서 이러한 상황에서는 두 번 호출되어야 Ready 상태에 놓이게 된다.
#include "stdio.h"
#include "tchar.h"
#include "windows.h"
#include "process.h"
unsigned int WINAPI ThreadProc(LPVOID lpParam) {
while (1) {
_tprintf(_T("Running State ! \\n"));
Sleep(1000);
}
return 0;
}
int _tmain(int argc, TCHAR *argv[]) {
DWORD dwThreadId;
HANDLE hThread;
DWORD susCnt; // suspend count 를 확인하기 위한 변수
hThread = (HANDLE)
_beginthreadex(
NULL, 0,
ThreadProc,
NULL,
CREATE_SUSPENDED,
(unsigned *) &dwThreadId
);
if(hThread == NULL)
_tprintf(_T("Thread creation fault ! \\n"));
susCnt = ResumeThread(hThread);
_tprintf(_T("suspend count : %d \\n"), susCnt);
Sleep(10000);
susCnt = SuspendThread(hThread);
_tprintf(_T("suspend count : %d \\n"),susCnt);
Sleep(10000);
susCnt = SuspendThread(hThread);
_tprintf(_T("suspend count : %d \\n"),susCnt);
Sleep(10000);
susCnt = ResumeThread(hThread);
_tprintf(_T("suspend count : %d \\n"),susCnt);
susCnt = ResumeThread(hThread);
_tprintf(_T("suspend count : %d \\n"),susCnt);
WaitForSingleObject(hThread , INFINITE);
return 0;
}
스레드의 우선순위 컨트롤
프로세스는 실행의 주체가 아닌 스레드를 담는 그릇에 지니지 않는다.
따라서 윈도우에서는 프로세스가 우선순위를 갖는 것이 아니라, 프로세스 안에서 동작하는 스레드가 우선순위를 갖는다.
스레드의 우선순위 결정 요소
스레드의 우선순위는 프로세스의 기준 우선순위와 스레드의 상대적 우선순위의 조합으로 결정된다.
예를 들어 기준 우선순위가 NORMAL_PRIORITY_CLASS(9)인 프로세스 안에 두 개의 스레드가 존재하는데 각각 상대적 우선순위가 -2, 0 이면 각 스레드의 최종 우선순위는 7(9-2), 9(9-0)로 결정된다. 즉 프로세스의 기준 우선순위를 기준으로 해서 상대적 우선순위에 해당하는 값을 더하거나 빼면 스레드의 실질적인 우선순위를 계산해 낼 수 있다.
'운영체제 > 뇌를 자극하는 윈도우즈 시스템 프로그래밍' 카테고리의 다른 글
뇌를 자극하는 윈도우즈 시스템 프로그래밍 14장 (0) | 2024.09.21 |
---|---|
뇌를 자극하는 윈도우즈 시스템 프로그래밍 13장 (1) | 2024.09.21 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 11장 (0) | 2024.09.07 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 10장 (0) | 2024.09.07 |
뇌를 자극하는 윈도우즈 시스템 프로그래밍 9장 (0) | 2024.09.07 |