VI : 멀티스레드
TCP/IP 윈도우 소켓 프로그래밍 을 읽고 정리한 문서입니다 ;)
스레드 기초
스레드를 사용하지 않는다면, 서버가 동시에 클라이언트 두 개 이상에 서비스할 수 없다. 또한, 서버와 클라이언트의 send()
, recv()
가 맞지 않는다면 교착 상태가 발생할 수 있다.
교착 상태란 영원히 일어나지 않을 사건을 두 프로세스/스레드가 기다리는 상황을 의미한다.
소켓에서 여러 클라이언트에게 동시에 서비스를 줄 수 있게끔 기존 설계를 고치려면 여러 방법으로 접근해 볼 수 있다.
- 서버가 각 클라이언트와 연결해 통신하는 시간을 짧게 줄인다. 즉, 데이터 전송 후 바로 연결을 끊게끔 한다.
- 이러한 경우 쉽게 구현은 가능하지만, 대용량 데이터에 맞지 않고 클라이언트가 많아진다면 처리 지연 시간이 길어질 것이다.
- 서버에 접속한 각 클라이언트를 스레드를 이용해 독립적으로 처리한다.
- 소켓 입출력 모델에 비해 비교적 쉽게 구현할 수 있다.
- 클라이언트 수에 비례해 스레드가 생기므로 서버의 시스템 자원이 많이 요구된다.
- 소켓 입출력 모델을 사용한다.
- 소수의 스레드로 다수의 클라이언트를 처리할 수 있다.
- 구현이 어렵다.
소켓의 문제를 해결하기 위해 멀티스레딩을 사용할 때, 교착 상태에 대한 해결 방법도 여러 가지가 있다.
- 데이터 송수신 부분을 잘 설계하여 교착 상태가 발생하지 않도록 한다.
- 데이터 송수신 패턴에 제약을 심하게 받기에 적용이 쉽지 않다.
- 소켓에 타임아웃 옵션을 적용하여 소켓 함수 호출 시 무조건 일정 시간 후 리턴하게 한다.
- 간단히 구현이 가능하지만 성능이 떨어진다.
- 넌블로킹 소켓을 사용한다.
- 구현이 복잡하고 CPU 자원을 불필요하게 낭비시킬 수 있다.
- 소켓 입출력 모델을 사용한다.
- 넌블로킹 소켓의 단점을 해결할 수 있으나 구현이 어렵다.
프로세스는 코드, 데이터, 리소스를 파일에서 읽어들여 윈도우 운영체제가 할당해놓은 메모리 영역에 담고 있는 일종의 컨테이너로 볼 수 있다.
스레드는 CPU 시간을 할당받아 프로세스 메모리 영역에 있는 코드를 수행하고 데이터를 사용하는 동적인 개념이다.
일반 운영체제의 프로세스는 곧 윈도우에서는 프로세스+스레드의 개념이다.
응용프로그램 실행 시 최초로 생성되는 스레드를 주 스레드 또는 메인 스레드라고 부른다.
스레드 실행 상태의 저장과 복원 작업을 컨텍스트 전환이라고 부른다.
스레드 실행 시작점이 되는 함수를 스레드 함수라고 부르며, 스레드를 생성할 때에는 스택 크기를 지정하면 운영체제가 스택 생성을 알아서 해준다.
스레드의 생성
윈도우에서 스택을 생성할 때에는 CreateThread()
API 함수를 사용한다. 이는 스레드 핸들을 리턴해주고, 이를 이용하여 스레드를 제어하는 다양한 윈도우 API 함수에 인자로 사용할 수 있다.
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
dwStackSize
: 스레드에 할당되는 스택 크기로서,0
을 사용하면 실행 파일의 헤더에 들어 있는 기본 크기인, 1MB가 설정된다.lpStartAddress
: 스레드 함수의 시작 주소이고, 스레드 함수는 반드시 다음과 같은 형태로 정의되어야 한다.DWORD WINAPI ThreadProc(LPVOID lpParameter) { ... }
lpParameter
: 스레드 함수에 전달할 인자이고, 만일 포인터 크기보다 같거나 작은 데이터는 값 또는 주소 형태로, 큰 데이터는 구조체나 배열에 넣고 주소 형태로 전달하면 된다.dwCreationFlags
: 만일0
이 아닌CREATE_SUSPENDED
를 사용하면, 스레드가 생성되지만 바로 실행되는 않고,ResumeThread()
를 호출할 때 실행되게 된다.
윈도우에서는 유저 메모리가 버티는 한 스레드를 자유로이 생성할 수 있고, 64bit 시스템에서는 사실상 제한이 없어졌다 할 수 있지만, 스레드를 많이 만드는 일은 여전히 경계해야 한다.
메모리를 많이 소모할 뿐 아니라, 스레드 간 컨텍스트 스위치를 할 일이 많아져 부하가 심해진다.
스레드의 종료
윈도우에서 스레드를 종료하는 방법에는 다음 네 가지가 있다.
- 스레드 함수가 리턴한다.
- 스레드 함수 안에서
**ExitThread()
함수를 호출**한다. - 다른 스레드가
TerminateThread()
함수를 호출해 강제 종료시킨다. - 메인 스레드가 종료되어 모든 스레드가 종료된다.
일반적으로 첫 번째와 두 번째 방법으로 스레드를 종료하는 것이 바람직하다.
스레드의 제어
스레드 스케줄링에 따라 모든 스레드는 우선순위를 적절히 부여받아 그에 따라 CPU를 사용하게 되는데, 이는 프로세스 우선순위와 스레드 우선순위가 결합되어 계산된다.
우선순위가 높은 스레드가 계속 CPU 시간을 요구하면 우선순위가 낮은 스레드가 CPU를 전혀 사용하지 못하게 되는 기아 현상이 있으므로, 오래 CPU를 사용하지 못할 수록 해당 스레드의 우선순위를 점차 끌어올리게 된다.
이러한 우선순위는 SetThreadPriority()
를 통해 레벨을 변경시킬 수 있다.
한 스레드가 다른 스레드의 종료 여부, 즉 작업 완료 여부를 확인해야 할 때가 생긴다. 이 때는 WaitForSingleObject()
함수를 사용하면 특정 스레드가 종료할 때까지 기다릴 수 있다.
DWORD WaitForSingleObject(
HANDLE hHandle, // 기다릴 대상 스레드
DWORD dwMilliseconds // TIMEOUT
)
이와 비슷한 WaitForMultipleObjects()
함수를 사용하면 여러 개의 스레드를 기다릴 수 있다.
이 둘은 스레드 종료를 기다리는 목적으로 사용한다기 보다는, 스레드 동기화를 위한 범용 함수이다.
스레드 핸들을 보유하고 있으면 SuspendThread()
와 ResumeThread()
함수를 호출해 해당 스레드를 일시 중지시키거나 재시작시킬 수 있다. 단 이는 카운트 기반으로 동작하므로, 중지시킨 횟수 만큼 재시작 함수를 호출해야 동작하게 된다.
혹은 스레드 내부에서 Sleep()
호출을 통해 원하는 시간만큼만 일시중지 시킬 수 있다.
이 Sleep()
은 인자로 0
을 넣음으로서, 여러 스레드가 동시에 유기적으로 작동해야 할 때 컨텍스트 스위칭을 유발시키는 목적으로 사용하기도 한다.
멀티스레드 TCP 서버
멀티스레드를 이용해 소켓을 관리할 때는, 별도의 주소 정보가 없으므로, 소켓을 통해 주소 정보를 얻는 기능이 필요하다. 이를 위해 다음 두 소켓 함수가 존재한다.
int getpeername( // 원격 IP 주소와 포트 가져오기
SOCKET s,
SOCKADDR *name,
int *namelen
);
int getsockname( // 로컬 IP 주소와 포트 가져오기
SOCKET s,
SOCKADDR *name,
int *namelen
)
스레드 동기화
멀티스레드를 이용하는 프로그램에서 스레드 두 개 이상이 공유 데이터에 접근하면 다양한 문제가 발생할 수 있다.
따라서 스레드 동기화라는 일련의 작업이 필요한데, 이는 다음의 기법들로 대표된다.
- 임계 영역 (critical section)
- 공유 자원에 대해 오직 한 스레드의 접근만 허용한다.
- 단 이는 한 프로세스 내부에서만 사용이 가능하다.
- 뮤텍스 (mutex)
- 공유 자원에 대해 오직 한 스레드의 접근만 허용한다.
- 이는 서로 다른 프로세스의 스레드끼리도 적용이 가능하다.
- 이벤트 (event)
- 사건 발생을 알려 대기중이던 스레드를 깨운다.
- 세마포어 (semaphore)
- 한정된 개수의 자원에 여러 스레드가 접근할 때, 접근 가능 스레드 수를 제한한다.
- 대기 가능 타이머 (waitable timer)
- 정해진 시간이 되면 대기 중인 스레드를 깨운다.
스레드 동기화가 필요한 상황은, 둘 이상의 스레드가 공유 자원에 접근하거나, 한 스레드가 작업을 완료하고 기다리던 다른 스레드에 알려주어야 할 때이다.
이럴 때에는 스레드들 사이에 매개체를 두어 진행 가능 여부를 판단하게 되는데, 윈도우에서는 이를 동기화 객체라고 부르며, 이 동기화 객체의 특징은 다음과 같다.
Create*()
함수를 호출하면 커널 메모리 영역에 동기화 객체가 생성되며, 이에 접근하는 핸들이 리턴된다.- 평소에는 비신호 상태에 있다가, 특정 조건이 만족되면 신호 상태가 된다. 이 변화는
Wait*()
함수를 이용하면 감지할 수 있다. - 사용이 끝나면
CloseHandle()
함수를 호출한다.
임계 영역
임계 영역은 둘 이상의 스레드가 공유 자원에 접근할 때 오직 한 스레드만 접근을 허용해야 하는 경우에 사용한다. 이는 대표적인 스레드 동기화 기법이지만, 동기화 객체로 분류하지는 않는다.
이는 일반 동기화 객체와 달리 개별 프로세스의 유저 메모리에 존재하는 단순한 구조체이기에, 단일 프로세스 내부의 스레드끼리 사용이 가능하며 빠르고 효율적이다.
#include <Windows.h>
CRITICAL_SECTION cs;
DWORD WINAPI MyThread1(LPVOID arg) {
EnterCriticalSection(&cs);
...
LeaveCriticalSection(&cs);
}
DWORD WINAPI MyThread2(LPVOID arg) {
EnterCriticalSection(&cs);
...
LeaveCriticalSection(&cs);
}
int main() {
InitializeCriticalSection(&cs);
...
DeleteCriticalSection(&cs);
}
만일 하나의 스레드가 임계 영역을 LeaveCriticalSection()
을 통해 빠져나가면, 대기중이던 스레드 중 하나가 깨어나게 된다.
이벤트
이벤트는 사건 발생을 다른 스레드에 알리는 동기화 기법이다. 작업을 완료 후 이를 기다리고 있는 다른 스레드에 알릴 때 사용할 수 있다.
- 이벤트를 비신호 상태로 생성한다.
- 한 스레드가 작업을 진행하고, 나머지 스레드는 이벤트에 대해
Wait*()
함수를 호출해 이벤트가 신호 상태가 될 때까지 대기한다. - 스레드가 작업을 완료하면 이벤트를 신호 상태로 바꾼다.
- 기다리고 있던 스레드 중 하나 혹은 전부가 깨어난다.
BOOL SetEvent(HANDLE hEvent); // 비신호->신호
BOOL ResetEvent(HANDLE hEvent); // 신호->비신호
이벤트는 특성에 따라, 기다리는 스레드 중 하나만 깨운 후 자동으로 비신호 상태가 되는 자동 리셋 이벤트와, 기다리는 스레드를 모두 깨우고 신호 상태를 유지하는 수동 리셋 이벤트가 존재한다.
HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes,
BOOL bManualReset, // TRUE 라면 수동 리셋 이벤트가 된다.
BOOL bInitialState, // TRUE 라면 신호 상태로 시작한다.
LPCTSTR lpName
);
이는 공유 버퍼에 저장하여 읽기와 쓰기가 분리된 스레드들 간 동기화에 사용할 수 있다. 공유 데이터에 접근하는 순서가 중요하기 때문이다.
스레드 로컬 스토리지
모든 스레드는 코드, 데이터, 힙, 환경 변수를 공유하지만, 스택은 스레드별로 할당된다. 또한 CPU의 레지스터 값도 운영체제가 스레드별로 독립적으로 유지하고 관리한다.
멀티스레드 환경에서 안심하고 호출하기 위해서는 최대한 지역 변수만 사용하도록 해야 한다. 만일 전역 변수나 정적 변수를 함수에서 사용하되, 데이터 공유가 목적이 아니라면, 스레드 로컬 스토리지를 사용한다면 안전하게 구현할 수 있다.
이는 선언 앞에 __declspec(thread)
접두사를 붙임으로서 명시할 수 있다.
요약
- 윈도우 운영체제의 프로세스와 스레드
- 프로세스는 코드, 데이터, 리소스를 파일에서 읽어들여 윈도우 운영체제가 할당해놓은 메모리 영역에 담고 있는 일종의 컨테이너로서 정적인 개념이다.
- 스레드는 CPU 시간을 할당받아 프로세스 메모리 영역에 있는 코드를 수행하고 데이터를 사용하는 동적인 개념이다.
- 스레드의 생성과 종료
- 스레드는
CreateThread()
함수를 사용하면 생성 후 핸들을 리턴받을 수 있다. - 스레드 종료 방법에는 스레드 함수가 리턴,
ExitThread()
호출,TerminateThread()
에 의한 강제종료, 메인스레드 종료가 있다.
- 스레드는
- 스레드 제어
- 스레드의 우선순위는 프로세스의 우선순위와 결합되어 CPU가 판단하는 기준이 되며,
SetThreadPriority()
를 통해 설정할 수 있다. - 다른 스레드의 종료를 기다리기 위해서는
WaitForSingleObject()
혹은WaitForMultipleObjects()
를 이용한다. - 스레드 실행 중지와 재시작은
SuspendThread()
,ResumeThread()
를 사용한다.
- 스레드의 우선순위는 프로세스의 우선순위와 결합되어 CPU가 판단하는 기준이 되며,
- 스레드 동기화
- 임계 영역
- 스레드 둘 이상이 같은 공유 자원에 접근할 때 오직 한 스레드만 접근을 허용한다.
InitializeCriticalSection()
,EnterCriticalSection()
,LeaveCriticalSection()
,DeleteCriticalSection()
을 사용한다.
- 이벤트
- 사건 발생을 이를 기다리던 다른 스레드에 알리는 방식이다.
CreateEvent()
,SetEvent()
,ResetEvent()
,WaitForSingleObject()
,WaitForMultipleObjects()
를 사용한다.
- 임계 영역
'공부한 이야기 > 윈도우 소켓 프로그래밍' 카테고리의 다른 글
VIII : 소켓 옵션 (0) | 2023.04.29 |
---|---|
VII : UDP 서버-클라이언트 (0) | 2023.04.29 |
V : 데이터 전송하기 (0) | 2023.04.29 |
IV : TCP 서버-클라이언트 (0) | 2023.04.29 |
III : 소켓 주소 구조체 다루기 (0) | 2023.04.29 |