01 . 쓰레드 풀에 대한 이해
쓰레드의 생성과 소멸은 시스템에 많은 부담을 준다. 쓰레드가 한번 일을 수행하고 삭제된다면 매우 비효율 적임.
쓰레드 풀을 유지하는것이 성능 향상에 도움이 된다. 쓰레드 풀의 기본 워니는 쓰레드의 재활용이다. 할당된 일을 마친 쓰레드를 소멸시키지 않고, 쓰레드 풀에 저장해 뒀다가 필요할 때 다시 꺼내 쓰는 개념이다. 즉 쓰레드의 생성과 소멸에 필요한 비용을 지불하지 않겠다는 것이다. 다음 그림은 시스템 관점에서의 쓰레드 풀 모델을 설명한다.
쓰레드 풀은 처리해야 할 일이 등록되기 전에 생성되는데, 풀이 생성됨과 동시에 쓰레드들도 생성되어 풀에서 대기하게 된다. 지능적인 풀은 처리해야 할 일의 증가 및 감소에 따라서 풀안의 쓰레드 개수를 늘리기도 하고, 줄이기도 한다.
쓰레드 풀이 생성된 상태에서 처리해야 할 일이 하나 등록되었다고 가정해 보자. 그렇다면, 쓰레드 풀에 존재하는 쓰레드 하나를 임의로 할당해서 일의 처리를 도모한다. 만약에 풀에 존재하는 쓰레드 수보다 처리해야 할 일의 수가 많다면, 일이 순서대로 처리되도록 디자인할 수도 있고, 빠른 일 처리를 위해 추가적인 쓰레드가 생성되도록 풀을 디자인할 수도 있다.
02 . 쓰레드 풀의 구현
쓰레드 풀 자료구조
WORK는 다음과 같은 타입의 함수를 가리키는 포인터로 선언되어 있다.
void function(void);
이것이 쓰레드에게 일을 시키기 위한 작업의 기본 단위이다. 즉 쓰레드에게 일을 시키기 위해서는 일에 해당하는 하나의 함수를 정의해야 하는데, 이함수의 반환형과 매개변수 타입이 모두 void이어야 한다는 뜻이다.
이번에는 구조체 WorkerThread를 보자. 이는 생성되는 쓰레드의 정보를 담기위한 구조체이다.
이제 가장 중요한 구조체 _ThreadPool를 보자. typedef 선언 없이, 자료형 선언과 동시에 전역변수 형태로 gThreadPool을 선언하고 있다. 이것이 바로 쓰레드 풀에 해당한다. 정확히 말해서 쓰레드 풀을 표현하는 자료구조이다.
일에 관련한 멤버들
WORK workList[WORK_MAX];
DWORD idxOfCurrentWork; // 대기 1순위 Work Index
DWORD idxOfLastAddedWork; // 마지막 추가 Work Index + 1
workList는 Work(일에 해당하는 함수를 Work으로 표현하겠다)을 등록하는 저장소이다. idxOfLastAddedWork는 마지막에 추가된 Work index보다 1많은 값을 유지하면서 새로운 Work가 등록될때 등록 위치를 가르쳐 준다. 반면에 idxOfCurrentWork은 처리되어야 할 Work의 위치를 가리킨다.
풀에 저장된 쓰레드와 관련된 멤버들
WorkerThread workerThreadList[THREAD_MAX];
HANDLE workerEventList[THREAD_MAX];
DWORD threadIdx;
풀에 저장된 쓰레드 정보는 workerThreadList에 저장한다. 그리고 workerEventList는 각 쓰레드 별로 하나씩 할당되는 이벤트 동기화 오브젝트를 저장하고 있는 배열이다. workerThreadList[3]에 등록된 쓰레드는 workerEventList[3]에 저장된 이벤트 동기화 오브젝트와 쌍을 이룬다.이 동기화 오브젝트의 용도를 이해하는 것이 참으로 중요하다. 마지막으로 threadIdx는 저장된 쓰레드의 개수 정보를 담는다. 따라서 이를 쓰레드 정보가 저장되어야 할 인덱스 정보로 활용할 수 있다.
workerEventList가 필요한 이유를 자세히 살펴보자 쓰레드에게 일이 부여된다는 것은 쓰레드가 호출해서 실행할 함수를 지정해 준다는 뜻이다. 만약에 쓰레드에게 할당된 일이 없다면, 쓰레드는 WaitFor~ 관련 함수 호출을 통해서 Blocked 상태가 되어야 한다. 그리고 새로운 일이 들어 왔을때 잠에서 깨어나서 일을 실행하러 달려들어야 한다.이러한 컨트롤을 위해서 쓰레드 하나당 하나씩의 이벤트 오브젝트가 필요한데, 이를 workerEventList에 저장해 둔 것이다.
쓰레드 풀의 함수 관계
WORK GetWorkFromPool(void) - 쓰레드 풀에서 Work을 가져올 때 호출하는 함수이다.
DWORD AddWorkToPool(WORK work) - 새로운 Work를 등록할 때 호출하는 함수이다.
DWORO MakeThreadToPool(DWORD numOfThread) - 쓰레드 풀이 생성된 이후에 풀에 쓰레드를 생성(등록)하는 함수이다. 인자로 전달되는 수 만큼 쓰레드가 생성된다.
void WorkerThreadFunction(LPVOID pParam) - 쓰레드가 생성되자마자 호출하는 쓰레드의 main함수이다. 이 함수의 구성을 봐야만 어떻게 Work를 할다앋아서 처리하는지, 그리고 Work이 없을 때의 쓰레드 상태들을 알 수 있다.
[단계1]
전역으로 선언된 쓰레드 풀에 MakeThreadToPool 함수의 호출을 통해서 쓰레드를 생성해 등록시킨다. 이렇게 생성된 쓰레드는 이벤트 오브젝트가 Signaled 상태가 되기를 기다리며 Blocked 상태가 된다.
[단계2]
AddWorktoPool 함수 호출을 통해서 workList에 Work를 등록한다.
[단계3]
Work가 등록되면, 쓰레드 풀에서 Blocked상태에 있는 모든 이벤트 오브젝트를 Signaled 상태로 변경한다.
[단계 4]
모든 이벤트 오브젝트가 Signaled 상태가 되므로, 모든 쓰레드가 Running 상태가 된다. 그러나 Work를 할당받은 하나의 쓰레드를 제외하고 나머지는 다시 Blocked 상태가 된다.
[단계 5]
Running 상태로 남아 있게 될 하나의 쓰레드는 GetWorkFromPool 함수 호출을 통해서 Work를 할당받아서 실행하게 된다.
쓰레드 풀에 해당하는 gThreadPool은 전역으로 선언되어 있고, 둘 이상의 쓰레드에 의해서 참조되는 메모리 영역이다. 따라서 gThreadPool의 접근에 동기화가 필요하다. 이를 위해 다음 함수들을 정의하고 있다. 뮤텍스 기반 동기화 함수들을 래핑한 것이다.
void InitMutex(void);
void DeInitMutex(void);
void AcquireMutex(void);
void ReleaseMutex(void);
쓰레드 풀링은 한번 생성한 쓰레드를 재활용해서 시스템의 부담을 덜어주기 위한 기법이다. 이는 쓰레드의 생성과 소멸에 소모되는 리소스가 상당하기 때문에 등장한 것이다. 기본적인 개념은 다음과 같다.일단 여러 개의 쓰레드를 생성한다. 그리고 실행해야 할 일이 등록될 때마다 미리 생성해 놓은 쓰레드 중 하나를 할당한다. 그리고 일이 끝나면 쓰레드는 소멸시키지 않고 다음 일을 위해서 보관한다.
쓰레드 풀링의 동작 원리를 프로그램 코드상에서 공부하는 것은 다양한 의미가 있다. 필요한 상황에 최적의 쓰레드 풀을 제공할 수도 있고, 쓰레드 풀의 특성을 정확히 알아서 풀의 도입여부를 결정하는 데도 도움이 된다.