https://github.com/LEEBONGHAK/TCP-IP_window_socket/tree/main/Chapter04/IPv4_server_client
위 링크에 존재하는 TCP 서버-클라이언트 예제는 다음과 같은 문제가 있다.
클라이언트 2개 이상이 서버에 접속할 수 는 있으나 서버가 동시에 접속할 수 있지만, 서버가 동시에 클라이어트 2개 이상에 서비스 할 수 없다.
해결책과 장단점은 다음과 같다.
서버와 클라이언트의 send()
, recv()
함수의 호출 순서가 서로 맞아야 한다. 데이터를 보내지 않은 상태에서 양쪽에서 동시에 recv()
함수를 호출하면 교착 상태가 발생할 수 있다. 여기서 교착상태(deadlock)란 영원히 일어나지 않을 사건을 두 프로세스가 기다리는 상황을 말한다.
해결책과 장단점은 다음과 같다.
여기에서는 1번 문제점을 해결할 수 있는 방법 중 하나인 멀티스레드를 다룰 것이다.
멀티스레드를 이용하면 서버에 접속한 각 클라이언트를 독립적으로 처리할 수 있다.
스레드 개념을 알아보기 전에 프로세스 개념을 정리해보자.
윈도우 운영체제를 제외한 대부분의 운영체제
윈도우 운영체제 (프로세스 개념을 프로세스와 스레드 두개로 구분함)
즉, 일반 운영체제의 프로세스 = 윈도우 운영체제의 프로세스 + 스레드
윈도우 응용 프로그램이 CPU 시간을 할당받아 실행하려면 스레드가 최소 하나 이상 필요하다.
응용 프로그램 실행 시 최초로 샐성되는 스레드를 주 스레드(primary thread) 또는 메인 스레드(main thread)라고 부르는데, WinMain()
또는 main()
함수에서 실행을 시작한다.
만약 응용 프로그램에서 주 스레드와 별도로 동시에 수행하고자 하는 작업이 있다면, 스레드를 추가로 생성해 이 스레드가 해당 작업을 수행하게 하면 된다. 이를 멀티스레드 응용 프로그램(multithreaded application)이라고 한다.
CPU가 하나고 스레드는 여러개라고 가정하고 동작원리를 살펴보자.
CPU 하나가 스레드 2개를 동시에 실행할 수는 없지만 교대로 실행하는 일은 가능하다. 교대로 실행하는 간격이 충분히 짧다면 사용자는 두 스레드가 동시에 실행되는 것처럼 느낀다. 이렇게 하려면 각 스레드의 최종 실행 상태를 저장하고 나중에 복원하는 작업을 반복해야 한다. 스레드의 실행 상태란 CPU와 메모리 상태를 말하며, 구체적으로는 CPU 레지스터 값과 메모리의 스택을 의미한다.하드웨어(CPU)와 소프트웨어(운영체제)의 협동으로 이루어지는 스레드 실행 상태의 저장과 복원 작업을 컨텍스트 전환(context switch)라고 말하며 이로 인해 각 스레드는 다른 스레드의 존재와 무관하게 상태를 유지하며 실행 할 수 있다.
(6-3 그림)
위 그림은 프로세스 하나가 스레드 2개를 사용하는 원리를 보여준다.
윈도우 응용 프로그램은 특성에 따라 크게 콘솔(console) 응용 프로그램과 GUI(Graphical User Interface) 응용 프로그램으로 나눌 수 있다. 두 종류 모두 멀티스레드를 이용할 수 있고 잘 활용하면 아주 유용하다. 특히 GUI 응용 프로그램은 메세지 구동 구조(message drived architecture)라는 특징 때문에 반드시 멀티 스레드를 이용해야하는 경우가 생긴다.
스레드 개념을 실제 응용 프로그램에 활용하려면 윈도우 운영체제가 제공하는 함수, 즉 윈도우 API를 사용해야 한다. 스레드 생성 API 함수를 소개하기 전에 스레드 생성에 필요한 요소를 살펴보자.
(6-6 그림)
위 그림은 두 함수로 구성된 응용 프로그램(프로세스)의 주소 공간을 나타낸 것이다. 프로세스가 생성되면 main()
함수를 실행 시작점으로 하는 주 스레드가 자동으로 생성된다. 이때 또 다른 함수인 f()
를 실행 시작점으로 하는 스레드를 생성하려면 다음과 같은 정보를 운영체제에 제공해야 한다.
f()
함수의 시작 주소f()
함수의 시작 주소를 알아야 한다. C/C++ 에서는 함수 이름이 곧 그 함수의 시작 주소를 의미한다. f()
함수와 같이 스레드 실행 시작점이 되는 함수를 스레드 함수(thread function)이라고 부른다.f()
함수 실행 시 사용할 스택의 크기f()
함수를 실행 시작점으로 하는 스레드 2개를 생성하고자 한다면, 서로 다른 메모리 위치에 스택 2개를 할당해야 한다. 스레드 실행에 필요한 스택 생성은 운영체제가 자동으로 해주기 때문에 응용 프로그램은 스택 크기만 알려주면 된다.CreateThread()
API 함수는 윈도우에서 스레드를 생성할 때 사용하며, 스레드를 생성한 후 스레드 핸들(thread handle)을 리턴한다.
스레드 핸들은 파일 디스크립터나 소켓 디스크립터와 비슷한 개념으로, 운영체제의 스레드 관련 데이터 구조체를 간접적으로 참조하는 매개체 역할을 한다. 응용 프로그램은 스레드 핸들을 윈도우 API 함수에 전달함으로써 다양한 방식으로 스레드를 제어할 수 있다.
HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
SIZE_T dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId
);
SECURITY_ATTRIBUTES
구조체를 통해 핸들 상속(handle inheritance)과 보안 디스크립터(security descriptor) 정보를 전달한다. NULL 값을 사용해도 스레드를 생성하고 활용하는데 무방하다.DWORD WINAPI ThreadProc(LPVOID lpParameter)
{
...
}
CREATE_SUSPENDED
를 사용한다. 0을 사용하면 스레드는 생성 후 곧바로 실해되고, CREATE_SUSPENDED
를 사용하면 스레드가 생성은 되지만 ResumeThread()
함수를 호출하기 전까지 실행되지 않는다.DWORD
형 변수를 전달하면 여기에 스레드 ID가 저장된다. 스레드 ID가 필요 없으면 윈도우 NT 계열에서는 NULL 값을 사용해도 된다.윈도우에서 스레드를 종료하는 방법에는 아래 4가지가 있다.
1. 스레드 함수가 리턴한다.
2. 스레드 함수 안에서 ExitThread()
함수를 호출한다.
3. 다른 스레드가 TerminateThread()
함수를 호출해 스레드를 강제 종료 시킨다.
4. 주 스레드가 종료하면 모든 스레드가 종료된다.
일반적으로 1, 2번 방법으로 스레드를 종료하는 것이 바람직하다. 3번은 꼭 필요한 경우에만 사용해야 하며, 4번의 경우는 정상적인 방법이라기보다 주 스레드(즉, 메인 함수)의 특성으로 이해하면 된다.
[스레드 함수 호출 규약]
스레드 함수에 사용하는WINAPI
키워드는 호출 규약을 의미한다. 호출 규약(calling convertion)이란 컴파일러가 코드를 생성하는 방법을 나타내는 것으로, 함수를 호출하는 쪽과 호출되는 함수쪽에서 일관성 있게 따라야 하는 약속이다.
WinDef.h
헤더 파일을 찾아보면 다음과 같은 정의문을 볼 수 있다.#define CALLBACK __stdcall #define WINAPI __stdcall #define APIENTRY WINAPI #define APIPRIVATE __stdcall #define PASCAL __stdcall
stdcall은 대부분 윈도우 API에서 사용하는 호출 규약이다.
윈도우 API 함수는 응용 프로그램이 호출하는 반면, 스레드 함수는 운영체제가 호출한다. 운영체제가 stdcall 호출 규약에 따라 스레드 함수를 호출하므로 응용 프로그램의 스레드 함수는 같은 호출 규약을 따라야 한다. 이러한 약속을 강제하는 것이 스레드 함수에 붙이는WINAPI
키워드며, 이 키워드를 제거하면 컴파일러는 오류를 발생시킨다.
void ExitThread(
DWORD dwExitCode // 종료 코드
);
// 성공: 0이 아닌 값, 실패: 0
BOOL TerminateThread(
HANDLE hThread, // 종료할 스레드를 가리키는 핸들
DWORD dwExitCode // 종료 코드
);
위에 내용을 토대로 스레드 생성과 종료를 보여주는 간단한 코드를 살펴보면 아래와 같다.
해당 코드는 스래드 함수 하나를 실행하는 스레드를 2개 생성하되, 스레드 함수 인자를 통해 값을 전달하는 응용 프로그램이다.
001 #include <windows.h>
002 #include <stdio.h>
003
004 struct Point3D
005 {
006 int x, y, z;
007 };
008
009 DWORD WINAPI MyThread(LPVOID arg)
010 {
011 Point3D *pt = (Point3D *) arg;
012 while (1)
013 {
014 printf("Running MyThread() %d : %d, %d, %d\n", \
015 GetCurrentThreadId(), pt->x, pt->y, pt->z);
016 Sleep(1000);
017 }
018 return 0;
019 }
020
021 int main(int argc, char **argv)
022 {
023 // 첫 번째 스레드 생성
024 Point3D pt1 = {10, 20, 30};
025 HANDLE hThread1 = CreateThread(NULL, 0, MyThread, &pt1, 0, NULL);
026 if (hThread == NULL)
027 return 1;
028 CloseHandle(hThread1);
029
030 // 두 번째 스레드 생성
031 Point3D pt2 = {40, 50, 60};
032 HANDLE hThread2 = CreateThread(NULL, 0, MyThread, &pt2, NULL);
033 if (hThread2 == NULL)
034 return 1;
035 CloseHandle(hThread2);
036
037 while (1)
038 {
039 printf("Running main() %d\n", GetCurrentThreadId());
040 Sleep(1000);
041 }
042
043 return 0;
044 }
CreateThread()
함수가 리턴하는 핸들은 나중에 스레드를 조작할 필요가 없으면 가급적 빨리 닫는 것이 좋다. CloseHandle()
함수를 호출해 핸들을 닫는다고해서 스레드가 종료되는 것은 아니라는 점에 유의하자실행결과는 아래와 같다.
이 때 다른 프로그램 몇 개를 실행하고 돌아오면 실행 패턴이 바뀔 수 있다. 한 프로세스 내 스레드 실행 순서는 시스템에 존재하는 다른 스레드의 영향을 받으므로 이와 같은 변화가 생긴다.
[_beginthreadex(), _endthreadex() 활용하기]
C/C++ 라이브러리 함수를 사용하는 응용 프로그램에서는CreateThread()
,ExitThread()
API 함수보다_beginthreadex()
,_endthreadex()
라이브러리 함수를 사용하는 것이 바람직하다. 두 함수의 원형은 다음과 같다.#include <process.h> uinptr_t _beginthreadex( void *security, unsigned stack_size, unsigned (__stdcall *start_address)(void *), void *arglist, unsigned initflag, unsigned *thrdaddr );
#include <process.h> void _endthreadex( unsigned retval );
_beginthreadex()
,_endthreadex()
함수는 각각CreateThread()
,EndThread()
함수와 같은 역할을 하며, 실제로 내부적으로 이들 API를 호출한다. 차이가 있다면 C/C++ 라이브러리가 멀티스레드 환경에서 문제없이 동작하도록 부가적인 작업을 한다는 점이다. API 함수와 인자의 타입만 약간 다를 뿐 순서와 의미가 같아 기존 코드를 거의 변경할 필요없이 사용할 수 있다.
스레드는 윈도우 운영체제의 실행 단위므로, 우선순위를 변경하거나 실행을 중지하고 재시작하는 등의 제어 기능을 윈도우 API 수준에서 지원한다. 기능별로 스레드 제어 API 함수를 살펴보자.
윈도우 운영체제에서 항상 여러 스레드가 CPU 시간을 사용하려고 경쟁한다. 따라서 각 스레드에 CPU 시간을 적절히 분배하기 위한 정책을 사용하는데, 이를 스레드 스케줄링(thread scheduling) 또는 CPU 스케줄링(CPU scheduling)이라 부른다.
윈도우 운영체제의 스케줄링 기법은 우선순위(priority)에 기반한 것으로, 우선순위가 높은 스레드에 우선적으로 CPU 시간을 할당한다. 스레드의 우선순위를 결정하는 요소는 다음과 같다.
우선순위 클래스는 프로세스 속성으로, 한 프로세스가 생성한 스레드는 우선순위 클래스가 모두 같다는 특징이 있다. 윈도우 운영체제에서 제공하는 우선순위 클래스는 다음과 같다.
우선순위 레벨은 스레드 속성으로, 같은 프로세스에 속한 스레드 간 상대적인 우선순위를 결정할 때 사용한다. 윈도우 운영체제에서 제공하는 우선순위 레벨은 다음과 같다.
우선순위 클래스와 우선순위 레벨을 결합하면 스레드의 기본 우선순위(base priority)가 결정되고, 이 값이 스레드 스케줄링에 사용된다. 윈도우의 스케줄링 방식에서는 우선순위가 가장 높은 스레드에 CPU 시간을 할당하되, 우선순위가 같은 스레드가 여러 개 있을 때는 CPU 시간을 번갈아가며 할당한다.
(6-10 그림)
우선순위가 높은 스레드가 계속 CPU 시간을 요구하면 우선순위가 낮은 스레드는 CPU 시간을 전혀 할당받지 못하는 문제가 생기는데, 이를 기아(starvation)이라고 한다. 이를 해겨리하려고 윈도우 운영체제는 오랜 시간 CPU 시간을 할당받지 못한 스레드의 우선순위를 단계적으로 끌어올려서 우선순위가 낮은 스레드도 CPU를 사용할 수 있게 한다. 또한 현제 사용자가 작업하고 있는 프로그램의 반응 속도를 빠르게 하려고 우선순위를 동적으로 변경하기도 한다.
멀티스레드를 이ㅛㅇ할 때 작업의 중요도에 따라 응용 프로그램이 직접 우선순위를 변경하기도 한다. 이때 우선순위 클래스를 변경하는 경우는 흔치 않으며, 대개 우선순위 레벨을 변경한다.
우선순위 레벨 관련 API 함수는 다음과 같다. SetThreadPriority()
함수는 우선순위 레벨을 변경할 때, GetThreadPriority()
함수는 우선순위 레벨을 얻을 때 사용한다.
// 성공: 0이 아닌 값, 실패: 0
BOOL SetThreadPriority(
HANDLE hThread, // 스레드 핸들
int nPriority // 우선순위 레벨
);
// 성공: 우선순위 레벨, 실패: THREAD_PRIORITY_ERROR_RETURN
int GetThreadPriority(
HANDLE hThread // 스레드 핸들
);
주 스레드 외에 새로운 스레드를 CPU 개수만큼 생성하고 우선순위 레벨을 변경하는 응용 프로그램 예제이다.
#include <windows.h>
#include <stdio.h>
DWORD WINAPI MyThread(LPVOID arg)
{
while (1);
return 0;
}
int main()
{
// CPU 개수를 알아낸다.
SYSTEM_INFO si;
GetSystemInfo(&si);
// CPU 개수만큼 스레드 생성
for (int i = 0; i < (int) si.dwNumberOfProcessors; i++)
{
HANDLE hThread = CreateThread(NULL, 0, MyThread, NULL, 0, NULL);
if (hThread == NULL)
return 1;
// 최고 우선순위로 변경한다.
SetThreadPriority(hThread, THREAD_PRIORITY_TIME_CRITICAL);
CloseHandle(hThread);
}
Sleep(1000);
while (1)
{
printf("주 스레드 실행!\n");
break;
}
return 0;
}
실행 결과는 다음과 같다. CPU 개수만큼 생성한 MyThread 스레드의 우선순위 레벨은 main 스레드 보다 높게 설정하고 무한 루프를 돌면서 CPU 시간을 계속 요구하므로, 우선 순위가 고정되어 있다면 MyThread 스레드만 CPU 시간을 할당받게 된다. 이때 기아(stravation)가 발생할 수 있으나, 윈도우 운영체제에서는 오랜 시간 CPU 시간을 할당 받지 못한 스레드의 우선순위를 단계적으로 끌어올리기 때문에 결과 같이 main 스레드도 실행할 기회를 얻는다.
스레드는 일단 CPU 시간을 사용하려고 다른 스레드와 경쟁하며 독립적으로 실행되지만 때로 한 스레드가 다른 스레드의 종료 여부, 즉 작업 완료 여부를 확인해야할 때가 생긴다.
WaitForSingleObject()
함수를 사용하면 특정 스레드가 종료할 때까지 기다릴 수 있다.
// 성공: WAIT_OBJECT_0 또는 WAIT_TIMEOUT, 실패: WAIT_FAILED
DWORD WaitForObject(
HANDLE hHandle,
DWORD dwMilliseconds
);
WaitForSingleObject()
함수는 리턴하고, 이때 리턴값은 WAIT_TIMEOUT
이 된다. 스레드가 종료한 경우 WAIT_OBJECT_0
을 리턴한다. 대기 시간으로 INFINITE
값을 사용하면 스레드가 종료할 때까지 무한히 기다린다.여러 스레드가 종료하기를 기다리려면 WaitForSingleObject()
함수를 스레드 개수만큼 호출해야 하는데, 대신 WaitForMultipleObjects()
함수를 사용하면 호출 한 번으로 끝낼 수 있다.
// 성공: WAIT_OBJECT_0 ~ WAIT_OBJECT_0 + nCount - 1 또는 WAIT_TIMEOUT, 실패: WAIT_FAILED
DWORD WaitForMultipleObjects(
DWORD nCount,
count HANDLE *lpHandles,
BOOL bWaitAll,
DWORD dwMilliseconds
);
WaitForMultipleObjects()
함수를 사용할 때는 스레드 핸들을 배열에 넣어서 전달해야 한다. nCount는 배열 원소개수, lpHandles는 배열의 시작 주소를 나타낸다. nCount의 최댓값은 MAXIMUM_WAIT_OBJECTS
(=64)로 정의되어 있다.TRUE
면 모든 스레드가 종료할 때까지 기다린다. FALSE
면 한 스레드가 종료하는 즉시 리턴한다.WaitForSingleObject()
함수의 두 번째 인자와 같다.참고로 WaitForSingleObject()
와 WaitForMultipleObjects()
함수는 스레드 종료를 기다리는 전용 함수가 아니*라는 점에 주의하자. 두 함수는 모두 스레드 동기화(thread synchronization)에 사용하는 범용 함수로, 여기서는 단지 스레드 종료를 기다리는 목적으로 이용한 것이다.
스레드 핸들을 보유하고 있으면 SuspendThread()
함수를 호출해 해당 스레드 실행을 일시 중지하거나 ResumeThread()
함수를 호출해 재시작할 수 있다. 윈도우 운영체제는 스레드의 중지 횟수(suspend count)를 관리하는데, 이 값은 SuspendThread()
함수를 호출할 때마다 1씩 증가하고 ResumeThread()
함수를 호출할 때마다 1씩 감소한다. 중지 횟수가 0보다 크면 스레드는 실행 중지 상태에 있게 한다. 따라서 한 스레드에 대해 SuspendThread()
함수를 두 번 호출했다면 ResumeThread()
함수를 두 번 호출해야 재시작할 수 있다.
// 성공: 중시 횟수, 실패: -1
DWORD SuspendThread(
HANDLE hThread // 스레드 핸들
);
// 성공: 중지 횟수, 실패: -1
DWORD ResumeThread(
HANDLE hThread // 스레드 핸들
);
비슷한 기능을 제공하는 것으로 Sleep()
함수가 있다. SyspendThread()
함수를 호출한 경우에는 반드시 ResumeThread()
함수를 사용해야 스레드가 재시작하지만, Sleep()
함수를 호출하면 dwMilliseconds
로 지정한 시간이 지나면 자동으로 재시작한다는 차이가 있다.
void Sleep(
DWORD dwMilliseconds // 밀리초(ms)
);
Sleep(0)를 호출하면 스레드는 자신에게 할당된 CPU 시간을 포기하고 남은 시간을 우선순위가 같은 다른 스레드에 넘겨준다. 이를 이용하면 스레드 간 컨텍스트 전환을 빠르게 할 수 있다.
#include <windows.h>
#include <stdio.h>
int sum = 0;
DWORD WINAPI MyThread(LPVOID arg)
{
int num = (int) arg;
for (int i = 1; i <= num; i++)
sum += i;
return 0;
}
int main(int argc, char **argv)
{
int num = 100;
HANDLE hThread = CreateThread(NULL, 0, MyThread, (LPVOID) num, CREATE_SUSPENDED, NULL);
if (hThread == NULL)
return 1;
printf("스레드 실행 전, 계산 결과 = %d\n", sum);
ResumeThread(hThread);
WaitForSingleObject(hThread, INFINITE);
printf("스레드 실행 후, 계산 결과 = %d\n", sum);
CloseHandle(hThread);
return 0;
}
실행 결과는 다음과 같다.
참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018
안녕하세요 글 작성하신게 제가 고민하는거랑 비슷하다고 생각해서 염치불구하고 댓글을 남깁니다.
[외부]client -> server -> Client1 -> [외부] server[port1]
-> Client2 -> [외부] server[port2]
이런식으로 구성하려고 하는데 처음에 한 것은 직접적으로 조건에 맞는 패킷이 들어올때마다 클라이언트를 실행시키고 끄고 했는데 서버연결한 다음 클라이언트 연결하고 조건에 맞는 패킷이 들어왔을때마다 내보내겠금하려고 합니다. 그런데 Client1에는 연결이 되는데 client2는 연결이 안됩니다. 하나만 되요.. 함수 한 개를 이용하여 두 포트를 동시에 실행시키려 스레드를 사용했는데도 똑같습니다. ㅠㅠ 도움주실 수 있나요?