스레드 동기화

bolee·2022년 4월 19일
2

스레드 동기화 필요성

멀티스레드를 이용하는 프로그램에서 스레드 2개 이상이 공유 데이터에 접근하면 다양한 문제가 발생할 수 있다.
이러한 멀티스레드 환경에서 발생하는 문제를 해결하기 위해 일련의 작업을 스레드 동기화(thread synchronization)라 한다. 윈도우 운영체제는 프로그래머가 상황에 따라 적절한 동기화 기법을 선택할 수 있도록 다양한 API 함수를 제공한다.

윈도우 운영체제에서 사용할 수 있는 대표적인 스레드 동기화 기법

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

여기에서는 네트워크 응용 프로그램에서 사용 빈도가 높은 임계 영역과 이벤트를 다룰 것이다.

스레드 동기화 기본 개념

스레드 동기화가 필요한 상황은 크게 다음 2가지 경우이다.

  1. 둘 이상의 스레드가 공유 자원에 접근한다.
  2. 한 스레드가 작업을 완료한 후, 기다리고 있는 다른 스레드에 알려준다.

두 경우 모두 각 스레드가 독립적으로 실행하지 않고 다른 스레드와의 상호 작용을 토대로 자신의 작업을 진행한다는 특징이 있다. 스레드 동기화를 하려면 스레드가 상호작용해야 하므로 중간 매개체가 필요하다. 두 스레드가 동시에 진행하면 안되는 상황이 있을 때, 두 스레드는 매개체를 통해 진행 가능 여부를 판단하고 이에 근거해 자신의 실행을 계속할지를 결정한다.

윈도우 운영체제에서 이러한 매개체 역할을 할 수 있는 것을 통틀어 동기화 객체(synchronization object)라고 한다. 동기화 객체의 특징은 아래와 같다.

  1. Create*()함수를 호출하면 커널(kernel: 운영체제의 핵심 부분을 뜻함) 메모리 영역에 동기화 객체가 생성되고, 이에 접근할 수 있는 핸들(HANDLE 타입)이 리턴된다.
  2. 평소에는 비신호 상태(non-signaled state)로 있다가 특정 조건이 만족되면 신호 상태(signaled state)가 된다. 비신호 상태에서 신호 상태로 변화 여부는 Wait*()함수를 사용해 감지할 수 있다.
  3. 사용이 끝나면 CloseHandle() 함수를 호출한다.

Wait*()함수는 스레드 동기화를 위한 필수 함수로, 동기화를 진행할때 비신호 -> 신호, 신호 -> 비신호 상태 변화 조건을 잘 이해해야 하며, 상황에 맞게 Wait*() 함수를 사용할 수 있어야 한다.

임계 영역(critical section)

임계 영역(critical section)둘 이상의 스레드가 공유 자원에 접근할 때, 오직 한 스레드만 접근을 허용해야 하는 경우에 사용한다. 임계 영역은 대표적인 스레드 동기화 기법이지만, 생성과 사용법이 달라서 앞에서 소개한 동기화 객체로 분류하지는 않는다.

  • 임계 영역은 일반 동기화 객체와 달리 개별 프로세스의 유저(user) 메모리 영역에 존재하는 단순한 구조체다. 따라서 다른 프로세스가 접근할 수 없으므로 *한 프로세스에 속한 스레드 간 동기화에만 사용한다.
  • 일반 동기화 객체보다 빠르고 효율적

임계영역 사용 예는 다음과 같다.

#include <windows.h>

CRIRICAL_SECTION cs;	// 1

DWORD WINAPI MyThread1(LPVOID arg)
{
	...
    EnterCriticalSection(&cs);	// 3
    // 공유 자원 접근
    LeaveCriticalSection(&cs);	// 4
    ...
}

DWORD WINAPI MyThread2(LPVOID arg)
{
	...
    EnterCriticalSection(&cs);	// 3
    // 공유 자원 접근
    LeaveCriticalSection(&cs);	// 4
    ...
}

int main(int argc, char **argv)
{
	...
    InitializeCriticalSection(&cs);	// 2
    // 스레드를 두개 이상 생성해 작업을 진행
    // 생성한 모든 스레드가 종료할 때까지 기다린다.
    DeleteCriticalSection(&cs);	// 5
    ...
}
  • 1: CRITICAL_SECTION 구조체 변수를 전역 변수로 선언한다. 일반 동기화 객체는 Create*()함수를 호출해 커널 메모리 영역에 생성하지만, 임계 영역은 유저 메모리 영역에 (대개는 전역 변수 형태로) 생성한다.
  • 2: 임계 영역을 사용하기 전에 InitializeCriticalSection() 함수를 호출해 초기화한다.
  • 3: 공유 자원에 접근하기 전에 EnterCriticalSection() 함수를 호출한다. 공유 자원을 사용하고 있는 스레드가 없다면 EnterCriticalSection() 함수는 곧바로 리턴한다. 하지만 공유 자원을 사용하고 있는 스레드가 있다면 EnterCriticalSection()함수는 리턴하지 못하고 스레드는 대기 상태가 된다.
  • 4: 공유 자원 사용을 마치면 LeaveCriticalSection() 함수를 호출한다. 이때 EnterCriticalSection()함수에서 대기 중인 스레드가 있다면 하나만 선택되어 깨어난다.
  • 5: 임계 영역을 사용하는 모든 스레드가 종료하면 DeleteCriticalSection() 함수를 호출해 삭제한다.

임계 영역 사용 시 주의점
임계 영역을 이용할 때 임계 영역을 이용해 공유 자원 접근을 제한하는 것으로 스레드 동기화 문제를 해결했다고 생각하는 것을 주의해야 한다. 반드시 기억해야 하는 것은 임계 영역만으로는 어느 스레드가 먼저 리소스를 사용할지 결정할 수 없다는 것이다. 즉, 어떤 스레드가 먼저 접근할 지 알 수 없다.

임계영역 연습 예제

임계 영역을 사용하지 않을 경우 문제가 발생하는 극단적인 상황을 예제를 작성하여 임계 영역의 효과를 연습해보자

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

#define MAXCNT	100000000
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)
{
	// 스레드 2개 생성
	HANDLE hThread[2];
	hThread1 = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
	hThread1 = CreateThread(NULL, 0, MyThread1, NULL, 0, NULL);
	// 스레드 2개 종료 대기
	WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
	// 결과 출력
	printf("g_count = %d\n", g_count);
	return 0;
}

실행 결과는 다음과 같다. 예상한 g_count가 0이 되어야 하지만 공유 자원 접근 제한을 하지 않아 매번 다른 값이 출력된다.

위에 코드를 바탕으로 임계 영역을 사용해 g_count 변수에 한 스레드만 접근하게 만들면 아래와 같다.

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

#define MAXCNT	100000000
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);

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

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

	// 임계 영역 삭제
	DeleteCriticalSection(&cs);

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

	return 0;
}

실행 결과는 다음과 같다. 동기화로 인한 오버헤드 때문에 결과가 나오는 시간이 걸리겠자만 항상 올바른 값(0)을 출력하는 것을 확인할 수 있다.
위 예제는 극단적인 상황에서 지나치게 세밀한 단위로 스레드 동기화를 하고 있어 성능 저하가 많이 느껴지지만, 실전에서 이와 같은 상황은 거의 발생하지 않기 때문에 스레드 동기화의 성능이 크게 문제 되지는 않는다.

이벤트(event)

이벤트(event)사건 발생을 다른 스레드에 알리는 동기화 기법이다.
이벤트를 사용하는 전형적인 절차는 다음과 같다.

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

이벤트는 대표적인 동기화 객체로, 신호와 비신호 2가지 상태를 가진다. 또한 상태를 변경할 수 있도록 다음과 같은 함수가 제공된다.

BOOL SetEvent(HANDLE hEvent);	// 비신호 -> 신호
BOOL ResetEvent(HANDLE hEvent);	// 신호 -> 비신호

이벤트는 특성에 따라 2종류가 있으며, 용도에 맞게 선택할 수 있어야 한다.

  • 자동 리셋(auto-reset) 이벤트: 이벤트를 신호상태로 바꾸면, 기다리고 있는 스레드 중 하나만 깨운 후 자동으로 비신호 상태가 된다. 즉, 자동 리셋 이벤트에 대해서는 ResetEvent() 함수를 사용할 필요가 없다.
  • 수동 리셋(manual-reset) 이벤트: 이벤트를 신호 상태로 바꾸면, 기다리고 있는 스레드를 모두 깨운 후 계속 신호 상태를 유지한다. 자동 리셋 이벤트와 달리 비신호 상태로 바꾸려면 명시적으로 ResetEvent() 함수를 호출해야 한다.

이벤트는 아래 이벤트 생성 함수 CreateEvent()를 사용해 생성한다.

// 성공: 이벤트 핸들, 실패: NULL
HANDLE CreateEvent(
	LPSECURITY_ATTRIBUTES	lpEventAttributes,
    BOOL					bManualReset,
    BOOL					bInitialState,
    LPCTSTR					lpName
);
  • lpEventAttributes: 핸들 상속(handle inheritance)과 보안 디스크립터(security descriptor) 관련 구조체로, 대부분은 기본값인 NULL을 사용하면 된다.
  • bManualReset: TRUE면 수동 리셋, FALSE면 자동 리셋 이벤트가 된다.
  • bInitialState: TRUE면 신호, FALSE면 비신호 상태로 시작한다.
  • lpName: 이벤트에 부여할 이름이다. NULL을 사용하면 이름 없는(anonymous) 이벤트가 생성되므로 같은 프로세스에 속한 스레드 간 동기화에만 사용할 수 있다. 서로 다른 프로세스에 속한 스레드 간 동기화를 하려면 같은 이름으로 생성해야 한다.

이벤트 연습 예제

데이터를 생성해 공유 버퍼에 저장하는 스레드 1개와 공유 버퍼에서 데이터를 읽어서 처리하는 스레드 2개를 생성할 것이다. 이 경우 한 스레드만 버퍼에 접근할 수 있게 해야하고, 접근 순서도 정해야한다. 스레드 실행 순서에 대한 제약 사항은 다음과 같다.

  1. 스레드 1이 쓰기를 완료 후 스레드 2나 스레드 3이 읽을 수 있다. 이때 스레드 2와 스레드 3 중 1개만 버퍼 데이터를 읽을 수 있으며, 일단 한 스레드가 읽기 시작하면 다른 스레드는 읽을 수 없다.
  2. 스레드 2나 스레드 3이 읽기를 완료하면 스레드 1이 다시 쓰기를 할 수 있다.
#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 i = 0; i <= 500; i++)
	{
		// 읽기 완료 대기
		// 읽기 이벤트가 신호 상태가 되기를 기다린다. 최초에는 읽기 이벤트가 신호 상태로 시작하기 때문에 곧바로 리턴해 다음 코드로 진행할 수 있다.
		retval = WaitForSingleObject(hReadEvent, INFINITE);
		if (retval != WAIT_OBJECT_0)
			break;

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

		// 쓰기 완료 알림
		// 쓰기 이벤트를 신호 상태로 만들어 두 읽기 스레드 중 하나을 대기 상태에서 깨운다.
		SetEvent(hWriteEvent);
	}

	return 0;
}

DWORD WINAPI ReadThread(LPVOID arg)
{
	DWORD retval;

	while (1)
	{
		// 쓰기 완료 대기
		// 쓰기 이벤트가 신호 상태가 되기를 기다린다. 최초에는 비신호 상태로 시작하기 때문에 이 지점에서 읽기 스레드는 대기 상태가 된다.
		retval = WaitEventSingleObject(hWriteEvent, INFINITE);
		if (retval != WAIT_OBJECT)
			break;

		// 읽은 데이터 출력
		printf("Thread %4d: ", GetCurrentThreadId());
		for (int i = 0; i < BUFSIZE; i++)
			printf("%3d\n", buf[i]);
		printf("\n");

		// 버퍼 초기화
		// 만약 데이터를 새로 쓰지 않은 생태에서 다시 읽게 된다면 0을 출력될 것이므로 오류 여부를 확인할 수 있다.
		ZeroMemory(buf, sizeof(buf));

		// 읽기 완료 알림
		// 읽기 이벤트를 신호 상태로 만들어 쓰기 스레드를 대기 상태에서 깨운다.
		SetEvent(hReadEvent);
	}

	return 0;
}

int main(int argc, char **argv)
{
	// 자동 리셋 이벤트 2개 생성(각각 비신호, 신호 상태)
	hWriteEvent = CreateEvent(NULL, FALSE, FALSE, NULL);	// 비신호
	if (hWriteEvent == NULL)
		return 1;

	hReadEvent = CreateEvent(NULL, FALSE, TRUE, NULL);	// 신호
	if (hReadEvent == NULL)
		return 1;

	// 스레드 3개 생성
	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);	// 읽기 스레드

	// 스레드 3개 종료 대기
	// 스레드 3개가 종료하기를 기다린다. 읽기 스레드는 별도의 루프 탈출 조건이 없어 사실상 영원히 리턴하지 못한다.
	WaitForMultipleObjects(3, hThread, TRUE, INFINITE);

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

실행 결과는 다음과 같다.

참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018

0개의 댓글