본문 바로가기

윈도우 소켓 프로그래밍

[소켓프로그래밍] 6. 스레드 동기화 (임계 영역, 이벤트)

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


 

멀티스레드를 이용하는 경우, 두 개 이상의 스레드가 공유 데이터에 접근하면 여러 문제가 발생할 수 있다.

 

예를 들어 공유 변수 int money = 1000이 존재한다고 할 때, 스레드 #1이 money에 2000을 더하여 money = 3000이 되었다고 해보자. 이때 스레드 #2가 money = 3000이 되기 전에 이미 money를 read 해놓은 상태라고 해보자. 스레드 #2 입장에서는 money = 1000이기 때문에, 만약 이에 3000을 더해준다면 money = 4000이 된다.

 

이러한 상황이 발생하면 스레드 #1의 명령이 무시되는 것이므로, 오류로 볼 수 있다. 이러한 멀티스레드 환경에서의 문제를 해결하기 위한 일련의 작업을 스레드 동기화(Thread synchronization)라고 한다.

 

 

 

 

스레드 동기화 기법

종류 기능
임계 영역 (critical section) 공유 자원에 대해 오직 한 스레드의 접근만 허용 (한 프로세스에 속한 스레드 간에만 적용 가능)
뮤텍스 (mutex) 공유 자원에 대해 오직 한 스레드의 접근만 허용 (서로 다른 프로세스에 속한 스레드 간에도 적용 가능)
이벤트 (event) 사건 발생을 알려 대기 중인 스레드를 깨움
세마포어 (semaphore) 한정된 개수의 자원에 여러 스레드가 접근할 때, 자원을 사용할 수 있는 스래드 개수를 제한
대기 가능 타이머 (waitable timer) 정해진 시간이 되면 대기 중인 스레드를 깨움

 

 

 

 

 

스레드 동기화가 필요한 상황

  • 두 개 이상의 스레드가 하나의 공유 자원에 접근할 때
  • 한 스레드가 작업을 완료한 후, 대기중인 다른 스레드에 알려준다.

 

 

 


 

 

임계영역 (Critical Section)

어떠한 스레드 동기화 기법도 적용하지 않는다면?

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

#define MAXCNT 10000000
int g_count = 0;

DWORD WINAPI MyThread1(LPVOID arg)
{
	for (int i = 0; i < MAXCNT; i++) {
		g_count += 2;
	}

	return 0;
}


DWORD WINAPI MyThread2(LPVOID arg)
{
	for (int i = 0; i < MAXCNT; i++) {
		g_count -= 2;
	}

	return 0;
}

int main(int argc, char* argv[])
{

	// 스레드 두 개 생성
	HANDLE hThread[2];
	hThread[0] = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
	hThread[1] = CreateThread(NULL, 0, MyThread2, NULL, 0, NULL);

	// 스레드 두 개 종료 대기
	WaitForMultipleObjects(2, hThread, TRUE, INFINITE);

	// 결과 출력
	printf("g_count = %d\n", g_count);
	return 0;

	
}

 

 

  • MyThread1, MyThread2라는 두 개의 스레드를 생성하여 각각 실행될 때마다 count 값에 +2, -2를 더하도록 하였다.
  • 두 개의 스레드가 번갈아서 실행되도록 코드를 작성했기 때문에, MAXCNT번 만큼 스레드가 수행된 이후의 g_count는 0이어야 할 것이다.

 

 

  • 결과를 출력해보면 0이 나오지 않는다. 심지어 0에서 아주 먼 값이 나왔다!
  • 스레드 동기화가 적용되지 않은 상황이기 때문에 Thread1의 동작이 무시되었을 가능성이 높아보인다.
  • 이렇게 간단한 예제에서도 스레드 동기화가 이루어지지 않으면 아주 큰 오류가 발생함을 알 수 있다.
  • 이제 같은 예제에 임계영역을 적용해본다.

 

 

 

임계영역 적용

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

#define MAXCNT 10000000
int g_count = 0;
CRITICAL_SECTION cs;


DWORD WINAPI MyThread1(LPVOID arg)
{
	for (int i = 0; i < MAXCNT; i++) {
		EnterCriticalSection(&cs);
		g_count += 2;
		LeaveCriticalSection(&cs);
	}

	return 0;
}


DWORD WINAPI MyThread2(LPVOID arg)
{
	for (int i = 0; i < MAXCNT; i++) {
		EnterCriticalSection(&cs);
		g_count -= 2;
		LeaveCriticalSection(&cs);
	}

	return 0;
}

int main(int argc, char* argv[])
{

	// 임계영역 초기화
	InitializeCriticalSection(&cs);

	// 스레드 두 개 생성
	HANDLE hThread[2];
	hThread[0] = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
	hThread[1] = CreateThread(NULL, 0, MyThread2, NULL, 0, NULL);

	// 스레드 두 개 종료 대기
	WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
	DeleteCriticalSection(&cs);

	// 결과 출력
	printf("g_count = %d\n", g_count);
	return 0;

	
}

 

  • 임계영역을 적용했다.
  • 스레드가 호출될 때마다 EnterCriticalSection, LeaveCriticalSection을 수행하여 임계영역에 들어갔다가 나온다.
  • 이에 따라 한 스레드가 공유자원에 접근하여 작업중일 때 다른 스레드는 자원에 접근할 수 없게 된다.
  • 하지만 임계영역 기법은 지나치게 세밀한 단위로 동기화를 수행하기 때문에 성능저하가 두드러진다는 단점이 있다. (가장 극단적인 동기화 기법이다.)

 

 

 

 

 


이벤트 (Event)

이벤트는 한 스레드가 작업을 완료한 후에 대기중엔 스레드에게 이를 알리는 기법이다. 이벤트의 절차는 다음과 같다.

 

  1. 이벤트를 비신호 상태로 생성
  2. 한 스레드가 작업을 진행하고, 나머지 스레드는 Wait*() 함수를 호출해 이벤트가 신호상태가 될 때까지 대기함(sleep)
  3. 스레드가 작업을 완료하면 이벤트를 신호 상태로 변경
  4. 기다리고 있던 스레드 중 하나 혹은 전부가 깨어남 (wakeup)

 

 

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

#define BUFSIZE 10

HANDLE hReadEvent;
HANDLE hWriteEvent;
int buf[BUFSIZE];

DWORD WINAPI WriteThread(LPVOID arg)
{
	DWORD retval;

	for (int k = 1; k <= 500; k++) {
		// 읽기 완료 대기
		retval = WaitForSingleObject(hReadEvent, INFINITE);	// hReadEvent(읽기 이벤트)가 신호상태가 될 때 까지 기다림
		if (retval != WAIT_OBJECT_0) break;		// 

		// 공유 버퍼에 데이터 저장
		for (int i = 0; i < BUFSIZE; i++)
			buf[i] = k;


		// 쓰기 완료 알림
		SetEvent(hWriteEvent);	// hWriteEvent(쓰기 이벤트)를 신호 상태로 만들어 나머지 스레드를 wakeup
	}

	return 0;
}

DWORD WINAPI ReadThread(LPVOID arg)
{
	DWORD retval;

	while (1) {
		// 쓰기 완료 대기
		retval = WaitForSingleObject(hWriteEvent, INFINITE);	// hWriteEvent가 신호 상태가 되기를 기다림
		if (retval != WAIT_OBJECT_0) break;

		// 읽은 데이터 출력
		printf("Thread %4d: ", GetCurrentThreadId());		// 현재 어떤 스레드가 데이터를 읽고있는지 표시하기 위함
		for (int i = 0; i < BUFSIZE; i++)	// 공유 버퍼에서 데이터를 읽어서 출력함.
			printf("%3d", buf[i]);
		printf("\n");

		// 버퍼 초기화
		ZeroMemory(buf, sizeof(buf));

		// 읽기 완료 알림
		SetEvent(hReadEvent);
	}

	return 0;
}

int main(int argc, char* argv[])
{
	// 자동 리셋 이벤트 두 개 생성(각각 비신호, 신호 상태)
	hWriteEvent = CreateEvent(NULL, FALSE, FALSE, NULL);	// WriteEvent는 비신호 상태로 시작
	if (hWriteEvent == NULL) return 1;
	hReadEvent = CreateEvent(NULL, FALSE, TRUE, NULL);	// ReadEvent는 신호 상태로 시작
	if (hReadEvent == NULL) return 1;


	// 스레드 세 개 생성
	HANDLE hThread[3];
	hThread[0] = CreateThread(NULL, 0, WriteThread, NULL, 0, NULL);
	hThread[1] = CreateThread(NULL, 0, ReadThread, NULL, 0, NULL);
	hThread[2] = CreateThread(NULL, 0, ReadThread, NULL, 0, NULL);

	// 스레드 세 개 종료 대기
	WaitForMultipleObjects(3, hThread, TRUE, INFINITE);

	// 이벤트 제거
	CloseHandle(hWriteEvent);
	CloseHandle(hReadEvent);
	return 0;

}

 

 

  • hReadEvent(읽기 이벤트), hWriteEvent(쓰기 이벤트)가 서로 번갈아서 sleep / wakeup 되는 구조이다.
  • ReadEvent가 신호 상태로 시작한다. Read가 끝난 상태이니 Write를 시작하라는 의미이다.
  • 예를들어 Thread #1이 ReadEvent가 신호 상태임을 확인하고 Write를 수행하여 데이터를 버퍼에 저장한다. 이때 WriteEvent를 신호 상태로 만든다.
  • 그러면 Thread #2 또는 #3이 버퍼(공유 자원)에 접근하여 데이터를 읽어온다. 이때 Read를 마치면 ReadEvent를 신호 상태로 만든다.