* 이번 포스팅은 멀티스레드를 사용하는 경우 두 개 이상의 스레드가 공유 데이터를 접근하는 경우 발생하는 문제를 알아보고 이를 해결하고자 사용하는 동기화 기법인 임계 영역, 뮤텍스, 이벤트, 세마포어등을 알아보겠습니다.
1. 동기화의 필요성
- 다음의 경우 멀티스레드 사용시 스레드 동기화가 필요하다.
● 두개 이상의 스레드가 공유 리소스를 접근할때, 오직 한 스레드만 접근을 허용해야 하는 경우.
● 특정 사건 발생을 다른 스레드에게 알리는 경우. 예를 들면, 한 스레드가 작업을 완료한후, 대기중인 다른 스레드를 깨우는 경우
간단히 다음의 그림 1을 보면서 설명하겠습니다.
그림 1) 스레드 동기화
- 위의 그림 1과 같은 경우 Thread1과 Thread2는 동시에 공유 변수 5에 접근하고 있습니다. 이 경우 Thread1은 공유 변수 5를 읽어 들여 1을 더하는 작업을 하고 그 결과 값 6을 공유 변수에 다시 쓰고 있습니다. Thread2도 공유 변수 5를 가져와 +1을 하고 그 값을 다시 공유 변수에 쓰고 있습니다. 이 경우 유저는 양쪽의 스레드에서 모두 +1을 했으니 +7의 값을 기대하고 있을 것 입니다. 즉 실제적으로 유저가 기대한 그림은 다음 그림 2와 같은 시나리오 일 것입니다.
그림 2) 동기화 적용
- 위와 같이 공유 변수에 대한 접근을 하나의 스레드로 제한 함으로써 정확한 값을 리턴 받을 수 있습니다. 즉 이와 같이 멀티스레드 환경에서 발생하는 문제를 해결하기 위한 일련의 작업을 스레드 동기화(Thread Synchronization)라 부릅니다. 윈도우는 다양한 동기화 관련 API를 제공하여 프로그래머가 상황에 따라 적절한 동기화 방법을 선택할 수 있도록 하고 있습니다. 이러한 스레드 동기화 기법은 다음 표1과 같습니다.
[표 1] - 스레드 동기화 기법
종류
용도
임계 영역(critical section)
* 공유 리소스에 대해 오직 하나의 스레드 접근만 허용한다.(한 프로세스에 속한 스레드에만 사용 가능)
뮤텍스(mutex)
* 공유 리소스에 대해 오직 하나의 스레드 접근만 허용한다.(서로 다른 프로세스에 속한 스레드에도 사용 가능)
이벤트(event)
* 특정 사건 발생을 다른 스레드에 알린다.
세마포어(semaphore)
* 한정된 개수의 자원을 여러 스레드가 사용하려고 할 때 접근을 제한한다.
* 동기화에 대한 더 자세한 내용은 제프리 리처의 "WINDOWS VIA C/C++"나 정덕영 저 "윈도우 구조와 원리"를 추천합니다.
2. 임계영역
- 임계역역은 두 개 이상의 스레드가 공유 리소스를 접근할 때 오직 한 스레드 접근만 허용해야 하는 경우에 사용한다. 임계 역역은 스레드 동기화를 위해 사용하지만 동기화 객체로 분류하지 않으며 특징 또한 다르다. 대표적인 특징은 다음과 같습니다.
● 임계 영역은 유저 영역 메모리에 존재하는 구조체다. 따라서 다른 프로세스가 접근 할 수 없으므로 한 프로세스에 속한 스레드 동기화에만 사용할 수 있다.
- 위의 소스를 그대로 실행하면 hThread[0]과 hThread[1]이 공유 변수 val을 한 번씩 점유해 hThread[0]은 앞에서부터 채워나가고 hThread[1]은 뒤에서부터 값을 채워나가니 결과적으로 배열에는 값 9, 3 이 대략 반반씩 채워지게 됩니다. 결과화면은 그림 3과 같습니다.
그림 3) 일반적인 실행화면
- 그렇다면 이번에는 임계 영역을 사용하여 하나의 스레드만이 공유 리소스를 점유하도록 해보겠습니다.
우선 CRITICAL_SECTION 구조체 변수를 전역 변수를 선언합니다.
임계 영역을 사용하기 전에 InitializeCriticalSection()함수를 호출하여 초기화 합니다.
공유 리소스를 사용하기 전에 EnterCriticalSection() 함수를 호출합니다. 공유 리소스를 사용하고 있는 스레드가 없다면 EnterCriticalSecrion() 함수는 곧바로 리턴합니다. 공유 리소스를 사용하고 있는 스레드가 있다면 EnterCriticalSecrion() 함수는 리턴하지 않고 해당 스레드는 대기 상태가됩니다.
공유 리소스 사용이 끝나면 LeaveCriticalSection() 함수를 호출합니다.
임계 영역 사용이 끝나면 DeleteCriticalSection() 함수를 호출합니다.
실행화면은 다음 그림 4와 같습니다. 소스는 위의 소스 1의 주석부분만 풀어주시면 됩니다.
그림 4) 임계 영역사용
3. 이벤트
- 이벤트는 특정 사건의 발생을 다른 스레드에 알리는 경우에 주로 사용합니다. 서두에 동기화의 필요성에서
● 특정 사건 발생을 다른 스레드에게 알리는 경우. 예를 들면, 한 스레드가 작업을 완료한 후, 대기중인 다른 스레드를 깨우는 경우
동기화가 필요하다고 했습니다. 즉 한 스레드가 작업을 완료한 후 대기 중인 다른 스레드가 깨어나서 진행하는 시나리오를 만들 때 이벤트를 이용합니다. 이벤트의 동기화의 간단한 과정은 다음과 같습니다.
1. 이벤트를 비신호 상태로 생성한다.
2. 한 스레드가 작업을 진행하고 나머지 스레드는 이벤트에 대해 Wait*()함수를 호출 함으로써 이벤트가 신호 상태가 되기를 기다린다.
3. 스레드가 작업을 완료하면 이벤트를 신호 상태로 바꾼다.
4. 기다리고 있던 모든 스레드가 깨어나서 작업을 진행한다.
- 이벤트는 대표적인 동기화 객체로 신호와 비신호라는 두 가지 상태를 가지며 상태를 변경 할 수 있도록 다음과 같은 함수를 제공한다.
1) SetEvent
형식 :
Bool SetEvent(HANDEL hEvent);
비신호 상태 - > 신호 상태
2) ResetEvent
형식 :
Bool ResetEvent(HANDEL hEvent);
신호 상태 - > 비신호 상태
3) CreateEvent
형식 :
HANDLE WINAPI CreateEvent(
__in_opt LPSECURITY_ATTRIBUTES lpEventAttributes,
__in BOOL bManualReset,
__in BOOL bInitialState,
__in_opt LPCTSTR lpName
);
파라미터
lpEventAttributes
* SECURITY_ATTRIBUTES 구조체 변수의 주소값을 대입한다. SECURITY_ATTRIBUTES 구조체는 핸들 상속과 보안 디스크립터 정보를 전달하는 용도로 사용된다. 만약 이 값이 NULL이면 핸들은 상속되어 질 수 없다.
이벤트를 서로 다른 프로세스에 속한 스레드가 사용(공유)할 수 있도록 이름을 줄 수 있습니다. NULL을 사용하면 이름 없는 이벤트가 생성됩니다.
리턴 값
성공
이벤트 핸들
실패
NULL
이벤트는 다음과 같은 종류가 있다.
[표 1] - 이벤트 특성에 따른 종류
종류
특징
자동 리셋 이벤트
* 이벤트를 신호 상태로 바꾸면 기다리는 스레드 중 하나만 깨운 후 자동으로 비신호 상태가 된다. 따라서 자동 리셋 이벤트에 대해서는 ResetEvent() 함수를 사용할 필요가 없다.
수동 리셋 이벤트
* 이벤트를 신호 상태로 바꾸면 계속 신호 상태를 유지하므로 결과적으로 기다리는 스레드를 모두 깨우게 된다. 자동 리셋 이벤트와 달리 이벤트를 비신호 상태로 바꾸려면 명시적으로 ResetEvent 함수를 호출해야 한다.
- 다음의 예제는 마스터 스레드가 버퍼에 값을 쓸 동안 다른 스레드들의 사용을 방지하고 있습니다. 첫 째 마스터 스레드는 비신호와 수동 리셋(manual-reset) 이벤트로 이벤트 오브젝트들을 생성해 초기에 비신호 상태를 유지합니다. 그 후 프로그램에서 요구하는 리더 스레드들을 생성하고 마스터 스레드는 쓰기 작업을 실행합니다. 마스터 스레드의 쓰기 작업이 끝나면 이벤트 오브젝트들은 신호상태로 바뀌고 쓰기 작업을 실행합니다. 실행 화면은 그림 5와 같습니다.
아래 쓰이는 모든 함수들은 멀티 스레드 포스팅에서 모두 다루었던 함수들입니다. 만약 이해가 되지 않으시면 다시 한 번 앞의 포스팅들을 참고 하시길 바랍니다.
그림 5) 이벤트 예제
Ex3) 이벤트
#include<windows.h>
#include<stdio.h>
#define THREADCOUNT 4
HANDLE ghWriteEvent;
HANDLE ghThreads[THREADCOUNT];
DWORD WINAPI ThreadProc(LPVOID);
void CreateEventsAndThreads(void)
{
int i;
DWORD dwThreadID;
// Create a manual-reset event object. The write thread sets this
// object to the nonsignaled state when it finishes writing to a
// 윈도우즈 운영체제가 설치된 디렉토리 경로를 알아내는 함수
::GetWindowsDirectory(szWinPath, MAX_PATH);
// System 32 폴더의 경로는 GetSystemDirectory() API함수 사용
lstrcat(szWinPath, TEXT("\\notepad.exe"));
void __cdecl AddValue(int nA, int nB, int* pnResult)
{
*pnResult = nA + nB;
}
void CCallConventionDlg::OnBnClickedButton1()
{
int nResult=0;
AddValue(10, 20, &nResult);
// 호출하는 측에서, 메모리 스택에 있는 값 해제를 담당한다
}
int __stdcall AddValue2(int nA, int nB)
{
// 실제 함수에서 메모리 스택을 해제한 뒤 반환한다
return nA+nB;
}
void CCallConventionDlg::OnBnClickedButton2()
{
int nResult=0;
nResult=AddValue2(10,20);
}
int __fastcall AddValue3(int nA, int nB, int nC, int nD, int nE)
{
return nA+nB+nC+nD+nE;
}
void CCallConventionDlg::OnBnClickedButton3()
{
int nResult=0;
nResult=AddValue3(1,2,3,4,5);
}
*함수 호출 규약을 붙이지 않고 쓰는 경우는 실제 __cdecl이 생략되어 있는 것이다.
굳이 호출규약을 붙여 주는 이유??
호출규약은 호출부가 아닌 실제 구현부에서 결정해 줌.
인자가 2개(3개?)일때 가장 빠름. 기본적으로 2개(3개?)까지 레지스터에 들어가고 나머지는 레지스터 공간에서 대기함