출처: TCP/IP 윈도우 소켓 프로그래밍(https://product.kyobobook.co.kr/detail/S000001636201)
1. 소켓 응용 프로그램과 멀티 스레드
지금까지 작성한 TCP 서버 클라이언트 프로그램은 싱글 스레드 기반으로 작동했기 때문에 다음과 같은 문제를 가지고 있다.
- 클라이언트 두 개 이상이 서버에 접속은 가능하다. 그러나 서버가 동시에 클라이언트 두 개 이상에 서비스할 수 없다.
- 서버와 클라이언트의
send
,recv
함수의 호출 순서가 서로 맞아야 한다. 데이터를 보내지 않은 상태에서 양쪽에서 동시에recv
함수를 호출하면 교착 상태가 발생할 수 있다.
첫 번째 문제에 대한 해결책과 각각의 장단점은 다음과 같다.
- 서버가 각 클라이언트와 연결해 통신하는 시간을 짧게 줄인다. 즉, 클라이언트는 데이터 전송을 위해 서버에 접속하고 데이터를 보내고 난 뒤 바로 접속을 해제하는 방식과 유사하다.
- 특별한 기법을 도입하지 않고도 쉽게 구현할 수 있으며, 서버의 자원을 적게 사용한다.
- 파일 전송 프로그램과 같이 대용량 데이터를 전송하는 응용 프로그램 구현에는 적합하지 않다. 클라이언트 수가 많을 수록 처리 지연 시간이 길어질 확률이 높다.
- 서버에 접속한 각 클라이언트를 스레드를 이용해 독립적으로 처리한다.
- 소켓 입출력 모델에 비해 비교적 쉽게 구현할 수 있다.
- 접속한 클라이언트 수에 비례해 스레드를 생성하므로 서버의 자원을 많이 사용한다.
- 소켓 입출력 모델을 사용한다.
- 소수의 스레드를 이용해 다수의 클라이언트를 처리할 수 있다. 멀티 스레드에 비해 서버의 자원을 덜 사용한다.
- 구현이 어렵고 복잡하다.
두 번째 문제에 대한 해결책과 각각의 장단점은 다음과 같다.
- 데이터 송수신 부분을 잘 설계해 교착 상태가 발생하지 않게 한다.
- 특별한 기법을 도입하지 않고도 구현이 가능하다.
- 설계에는 결함이 따를 수 밖에 없다. 데이터 송수신 패턴에 따라 교착 상태가 발생할 수 있다.
- 소켓에 타임아웃 옵션을 적용해, 소켓 함수 호출 시 작업이 완료되지 않아도 일정 시간 후에 리턴한다.
- 구현이 비교적 간단하다.
- 성능이 떨어진다.
- 넌블로킹 소켓을 사용한다.
- 교착 상태를 막을 수 있다.
- 구현이 복잡하며, 시스템 자원을 불필요하게 낭비할 가능성이 크다.
- 소켓 입출력 모델을 사용한다.
- 넌블로킹 소켓의 단점을 보완함과 더불어 교착 상태를 막을 수 있다.
- 구현이 복잡하지만, 넌블로킹 소켓에 비해 쉽고 일관성 있게 구현할 수 있다.
여기에서는 첫 번째 문제를 해결할 수 있는 방법 중 하나인 멀티 스레드에 대해 알아본다. 멀티 스레드를 이용하면 서버에 접속한 각 클라이언트를 독립적으로 처리할 수 있다.
2. 스레드 기본 개념
윈도우 운영체제를 제외한 대부분의 운영체제에서 프로세스는 CPU 시간을 할당받아 실행 중인 프로그램을 일컫는다.
윈도우 운영체제에서는 일반적인 의미의 프로세스 개념을 프로세스와 스레드 두 개로 구분한다. 프로세스는 코드, 데이터, 리소스를 파일에서 읽어들여 윈도우 운영체제가 할당해놓은 메모리 영역에 보관하는 일종의 컨테이너로 정적인 개념을 가진다. 스레드는 CPU 시간을 할당받아 프로세스 메모리 영역에 있는 코드를 수행하고 데이터를 사용하는 동적인 개념이다.
윈도우 응용 프로그램은 CPU 시간을 할당받아 실행하려면 최소 하나 이상의 스레드가 필요하다. 응용 프로그램 실행 시 최초로 생성되는 스레드를 메인 스레드라 부르는데, WinMain
또는 main
함수에서 실행을 시작한다. 메인 스레드와 별도로 동시에 수행하고자 하는 작업이 있다면, 스레드를 추가로 생성해야 한다. 이를 멀티 스레드 응용 프로그램이라 한다.
CPU 하나가 여러 스레드를 동시에 실행할 수는 없지만 교대로 실행하는 일은 가능하다. 교대로 실행하는 간격이 짧다면 사용자는 두 스레드가 동시에 실행되는 것처럼 느낀다. 이를 위해선 각 스레드의 최종 실행 상태를 저장하고 나중에 복원하는 작업을 반복해야 한다. 스레드의 실행 상태란 CPU와 메모리 상태를 말하며, CPU 레지스터 값과 메모리의 스택을 의미한다. 하드웨어(CPU)와 운영체제의 협동으로 이루어지는 스레드 실행 상태의 저장과 복원 작업을 컨텍스트 스위칭이라 한다.
윈도우 응용 프로그램은 특성에 따라 콘솔, GUI 응용 프로그램으로 나눌 수 있다. 두 종류 모두 멀티 스레드를 이용할 수 있다. GUI 응용 프로그램은 메시지 구동 구조를 가지고 있어 반드시 멀티 스레드를 이용해야 하는 경우가 있다.
3. 스레드 생성과 종료
스레드의 개념을 실제 응용 프로그램에 활용하려면 윈도우 운영체제가 제공하는 함수가 필요하다.(Windows API)
다음과 같은 코드가 있다고 가정해보자.
f() {}
main() {}
프로세스가 생성되면 main
함수를 실행 시작점으로 하는 메인 스레드가 자동으로 생성된다. 이때 또 다른 함수인 f
를 실행 시작점으로 하는 스레드를 생성하려면 다음과 같은 정보를 운영체제에 제공해야 한다.
f
함수의 시작 주소: 운영체제는f
함수의 시작 주소를 알아야 한다. C, C++에서는 함수 이름이 곧 그 함수의 시작 주소를 의미한다.f
와 같이 스레드 실행 시작점이 되는 함수를 스레드 함수라 부른다.f
함수 실행 시 사용할 스택의 크기: C, C++의 모든 함수는 실행 중 인자 전달과 변수 할당을 위해 스택이 필요하다. 만약f
함수를 실행 시작점으로 하는 스레드 두 개를 생성하고자 한다면, 서로 다른 메모리 위치에 스택 두 개를 할당해야 한다. 스레드 실행에 필요한 스택 생성은 운영체제가 자동으로 해주므로 응용 프로그램은 스택 크기만 알려주면 된다.
윈도우에서는 CreateThread
API를 사용하면 된다. 이 함수는 스레드를 생성한 후 스레드 핸들을 리턴한다. 스레드 핸들은 운영체제의 스레드 관련 데이터 구조체를 간접적으로 참조하는 매개체 역할을 한다. 응용 프로그램은 스레드 핸들을 윈도우 API 함수에 전달해 다양한 방식으로 스레드를 제어할 수 있다.
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
// 실패 -> NULL 리턴
lpThreadAttributes
:LPSECURITY_ATTRIBUTES
구조체를 통해 핸들 상속과 보안 디스크립터 정보를 전달한다. NULL을 사용해도 스레드 생성과 활용에는 문제가 없어 이 책에서는 NULL을 사용한다.dwStackSize
: 스레드에 할당되는 스택 크기다. 0을 사용하면 실행 파일의 헤더에 들어 있는 기본 크기를 사용하는데, 비주얼 C++은 기본 크기가 1MB다.lpStartAddress
: 스레드 함수의 시작 주소다. 스레드 함수는 다음과 같은 형태로 정의해야 한다.
DWORD WINAPI ThreadProc(LPVOID lpParameter) {};
lpParameter
: 스레드 함수에 전달할 인자다.void
형 포인터므로 포인터 크기보다 같거나 작은 데이터는 값 또는 주소 형태로 전달하면 된다. 포인터 크기보다 큰 데이터는 값을 구조체나 배열에 넣고 주소 형태로 전달하면 된다. 없으면 NULL을 사용하면 된다.int value
를 사용한다면(LPVOID)value
로 값을 직접 전달해도 된다.int value
를 사용한다면&value
로 주소를 전달해도 된다.struct MyData data
와 같이 포인터보다 크다면&data
로 주소를 전달해도 된다.
dwCreationFlags
: 스레드 생성을 제어하는 값으로 0 또는CREATE_SUSPENDED
를 사용한다. 0을 사용하면 스레드는 생성 후 바로 실행되고,CREATE_SUSPENDED
를 사용하면 생성은 되지만ResumeThread
함수를 호출하기 전까지 실행되지 않는다.lpThreadId
: DWORD형 변수를 전달하면 여기에 스레드 아이디가 저장된다. 스레드 아이디가 필요없으면 NULL을 사용해도 된다.(윈도우 NT 계열에서만)
윈도우에서 스레드를 종료하는 방법은 다음 네 가지가 있다.
- 스레드 함수가 리턴한다.
- 스레드 함수 안에서
ExitThread
함수를 호출한다. - 다른 스레드가
TerminateThread
함수를 호출해 스레드를 강제 종료시킨다. - 메인 스레드가 종료하면 모든 스레드가 종료된다.
일반적으로 1, 2번 방법을 사용해 스레드를 종료하는 것이 바람직하다. 3번 방법은 꼭 필요한 경우에만 사용해야 하며, 4번 방법은 메인 스레드의 특성이다.
void ExitThread(
DWORD dwExitCode // 종료 코드
);
BOOL TerminateThread(
HANDLE hThread, // 종료할 스레드를 가리키는 핸들
DWORD dwExitCode // 종료 코드
);
스레드를 생성하고 종료하는 코드는 다음과 같다. 편의상 f
함수에 전달할 인자는 없고, 윈도우 NT 계열에서 실행한다고 가정한다.
DWORD WINAPI f(LPVOID arg) {
return 0;
}
int main() {
HANDLE hThread1 = CreateThread(NULL, 0, f, NULL, 0, NULL);
if(hThread1 == NULL) // 오류 처리
HANDLE hTread2 = CreateThread(NULL, 0, f, NULL, 0, NULL);
if(hThread2 == NULL) // 오류 처리
}
4. 스레드 제어
스레드는 윈도우 운영체제의 실행 단위이므로, 우선순위를 변경하거나 실행을 중지하고 재시작하는 등의 제어 기능을 윈도우 API 수준에서 지원한다.
4.1 스레드 우선순위 변경하기
윈도우 운영체제에서는 항상 여러 스레드가 CPU 시간을 사용하려고 경쟁한다. 따라서 각 스레드에 CPU 시간을 적절히 분배하기 위한 정책을 사용하는데, 이를 스레드 스케줄링이라 부른다.
윈도우 운영체제의 스케줄링은 우선순위에 기반한 것으로, 우선순위가 높은 스레드에 우선적으로 CPU 시간을 할당한다. 스레드의 우선순위를 결정하는 요소는 다음과 같다.
- 프로세스 우선순위: 우선순위 클래스라 부른다.
- 스레드 우선순위: 우선순위 레벨이라 부른다.
우선순위 클래스는 프로세스 속성으로, 한 프로세스가 생성한 스레드는 우선순위 클래스가 모두 같다는 특징이 있다. 윈도우 운영체제에서 제공하는 우선순위 클래스는 다음과 같다.
- REALTIME_PRIORITY_CLASS(실시간)
- HIGH_PRIORITY_CLASS(높음)
- ABOVE_NORMAL_PRIORITY_CLASS(높은 우선순위, 윈도우 2000 이상)
- NORMAL_PRIORITY_CLASS(보통)
- BELOW_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를 사용할 수 있게 한다. 또한 현재 사용자가 사용중인 프로그램의 반응 속도를 빠르게 하기 위해 우선순위를 동적으로 변경하기도 한다.
멀티 스레드를 이용할 때 작업의 중요도에 따라 응용 프로그램이 우선순위를 직접 변경하기도 한다. 이때 우선순위 클래스를 변경하는 경우는 흔치 않으며, 대개는 우선순위 레벨을 변경한다.
// 우선순위 레벨 변경
BOOL SetThreadPriority(
HANDLE hThread, // 스레드 핸들
int nPriority // 우선순위 레벨
);
// 우선순위 레벨
int GetThreadPriority(
HANDLE hThread // 스레드 핸들
);
4.2 스레드 종료 기다리기
경우에 따라 한 스레드가 다른 스레드의 종료 여부를 확인해야 할 때가 있다.
DWORD WiatForSingleObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
// 성공: WAIT_OBJECT_O, WAIT_TIMEOUT
// 실패: WAIT_FAILED
hHandle
: 종료를 기다릴 대상 스레드를 나타낸다.dwMilliseconds
: 대기 시간으로, 밀리초 단위를 사용한다. 이 시간 안에 대상 스레드가 종료하지 않으면WaitForSingleObject
함수는 리턴하고WAIT_TIMEOUT
값을 가진다. 스레드가 종료하면WAIT_OBJECT_O
를 리턴한다. 대기 시간으로INFINITE
를 사용하면 스레드가 종룔할 때까지 무한히 기다린다.
여러 스레드가 종료하기를 기다리려면 WaitForMultipleObjects
함수를 사용하자.
DWORD WaitForMultipleObjects(
DWORD nCount,
const HANDLE* lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds,
);
// 성공: WAIT_OBJECT_O~nCount-1, WAIT_TIMEOUT
// 실패: WAIT_FAILED
nCount
,lpHandles
: 여러 개의 스레드 핸들을 전달하기 때문에 배열의 크기와 배열의 시작 주소를 전달해야 한다.nCount
의 최대값은MAXIMUM_WAIT_OBJECTS(64)
로 정의되어 있다.bWaitAll
: TRUE면 모든 스레드가 종료할 때까지 기다린다. FALSE면 한 스레드라도 종료하면 즉시 리턴한다.dwMilliseconds
: 대기 시간이다.
4.3 스레드 실행 중지와 재시작
스레드 핸들을 보유하고 있으면 SuspendThread
를 호출해 해당 스레드 실행을 일시 중지하거나 ResumeThread
함수를 호출해 재시작할 수 있다. 윈도우 운영체제는 스레드의 중지 횟수를 관리하는데, 이 값은 SuspendThread
를 호출할 때마다 1씩 증가하고 ResumeThread
를 호출할 때마다 1씩 감소한다. 중지 횟수가 0보다 크면 스레드는 실행 중지 상태에 있게 된다.
DWORD SuspendThread(
HANDLE hThread // 스레드 핸들
);
// 성공: 중지 횟수
// 실패: -1
DWORD ResumeThread(
HANDLE hThread // 스레드 핸들
);
// 성공: 중지 횟수
// 실패: -1
// dwMilliseconds 이후 자동 재시작
// Sleep(0)을 호출하면 CPU 시간을 포기하고 우선순위가 같은 스레드로 넘김
void Sleep(
DWORD dwMilliseconds;
)
'네트워크 > 윈도우 소켓 프로그래밍' 카테고리의 다른 글
스레드 동기화 - 임계 영역, 이벤트 (0) | 2024.04.08 |
---|---|
멀티스레드 TCP 서버 (0) | 2024.04.08 |
응용 프로그램 데이터 전송 (0) | 2024.04.08 |
TCP 데이터 전송 함수 (0) | 2024.04.05 |
TCP 클라이언트 함수 (0) | 2024.04.05 |