앞서 4장에서 다룬 TCP 서버-클라이언트 예제는 두 개 이상의 클라이언트가 서버에 접속할 수는 있으나, 서버가 동시에 두 개 이상의 클라이언트를 서비스할 수 없다는 문제가 있습니다. 따라서 이런 다중 처리 문제를 해결하는 방법 중 하나인 멀티스레드를 이 장에서 공부합니다.
스레드 개념을 이해하려면 프로세스가 무엇인지 알아야 합니다. 대부분 운영체제에서의 프로세스는 CPU 시간을 할당받아 실행 중인 프로그램을 일컫습니다. 프로그램이 저장 장치에 실행 파일로 존재하는 정적인 개념인 데 반해, 프로세스는 코드(CPU 명령), 데이터(전역 변수, 정적 변수), 리소스(그림 파일, 사운드 파일 등)를 파일에서 읽어들여 작업을 수행하는 동적인 개념입니다.
윈도우 운영체제에서는 일반적인 의미의 프로세스 개념을 프로세스와 스레드 두 가지로 구분합니다. 프로세스는 코드, 데이터, 리소스를 파일에서 읽어들어 메모리 영역에 담고 있는 일종의 컨테이너로, 정적인 개념입니다. 스레드는 CPU 시간을 할당받아 프로세스 메모리 영역에 있는 코드를 수행하고 데이터를 사용하는 실행 흐름으로, 동적인 개념입니다. 요약하면 "일반 운영체제의 프로세스 = 윈도우 운영체제의 프로세스 + 스레드"입니다.
응용 프로그램이 CPU 시간을 할당받아 실행하려면 한 개 이상의 스레드가 필요하고, 최초로 생성되는 스레드를 주 스레드 또는 메인 스레드라고 부릅니다. 이는 main() 함수 또는 WinMain() 함수에서 실행을 시작합니다.
멀티스레드의 동작원리는 하드웨어(CPU)와 소프트웨어(운영체제)의 협동으로, 스레드 실행 상태의 저장과 복원 작업이 이루어지는 Contect Switch입니다. 이를 통해 각 스레드는 다른 스레드의 존재와 무관하게 상태를 유지하면서 실행할 수 있습니다.
프로세스가 생성되면 main() 함수를 실행 시작점으로 하는 메인 스레드가 자동으로 생성됩니다. 이때 또 따른 함수인 f() 함수를 실행 시작점으로 하는 스레드를 생성하려면 다음과 같은 정보를 운영체제에 제공해야 합니다.
윈도우에서 스레드를 생성할 때는 CreateThread() 함수를 씁니다. 해당 함수는 스레드를 생성한 후, 스레드 핸들을 리턴합니다. 스레드 핸들은 파일 디스크립터나 소켓 디스크립터와 비슷한 개념으로, 운영체제의 스레드 관련 데이터 구조체를 간접적으로 참조하는 역할입니다.
// CreateThread() 함수
#include <windows.h>
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize, // 스레드에 할당되는 스택 크기(바이트 크기)
LPTHREAD_START_ROUTINE lpStartAddress, // 스레드 함수의 시작 주소
LPVOID lpParameter, // 스레드 함수에 전달할 인수
DWORD dwCreationFlags, // 스레드 생성을 제어하는 값으로 0 또는 CREATE_SUSPENDED를 사용 (0은 스레드 생성 후 곧바로 실행, CREATE_SUSPENDED는 스레드 생성 후 ResumeThread() 함수 호출하기 전까지 실행 X)
LPDWORD lpThreadId // DWORD형 변수를 전달하면 스레드 ID 저장 (스레드 ID가 필요 없다면 null)
);
윈도우에서 스레드를 종료하는 방법에는 네 가지가 있습니다.
1. 스레드 함수가 리턴합니다.
2. 스레드 함수 내에서 ExitThread() 함수를 호출합니다.
3. 다른 스레드가 TerminateThread() 함수를 호출하여 스레드를 강제 종료합니다.
4. 메인 스레드가 종료하면 프로세스 내의 다른 모든 스레드가 강제 종료됩니다.
일반적으로 1번 또는 2번으로 스레드를 종료하는 것이 바람직합니다. 3번은 꼭 필요한 경우에만 사용해야 하며, 4번은 정상적인 방법이라기보다는 메인 스레드(main() 함수)의 특성으로 이해하면 됩니다.
// ExitThread() 함수
#include <windows.h>
void ExitThread(
DWORD dwExitCode // 종료 코드
);
// TerminateThread() 함수
#include <windows.h>
void TerminateThread(
HANDLE hThread, // 종료할 스레드를 가리키는 핸들
DWORD dwExitCode // 종료 코드
);
CreateThread()가 리턴하는 핸들은 나중에 스레드를 별도로 조작할 필요가 없다면 빨리 닫는 것이 좋다. CloseHandle() 함수를 호출하여 핸들을 닫는다고 해서 스레드가 종료하는 것은 아니라는 점에 유의한다.
윈도우 운영체제에서는 항상 여러 스레드가 CPU 시간을 사용하기 위해 경쟁합니다. 따라서 각 스레드에 CPU 시간을 적절히 분배하기 위한 정책을 사용하는데, 이를 CPU 스케줄링 또는 스레드 스케줄링이라고 부릅니다.
윈도우 운영체제의 스케줄링 기법은 우선순위에 기반한 것으로, 스레드의 우선순위를 결정하는 요소는 프로세스 우선순위(우선순위 클래스), 스레드 우선순위(우선순위 레벨)이 존재합니다.
우선순위 클래스는 한 프로세스가 생성한 스레드는 우선순위 클래스가 모두 같다는 특징이 있습니다. 윈도우 운영체제가 제공하는 우선순위 클래스는 REALTIME_PRIORITY_CLASS(실시간), HIGH_PRIORITY_CLASS(높음), ABOVE_NORMAL_PRIORITY_CLASS(높은 우선순위; 윈도우 2000 이상), NORMAL_PRIORITY_CLASS(보통), BELOW_NORMAL_PRIORITY_CLASS(낮은 우선순위; 윈도우 2000 이상), IDLE_PRIORITY_CLASS(낮음)이 있습니다.
우선순위 레벨은 같은 프로세스에 속한 스레드 간에 상대적인 우선순위를 결정할 때 사용합니다. 윈도우 운영체제가 제공하는 우선순위 클래스는 THREAD_PRIORITY_TIME_CRITICAL, THREAD_PRIORITY_HIGHEST, THREAD_PRIORITY_ABOVE_NORMAL, THREAD_PRIORITY_NORMAL, THREAD_PRIORITY_BELOW_NORMAL, THREAD_PRIORITY_LOWEST, THREAD_PRIORITY_IDLE이 있습니다.
우선순위 클래스와 우선순위 레벨을 결합하면 스레드의 기본 우선순위가 결정되고, 이 값이 스레드 스케줄링에 사용됩니다. 윈도우에서는 우선순위가 가장 높은 스레드에 CPU 시간을 할당하되, 우선순위가 같은 스레드가 여러 개 있을 때는 CPU 시간을 번갈아 할당합니다.
이런 방식에서는 우선순위가 높은 스레드가 계속 CPU 시간을 요구하면 우선순위가 낮은 스레드는 CPU 시간을 전혀 할당하지 못하는 문제가 생기고, 이를 Starvation(기아)라고 합니다. 윈도우 운영체제는 starvation 문제를 해결하기 위해 오랜 시간 CPU 시간을 할당받지 못한 스레드의 우선순위를 단계적으로 끌어올립니다. 또한, 현재 사용자가 작업하고 있는 프로그램의 반응 속도를 빠르게 하기 위해 우선순위를 동적으로 변경하기도 합니다. 예를 들면, 같은 프로그램이 두 개 실행 중일 때 사용자가 작업하고 있는 포그라운드 프로그램의 스레드가 더 높은 우선순위를 가집니다.
멀티스레드를 이용할 때 작업의 중요도에 따라 응용 프로그램이 직접 우선순위를 변경하기도 합니다. 우선순위 클래스를 변경하는 경우는 흔치 않으며, 대개로 우선순위 레벨을 변경합니다. SetThreadPriority() 함수는 우선순위 레벨을 변경할 때, GetThreadPriority() 함수는 현재의 우선순위 레벨을 얻을 때 이용합니다.
스레드는 일단 생성되면 CPU 시간을 사용하기 위해 다른 스레드와 경쟁하면서 독립적으로 실행된다. 하지만 때로는 한 스레드가 다른 스레드의 종료 여부, 즉 작업 완료 여부를 확인해야 할 때가 있습니다. 이때 WaitForSingleObject() 함수를 사용하면 특정 스레드가 종료될 때까지 기다릴 수 있습니다.
#include <windows.h>
DWORD WaitForSingleObject(
HANDLE hHandle, // 종료를 기다릴 대상 스레드
DWORD dwMilliseconds // 대기 시간으로, 밀리초 단위를 사용.
// 이 시간 안에 스레드가 종료하지 않으면 WAIT_TIMEOUT 값을 리턴하고, 스레드가 종료한 경우에는 WAIT_OBJECT_0을 리턴.
// 대기 시간으로 INFINITE 값을 사용하면 스레드가 종료할 때까지 무한히 대기.
);
WaitForSingleObject() 함수 사용 예는 다음과 같습니다.
HANDLE hThread = CreateThread(...);
DWORD retval = WaitForSingleObject(hThread, 1000);
if (retval == WAIT_OBJECT_0) { ... } // 스레드 종료
else if (retval == WAIT_TIMEOUT) { ... } // 타임아웃(스레드는 아직 종료 안 함)
else { ... } // 에러 발생
여러 스레드가 종료하기를 기다리려면 WaitForSingleObject() 함수를 스레드 개수만큼 호출해야 하는데, 대신 WaitForMultipleObjects() 함수를 사용하면 한 번으로 끝낼 수 있습니다.
#include <windows.h>
DWORD WaitForMultipleObjects(
DWORD nCount, const HANDLE *lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds
);
WaitForMultipleObjects() 함수 사용 예는 다음과 같습니다.
// 모든 스레드의 종료를 기다림
HANDLE hThread[2];
hThread[0] = CreateThread(...);
hThread[1] = CreateThread(...);
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
// 스레드 하나의 종료를 기다림
HANDLE hThread[2];
hThread[0] = CreateThread(...);
hThread[1] = CreateThread(...);
DWORD retval = WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
switch (retval) {
case WAIT_OBJECT_0; // hThread[0] 종료
...
break;
case WAIT_OBJECT_1; // hThread[1] 종료
...
break;
case WAIT_FAILED; // 오류 발생
...
break;
}
스레드 핸들을 보유하고 있으면 SuspendThread() 함수를 호출하여 해당 스레드 실행을 일시정지하거나 ResumeThread() 함수를 호출하여 재시작할 수 있습니다.
#include <windows.h>
DWORD SuspendThread(
HANDLE hHandle // 스레드 핸들
);
#include <windows.h>
#include <windows.h>
DWORD ResumeThread(
HANDLE hHandle // 스레드 핸들
);
윈도우 운영체제는 스레드의 중지 횟수를 관리하는데, 이 값은 SuspendThread() 함수를 호출할 때마다 1씩 증가하고 ResumeThread() 함수를 호출할 때마다 1씩 감소합니다. 따라서 중지 횟수가 0보다 크면 스레드는 실행 중지 상태에 있기 때문에, 한 스레드에 대해 SuspendThread() 함수를 두 번 호출했다면 ResumeThread() 함수를 두 번 호출해야 재시작할 수 있습니다.
또한 Sleep() 함수는 dwMilliseconds로 지정한 시간이 지나면 자동으로 재시작합니다.
Sleep(0)를 호출하면 스레드는 자신에게 할당된 CPU 시간을 포기하고 남은 시간을 우선순위가 같은 다른 스레드에 넘겨줍니다. 이를 이용하면 스레드 간에 컨텍스트 변환을 빠르게 할 수 있습니다.
멀티스레드 TCP의 기본 형태입니다.
DWORD WINAPI ProcessClient(LPVOID arg)
{
SOCKET client_sock = (SOCKET)arg;
addrlen = sizeof(client_sock, (struct sockaddr *)&clientaddr, &addrlen);
while(1) {
...
}
...
}
int main(int argc, char *argv[])
{
...
while (1) {
client_sock = accept(listen_sock, ...);
...
CreateThread(NULL, 0, ProcessClient, (LPVOID)client_sock, 0, NULL);
}
...
}
스레드 함수에 소켓만 전달한 경우에는 별도의 주소 정보가 없으므로, 소켓 자체에서 주소 정보를 얻는 기능이 필요합니다. 이런 경우를 위해 다음 두 소켓 함수가 준비되어 있습니다.
// getpeername: 소켓 데이터 구조체에 저장된 원격 IP 주소와 원격 포트 번호 리턴
#include <windows.h>
int getpeername(
SOCKET sock,
struct sockaddr *addr,
int *addrlen
);
// getsockname: 소켓 데이터 구조체에 저장된 지역 IP 주소와 지역 포트 번호 리턴
#include <windows.h>
int getsockname(
SOCKET sock,
struct sockaddr *addr,
int *addrlen
);
만약 2000을 더하는 스레드 1, 2가 있다고 가정해봅시다. 각 스레드가 번갈아가면서 작업을 하기 때문에 스레드 1이 2000을 더한 후, 스레드 2가 2000을 더하기 전입니다. 본래는 2000이어야 하지만, 확인해보면 합계가 0입니다. 이는 CPU 레지스터 값은 스레드마다 별도로 유지되는 특성 때문에 생기는 일로, 이와 같은 문제를 해결하기 위한 일련의 작업을 스레드 동기화라고 합니다. 윈도우 운영체제는 프로그래머가 상황에 따라 적절한 동기화 방법을 선택할 수 있도록 다양한 API 함수를 제공합니다.
| 종류 | 기능 |
|---|---|
| 임계 영역 (동기화 객체 X) | 공유 자원에 오직 한 스레드의 접근만 허용한다. (한 프로세스에 속한 스레드 간에만 사용 가능) |
| 뮤텍스 | 공유 자원에 오직 한 스레드의 접근만 허용한다. (서로 다른 프로세스에 속한 스레드 간에도 사용 가능) |
| 이벤트 (동기화 객체 O) | 사건 발생을 알려서 대기 중인 스레드를 깨운다. |
| 세마포어 | 한정된 개수의 자원에 여러 스레드가 접근할 때, 자원을 사용할 수 이쓴 스레드 개수를 제한한다. |
| 대기 기능 타이머 | 시간과 관련된 조건이 만족되면 대기 중인 스레드를 깨운다. |
참고로, 임계 영역을 제외한 모든 스레드 동기화 기법은 서로 다른 프로세스에 속한 스레드 간에도 사용할 수 있습니다.
위는 스레드 동기화가 필요한 상황입니다. 스레드를 동기화하려면 스레드가 상호 작용해야 하므로 중간 매개체가 필요합니다. 윈도우 운영체제에서 이런 매개체 역할을 할 수 있는 것을 통틀어 동기화 객체라고 합니다. 동기화 객체의 특징을 요약하면 다음과 같습니다.
스레드 자체도 이 특징을 만족하므로 일종의 동기화 객체라고 할 수 있습니다.
Wait*() 함수는 스레드 동기화를 위한 필수 함수인데, 자주 사용하는 WaitForSingleObject() 함수와 WaitForMultipleObjects() 함수는 앞서 학습하였습니다. 동기화 객체를 학습할 때는 비신호 -> 신호, 신호 -> 비신호 상태 변화 조건을 잘 이해해야 하며, 상황에 맞게 Wait*() 함수를 사용할 수 있도록 연습해야 합니다.
임계 영역은 두 개 이상의 스레드가 공유 자원에 접근할 때, 오직 한 스레드만 접근을 허용해야 하는 경우에 사용합니다. 대표적인 특징으로는, 프로세스의 사용자 메모리 영역에 존재하는 단순한 구조체이므로 한 프로세스에 속한 스레드 간 동기화에만 사용할 수 있다는 점과, 다른 동기화 객체보다 빠르고 효율적이라는 점이 있습니다.
다음은 임계 영역 사용 예입니다.
코드를 입력하세요
하지만 임계 영역 사용 시 주의해야 할 사항이 있습니다. 임계 영역만으로는 어느 스레드가 먼저 리소스를 사용할지 결정할 수 없기 때문에, 어느 스레드가 먼저 리소스를 사용할지, 즉 EnterCriticalSection() 함수를 호출할지 알 수 없다는 점입니다. 따라서 공유 자원을 보호하되 접근 순서가 중요한 상황에는 주로 이벤트를 사용합니다.
이벤트는 사건 발생을 다른 스레드에 알리는 동기화 기법입니다. 이벤트를 사용하는 전형적인 절차는 아래와 같습니다.
1. 이벤트를 비신호 상태로 생성합니다
2. 한 스레드가 작업을 진행하고, 나머지 스레드는 이벤트에 대해 Wait*() 함수를 호출해 이벤트가 신호 상태가 될 때까지 대기합니다.
3. 스레드가 작업을 완료하면 이벤트를 신호 상태로 변경합니다.
4. 대기 중인 스레드 중 한 개 혹은 전부가 깨어나고, 깨어난 스레드는 후속 작업을 합니다.
5. 2~4번을 반복 수행하면서 원하는 작업이 모두 끝났으면, 즉 이벤트가 필요하지 않으면 CloseHandle() 함수를 호출해 이벤트를 제거합니다.
이벤트는 대표적인 동기화 객체로, 신호와 비신호 두 가지 상태를 가집니다. 또한 상태를 변경할 수 있도록 다음과 같은 함수가 제공됩니다.
BOOL SetEvent(HANDLE hEvent); // 비신호 상태 -> 신호 상태
BOOL ResetEvent(HANDLE hEvent); // 신호 상태 -> 비신호 상태
이벤트의 종류에는 다음 두 종류가 있으므로, 용도에 맞게 선택해야 합니다.
이벤트 생성 함수는 다음과 같습니다.
#include <windows.h>
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, // 대부분 기본값인 NULL 사용
BOOL bManualReset, // TRUE면 수동 리셋 이벤트, FALSE면 자동 리셋 이벤트
BOOL bInitialState, // TRUE면 신호 상태, FALSE면 비신호 상태
LPCTSTR lpName // 이벤트에 부여할 이름.
// NULL를 사용하면 이름 없는 이벤트가 생성되므로 한 프로세스에 속한 스레드 간의 동기화에만 사용 가능.
// 같은 이름으로 생성해야만 서로 다른 프로세스에 속한 스레드 간 동기화 가능. 둘 중 먼저 CreateEvent() 함수를 호출한 쪽이 이벤트를 생상하고, 다른 쪽은 이벤트를 열게 됨.
);