외로운 Nova의 작업실

c 소켓프로그래밍 공부 -13(멀티 쓰레드) 본문

Programming/C

c 소켓프로그래밍 공부 -13(멀티 쓰레드)

Nova_ 2022. 4. 1. 13:04

안녕하세요. 오늘은 지난시간에이어 다중 접속 서버 구현 3번째 방법 멀티쓰레드에대해서 배워보겠습니다.

먼저 "쓰레드"란 무엇일까요?

한번 쓰레드를 프로세서에 비교해 설명해볼까합니다.

우리는 저번 11장에서 멀티 프로세서를 배웠습니다.

프로세서는 ram에 공간을 차지하며 실행중인 프로그램이라고 설명드렸습니다.

또한 구조는 스택,데이터영역,힙으로 이루어져있습니다.

멀티프로세서는 부모프로세서에서 스택,데이터영역,힙 3개를 다 복사해서 따로 ram에 공간을 또 만들어서 자식프로세서를 만드는 구조였습니다.

하지만 쓰레드는 부모프로세서에서 지정 스택만 복사해서 따로 가져가고 데이터영역과 힙은 부모프로세서랑 공유합니다.

이는 멀티프로세서가 굉장히 무거운일이기때문에 고안되었습니다.

ram의 자원은 한정적이였기때문에 최대한 ram을 적게쓰면서 다중 서버를 만들기위해 고안되었다고 말씀드릴수도있습니다.

아래는 쓰레드의 도식화입니다. --->는 새로만드는것 --- 는 공유

프로세스     ---> 쓰레드

1.스택        --->  스택

2.데이터영역--- 데이터영역

3.힙           ---     힙

 

또한 쓰레드는 스택을 따로 만들기에 만들때 함수를 하나 지정해줘야합니다.

이는 쓰레드가 실행될때 지정된 함수를 먼저 실행하게됩니다.

 

그렇다면 간단하게 2개의 쓰레드를 만들어서 하나는 변수 n의 값을 1씩 100번 빼고, 다른 하나는 변수 n의 값을 1씩 100번 더하는 쓰레드 프로세서를 만들어보도록 하겠습니다.

 

-간단한 쓰레드 구현원리

1. 쓰레드에 할당될 함수구현 하나는 n의값 1씩 100번빼는함수, 다른 하나는 1씩 100번더하는 함수

2. 쓰레드 2개 생성(함수구현 할당)

3. 쓰레드 종료확인

4. n의 결과값확인

 

-코드적 구현

1. 이전에 배운 것들로 하시면 됩니다.

2.쓰레드의 생성함수는 아래와 같습니다.

#include <process.h>

uintptr_t  _beginthreadex{
	void* security, //쓰레드의 보안관련전당 디폴트는 null
    unsigned stack_size, //쓰레드에게 할당될 스택의 크기, 디폴트 크기는 0
    unsigned (*start_address)(void * arg), //쓰레드의 메인함수 정보 전달
    void *arglist, //쓰레드의 함수호출시 전달할 인자정보 전달
    unsigned initflag, //쓰레드 생성이후 행동결정, 0전달하면 바로 실행
    unsigned *thrdaddr //쓰레드 ID의 저장을위한 변수의 주소값 전달
};
//쓰레드가 종료되면 signaled - 메모리를 줌 , 종료되지않으면 non-signaled - 메모리 차지중

//아래는 함수 포인터의 선언
//↓ 반환값 자료형
void (*fp)();    // 반환값과 매개변수가 없는 함수 포인터 fp 정의
//     ↑   ↖ 매개변수가 없음
// 함수 포인터 이름

3. 쓰레드의 종료확인은 아래와 같은 함수의 반환형으로 확인합니다.

#include <windows.h>

DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);
//hHandle : 상태확인이 되는 오브젝트의 핸들 전달
//dwMilliseconds : 무한블로킹을 막기위해 시간 지정, INFINITE전달시 오브젝트가 signaled 상태가 되기전엔 반환안함
//반환값 : signaled상태의 반환시 WAIT_OBJECT_0 반환, 타임아웃으로 반환시 WAIT_TIMEOUT 반환

DWORD WaitForMultipleObject(DWORD nCount, HANDLE* lpHandles, BOOL bWaitAll, DWORD dwMilliseconds);
//nCount : 검사할 커널 오브젝트 수 전달
//lpHandles : 핸들정보를 담고있는 배열의 주소값 전달
//bWaitAll : TRUE전달시 모든 검사대상이 signaled상태가 되어야반환, FALSE시 하나라도 signaled 상태면 반환
//dwMilliseconds : 타임아웃지정, INFINITE전달시 모든 검사대상이 signaled 상태가 되어야반환

쓰레드의 핸들은 프로세스 내에서 고유한 값이고, 쓰레드 ID는 운영체제 측면에서 고유한값입니다.

4. 이전에 배운것들로 하시면됩니다.

 

아래는 멀티 쓰레드기반 숫자를 더하는 프로그램입니다.

#include <stdio.h>
#include <process.h>
#include <windows.h>
unsigned WINAPI ThreadInc(void * arg); //쓰레드 형식을 맞춰서 함수 선언 WINAPI가 포인터로 변경해줌
unsigned WINAPI ThreadDes(void * arg); //쓰레드 형식을 맞춰서 함수 선언 WINAPI가 포인터로 변경해줌
int n = 0;

int main() {

	HANDLE inc, des;

	inc = (HANDLE)_beginthreadex(NULL, 0, ThreadInc, NULL, 0, 0); //쓰레드 생성및 시작
	if (inc == 0) {
		printf("_beginthreadex() error!");
	}
	des = (HANDLE)_beginthreadex(NULL, 0, ThreadDes, NULL, 0, 0); //쓰레드 생성및 시작
	if (des == 0) {
		printf("_beginthreadex() error!");
	}

	WaitForSingleObject(inc, INFINITE); //inc쓰레드가 끝날때까지 무한정 블로킹
	WaitForSingleObject(des, INFINITE); //des쓰레드가 끝날땍까지 무한정 블로킹

	printf("result n : %d", n); //결과값출력

	return 0;

	
}

unsigned WINAPI ThreadInc(void *arg) { //쓰레드가 실행할 함수

	for (int i = 0; i < 100; i++) {

		n++;
	}
	return 0;
}

unsigned WINAPI ThreadDes(void* arg) { //쓰레드가 실행할 함수

	for (int i = 0; i < 100; i++) {

		n--;
	}
	return 0;
}

아래는 실행 화면입니다.

 

이렇듯 아주 잘 실행이 됩니다.

하지만 위의 코드는 큰 오류가 있습니다.

이 오류는 변수를 공유하는 쓰레드의 특성에 있습니다.

아래와 같은 상황을 한번 생각해봅시다.

1. n = 0, 쓰레드1이 n의값인 0을 가져와서 +1하고 아직 저장은하지않는다.

2. n = -1, 쓰레드2가 n의값인 0을 가져와서 -1 하고 저장한다.

3. n = 1, 쓰레드 1이 첫번째 순서에서 =1 한값을 저장한다

 

위와 같은 순서에서 쓰레드 1과 쓰레드2는 각각 한번씩 호출됬지만 n의 결과는 0이아니라 1입니다.

이처럼 변수를 공유하다 보니 이러한 상황이 발생합니다.

아래는 실제로 이러한 상황을 만들어본 코드입니다.

#include <stdio.h>
#include <process.h>
#include <windows.h>
#define NUM_THREAD 50
unsigned WINAPI ThreadInc(void * arg); //쓰레드 형식을 맞춰서 함수 선언 
unsigned WINAPI ThreadDes(void * arg); //쓰레드 형식을 맞춰서 함수 선언
int n = 0;

int main() {

	HANDLE tHandles[NUM_THREAD];

	for (int i = 0; i < NUM_THREAD; i++) {

		if (i % 2) {
			tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadInc, NULL, 0, 0);
			if (tHandles[i] == 0) {
				printf("_beginthreadex() error!");
			}
		}
		else {
			tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadDes, NULL, 0, 0);
			if (tHandles[i] == 0) {
				printf("_beginthreadex() error!");
			}
		}
	}
	
	WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);

	printf("result n : %d", n);

	return 0;

	
}

unsigned WINAPI ThreadInc(void *arg) {

	for (int i = 0; i < 50000000; i++) {

		n++;
	}
	return 0;
}

unsigned WINAPI ThreadDes(void* arg) {

	for (int i = 0; i < 50000000; i++) {

		n--;
	}
	return 0;
}

위에 100번만 하던것을 5천만번씩 50번 해본 코드입니다.

결과값은 0이 나와야 정상입니다.

아래는 실행 화면입니다.

이렇듯 실제로 이상한 값이 나오게됩니다.

또한 n변수에 참조하는 코드를 임계영역이라고합니다.

이러한 현상은 위에서 말한 것처럼 변수를 공유하다보니 생기는일입니다.

괜찮습니다. c언어 개발자분들이 이러한 상황을 막기위해서 만들어놓은게 있습니다.

바로 mutex와 semaphore 입니다.

 

먼저 mutex의 원리를 설명드리고 함수적 구현을 보겠습니다.

mutex는 데이터영역의 자물쇠라고 생각하시면됩니다.

데이터영역에 자물쇠를 채우는 겂니다.

자물쇠를 열려면 열쇠가 필요하겠죠?

즉, 접근(값을 가져와서 ++이나--후 저장까지)하려면 열쇠를 받아야하고, 열쇠가 없다면 기다리는 원리입니다.

위의 코드에 mutex를 적용하면 이러한 원리가 됩니다.

1. n = 0, inc쓰레드가 n변수 열쇠 가져감

2. n = 0, des쓰레드가 n변수에 접근하려하지만 열쇠가 없어서 기다림

3. n = 1, inc쓰레드가 n변수의 값을 가져와서 +1한후 저장한뒤 열쇠를 반납

4. n = 1, des쓰레드가 반납된 열쇠를 받고 n변수에 접근

5. n = 0, des쓰레드가 n변수의 값을 가져와서 -1 한후 저장한뒤 열쇠를 반납

이런식으로 흘러가게 됩니다.

 

만약 뮤텍스를 적용하지않는다면 위에서 설명한대로 아래와 같은 상황일 겁니다.

1. n = 0, 쓰레드1이 n의값인 0을 가져와서 +1하고 아직 저장은하지않는다.

2. n = -1, 쓰레드2가 n의값인 0을 가져와서 -1 하고 저장한다.

3. n = 1, 쓰레드 1이 첫번째 순서에서 =1 한값을 저장한다

 

그렇다면 mutex의 함수 구현 방법을 살펴봅겠습니다.

1. 뮤텍스를 생성한다.

2. inc쓰레드가 열쇠를 받아가고 처리한다음에 열쇠를 반납한다

3. des쓰레드가 열쇠를 받아가고 처리한다음에 열쇠를 반납한다

4. 3~4를 반복후에 결과값을 도출한다.

 

아래는 뮤텍스 관련 함수 정의입니다.

#include <windows.h>

//자물쇠 뮤텍스 생성함수
HANDLE CreateMutex(LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);
//lpMutexAttributes : 보안관련특성, 디폴트값은 0
//bInitialOwner : True 전달시 생성되는 Mutex는 함수를 호출한 쓰레드의 소유가 되면서 non-signaled 
//상태가된다. FALSE전달시 소유자가 존재하지않으며 signaled 상태로 생성된다.
//lpName : mutex의 이름을 부여할때 사용된다. NULL을 전달하면 이름없는 뮤텍스가 생성된다

//자물쇠 삭제 함수
BOOL CloseHandle(HANDLE Mutex);

//열쇠 획득하는 함수
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);
//hHandle : 상태확인이 되는 오브젝트의 핸들 전달
//dwMilliseconds : 무한블로킹을 막기위해 시간 지정, INFINITE전달시 오브젝트가 signaled 상태가 되기전엔 반환안함
//반환값 : signaled상태의 반환시 WAIT_OBJECT_0 반환, 타임아웃으로 반환시 WAIT_TIMEOUT 반환
//뮤텍스는 auto-reset모드 오브젝트이기때문에 뮤텍스가
//signaled 일때 waitForSigleObject가 반환되고 뮤텍스를 non-signaled로 바꾼다.

//열쇠 반납하는 함수
BOOL ReleaseMutex(HANDLE Mutex);

//뮤택스가 signaled이면 열쇠가 있는상태, 뮤택스가 non-signaled이면 열쇠가 없는 상태

아래는 위에서 5000만번 더하는 코드에 뮤텍스를 적용시킨 코드입니다.

#include <stdio.h>
#include <process.h>
#include <windows.h>
#define NUM_THREAD 50
unsigned WINAPI ThreadInc(void * arg); //쓰레드 형식을 맞춰서 함수 선언 
unsigned WINAPI ThreadDes(void * arg); //쓰레드 형식을 맞춰서 함수 선언
int n = 0;
HANDLE mHandle; //뮤텍스 핸들

int main() {

	HANDLE tHandles[NUM_THREAD]; //핸들배열

	mHandle = CreateMutex(0, FALSE, NULL); //뮤텍스 생성

	for (int i = 0; i < NUM_THREAD; i++) {
		
		if (i % 2) {
			tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadInc, NULL, 0, 0);
			if (tHandles[i] == 0) {
				printf("_beginthreadex() error!");
			}
		}
		else {
			tHandles[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadDes, NULL, 0, 0);
			if (tHandles[i] == 0) {
				printf("_beginthreadex() error!");
			}
		}
	}
	
	WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INFINITE);

	printf("result n : %d", n);
	CloseHandle(mHandle);

	return 0;

	
}

unsigned WINAPI ThreadInc(void *arg) {

	WaitForSingleObject(mHandle, INFINITE); //열쇠 획득

	for (int i = 0; i < 50000000; i++) {

		n++;
	}

	ReleaseMutex(mHandle); //열쇠 반납
	return 0;
}

unsigned WINAPI ThreadDes(void* arg) {

	WaitForSingleObject(mHandle, INFINITE); //열쇠 획득

	for (int i = 0; i < 50000000; i++) {

		n--;
	}

	ReleaseMutex(mHandle); //열쇠 반납
	return 0;
}

뮤텍스의 적용은 for안에 넣을수도있고 for 밖에 넣을수도있습니다.

다만 for안에 넣게되면 5000만번 열쇠를 줫다뺏다 하고 그걸 50번이나 하기때문에 시간이 많이 소요됩니다.

그래서 적절하게 열쇠를 컨트롤 해줘야합니다.

아래는 실행 화면입니다.

예상한대로 0이 나오는걸 볼 수 있습니다.

뮤텍스는 간단하게 임계영역을 컨트롤 할 수 있습니다.

하지만 뮤텍스로는 할 수 없는게 하나 있습니다.

바로 순서대로 임계영역에 진입하는것입니다.

아래는 순서대로 임계영역에 진입하는 것에대한 구체적인 원리입니다.

1. 쓰레드1은 열쇠를 획득하여 n변수를 처리하고 반납한다.

2. 1번이 끝나면 쓰레드 2는 열쇠를 획득하여 n변수를 처리하고 반납한다.

3. 2번이 끝나면 쓰레드 1은 열쇠를 획득하여 n변수를 처리하고 반납한다.

4. 1번이 끝나면 쓰레드 2는 열쇠를 획득하여 n변수를 처리하고 반납한다.

.

.

.

.

50.1번이 끝나면 쓰레드 2는 열쇠를 획득하여 n변수를 처리하고 반납하다.

 

위와같이 "순서대로" 1쓰레드->2쓰레드->...2쓰레드 임계영역 진입에대한 순서를 정하고싶은데, 뮤텍스로는 할 수 없습니다.

아까 구현한 뮤텍스 기반 프로그램도 1쓰레드 다음에 또 1쓰레드가 호출 될 수 있습니다. 

따라서 이런식으로 순서를 정해주기위해 c언어 개발자들은 세마포어라는것을 만들어놨습니다.

세마포어는 자물쇠를 여러개 만들수도있고, 하나의 자물쇠의 열쇠가 여러개만들 수 도 있습니다.

즉 뮤텍스는 데이터영역에 들어가기위한 키가 하나지만 세마포어는 여러개입니다.

쉽게 이해하기위해 다른 프로그램을 짜보도록하겠습니다.

이번 프로그램은 2개의 쓰레드를 생성해서 하나의 쓰레드는 사용자로부터 입력을 받아 변수 n에 저장하고, 다른 하나의 쓰레드는 n에 저장된걸 sum변수에 계속 더합니다.

이후 쓰레드를 5번 실행후 sum에 입력한 값을 출력해보는 프로그램을 짜보겠습니다.

아래는 세마포어로 구현한 위의 프로그램 원리입니다.

1. 사용자로부터 입력받아 변수 n에 저장하는 함수구현(쓰레드1)

2. 변수 n의 값을 sum에 넣는 함수구현(쓰레드2)

3. 쓰레드 2개 생성 1,2 번 함수 지정

4. 세마포어(자물쇠)2개 생성(데이터영역 자물쇠2개 각 자물쇠마다 열쇠는 1개, 하나는 열쇠가 있는 상태(세마포어1) 다른 하나는 열쇠가 없는상태로(세마포어2) 시작

5. 쓰레드 1이 세마포어1의 열쇠 획득(쓰레드2는 세마포어2에대해서 열쇠가 없으므로 열쇠반납을기다리는중)

6. 쓰레드 1이 처리후에 세마포어 2의 열쇠 반납

7. 쓰레드 2는 세마포어 2의 열쇠획득(쓰레드1은 세마포어1에대해서 열쇠가 없으므로 열쇠반납을 기다리는중) 

8. 쓰레드 2는 세마포어 1의 열쇠 반납

9. 5번으로 돌아가 반복

 

아래는 세마포어를 구현하는 함수입니다.

#include <windows.h>

//자물쇠의 키를 여러개 생성하는 함수/ 세마포어 생성함수
HANDLE CreateSemaphore(LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG lInitialCount,	
	LONG lMaximumCount, LPCTSTR lpName);
//lpSemaphoreAttributes : 보안관련 정보전달, 디폴트값 0
//lInitialCount : 세마포어의 초기값, 0이면 non-signaled 상태
//lMaxumumCount : 세마포어의 최대갯수, 자물쇠 열쇠의  갯수
//lpName : 세마포어의 이름, NULL 전달시 이름없음


//열쇠를 획득하는 함수
DWORD WaitForSingleObject(HANDLE hHandle, DWORD dwMilliseconds);
//hHandle : 상태확인이 되는 오브젝트의 핸들 전달
//dwMilliseconds : 무한블로킹을 막기위해 시간 지정, INFINITE전달시 오브젝트가 signaled 상태가 되기전엔 반환안함
//반환값 : signaled상태의 반환시 WAIT_OBJECT_0 반환, 타임아웃으로 반환시 WAIT_TIMEOUT 반환
//세마포어는 auto-reset모드 오브젝트이기때문에 세마포어가
//signaled 일때 waitForSigleObject가 반환되고 뮤텍스를 non-signaled로 바꾼다.

//열쇠를 반납하는 함수
BOOL ReleaseSemaphore(HANDLE hSemaphor, LONG lReleaseCount, LPLONG lpPreviousCount);
//hSemaphore : 반납할 세마포어 핸들 전달
//lReleaseCount : 반납은 세마포어의 값의 증가를 의미하는데, 그 증가의 크기를 지정함
//lpPreviousCount : 변경이전의 세마포어 값 저장을 위한 변수의 주소값 전달, 불필요하면 NULL

 

실제로 세마포어를 활요해 위의 프로그램을 짜보도록 하겠습니다.

#include <stdio.h>
#include <process.h>
#include <windows.h>
unsigned WINAPI GetNumber(void *grd);
unsigned WINAPI AddSum(void* grd);
int n, sum; //n은 받을 변수, sum은 합칠 변수
HANDLE hSema1, hSema2; //세머포어 핸들 변수 선언

int main() {

	HANDLE hThread1, hThread2; //쓰레드 핸들변수 선언

	hSema1 = CreateSemaphore(0, 1, 1, NULL);
	hSema2 = CreateSemaphore(0, 0, 1, NULL);

	hThread1 = (HANDLE)_beginthreadex(NULL, 0, GetNumber, NULL, 0, NULL);
	hThread2 = (HANDLE)_beginthreadex(NULL, 0, AddSum, NULL, 0, NULL);

	WaitForSingleObject(hThread1, INFINITE);
	WaitForSingleObject(hThread2, INFINITE);

	CloseHandle(hSema1);
	CloseHandle(hSema2);

	return 0;
	
}

unsigned WINAPI GetNumber(void* grd) {

	for (int i = 0; i < 5; i++) {
		WaitForSingleObject(hSema1, INFINITE);
		printf("숫자를 입력해주세요 : ");
		scanf_s("%d", &n);
		ReleaseSemaphore(hSema2, 1, NULL);
	}

	return 0;

}

unsigned WINAPI AddSum(void* grd) {

	for (int i = 0; i < 5; i++) {
		WaitForSingleObject(hSema2, INFINITE);
		sum += n;
		ReleaseSemaphore(hSema1, 1, NULL);
	}

	printf("result : %d", sum);

	return 0;
}

아래는 실행 화면입니다.

실제로 아주 잘 동작하고 있습니다.

이로써 다중접속 서버 구현 - 멀티프로세서, 멀티플렉싱, 멀티 쓰레드(뮤텍스, 세마포어)의 설명을 마치도록 하겠습니다.

이로써 소켓프로그래밍 공부를 끝내겠습니다.

다음시간엔 여러명이 접속하여 채팅할수 있는 채팅 프로그램을 윈도우기반 c언어로 작성해보도록 하겠습니다.

making 카테고리에서 보실 수 있습니다.

 

 

 

Comments