<쓰레드 동기화 기법 - (1)>

[공부했던 것을 되짚어보며]

이전에 공부했던 내용은 쓰레드의 생성과 소멸과 관련해서 공부를 했었습니다.

그리고 여기서 동시접근에 대한 문제점도 다뤘었습니다.

실제로 strtok과 같은 ANSI C 라이브러리 함수를 사용할 때에는 동시 참조의 위험성이 있다고 했습니다.

멀티 쓰레드 기반 프로그램에서 쓰레드에서 strtok을 사용하면 다른 쓰레드에서도 내부 정적 변수에 접근이 가능해집니다.

다른 쓰레드에서 이 함수를 호출할 때에는 정적 변수를 덮어쓰는 문제가 생기죠.

그래서 앞으로는 CreateThread가 아닌 ANSI 표준 라이브러리를 이용한 _beginthreadex 함수를 사용하기로 했습니다.

이 함수는 CretateThread와 같이 단순하게 쓰레드만 생성하지 않습니다.

쓰레드를 생성하기 앞서서 쓰레드를 위한 독립적인 메모리 블록을 할당하는 과정이 추가되어 있습니다.

다시 말해서 이는 '동기화와 관련된 안정성을 보장'받게 됩니다.

여기서 '동기화'라는 키워드가 나왔습니다.

동시접근에 대한 해결을 위해 '동기화'가 필요하다는 이야기를 했었는데, 이번 글에서 다루게 될 것 같습니다.

[쓰레드 동기화란 무엇인가?]

이전 글에서는 두 개 이상의 쓰레드가 같은 메모리 영역을 동시에 접근하게 될 때 문제가 생긴다고 했었습니다.

그리고 이를 '동기화'를 통해 해결할 수 있다고 했었고요.

동기화라는게 뭔지는 잘 모르겠지만, 적어도 '메모리의 동시 접근 문제의 해결책'이라는 것을 알고 있습니다.

그래서 여기서는 '쓰레드 동기화'에 대한 개념을 이야기해보려고 합니다.

[두 가지 관점에서의 쓰레드 동기화]

우선 '동기화'라고 하면 '무엇인가를 일치시키는 것'으로 생각할 수 있습니다.

간단하게 A와 B라는 서버가 두 대 있다고 가정을 해봅시다.

'A와 B가 동기화 되었다'라고 하면 '두 서버가 가지고 있는 데이터가 동일한 상태'라는 것을 말합니다.

만약 '영상과 음성 데이터가 잘 동기화가 되어있다'고 하면?

'영상에서 나오는 인물이 말할 때와 음성이 잘 일치하는 경우'겠죠.

우리가 흔히 알고 있기로 '싱크로율 100%'라는 말도 많이 사용합니다.

그만큼 무언가와 잘 일치되어 있을 때 쓰는 말입니다.

 

하지만 '쓰레드에서의 동기화'는 위와는 좀 다른 개념을 지닙니다.

'무엇인가와 일치한다'라는 개념이 아니라 '순서와 관련하여 질서가 잘 지켜지고 있음'을 뜻하게 됩니다.

'두 가지 관점에서의 쓰레드 동기화'라는 제목을 달게 되었는데, 그 두 가지 관점에 대해서 한 번 정리해보고자 합니다.

 

[실행순서의 동기화]

앞서 말했던 것처럼 '순서와 관련하여 질서가 잘 지켜지고 있음'의 관점에서 동기화입니다.

조금 쉬운 예를 들어보자면 공원에 공용 화장실이 있습니다.

(예시가 좀 지저분한 감이 있습니다만 양해 바랍니다 ㅋㅋ)

공원에 있는 공용 화장실은 딱! 하나만 있습니다.

그리고 공원을 이용하던 이용객 A와 B가 동시에 화장실을 이용하고자 합니다.

A는 작은 일, B는 큰 일을 보려고 하는 상황입니다.

화장실을 효율적으로 사용한다고 보면 A가 먼저 일을 보고 B가 일을 보고 나오는 것이 가장 효율적입니다.

그런데 B가 먼저 들어가버리고 A는 화장실을 이용하지 못하고 기다리는 상황이 생겼습니다.

이런 경우를 '순서가 무너진 상황'이라고 볼 수 있습니다.

효율적인 관점에서 A가 먼저, 그리고 B가 나중에 이용해야 효율적인 '순서'가 되기 때문입니다.

 

위의 예를 쓰레드의 관점에서 본다면 쓰레드 A가 먼저, 그리고 쓰레드 B가 나중에 수행되어야 하는 '순서'가 존재합니다.

또 다른 예로는 쓰레드 A의 작업 결과를 토대로 쓰레드 B가 받아서 수행해야 하는 경우도 있을 수 있습니다.

이런 경우에는 반드시 쓰레드 A가 먼저 수행되고 그 다음 쓰레드 B가 수행되어야 하는 '순서'가 있어야 합니다.

즉, 쓰레드의 실행순서를 정의하고, 이 순서에 반드시 따르도록 하는 것이 '실행순서 관점에서의 쓰레드 동기화'입니다.

 

[메모리 접근에 대한 동기화]

이번에도 앞에서 사용했던 예를 이어서 들어보겠습니다.

(마찬가지로 예시가 지저분한 점 양해바랍니다.)

A와 B 모두 작은 일을 보려고 하는 상황입니다.

그래서 급하게 화장실을 이용하려고 하는데 둘 다 동시에 들어가버리고 말았습니다.

그 뒤는... 굳이 이야기 안해도 될 것 같습니다.

 

위의 예를 쓰레드의 관점으로 본다면 쓰레드 A와 쓰레드 B는 접근해야 하는 메모리 영역에 '동시 접근'을 한 것입니다.

한 순간에 하나의 쓰레드만 접근해야 하는 메모리 영역은 대표적으로 '데이터와 힙 영역'입니다.

데이터와 힙 영역에 할당된 변수를 둘 이상의 쓰레드가 동시에 접근하면 문제가 발생하게 됩니다.

이 문제에 대해서는 이전 글과 이번 글의 서두에서 설명했던 내용입니다.

그래서 _beginthreadex 함수를 왜 사용하는지에 대한 이유를 설명하기도 했었죠.

이처럼 메모리 접근에 있어서 동시접근을 막는 것 역시 쓰레드의 동기화에 해당하게 됩니다.

 

[정리]

쓰레드의 동기화를 두 가지 관점에서 설명하게 되었는데, 이게 어떻게 보면 구분짓기가 좀 애매하기도 합니다.

둘 다 실행에 있어서 '순서'를 중요시하고 있어서 그렇습니다.

그렇지만 차이가 나는 부분은 이 둘의 '상황'에서 구분을 지을 수 있습니다.

 

1) 실행순서에 대한 동기화

여기서 소개했던 상황은 '실행, 혹은 접근의 순서가 이미 정해져 있는 상황'입니다.

그래서 A, B, C라는 쓰레드가 있다면 반드시 A→B→C의 순서로 실행되어야 한다고 정의했다고 합시다.

그러면 이 순서는 반드시 지켜져야 하는 순서가 됩니다.

 

2) 메모리 접근에 대한 동기화

여기서는 실행의 순서가 중요한 상황이 아닙니다.

한 순간에 하나의 쓰레드만 접근을 하면 되는 상황입니다.

그래서 A, B, C라는 쓰레드가 있다면 순서는 중요치 않습니다.

여기서는 메모리에 동시접근하는 문제만 발생하지 않도록 하면 됩니다.

 

참고로 '실행순서를 동기화한다'라는 것은 쓰레드의 메모리 접근순서를 동기화하기 위한 경우가 대다수입니다.

그래서 '실행순서의 동기화'는 결국 메모리 접근과도 관련이 있습니다.

하지만 개념적으로는 메모리 접근의 측면보다는 메모리에 접근하는 '쓰레드의 실행순서'가 더 강조가 됩니다.

그래서 '실행순서의 동기화'라는 개념을 따로 분리해둔 것이므로 헷갈리시면 안됩니다.

[쓰레드 동기화에 있어서의 두 가지 방법]

우리가 현재 공부하고 있는 Windows 시스템에서는 다양한 동기화 기법을 제공하고 있습니다.

Windows에서 제공하는 동기화 기법들이 앞에서 나눈 두 가지로 양분화되어 딱 잘라져 있지는 않습니다.

하지만 상황에 따라 어울리는 동기화 기법이 있습니다.

 

Windows에서 제공하는 동기화 기법은 제공하는 주체에 따라서 두 가지로 나뉘게 됩니다.

OS에서 제공하는 기법이면 '커널 모드', OS에서 제공하는 기법이 아니면 '유저 모드'였던 것처럼 여기서도 동일합니다.

 

1) 유저 모드 동기화(User Mode Synchronize)

동기화가 진행되는 과정에서 커널을 이용하지 않는 동기화 기법입니다.

그래서 커널 모드로의 전환이 필요하지 않기 때문에 성능상 이점이 있습니다.

하지만 커널 모드에서 제공하는 수준의 기능을 사용할 수 없으므로 기능상 제한이 있습니다.

 

2) 커널 모드 동기화(Kernel Mode Synchronize) 

반대로 동기화가 진행되는 과정에서 커널을 이용하는 동기화 기법입니다.

그래서 동기화에 관련된 함수가 호출될 때마다 커널 모드로의 변경이 필요하게 됩니다.

유저 모드와는 반대로 성능의 저하가 있을 수 있습니다.

하지만 성능의 저하가 따르는만큼 유저 모드 동기화에서 제공하지 못하는 기능을 제공받을 수 있게 됩니다.

[임계 영역(Critical Section) 접근 동기화]

이번 글에서는 '메모리 접근의 동기화'에 대해서만 다루고자 합니다.

다음 글에서 '실행순서에 대한 동기화'를 다루겠습니다.

우선 '메모리 접근의 동기화'에 대한 이해를 위해서 '임계 영역(Critical Section)'이라는 개념을 먼저 정리하고 가겠습니다.

[임계 영역(Critical Section)에 대한 이해]

간단한 예를 하나 들어보겠습니다.

우리가 구현하고자 하는 프로그램은 종합경기장에 입장하는 관람객의 수를 카운팅하는 프로그램을 만들려고 합니다.

여기서 종합경기장에 관람객이 들어오고 나가는 출입구가 6개라고 가정하겠습니다.

출입구에는 센서가 달려있어서 사람들이 입장할 때마다 중앙에 있는 컴퓨터로 데이터가 전송이 됩니다.

출입구가 6개이므로 쓰레드를 6개 생성하여 입구 하나마다 쓰레드가 처리할 수 있도록 설계를 합니다.

그리고 각각의 쓰레드가 보내는 데이터를 메인 프로그램에서 합산하는 방식으로 구현을 하고자 합니다.

 

[CriticalSection.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: CriticalSection.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-08
* 이전 버전 작성 일자:
* 버전 내용: 임계 영역(Critical Section)를 이해하기 위한 예제
* 이전 버전 내용:
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>

#define NUM_OF_GATE 6

// 전역 변수로 사용. -> 쓰레드는 공유 가능
LONG gTotalCount = 0;

void IncreaseCount()
{
	gTotalCount++; // 임계 영역(Critical Section)
}

unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
	for (DWORD i = 0; i < 1000; i++)
	{
		IncreaseCount();
	}

	return 0;
}

int _tmain(int argc, TCHAR argv[])
{
	DWORD dwThreadID[NUM_OF_GATE];
	HANDLE hThread[NUM_OF_GATE];

	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		hThread[i] = (HANDLE)_beginthreadex(
			NULL, // 보안(상속) 속성, 안하므로 NULL
			0, // 쓰레드의 스택 크기, 0으로 주면 디폴트값(1M)
			ThreadProc, // 쓰레드의 main이 될 함수
			NULL, // 쓰레드에 전달할 인자, 없으므로 NULL
			CREATE_SUSPENDED, // 생성 시 즉시 실행 여부를 인자로 전달, CREATE_SUSPENDED를 전달했으므로 바로 실행하지 않는다
			(unsigned*)&dwThreadID[i] // 쓰레드의 ID값을 저장할 주소값을 인자로 전달
		);

		// 쓰레드의 생성이 실패했을 경우
		if (hThread[i] == NULL)
		{
			_tprintf(TEXT("Thread Creation Fault\n"));
			return -1;
		}
	}

	// 모든 쓰레드를 실행 상태로 바꾸는 부분
	// 쓰레드 생성 또한 시간이 걸리는 작업이므로 동시 실행을 위해서 SUSPENDED로 생성 
	// ResumeThread 함수를 통해 모든 쓰레드를 실행 상태로 바꾼다. 
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		ResumeThread(hThread[i]);
	}

	// 모든 쓰레드의 작업이 끝나기를 기다리는 부분
	WaitForMultipleObjects(
		NUM_OF_GATE, // 관찰할 오브젝트의 갯수
		hThread, // 관찰할 오브젝트를 담고 있는 배열의 주소
		TRUE, // 모든 오브젝트가 Signaled 상태가 되면 값을 반환
		INFINITE // 무한정으로 대기
	);

	_tprintf(TEXT("total count: %d\n"), gTotalCount);

	// 생성한 쓰레드의 커널 오브젝트 Usage Count를 감소 (리소스 해제 요청)
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
		CloseHandle(hThread[i]);

	return 0;
}

 

프로그램의 코드 자체는 굉장히 단순합니다.

각 쓰레드 별로 전역변수인 gTotalCount의 값을 1씩 1000번 증가시키는 작업을 수행하고 있습니다.

이는 각 입구마다 1000명의 사람이 들어왔다고 가정한 것입니다.

실제로 실행을 시키면 6000이라는 결과를 보시는 분들도 있지만, 아닌 분들도 있을 겁니다.

(웬만해서는 6000이라는 결과를 보게 될 것입니다. 무리해서 실행할 필요는 없습니다.)

주석으로 달아놓은 '임계 영역'이라는 부분에 의해서 문제가 생기기 때문입니다.

gTotalCount++이라는 연산을 둘 이상의 쓰레드가 동시에 실행하는 경우 문제가 발생하게 됩니다.

이처럼 '임계 영역(Critical Section)' 쓰레드의 동시 접근에 의해서 문제가 생길 수 있는 코드 블록을 뜻합니다.

 

위의 예제에서는 단 한 줄만 임계 영역으로 해당이 되어있습니다.

하지만 경우에 따라서는 여러 줄이 묶여서 임계 영역을 구성할 수도 있습니다.

다시 말하자면, 위의 예시처럼 단 한 줄의 연산만이 임계 영역이 되는 것은 아니라는 것을 말합니다.

마지막으로 임계 영역에 대한 용어를 정리를 하면 다음과 같습니다.

 

'임계 영역(Critical Section)'이란?

→ 배타적 접근(한 순간에 하나의 쓰레드만 접근)이 요구되는 공유 리소스(전역변수와 같은)에 접근하는 코드 블록

 

이제 임계 영역이 무엇인지 알았고, 문제점에 대해서도 알게 되었습니다.

문제를 해결하기 위해서는 임계 영역으로의 동시 접근을 막아야 합니다.

그래서 동기화 기법을 통해 임계 영역에는 한 순간에 하나의 쓰레드만 실행될 수 있도록 제한하는 것이죠.

Windows에서는 다양한 동기화 기법을 제공합니다.

 

1. 크리티컬 섹션(Critical Section) 기반의 동기화 - 유저 모드, 메모리 접근 동기화

2. 인터락 함수(Interlocked Family Of Function) 기반의 동기화 - 유저 모드, 메모리 접근 동기화

3. 뮤텍스(Mutex) 기반의 동기화 - 커널 모드, 메모리 접근 동기화

4. 세마포어(Semaphore) 기반의 동기화 - 커널 모드, 메모리 접근 동기화

5. 이름있는 뮤텍스(Named Mutex) 기반의 동기화 - 커널 모드, 프로세스 간의 동기화

6. 이벤트(Event) 기반의 동기화 - 커널 모드, 실행 순서 동기화

 

여기서는 메모리 접근에 대한 동기화만 다루기로 했습니다.

실행 순서 동기화에 사용되는 이벤트 기반의 동기화 기법은 다음 글에서 자세하게 다루겠습니다.

[유저 모드 동기화(Synchronization In User Mode)]

앞서 말했던 것과 같이 '유저 모드 동기화'커널 모드로의 전환이 필요하지 않습니다.

그래서 성능상 이점을 얻을 수 있다는 장점이 있습니다.

또한 커널 모드 동기화 기법에 비해서 활용하는 방법도 단순합니다.

유저 모드 기법으로도 문제 해결이 충분한 상황이라면 굳이 커널 모드 동기화 기법을 사용할 필요가 없을 수도 있습니다.

[크리티컬 섹션(Critical Section) 기반의 동기화]

대부분의 동기화 기법을 이해할 때는 앞서 예시로 든 화장실과 그 앞에 걸어놓은 열쇠를 예로 들면 이해하기가 쉽습니다.

이 임계 영역을 화장실이라고 하면, 이 화장실에 들어가기 위해서는 열쇠가 필요합니다.

그래서 문 앞에 걸려있는 열쇠를 가지게 되면 화장실로 들어갈 수 있는 것입니다.

화장실을 다 이용하고 나면 이 열쇠를 다시 문 앞에 걸어놓으면 됩니다.

그래야 다음 사람도 이용할 수 있으니까요.

 

위의 예시를 이용하는 방식이 크리티컬 섹션의 동기화 방식입니다.

요점은 열쇠를 얻은 사람만 화장실에 들어갈 수 있다는 것입니다.

이를 바꿔서 말하면 열쇠를 얻은 쓰레드가 임계영역에 들어갈 수 있다는 말이 되는 것이죠.

크리티컬 섹션 동기화 기법에서 사용하는 열쇠는 CRITICAL_SECTION이라는 오브젝트입니다.

이 오브젝트를 만들고 초기화를 하면 그때부터 사용할 수 있는 열쇠가 됩니다.

초기화에는 InitializeCriticalSection이라는 함수를 사용하게 됩니다.

그리고 열쇠를 획득하는 과정은 EnterCriticalSection, 열쇠를 돌려놓는 과정은 LeaveCriticalSection 함수를 사용합니다.

마지막으로 열쇠를 아예 없앨 때는 DeleteCriticalSection 함수를 이용하면 됩니다.

 

[CriticalSectionSync.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: CriticalSectionSync.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-08
* 이전 버전 작성 일자:
* 버전 내용: 유저모드 동기화 기법(1) - Critical Section 기반 동기화 예제
* 이전 버전 내용:
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>

#define NUM_OF_GATE 6

// 전역 변수로 사용. -> 쓰레드는 공유 가능
LONG gTotalCount = 0;

// Critical Section 오브젝트를 선언
CRITICAL_SECTION hCriticalSection;

void IncreaseCount()
{
	// 임계 영역 진입 전 쓰레드는 EnterCriticalSection 함수를 호출하여 크리티컬 섹션 오브젝트를 얻게됨
	// 크리티컬 섹션 오브젝트를 얻은 쓰레드가 이미 있는 경우, 다른 쓰레드가 해당 함수를 호출하면 블로킹 상태가 됨
	// LeaveCriticalSection 함수를 호출해서 크리티컬 섹션 오브젝트를 반환하면 블로킹 상태에서 빠져나오게 된다.
	EnterCriticalSection(&hCriticalSection);
	gTotalCount++; // 임계 영역(Critical Section)
	LeaveCriticalSection(&hCriticalSection);
}

unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
	for (DWORD i = 0; i < 1000; i++)
	{
		IncreaseCount();
	}

	return 0;
}

int _tmain(int argc, TCHAR argv[])
{
	DWORD dwThreadID[NUM_OF_GATE];
	HANDLE hThread[NUM_OF_GATE];

	// Critical Section 오브젝트 초기화
	InitializeCriticalSection(&hCriticalSection);

	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		hThread[i] = (HANDLE)_beginthreadex(
			NULL, // 보안(상속) 속성, 안하므로 NULL
			0, // 쓰레드의 스택 크기, 0으로 주면 디폴트값(1M)
			ThreadProc, // 쓰레드의 main이 될 함수
			NULL, // 쓰레드에 전달할 인자, 없으므로 NULL
			CREATE_SUSPENDED, // 생성 시 즉시 실행 여부를 인자로 전달, CREATE_SUSPENDED를 전달했으므로 바로 실행하지 않는다
			(unsigned*)&dwThreadID[i] // 쓰레드의 ID값을 저장할 주소값을 인자로 전달
		);

		// 쓰레드의 생성이 실패했을 경우
		if (hThread[i] == NULL)
		{
			_tprintf(TEXT("Thread Creation Fault\n"));
			return -1;
		}
	}

	// 모든 쓰레드를 실행 상태로 바꾸는 부분
	// 쓰레드 생성 또한 시간이 걸리는 작업이므로 동시 실행을 위해서 SUSPENDED로 생성 
	// ResumeThread 함수를 통해 모든 쓰레드를 실행 상태로 바꾼다. 
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		ResumeThread(hThread[i]);
	}

	// 모든 쓰레드의 작업이 끝나기를 기다리는 부분
	WaitForMultipleObjects(
		NUM_OF_GATE, // 관찰할 오브젝트의 갯수
		hThread, // 관찰할 오브젝트를 담고 있는 배열의 주소
		TRUE, // 모든 오브젝트가 Signaled 상태가 되면 값을 반환
		INFINITE // 무한정으로 대기
	);

	_tprintf(TEXT("total count: %d\n"), gTotalCount);

	// 생성한 쓰레드의 커널 오브젝트 Usage Count를 감소 (리소스 해제 요청)
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
		CloseHandle(hThread[i]);

	// Critical Section 오브젝트 역시 오브젝트(커널 오브젝트는 아님)이므로 리소스 해제.
	DeleteCriticalSection(&hCriticalSection);

	return 0;
}

 

이전의 코드에서 크리티컬 섹션 동기화 기법을 적용하여 임계 영역 접근 동기화를 적용한 예제 코드입니다.

만약 둘 이상의 쓰레드가 접근하게 될 경우 한 쓰레드가 먼저 EnterCriticalSection 함수에 진입하게 됩니다.

그러면 LeaveCriticalSection을 호출할 때까지 다른 쓰레드는 Blocked 상태가 됩니다.

 

+ 추가 사항 - '임계 영역을 지정할 때 범위를 얼마나 잡아야할까'

임계 영역을 넓게 잡으면 프로그램은 안정적이게 됩니다.

그렇지만 안정적인만큼 성능이 저하가 될 수 있습니다.

위에서 말했던 것처럼 하나의 쓰레드가 임계 영역에서 작업을 하는 동안 다른 쓰레드는 Blocked 상태가 되기 때문입니다.

동시 접근이 불필요한 영역까지 잡으면 효율이 떨어지게 됩니다.

그래서 임계 영역은 '가능한 최소한의 영역으로 필요한 부분만 감싸는 것이 좋다'라는 결론을 내릴 수 있습니다.

 

[인터락 함수(Interlocked Family of Function) 기반의 동기화]

앞선 예제 코드에서는 전역 변수 하나에 대해서 접근방식을 동기화를 하는 예제였습니다.

이처럼 굉장히 단순한 메모리 접근의 동기화에는 인터락 함수를 사용하는 것도 고려해볼 수 있습니다.

인터락 함수는 원자적 접근(Atomic Access), 즉 한 순간에 하나의 쓰레드만 접근하는 것을 보장해주는 함수입니다.

그래서 모든 쓰레드가 인터락 계열의 함수를 통해 값을 증가시키거나 감소시킬 수 있습니다.

또한 동시에 둘 이상의 쓰레드 접근에 의한 문제도 발생하지 않게 됩니다.

앞에서 사용했던 크리티컬 섹션 동기화 기법도 내부적으로는 인터락 함수를 기반으로 구현되어 있습니다.

 

[InterlockedSync.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: InterlockedSync.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-08
* 이전 버전 작성 일자:
* 버전 내용: 유저모드 동기화 기법(2) - 인터락(Interlock) 함수 기반 동기화 예제
* 이전 버전 내용:
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>

#define NUM_OF_GATE 6

// 전역 변수로 사용. -> 쓰레드는 공유 가능
LONG gTotalCount = 0;

void IncreaseCount()
{
	// gTotalCount++; // 임계 영역(Critical Section)
	InterlockedIncrement(&gTotalCount); // 대상의 값을 1 증가시키는 인터락 함수.
	// 이외에도 다양한 인터락 함수가 있음
}

unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
	for (DWORD i = 0; i < 1000; i++)
	{
		IncreaseCount();
	}

	return 0;
}

int _tmain(int argc, TCHAR argv[])
{
	DWORD dwThreadID[NUM_OF_GATE];
	HANDLE hThread[NUM_OF_GATE];

	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		hThread[i] = (HANDLE)_beginthreadex(
			NULL, // 보안(상속) 속성, 안하므로 NULL
			0, // 쓰레드의 스택 크기, 0으로 주면 디폴트값(1M)
			ThreadProc, // 쓰레드의 main이 될 함수
			NULL, // 쓰레드에 전달할 인자, 없으므로 NULL
			CREATE_SUSPENDED, // 생성 시 즉시 실행 여부를 인자로 전달, CREATE_SUSPENDED를 전달했으므로 바로 실행하지 않는다
			(unsigned*)&dwThreadID[i] // 쓰레드의 ID값을 저장할 주소값을 인자로 전달
		);

		// 쓰레드의 생성이 실패했을 경우
		if (hThread[i] == NULL)
		{
			_tprintf(TEXT("Thread Creation Fault\n"));
			return -1;
		}
	}

	// 모든 쓰레드를 실행 상태로 바꾸는 부분
	// 쓰레드 생성 또한 시간이 걸리는 작업이므로 동시 실행을 위해서 SUSPENDED로 생성 
	// ResumeThread 함수를 통해 모든 쓰레드를 실행 상태로 바꾼다. 
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		ResumeThread(hThread[i]);
	}

	// 모든 쓰레드의 작업이 끝나기를 기다리는 부분
	WaitForMultipleObjects(
		NUM_OF_GATE, // 관찰할 오브젝트의 갯수
		hThread, // 관찰할 오브젝트를 담고 있는 배열의 주소
		TRUE, // 모든 오브젝트가 Signaled 상태가 되면 값을 반환
		INFINITE // 무한정으로 대기
	);

	_tprintf(TEXT("total count: %d\n"), gTotalCount);

	// 생성한 쓰레드의 커널 오브젝트 Usage Count를 감소 (리소스 해제 요청)
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
		CloseHandle(hThread[i]);

	return 0;
}

 

인터락 함수를 기반으로 구현하게 되면 크리티컬 섹션 동기화 기법과 달리 초기화나 소멸의 과정이 불필요해집니다.

그래서 간단한 경우에는 인터락 함수를 사용하는 것도 고려해볼 수 있습니다.

 

+ 추가사항 - volatile이란?

LONG InterlockedIncrement(
  LONG volatile *Addend
);

LONG InterlockedDecrement(
  LONG volatile *Addend
);

 

위에서 사용된 인터락 함수의 원형은 이와 같습니다.

그런데 여기에 volatile이라는 난생 처음보는 키워드가 있습니다.

사실 흔히 접하게 되는 키워드가 아니다보니 이해하기가 어려울 수 있습니다. (저도 그랬습니다)

이 키워드는 C, C++ 언어에서 사용하는 ANSI 표준 키워드입니다.

이 키워드는 크게 두 가지의 의미를 가지고 있습니다.

 

1. 최적화를 수행하지 마라.

int function(void)
{
    int a = 10;
    a = 20;
    a = 30;
    cout << a;
}

 

위와 같은 코드가 있습니다.

보다시피 a에 10을 대입하고 그 다음에는 20을, 마지막에는 30이 대입되고 출력을 하게 됩니다.

그래서 실질적으로는 a = 30이라는 문장 하나만 수행하면 끝인 코드입니다.

실제로 컴파일러 입장에서는 결과만 놓고 보면 30이 될 것이니 불필요한 변경은 다음과 같이 최적화를 하게 됩니다.

int function(void)
{
    int a = 30;
    cout << a;
}

 

그런데 이 최적화를 수행하면 안되는 경우가 있습니다.

간단한 예로 '임베디드 시스템'을 예로 들어볼까 합니다.

임베디드 시스템을 구성할 때에는 메모리 맵(Memory Map) 디자인이라고 하는 과정을 거칩니다.

여기서 출력을 위해 사용되는 LCD나 소리를 내는데 사용되는 오디오 칩과 같은 하드웨어 장치에도 주소를 할당합니다.

다시 말하면 메모리 주소가 RAM과 같은 저장장치에만 할당되는 것이 아니라 하드웨어 장치에도 할당이 되는 것입니다.

임베디드 시스템을 설계하는 과정에서 오디오 칩에 0x30000번지를 할당했다고 가정해봅시다.

이 버퍼에 데이 데이터 1을 입력하면 '도', 2를 입력하면 '미', 3을 입력하면 '솔'이라는 음이 나오게 됩니다.

그래서 다음과 같은 코드를 작성하게 되면 "도, 미, 솔, 도"라는 음이 출력되어야 합니다.

 

int function(void)
{
    int* pSound = 0x30000;
    *pSound = 1;
    *pSound = 2;
    *pSound = 3;
    *pSound = 1;
}

 

그런데 이 코드를 컴파일을 하면 컴파일러가 최적화를 수행해서 다음과 같은 코드가 됩니다.

int function(void)
{
    int* pSound = 0x30000;
    *pSound = 1;
}

 

실제로 의도했던 "도, 미, 솔, 도"가 아닌 "도"만 출력되고 끝나는 결과가 나옵니다.

이 문제를 해결하기 위해 키워드를 붙여 컴파일러에게 '최적화를 수행하지 말아달라'라고 요청을 합니다.

int function(void)
{
    int volatile* pSound = 0x30000;
    *pSound = 1;
    *pSound = 2;
    *pSound = 3;
    *pSound = 1;
}

 

volatile 키워드가 붙으면 컴파일러는 이 포인터를 기반으로 하는 모든 연산에 대해서 최적화를 고려하지 않게 됩니다.

실제로 임베디드 개발자들에게 키워드 volatile은 익숙한 키워드라고 볼 수 있습니다.

 

2. 메모리에 직접 연산하라.

int function(void)
{
    int* pSound = 0x30000;
    SleepUntil(3, 35, 12);
    *pSound = 3;
}

 

이 개념은 캐시 메모리와 관련이 있으므로 알고 있다는 가정 하에 추가로 설명하는 부분입니다.

앞에서 작성했던 예제의 연장선상에서 설명하겠습니다.

SleepUntil이라는 가상의 함수가 있습니다.

이 함수를 이용하여 3시간 35분 12초 뒤에 딱 맞춰서 '솔'이라는 음을 내기 위해 다음과 같이 프로그램을 작성합니다.

그러면 시간이 되었을 때 pSound가 가리키는 주소에 데이터 2가 입력되면서 소리가 나야 합니다.

그런데 여기서 문제가 생깁니다.

성능 향상을 위해서 동작 중이던 캐시 매니저가 값을 메모리(오디오 칩)가 아닌 캐시 메모리에 저장한 것입니다.

언젠가는 캐시에 저장된 데이터가 메모리에 저장되기 때문에 '솔'이라는 음이 발생하긴 할겁니다.

다만 우리가 원하는 시점에서 이 소리를 들을 수가 없게 됩니다.

 

여기서도 마찬가지로 volatile 키워드를 이용하면 위와 같은 문제를 막을 수 있습니다.

volatile 키워드가 선언되면 해당 데이터는 절대로 캐시되지 않습니다.

그래서 포인터를 volatile로 선언하게 되면 포인터가 가리키는 메모리 공간으로 전송되는 데이터는 캐시가 되지 않습니다.

위 예제에서 volatile 키워드가 사용이 되었다면 캐시 메모리가 아닌 해당 메모리 공간으로 바로 전송이 이뤄지게 됩니다.

 

인터락 함수의 선언을 보면 전달인자가 volatile 키워드가 붙어있습니다.

이는 전달되는 포인터를 이용해서 함수 내부적으로 최적화를 수행하지 않음을 의미합니다.

그리고 해당 포인터가 가리키는 메모리 영역을 캐시하지 않겠다는 것을 의미합니다.

따라서 우리가 변수를 직접 volatile로 선언하고 이렇게 선언된 변수의 주소값을 인자로 전달할 필요가 없습니다.

[커널 모드 동기화(Synchronization In Kernel mode)]

지금까지 유저 모드 동기화 기법에 대한 소개를 했습니다.

이번에는 커널 모드 동기화 기법에 대해서 정리를 해보려고 합니다.

유저 모드 동기화 기법과 비교하면 성능은 떨어질 수밖에 없습니다.

유저 모드에서 커널 모드로의 전환, 그리고 다시 커널 모드에서 유저 모드로 전환을 필요로 하기 때문입니다.

하지만 커널 모드 동기화 기법은 유저 모드와 달리 Windows 커널 레벨에서 제공해주는 동기화 기법입니다

따라서 유저 모드 동기화에서 제공해주지 못하는 기능을 제공받을 수 있습니다.

커널 모드 동기화 기법은 크게 뮤텍스(Mutex)세마포어(Semaphore) 기법이 있습니다.

[뮤텍스(Mutex) 기반의 동기화]

앞서 소개했던 유저 모드 동기화 기법의 크리티컬 섹션 동기화 기법과 큰 차이가 없습니다.

크리티컬 섹션 동기화 기법에서 열쇠가 되었던 것이 크리티컬 섹션 오브젝트였습니다.

그리고 이는 CRITICAL_SECTION이라는 자료형의 변수였죠.

여기서는 열쇠의 역할을 뮤텍스 오브젝트(이하 뮤텍스)가 대신합니다.

크리티컬 섹션 오브젝트를 만드는 것과 달리 CreateMutex라는 함수 호출을 통해 뮤텍스를 생성하게 됩니다.

HANDLE CreateMutex(
  LPSECURITY_ATTRIBUTES lpMutexAttributes,	// 보안(상속) 설정
  BOOL                  bInitialOwner,		// 뮤텍스의 소유 여부
  LPCSTR                lpName				// 뮤텍스 이름을 지정
);

 

보시면 반환형이 HANDLE로 되어있습니다.

즉, 뮤텍스는 커널 오브젝트라는 것이 됩니다.

그리고 두 번째 속성은 TRUE를 줄 경우 뮤텍스를 생성한 쓰레드에게 오브젝트를 먼저 획득할 기회를 줄 수 있습니다.

FALSE를 줄 경우에는 어떤 쓰레드건 먼저 선점하는 방식으로 결정됩니다.

마지막 인자는 뮤텍스에 이름을 지정하는 것으로 '이름있는 뮤텍스' 부분에서 이어서 설명하겠습니다.

 

보다시피 핸들을 반환하기 때문에 뮤텍스는 커널 오브젝트입니다.

그리고 커널 오브젝트는 Signaled와 Non-Signaled라는 상태를 지니게 됩니다.

만약 뮤텍스가 어떤 상황에서 Signaled가 되고 Non-Signaled 상태가 되는지를 알아야 합니다.

뮤텍스는 열쇠에 비유된다고 했습니다.

그래서 뮤텍스를 획득하게 되었을 때는 Non-Signaled 상태가 됩니다.

이후 뮤텍스를 반환했을 때 Signaled 상태가 됩니다.

다시 말하면, 뮤텍스는 누군가에 의해 획득이 가능할 때 Signaled 상태에 놓인다는 것입니다.

 

이제 뮤텍스를 획득하고 반환하는 함수에 대해서 알아보겠습니다.

뮤텍스를 획득할 때는 WaitForSingleObject 함수를 사용합니다.

여러분들도 아시다시피, WaitForSingleObject 함수는 Signaled 상태일 때 반환을 하는 함수입니다.

그리고 전달된 핸들의 커널 오브젝트가 반환 시 Signaled 상태일 때, Non-Signaled로 변경하는 특성이 있습니다.

뮤텍스의 반환에는 ReleaseMutex라는 함수를 통해 Non-Signaled 상태인 뮤텍스를 Signaled 상태로 되돌립니다.

 

뮤텍스를 이용한 임계 영역의 동기화 과정을 그림으로 표현하면 위와 같이 됩니다.

다음은 뮤텍스를 이용하여 동기화를 적용한 예제 코드입니다.

 

[CriticalSectionSyncMutex.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: CriticalSectionSyncMutex.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-08
* 이전 버전 작성 일자:
* 버전 내용: 커널모드 동기화 기법(1) - Mutex 기반 동기화 예제
* 이전 버전 내용:
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>

#define NUM_OF_GATE 6

// 전역 변수로 사용. -> 쓰레드는 공유 가능
LONG gTotalCount = 0;

// Mutex 오브젝트를 선언
// 커널에 의해 생성되는 오브젝트이므로 HANDLE값을 가지게 된다.
HANDLE hMutex; // CRITICAL_SECTION hCriticalSection;

void IncreaseCount()
{
	// 크리티컬 섹션의 메커니즘과 유사하다
	// 차이점은 유저 모드 / 커널 모드에서 지원 여부다.
	// 뮤텍스는 커널 모드에서 지원하므로 커널 오브젝트다.
	
	// Signaled -> Non-Signaled
	WaitForSingleObject(hMutex, INFINITE); // EnterCriticalSection(&hCriticalSection);
	
	gTotalCount++; // 임계 영역(Critical Section)
	
	// Non-Signaled -> Signaled
	ReleaseMutex(hMutex); // LeaveCriticalSection(&hCriticalSection);
}

unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
	for (DWORD i = 0; i < 1000; i++)
	{
		IncreaseCount();
	}

	return 0;
}

int _tmain(int argc, TCHAR argv[])
{
	DWORD dwThreadID[NUM_OF_GATE];
	HANDLE hThread[NUM_OF_GATE];

	// InitializeCriticalSection(&hCriticalSection);
	// 뮤텍스를 생성
	hMutex = CreateMutex(
		NULL, // 디폴트 보안 속성 (상속)
		FALSE, // 누구나 소유할 수 있도록 함 -> 선점형 / TRUE로 설정할 경우 뮤텍스를 생성하는 쓰레드가 우선적으로 소유하게 된다
		NULL // 이름을 정하지 않음
	);

	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		hThread[i] = (HANDLE)_beginthreadex(
			NULL, // 보안(상속) 속성, 안하므로 NULL
			0, // 쓰레드의 스택 크기, 0으로 주면 디폴트값(1M)
			ThreadProc, // 쓰레드의 main이 될 함수
			NULL, // 쓰레드에 전달할 인자, 없으므로 NULL
			CREATE_SUSPENDED, // 생성 시 즉시 실행 여부를 인자로 전달, CREATE_SUSPENDED를 전달했으므로 바로 실행하지 않는다
			(unsigned*)&dwThreadID[i] // 쓰레드의 ID값을 저장할 주소값을 인자로 전달
		);

		// 쓰레드의 생성이 실패했을 경우
		if (hThread[i] == NULL)
		{
			_tprintf(TEXT("Thread Creation Fault\n"));
			return -1;
		}
	}

	// 모든 쓰레드를 실행 상태로 바꾸는 부분
	// 쓰레드 생성 또한 시간이 걸리는 작업이므로 동시 실행을 위해서 SUSPENDED로 생성 
	// ResumeThread 함수를 통해 모든 쓰레드를 실행 상태로 바꾼다. 
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
	{
		ResumeThread(hThread[i]);
	}

	// 모든 쓰레드의 작업이 끝나기를 기다리는 부분
	WaitForMultipleObjects(
		NUM_OF_GATE, // 관찰할 오브젝트의 갯수
		hThread, // 관찰할 오브젝트를 담고 있는 배열의 주소
		TRUE, // 모든 오브젝트가 Signaled 상태가 되면 값을 반환
		INFINITE // 무한정으로 대기
	);

	_tprintf(TEXT("total count: %d\n"), gTotalCount);

	// 생성한 쓰레드의 커널 오브젝트 Usage Count를 감소 (리소스 해제 요청)
	for (DWORD i = 0; i < NUM_OF_GATE; i++)
		CloseHandle(hThread[i]);

	// 뮤텍스는 커널 오브젝트이므로 핸들을 반환 및 Usage Count 1 감소
	CloseHandle(hMutex); // DeleteCriticalSection(&hCriticalSection);

	return 0;
}

 

크리티컬 섹션 동기화 기법과 비교했을 때 크게 바뀌는 부분이 없습니다.

[세마포어(Semaphore) 기반의 동기화]

세마포어는 뮤텍스와 상당히 유사하다고 말할 수 있습니다.

사실 뮤텍스는 세마포어의 일종입니다.

세마포어 중에서 단순화된 세마포어(Binary Semaphore)를 가리켜 뮤텍스(Mutex)라고 합니다.

앞에서 뮤텍스에 대해 설명을 하였고, 뮤텍스가 어떻게 동작하는지도 알고 있습니다.

그래서 뮤텍스를 알고 있다면 사실상 세마포어를 거의 다 알고 있는 것과 진배 없습니다.

그런데 Windows에서는 뮤텍스와 세마포어를 나눠서 동기화 기법을 제공하고 있습니다.

어떤 차이가 있어서 그런데, 그것은 카운트(Count) 기능의 존재 여부에 따라서 달라집니다.

우리가 알고 있는 뮤텍스에는 카운트 기능이 없었습니다.

그렇다는 말은 세마포어에는 카운트 기능이 있다는 말입니다.

쉽게 생각하면 여러 개의 열쇠가 있다고 생각하면 될 것 같습니다.

 

함수 사용 방법은 뮤텍스와는 큰 차이가 없지만 유의해야 할 부분은 세마포어의 생성 부분입니다.

이 부분만 짚고 넘어가겠습니다.

HANDLE CreateSemaphore(
  LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
  LONG                  lInitialCount,  // 세마포어의 카운트 = 임계 영역에 동시 접근할 수 있는 쓰레드 수
  LONG                  lMaximumCount,	// 세마포어의 최대 카운트 (InitialCount 이상)
  LPCSTR                lpName
);

 

여기서 주목해야 할 부분은 두 번째와 세 번째 인자입니다.

두 번째 인자에는 세마포어가 지니게 되는 값으로 카운트를 가지게 됩니다.

이 값을 통해서 임계 영역에 동시 접근 가능한 쓰레드의 개수를 제한합니다.

세 번째 인자는 세마포어가 지닐 수 있는 카운트의 최대 크기를 지정하게 됩니다.

여기서 주의해야 할 점은 최대 카운트는 반드시 두 번째 인자 이상의 값을 가져야 합니다.

만약 세 번째 인자의 값이 1이 된다면 세마포어는 바이너리 세마포어, 즉 뮤텍스와 동일하게 동작하게 됩니다.

 

이제 세마포어를 획득하고 반환하는 함수에 대해서 알아보겠습니다.

뮤텍스 때와 마찬가지로 세마포어를 획득할 때는 WaitForSingleObject 함수를 사용합니다.

WaitForSingleObject 함수는 Signaled 상태일 때 반환을 하는 함수입니다.

그리고 전달된 핸들의 커널 오브젝트가 반환 시 Signaled 상태일 때, Non-Signaled로 변경하는 특성이 있습니다.

단, 세마포어에서는 카운트라는 개념이 있습니다.

그래서 WaitForSingleObject 함수가 호출 될 때마다 카운트가 1씩 감소하게 됩니다.

세마포어의 카운트가 1 이상인 경우에는 Signaled 상태가 됩니다.

그리고 카운트가 0일 때 Non-Signaled가 되어 임계 영역에 들어갈 수 없게 합니다.

세마포어의 반환에는 ReleaseSemaphore라는 함수를 통해 세마포어의 카운트를 증가시키게 됩니다.

 

 

세마포어를 이용한 임계 영역의 동기화 과정을 그림으로 표현하면 위와 같이 됩니다.

다음은 세마포어를 이용하여 동기화를 적용한 예제 코드입니다.

 

[CriticalSectionSyncSemaphore.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: CriticalSectionSyncSemaphore.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-08
* 이전 버전 작성 일자:
* 버전 내용: 커널모드 동기화 기법(2) - Semaphore 기반 동기화 예제
* 이전 버전 내용:
*/

/* '명동교자'라는 가게에 대한 시뮬레이션을 한다고 가정 
* 시뮬레이션 제한 요소:
* 1. 테이블은 총 10개, 동시에 총 10명의 고객만 받을 수 있음
* 2. 점심 시간에 식사를 하러 올 고객은 50명으로 예상됨
* 3. 고객의 식사 시간은 10~30분 사이
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>
#include <time.h>

#define NUM_OF_CUSTOMER 50 // 최대 고객 수
#define RANGE_MIN 10 // 최소 식사 시간 범위
#define RANGE_MAX (30 - RANGE_MIN) // 최대 식사 시간 범위
#define TABLE_CNT 10 // 테이블 수

// 세마포어 선언
HANDLE hSemaphore;
// 난수를 통해 계산한 식사 시간(10~30분)을 저장하는 배열
DWORD randTimeArr[50];
// 테이블 갯수 변화 확인용 변수
DWORD leftTblCnt = TABLE_CNT;
// 총 식사 시간
DWORD total_Time = 0;

// 식사를 하는 상황을 함수로 구현
void TakeMeal(DWORD time)
{
	// 식사를 하기 위해 손님이 들어오는 상황.
	// 테이블 == 세마포어, 손님 == 쓰레드
	// Count가 0이면 Non-Signaled, Count가 1이상이면 Signaled
	WaitForSingleObject(hSemaphore, INFINITE);
	_tprintf(TEXT("Enter Customer: %d \n"), GetCurrentThreadId());
	leftTblCnt--;
	_tprintf(TEXT("Left Table Count: %d\n\n"), leftTblCnt);
	

	// 식사 중
	_tprintf(TEXT("Cumstomer %d having lunch\n"), GetCurrentThreadId());
	Sleep(1000 * time);
	total_Time += time;

	// 식사를 마치고 나감
	// 두 번째 인자는 세마포어의 카운트를 얼마나 증가시킬 것인가를 인자로 받음
	// 통상적으로는 1을 쓰는게 다반사임
	// 최대 카운트를 넘는 값을 증가시킬 경우에는 값은 변경되지 않고 FALSE를 반환
	ReleaseSemaphore(hSemaphore, 1, NULL);
	leftTblCnt++;
	_tprintf(TEXT("Out Customer: %d / Time spent: %d  \n"), GetCurrentThreadId(), time);
	_tprintf(TEXT("Left Table Count: %d\n\n"), leftTblCnt);
}

// 쓰레드의 main이 될 함수
unsigned int WINAPI ThreadProc(LPVOID lpParam)
{
	TakeMeal((DWORD)lpParam);
	return 0;
}

int _tmain(int argc, TCHAR* argv[])
{
	DWORD dwThreadID[NUM_OF_CUSTOMER]; // 쓰레드의 ID를 담을 변수
	HANDLE hThread[NUM_OF_CUSTOMER]; // 쓰레드 오브젝트

	// 난수 생성에 현재 시간을 seed로 설정
	// srand는 난수 생성에 사용할 seed를 설정한다.
	srand((unsigned)time(NULL));

	// 쓰레드에 전달할 난수값 총 50개 생성
	for (DWORD i = 0; i < NUM_OF_CUSTOMER; i++)
		// 난수 생성에 대해서 지식이 부족하여 이 부분은 추가로 정리
		// rand 함수는 0부터 0x7fff(32767)까지의 난수를 정수값으로 반환한다.
		// 그래서 RAND_MAX의 값이 32767인 것.
		// 만약 해당 범위 내에서 0~99까지의 난수를 구하고 싶다면 100의 나머지를 구하면 된다. -> rand() % 100
		// rand() / RAND_MAX 은 0과 1만 나올 수 있는 경우다.
		// 여기서 실수형으로의 형 변환을 통해 0.0 ~ 1.0 사이의 값이 나오게 된다.
		// 아래의 식을 해석하면 (0.0 ~ 1.0 중 하나) * 20 + 10이 된다.
		// 그래서 생성되는 난수는 10~30 사이가 된다.
		randTimeArr[i] = (DWORD)(((double)rand() / (double)RAND_MAX) * RANGE_MAX + RANGE_MIN);

	// 세마포어 생성
	hSemaphore = CreateSemaphore(
		NULL, // 보안(상속) 속성 설정 -> 상속하지 않는다
		TABLE_CNT, // 세마포어 Count 초기값 설정
		TABLE_CNT, // 세마포어 Count 최대값 설정
		NULL // 세마포어의 이름 설정, 없다면 NULL
	);

	// 세마포어 생성에 실패했다면
	if (hSemaphore == NULL)
		_tprintf(TEXT("Create Semaphore Error: %d\n"), GetLastError()); // 에러코드를 출력

	// Customer가 될 쓰레드 생성
	for (DWORD i = 0; i < NUM_OF_CUSTOMER; i++)
	{
		hThread[i] = (HANDLE)_beginthreadex(
			NULL, // 보안(상속) 속성, 상속 안하므로 NULL
			0, // 쓰레드의 스택 크기, 0으로 줬으므로 디폴트(1M)
			ThreadProc, // 쓰레드의 main이 될 함수
			(void*)randTimeArr[i], // 쓰레드에 전달할 인자. 전달 인자 데이터형이 void*이므로 형 변환하여 전달
			CREATE_SUSPENDED, // 쓰레드 생성 시 실행 여부를 전달, 바로 실행하지 않음
			(unsigned*)&dwThreadID[i] // 쓰레드의 ID값을 저장할 주소값을 인자로 설정
		);

		// 쓰레드 생성이 실패했다면
		if (hThread[i] == NULL)
		{
			_tprintf(TEXT("Thread Creation Failed\n"));
			return -1;
		}
	}

	// 쓰레드 생성이 완료되면 순차적으로 쓰레드 실행
	for (DWORD i = 0; i < NUM_OF_CUSTOMER; i++)
		ResumeThread(hThread[i]);

	// 모든 쓰레드가 Signaled가 될 때까지(작업이 끝날 때까지) 블로킹
	WaitForMultipleObjects(
		NUM_OF_CUSTOMER, // 관찰할 오브젝트의 수
		hThread, // 관찰할 오브젝트를 담고 있는 배열
		TRUE, // 모든 오브젝트가 Signaled 상태가 되면 값을 반환, 하나라도 Signaled가 되서 반환을 원한다면 FALSE
		INFINITE // 무한정 대기
	);

	_tprintf(TEXT("Estimated time to complete meal serving: %d\n"), total_Time);
	_tprintf(TEXT("-----END-----\n"));

	// 쓰레드의 핸들을 반환하는 과정
	for (DWORD i = 0; i < NUM_OF_CUSTOMER; i++)
		CloseHandle(hThread[i]);

	// 세마포어의 핸들을 반환
	CloseHandle(hSemaphore);
	return 0;
}

 

+ 추가사항 - WaitForMultipleObject에 대해

쓰레드를 생성하는 갯수는 메모리가 허용하는 내에서 생성이 가능하다고 했습니다.

그런데 WaitForMultipleObject 함수에서 관찰할 수 있는 커널 오브젝트의 수는 MAXIMUM_WAIT_OBJECT 입니다.

이 매크로에 정의되어 있는 상수는 현재 64입니다.

그래서 64개의 쓰레드까지만 WaitForMultipleObject 함수를 통해서 Signaled 상태가 되는 것을 확인 가능합니다.

 

[이름있는 뮤텍스(Named Mutex) 기반의 동기화]

앞서 설명했던 뮤텍스와 세마포어의 생성 함수에서 마지막 인자가 이름을 지정하는 인자였습니다.

지금까지 사용했던 예제들에서는 모두 NULL을 지정했습니다.

그럼 이 이름은 도대체 어디서 쓰는 것일까요?

바로 프로세스 간의 쓰레드 동기화에서 사용하게 됩니다.

 

위의 그림과 같이 서로 다른 프로세스 영역에 존재하는 쓰레드가 뮤텍스를 이용해서 동기화하는 상황입니다.

프로세스 A의 요청에 의해서 뮤텍스가 생성되었다고 가정해봅시다.

뮤텍스는 커널 오브젝트이기 때문에 프로세스 A에 종속적이지 않습니다.

운영체제에 종속적이기 때문에 다음과 같이 동기화하는 것도 충분히 가능합니다.

다만, 핸들의 유효성에 대해서 생각해볼 필요가 있습니다.

 

 

앞서 말했던대로 프로세스 A에서 뮤텍스 생성을 요청하였습니다.

그래서 프로세스 A의 핸들 테이블에는 뮤텍스의 핸들 정보가 담겨져 있게 됩니다.

하지만 프로세스 B의 핸들 테이블에는 뮤텍스의 핸들 정보가 없는 상황입니다.

이럴 때 사용할 수 있는 것이 바로 '이름'입니다.

이 이름은 Windows 내에서 유일한 이름(고유한 값)이 됩니다.

그래서 이름을 통해 커널 오브젝트에 접근 가능한 핸들 정보를 얻을 수 있습니다.

 

[NamedMutex.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: NamedMutex.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-08
* 이전 버전 작성 일자:
* 버전 내용: 커널모드 동기화 기법(3) - NamedMutex 기반 동기화 예제
* 이전 버전 내용:
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>

HANDLE hMutex; // 뮤텍스 오브젝트 선언
DWORD dwWaitResult; // WaitForSingleObject의 반환값을 받기 위한 변수

//임계 영역
void ProcessBaseCriticalSection()
{
	// WaitForSingleObject의 반환값을 통해 결과를 확인하기 위해 변수에 저장
	// Signaled 상태에서 Non-Signaled 상태가 된다면 WAIT_OBJECT_0을 반환
	// 그게 아니라면 대기(블로킹)
	// 타임아웃을 설정해놓았다면, 타임아웃 시간이 다 지난 후에는 WAIT_TIMEOUT 반환
	// WAIT_ABANDONED는 정상적으로 뮤텍스의 반환이 이뤄지지 않았을 경우에 반환
	dwWaitResult = WaitForSingleObject(hMutex, INFINITE);

	switch (dwWaitResult)
	{
	case WAIT_OBJECT_0: // 쓰레드가 뮤텍스를 소유한 경우
		_tprintf(TEXT("Thread got mutex\n"));
		break;
	case WAIT_TIMEOUT: // time-out이 발생한 경우
		_tprintf(TEXT("Time out\n"));
		return;
	case WAIT_ABANDONED: // 뮤텍스의 반환이 적절하게 이뤄지지 않은 경우
		return;
	}

	for (DWORD i = 0; i < 5; i++)
	{
		_tprintf(TEXT("Thread is running: %d\n"), i);
		Sleep(10000);
	}

	ReleaseMutex(hMutex); // Non-Signaled가 된 뮤텍스를 Signaled로
}

int _tmain(int argc, TCHAR* argv[])
{
// 조건부 컴파일을 수행하는 부분
// 0이라면 이름있는 뮤텍스(Named Mutex)를 생성, 아니라면 이름있는 뮤텍스에 접근한다.
// if 1로 설정하고 빌드 후, 실행 파일 명은 NamedMutex.exe로
// 그리고 if 0으로 설정 후 빌드하고 실행 파일명을 OpenMutex.exe로
#if 0
	hMutex = CreateMutex(
		NULL,
		FALSE,
		TEXT("NamedMutex")
	);
#else
	hMutex = OpenMutex(
		MUTEX_ALL_ACCESS, // 접근 권한 요청, MUTEX_ALL_ACCESS를 전달하여 접근 권한을 요청한다.
		FALSE, // 상속 관련 인자, 상속을 하지 않으므로 FALSE
		TEXT("NamedMutex") // 뮤텍스에 이름을 설정하였다면 해당 이름의 뮤텍스를 찾는다. 있다면 핸들의 반환과 핸들테이블에 핸들값이 추가된다.
	);
#endif

	if (hMutex == NULL) // 뮤텍스가 제대로 생성되지 않았을 경우
	{
		_tprintf(TEXT("Create Mutex Error: %d\n"), GetLastError());
		return -1;
	}

	// 임계 영역인 함수 호출
	ProcessBaseCriticalSection();

	// 뮤텍스의 핸들을 반환, Usage Count 1감소
	CloseHandle(hMutex);
	return 0;
}

 

코드를 보시면 조건부 컴파일을 하는 매크로 부분이 있습니다.

이 조건부 컴파일을 통해서 하나는 뮤텍스를 생성하는 프로세스가 됩니다. (CreateMutex)

그리고 다른 프로세스는 이름을 통해 뮤텍스의 핸들을 받아오게 됩니다. (OpenMutex)

특별한 내용이 있는 코드는 아니지만, 서로 다른 프로세스 간의 쓰레드 동기화도 가능하다는 것을 알아두시길 바랍니다.

 

[뮤텍스의 소유와 WAIT_ABANDONED]

이전에 WaitForSingleObject 함수에서 반환되는 값 중에 WAIT_ABANDONED라는 값이 있다고 했습니다.

이 값이 발생하는 경우는 뮤텍스와 관련되어 있습니다.

뮤텍스와 세마포어의 차이는 엄밀하게 따지면 '소유'의 개념에서 약간의 차이가 있습니다.

뮤텍스의 경우에는 쓰레드가 뮤텍스를 획득하게 되면 뮤텍스를 획득한 쓰레드가 반드시 반환을 해야합니다.

하지만 세마포어는 쓰레드가 세마포어를 획득한 쓰레드와 세마포어를 반환하는 쓰레드가 일치할 필요가 없습니다.

위의 말이 어려울 수도 있으니 조금 쉽게 정리하면 다음과 같습니다.

 

"뮤텍스는 획득한 쓰레드가 직접 반환하는 것이 원칙이다. 획득한 쓰레드만이 반환하는 것이 가능하다.

그러나 세마포어와 그 이외의 동기화 오브젝트는 도서관처럼 다른 쓰레드가 반환을 해도 문제가 되지 않는다."

 

이제 다시 돌아와서 뮤텍스와 WaitForSingleObject 함수에 대해서 이야기를 해보겠습니다.

쓰레드 A와 B가 있고, 뮤텍스 C가 있다고 가정하겠습니다.

여기서 쓰레드 A가 뮤텍스 C를 획득하고 임계 영역에 들어가게 됩니다.

그리고 쓰레드 B는 뮤텍스 C가 반환될 때까지 블로킹 상태에 있게 됩니다.

그런데 쓰레드 A가 임계 영역에 들어가 있는 도중 느닷없이 종료가 됩니다. (이유 불문하고)

쓰레드 A는 ReleaseMutex 함수를 통해 뮤텍스를 반환해야하지만 정상적으로 반환이 되지 않은 상태입니다.

이 상황에서 쓰레드 B는 이걸 무한정 기다릴 수 없습니다.

그래서 Windows에서는 이런 상황을 파악하고 정상적으로 반환할 수 없는 뮤텍스를 쓰레드 B에 넘겨주려고 합니다.

이 때 쓰레드 B에서 WaitForSingleObject를 통해 반환하게 되는 값이 WAIT_ABANDONED라는 값입니다.

 

마지막으로 WAIT_ABANDONED를 발생시키는 예제 코드입니다.

 

[MUTEX_WAIT_ABANDONED.cpp]

/*
* Windows System Programming - 쓰레드 동기화 기법(1)
* 파일명: MUTEX_WAIT_ABANDONED.cpp
* 파일 버전: 0.1
* 작성자: Sevenshards
* 작성 일자: 2023-12-09
* 이전 버전 작성 일자:
* 버전 내용: 커널모드 동기화 기법 - 뮤텍스 기법의 WAIT_ABANDONED에 대한 예제
* 이전 버전 내용:
*/

#include <stdio.h>
#include <tchar.h>
#include <Windows.h>
#include <process.h>

LONG gTotalCount = 0;
HANDLE hMutex;

unsigned int WINAPI IncreaseCountOne(LPVOID lpParam)
{
	// hThread1이 뮤텍스를 획득.
	// Signaled 상태에서 Non-Signaled 상태로 바뀐다.
	WaitForSingleObject(hMutex, INFINITE);
	gTotalCount++;

	// 문제는 ReleaseMutex를 하지 않으므로 계속 Signaled 상태를 유지한채 뮤텍스를 반환하지 않고 쓰레드는 종료된다.
	return 0;
}

unsigned int WINAPI IncreaseCountTwo(LPVOID lpParam)
{
	DWORD dwWaitResult = 0;
	// 그래서 두 번째 쓰레드는 뮤텍스를 획득하려고 하나, 획득을 하지 못하는 상황.
	dwWaitResult = WaitForSingleObject(hMutex, INFINITE);

	// 결과적으로 WAIT_ABANDONED가 나오게 되고, Windows 운영체제는 반환되지 않은 뮤텍스를 처리한다.
	switch (dwWaitResult)
	{
	case WAIT_OBJECT_0:
		ReleaseMutex(hMutex);
		break;
	case WAIT_ABANDONED:
		_tprintf(TEXT("WAIT_ABANDONED\n"));
		break;
	}

	gTotalCount++;

	ReleaseMutex(hMutex);
	return 0;
}

int _tmain(int argc, TCHAR* argv[])
{
	DWORD dwThreadIDOne;
	DWORD dwThreadIDTwo;

	HANDLE hThreadOne;
	HANDLE hThreadTwo;

	hMutex = CreateMutex(
		NULL,
		FALSE,
		NULL
	);

	if (hMutex == NULL) // 뮤텍스 생성을 실패한 경우
	{
		_tprintf(TEXT("CreateMutex Error Occured: %d\n"), GetLastError());
		return -1;
	}

	// 무례한 쓰레드 (ㅋㅋㅋ)
	hThreadOne = (HANDLE)_beginthreadex(
		NULL, // 보안(상속) 관련 속성
		0, // 쓰레드의 스택 사이즈, 0이면 기본 크기(1M)
		IncreaseCountOne, // 쓰레드의 메인이 될 함수
		NULL, // 함수에 전달할 인자 목록
		0, // 쓰레드 생성 시 제어 인자, 0일 경우 생성과 함께 바로 실행
		(unsigned*)&dwThreadIDOne // 쓰레드의 ID를 저장할 주소값
	);

	hThreadTwo = (HANDLE)_beginthreadex(
		NULL, // 보안(상속) 관련 속성
		0, // 쓰레드의 스택 사이즈
		IncreaseCountTwo, // 쓰레드의 메인이 될 함수
		NULL, // 함수에 전달할 인자 목록
		CREATE_SUSPENDED, // 쓰레드 생성 시 제어 인자, CREATE_SUSPENDED로 생성 후 바로 실행하지 않음(Blocked 상태, Suspend Count 1)
		(unsigned*)&dwThreadIDTwo // 쓰레드의 ID를 저장할 주소값
	);

	Sleep(1000);
	ResumeThread(hThreadTwo); // hThreadTwo를 실행 상태로 전환 (Blocked -> Ready, Suspend Count 0)

	// 쓰레드가 다시 뮤텍스를 얻는 과정
	WaitForSingleObject(hThreadTwo, INFINITE);
	_tprintf(TEXT("Total Count: %d\n"), gTotalCount);

	// 생성했던 커널 오브젝트의 핸들을 반환, Usage Count 1 감소
	CloseHandle(hThreadOne);
	CloseHandle(hThreadTwo);
	CloseHandle(hMutex);
	return 0;
}

 

실제로 실행을 시키면 WAIT_ABANDONED가 출력되는 것을 확인할 수 있습니다.

그리고 실행 결과는 기대했던대로 2가 나오게 됩니다.

다시 말해서 WAIT_ABANDONED 자체는 오류는 아닙니다.

Windows에서 뮤텍스의 소유와 관련하여 문제가 발생한 것을 인지하고 처리가 되었기 때문입니다.

그래서 WAIT_ABANDONED 반환값을 갖게 된 쓰레드가 뮤텍스를 소유하게 되고 결과가 2가 나온 것입니다.

엄밀히 따지면 프로그램의 흐름 상으로는 문제가 안된다고 말한 것이지, 프로그램 코드가 문제가 없다는 것은 아닙니다.

WAIT_ABANDONED를 예견해가면서 활용하는 프로그램 코드를 만드는 사람은 극히 소수라고 합니다.

그래서 디버깅과 관련해서 삽입되는 경우가 많다고 합니다.

sevenshards