본문 바로가기

윈도우 소켓 프로그래밍

[소켓프로그래밍] 5. 스레드, 멀티스레드, 스레드 제어

이 게시글은 'TCP/IP 윈도우 소켓 프로그래밍 (김선우 저, 한빛아카데미)'를 공부한 내용을 기반으로 작성됨


 

직전 게시글에서 수행했던 TCP Server-Client 예제는 다음과 같은 문제를 갖는다.

 

  • 여러 개의 Client가 Server에 접속하는 것은 가능하나, Server가 동시에 여러 Client에 서비스하는 것은 불가능하다. 예를 들어 두 개의 Client가 하나의 Server에 접속되어 있다면 먼저 접속한 Client가 Server에 전송한 메시지는 수신되지만, 늦게 접속한 Client가 전송한 메시지는 Server가 수신할 수 없다.
    • 이는 서버에 접속한 각 클라이언트들을 스레드를 이용하여 독립적으로 처리하여 해결한다. (멀티스레드)
    • 멀티스레드는 소켓 입출력 모델에 비해 비교적 쉽게 구현할 수 있지만, 클라이언트 수에 비례하여 스레드를 생성하기 때문에 서버의 자원을 많이 사용해야 한다는 단점이 있다.

 

  • Server-Client가 1:1 연결되어 있는 상황일지라도 Server와 Client 간 send(), recv() 함수의 호출 순서가 맞아 떨어져야 한다. 어느쪽도 데이터를 전송하지 않은 상황에서 둘 다 recv()를 호출한다면 상대방의 응답을 무한정 기다리게 되는 교착 상태(deadlock)에 빠지게 된다.
    • 소켓 timeout ➜ 간단하게 구현할 수 있지만 다른 방법에 비해 성능이 떨어짐
    • Nonblocking 소켓 사용 ➜ 교착 상태를 막을수 있지만 구현이 복잡하고 CPU 자원 낭비 가능성이 큼
    • 소켓 입출력 모델 사용 ➜ Nonblocking 소켓의 단점을 보완하면서 교착 상태를 막을 수 있다. 하지만 구현이 어렵다.

 

 

 

 

 


스레드란?

 

기존 TCP Server-Client 통신에서 발생하는 '서버에서의 다중 클라이언트 접속 처리 불가' 문제를 해결하기 위한 방법인 멀티스레드에 대하여 알아본다.

 

 

 

  • 윈도우 OS에서의 일반적 개념인 프로세스 = '프로세스 + 스레드'
  • 프로세스(Process) ➜ 코드, 데이터, 리소스를 파일에서 읽어들여 윈도우 OS가 할당해놓은 메모리 영역에 담고 있는 일종의 컨테이너로써 정적 개념이다.
  • 스레드(Thread) ➜ CPU 시간을 할당받아 프로세스 메모리 영역에 있는 코드를 수행하고 데이터를 사용하는 동적 개념.
  • 윈도우 응용 프로그램이 CPU 시간을 할당받아 실행하기 위해서는 스레드가 최소 하나 이상 필요하다.
  • 응용 프로그램 실행 시 최초로 생성되는 스레드를 주 스레드(Primary thread, Main thread)라고 부른다.
  • 응용 프로그램에서 Main thread와 별도로 동시에 수행하고자 하는 작업이 있다면, 스레드를 추가로 생성하여 해당 작업을 수행하게 하면 된다. ➜ 이를 멀티스레드 응용 프로그램이라고 한다.

 

 

 

 

CPU는 한 개이고 스레드는 두 개인 멀티스레드 환경을 가정해보자. CPU가 하나이기 때문에 한 시간 간격 동안 하나의 스레드만을 실행하게 되는데, 사용자는 어떻게 '응용 프로그램 하나가 동시에 여러 작업을 수행하는 것'처럼 느끼는 것일까?

 

➡️ CPU 하나가 두 개의 스레드를 동시에 실행할 수는 없지만, 아주 빠른 속도로 교대하며 실행한다면 사용자는 두 스레드가 동시에 실행되는 것처럼 느낀다.

➡️ 이에 따라 스레드를 교대할 때마다 각 스레드의 최종 실행 상태를 저장하고, 다시 실행할 때 복원하는 작업을 반복해야 한다. 여기서 스레드의 실행 상태는 CPU 레지스터 값과 메모리의 스택을 의미한다.

➡️ 스레드의 실행 상태의 저장/복원 작업을 컨텍스트 전환(Context switch)라고 부르는데, 이 작업은 HW(CPU)와 SW(OS)의 협동으로 이루어진다.

 

 

 

 

 

 

하나의 프로세스가 두 개의 스레드를 사용하는 원리는 다음과 같다. (그림과 설명은 딱히 관계 없다)

 

https://en.wikipedia.org/wiki/Multithreading_%28computer_architecture%29

 

 

 

  1. 스레드 #1이 실행중이다. 스레드가 CPU로 부터 받은 명령을 하나씩 수행할 때마다 CPU 레지스터 값과 메모리 스택 내용이 변경됨
  2. 스레드 #1의 실행을 중지하고 실행 상태를 저장한다. 이전에 저장해둔 스레드 #2의 상태를 복원한다.
  3. 스레드 #2를 실행한다. 마찬가지로 스레드가 명령을 하나 수행할 때마다 CPU 레지스터 값과 메모리 스택 내용이 변경됨
  4. 스레드 #2의 실행을 중지하고 실행 상태를 저장한다. 이전에 저장해둔 스레드 #1의 상태를 복원한다.
  5. 스레드 #1를 실행한다. 이전 실행 상태를 복원한 것이므로 마지막 수행한 명령 다음 위치부터 실행한다.

 

 

 

 

 

스레드 실습

#include <windows.h>
#include <stdio.h>


// 스레드 함수에 32비트보다 큰 값을 전달하기 위한 구조체
struct Point3D {
	int x, y, z;
};



DWORD WINAPI MyThread(LPVOID arg)
{
	Point3D* pt = (Point3D*)arg; // void형 포인터를 Point3D형 포인터로 변환하여 구조체 멤버 x,y,z에 접근
	while (1) {		// 무한 루프를 통해 1초마다 스레드ID와 x,y,z 출력
		printf("Running MyThread () %d : %d, %d, %d\n", GetCurrentThreadId(), pt->x, pt->y, pt->z);
		Sleep(1000);
	}

	return 0;
}


int main(int argc, char* argv[])
{
	// 첫 번째 스레드 생성
	Point3D pt1 = { 10, 20, 30 };
	HANDLE hThread1 = CreateThread(NULL, 0, MyThread, &pt1, 0, NULL);
	if (hThread1 == NULL) return 1;
	CloseHandle(hThread1);

	// 두 번째 스레드 생성
	Point3D pt2 = { 40, 50, 60 };
	HANDLE hThread2 = CreateThread(NULL, 0, MyThread, &pt2, 0, NULL);
	if (hThread2 == NULL) return 1;
	CloseHandle(hThread2);


	while (1) {
		printf("Running main() %d\n", GetCurrentThreadId());
		Sleep(1000);
	}

	return 0;
}

 

 

 

 

 

위 코드를 실행하면 다음과 같은 결과를 얻는다. 여기서는 Thread ID 34652, 3592를 갖는 두 개의 스레드가 서로 번갈아가며 실행되는 것을 확인할 수 있다.

 

 

 


스레드 제어

 

우리는 기본적으로 멀티스레드를 지원하는 OS를 사용하기 때문에, 항상 여러 스레드가 CPU 시간자원을 사용하기 위해 경쟁한다. 따라서 OS에서는 스레드에 CPU 시간을 적절히 분배하기 위한 젖ㅇ책을 적용하는데, 이를 thread scheduling 또는 CPU scheduling이라고 한다.

 

 

스레드의 우선순위를 결정하는 두 요소는 다음과 같다.

  • 프로세스 우선순위 : 우선순위 클래스 (Priority class)
  • 스레드 우선순위 : 우선순위 레벨 (Priority level)

 

우선순위 클래스 (프로세스) 우선순위 레벨 (스레드)
REALTIME_PRIORITY_CLASS (높음) THREAD_PRIORITY_TIME_CRITICAL
ABOVE_NORMAL_PRIORITY_CLASS (높은 우선순위) THREAD_PRIORITY_HIGHEST
NORMAL_PRIORITY_CLASS (보통) THREAD_PRIORITY_AVOBE_NORMAL
BELOW_NORMAL_PRIORITY_CLASS (낮은 우선순위) THREAD_PRIORITY_BELOW_NORMAL
IDLE_PRIORITY_CLASS (낮음) THREAD_PRIORITY_LOWEST
  THREAD_PRIORITY_IDLE

 

 

 

멀티스레드 환경에서 작업의 중요도에 따라 응용 프로그램이 직접 우선순위를 변경하는 경우도 있다. 하지만 우선순위 클래스를 변경하는 경우는 흔치 않으며, 대개는 우선순위 레벨을 변경하여 스레드의 우선순위를 변경한다.

 

 

 

 

 

 

스레드 우선순위 변경 실습

#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 개수만큼 추가 스레드를 생성하고, 추가된 스레드들의 우선순위를 THREAD_PRIORITY_TIME_CRITICAL (최고 우선순위)로 변경했다. 그에 따라 우선순위를 변경하지 않은 주 스레드는 최하위 우선순위를 갖게 되는데, 그럼에도 불구하고 주 스레드가 실행된 것을 결과로 확인할 수 있다.

 

Window OS에서는 오랜 시간 CPU 자원을 할당받지 못한 스레드가 있다면 해당 스레드의 우선순위를 단계적으로 올려주는 로직이 적용되어있기 때문에 결과적으로 최하위 우선순위였던 주 스레드도 실행될 수 있는 것이다.