윈도우즈 시스템 프로그래밍 13장 - 쓰레드 동기화란 무엇인가? / 임계 영역(Critical Section) 접근 동기화 / 유저 모드의 동기화(Synchronization In User Mode)
윈도우즈 시스템 프로그래밍이라는 책과 해당 책의 저자이신 윤성우님의 강의를 통해 공부한 내용을 정리하는 글입니다.
두 가지 관점에서의 쓰레드 동기화
⦁ 실행순서의 동기화
쓰레드의 실행순서를 정의하고, 이 순서에 반드시 따르도록 하는 것이 쓰레드 동기화이다.
⦁ 메모리 접근에 대한 동기화
메모리 접근에 있어서 동시접근을 막는 것 또한 쓰레드의 동기화에 해당한다.
쓰레드 동기화에 있어서의 두 가지 방법
⦁ 유저 모드 동기화
커널 코드가 실행되지 않는 동기화 기법, 커널 모드로의 전환이 불필요하므로 성능에 이점이 있다. 그만큼 기능상의 제한도 있다.
⦁ 커널 모드 동기화
커널에서 제공하는 동기화 기능을 활용하는 방법, 따라서 커널 모드로의 변경이 필요하므로 성능 저하가 야기된다. 하지만 그 만큼 유저 모드 동기화에서 제공하지 못하는 기능을 제공받을 수 있다.
임계 영역(Critical Section)에 대한 이해
⦁ 종합 운동장에 1~6까지 번호가 메겨진 입구가 있다. 입구에는 입장객의 수를 카운트하는 센서가 설치되어 있다고 가정하자. 이를 프로그램으로 구현하면 각 문에 달린 센서를 통해서 들어오는 정보를 확인하고, 총 입장객의 수를 증가시키는 일을 해야하는데 이를 쓰레드로 처리하기로 하자.
⦁ CriticalSection.cpp
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
#include <process.h>
#define NUM_OF_GATE 6
LONG gTotalCount = 0;
void IncreaseCount()
{
gTotalCount++;
}
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, 0, ThreadProc, NULL, CREATE_SUSPENDED, (unsigned*)&dwThreadId[1]);
if (hThread[i] == NULL)
{
_tprintf(_T("Thread creation fault! \n"));
return -1;
}
}
for (DWORD i = 0; i < NUM_OF_GATE; i++)
{
ResumeThread(hThread[i]);
}
WaitForMultipleObjects(NUM_OF_GATE, hThread, TRUE, INFINITE);
_tprintf(_T("total count: %d \n"), gTotalCount);
for (DWORD i = 0; i < NUM_OF_GATE; i++)
{
CloseHandle(hThread[i]);
}
return 0;
}
⦁ 실행결과
위 프로그램에서 gTotalCount++; 부분이 임계영역이다.
즉, 문제의 원인이 될 수 있는 코드의 블록을 가리켜 임계 영역이라고 하는 것이지, 전역변수 자체의 메모리 공간을 가리켜 임계영역이라고 하는 것이 아니다.
실행결과는 문제 없이 정상적으로 출력되었으나 문제의 소지를 지니고 있는 코드이다.
⦁ 임계영역이란 배타적 접근이 요구되는 공유 리소스에 접근하는 코드 블록을 의미한다.
Windows에서 제공하는 동기화 기법
① 크리티컬 섹션(Critical Section) 기반의 동기화
메모리 접근 동기화에 사용할 예정
② 인터락 함수(Interlocked Family Of Function) 기반의 동기화
메모리 접근 동기화에 사용할 예정
③ 뮤텍스(Mutex) 기반의 동기화
메모리 접근 동기화에 사용할 예정
④ 세마포어(Semaphore) 기반의 동기화
메모리 접근 동기화에 사용할 예정
⑤ 이름있는 뮤텍스(Named Mutex) 기반의 프로세스 동기화
프로세스간 동기화에 사용할 예정
⑥ 이벤트(Event) 기반의 동기화
실행순서 동기화에 사용할 예정
1~2번은 유저 모드 동기화, 3~6번은 커널 모드 동기화이다.
유저 모드 동기화(Synchronization In User Mode)
⦁ 크리티컬 섹션(Critical Section) 기반의 동기화 (상호 배제 동기화 Mutual-Exclusive Synchronization)
문 앞에 열쇠를 두고 열쇠가 걸려있으면 문을 열고 들어가면 되고, 열쇠가 없으면 안에 사람이 있다고 생각하면 된다.
크리티컬 섹션 기반의 동기화를 사용하려면 크리티컬 섹션 오브젝트라는 것을 만들고 초기화해야한다. 크리티컬 섹션 오브젝트는 CRITICAL_SECTION 자료형의 변수를 뜻한다. 아래와 같이 선언하면 된다.
CRITICAL_SECTION gCriticalSection;
선언 후에는 아래 함수를 통해서 반드시 초기화 과정을 거쳐야 한다. 이 과정을 통해서 크리티컬 섹션 오브젝트는 사용 가능한 상태가 된다. 열쇠를 문 앞에 걸어놓는 행위이다.
void InitializeCriticalSection (
LPCRITICAL_SECTION lpCriticalSection
);
⦁ lpCriticalSection : 초기화하고자 하는 크리티컬 섹션 오브젝트의 주소값을 인자로 전달한다.
열쇠를 걸어 놓았으니 들어가기 위해서는 열쇠를 사용해야 한다. 문을 열고 들어가기 위해서 열쇠를 획득하는 행위와 방에서 나와서 열쇠를 제자리에 걸어두는 행위의 역할을 하는 함수이다.
void EnterCriticalSection (
LPCRITICAL_SECTION lpCriticalSection
);
⦁ lpCriticalSection : 임계영역에 진입하기 위해 필요한 크리티컬 섹션 오브젝트의 주소값을 인자로 전달한다. 다른 쓰레드에 의해서 이미 이 함수가 호출된 상황이라면 호출된 함수는 블로킹된다. 열쇠가 반환되면 블로킹 상태의 함수는 빠져 나오게 된다. 이 함수의 호출에 성공하고 임계 영역으로 들어갔을 떄 이를 호출한 쓰레드가 크리티컬 섹션 오브젝트를 획득했다고 말한다.
void LeaveCriticalSection (
LPCRITICAL_SECTION lpCriticalSection
);
⦁ lpCriticalSection : 임계 영역을 빠져 나오고 나서 호출하는 함수이다. 열쇠를 다시 걸어놓는 역할이다. 만약에 EnterCriticalSection을 호출하고 블로킹 상태에 놓인 쓰레드가 있다면, 이 함수 호출로 인해서 블로킹 상태에서 빠져 나와 임계 영역으로 진입하게 된다. 블로킹 상태에서 빠져나왔다는 것은 열쇠를 획득했다는 뜻이다. 이 함수 호출이 완료되었을 때, 이를 호출한 쓰레드가 크리티컬 섹션 오브젝트를 반환했다고 표현한다.
⦁ 임계영역에 진입하기 전에 "EnterCriticalSection"을 호출하고, 빠져 나온 뒤에 "LeaveCriticalSection" 함수를 호출해서 임계영역에 한 순간에 하나의 쓰레드만 실행할 수 있도록 구성하는 것이 크리티컬 섹션 동기화 기법이다.
함수가 호출되는 과정에서 할당된 리소스를 반환하는 함수이다.
void DeleteCriticalSection (
LPCRITICAL_SECTION lpCriticalSection
);
⦁ lpCriticalSection : 반환하고자 하는 크리티컬 섹션 오브젝트의 주소값을 인자로 전달한다.
⦁ CriticalSectionSync.cpp
CriticalSection.cpp에서 크리티컬 섹션 동기화 기법을 활용한 예제이다.
#include <stdio.h>
#include <tchar.h>
#include <windows.h>
#include <process.h>
#define NUM_OF_GATE 6
LONG gTotalCount = 0;
CRITICAL_SECTION hCriticalSection;
void IncreaseCount()
{
EnterCriticalSection(&hCriticalSection);
gTotalCount++;
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);
for (DWORD i = 0; i < NUM_OF_GATE; i++)
{
hThread[i] = (HANDLE)_beginthreadex(NULL, 0, ThreadProc, NULL, CREATE_SUSPENDED, (unsigned*)&dwThreadId[1]);
if (hThread[i] == NULL)
{
_tprintf(_T("Thread creation fault! \n"));
return -1;
}
}
for (DWORD i = 0; i < NUM_OF_GATE; i++)
{
ResumeThread(hThread[i]);
}
WaitForMultipleObjects(NUM_OF_GATE, hThread, TRUE, INFINITE);
_tprintf(_T("total count: %d \n"), gTotalCount);
for (DWORD i = 0; i < NUM_OF_GATE; i++)
{
CloseHandle(hThread[i]);
}
DeleteCriticalSection(&hCriticalSection);
return 0;
}
⦁ 인터락 함수(Interlocked Family Of Function) 기반의 동기화
인터락 함수는 내부적으로 한 순간에 하나의 쓰레드에 의해서만 실행되도록 동기화되어 있다. 아래는 관련 함수이다.
LONG InterlockedIncrement (
LONG volatile* Addend
);
//The function returns the resulting incremented value.
⦁ Addend : 값을 하나 증가시킬 32비트 변수의 주소값을 전달한다. 둘 이상의 쓰레드가 공유하는 메모리에 저장된 값을 이 함수를 통해서 증가시킬 경우, 동기화된 상태에서 접근하는 것과 동일한 안정성을 보장한다.
LONG InterlockedDecrement (
LONG volatile* Addend
);
// The function returns the resulting decremented value.
⦁ Addend : 값을 하나 감소시킬 32비트 변수의 주소값을 전달한다. 둘 이상의 쓰레드가 공유하는 메모리에 저장된 값을 이 함수를 통해서 감소시킬 경우, 동기화된 상태에서 접근하는 것과 동일한 안정성을 보장한다.
크리티컬 섹션 동기화 기법도 내부적으로는 인터락 함수를 기반으로 구현되어 있다. 이 역시도 유저 모드 기반으로 동작하므로 속도가 빠르다.
이 함수를 활용할 경우 CriticalSection.cpp는 보다 더 간결하고 쓰레드 안정성이 보장된 형태가 된다.
void IncreaseCount()
{
// gTotalCount();
InterlockedIncrement(&gTotalCount);
}
⦁ volatile 키워드
C/C++ ANSI 표준 키워드이다.
크게 두 가지 의미가 있는데 그 중 하나는 최적화를 수행하지 말라는 뜻이다.
int function(void)
{
int a = 10;
a = 20;
a = 30;
cout<<a;
.....
}
위와 같이 구현된 함수가 있는데 컴파일러가 아래와 같이 최적화를 하게된다.
int function(void)
{
int a = 30;
cout<<a;
.....
}
a의 값을 10, 20, 30으로 변경해주는 과정은 불필요해 보일 수 있겠으나 임베디드 시스템에서는 하드웨어 장치에도 주소를 할당하여 사용하므로 문제가 될 수 있다.
예를들어, 오디오 칩에 0x3000 번지를 할당한다고 가정하자. 이 주소는 오디오 칩의 버퍼의 주소가 되었다.
여기에 1을 입력하면 '도', 2를 입력하면 '미', 3을 입력하면 '솔' 음이 출력된다. 그렇게 되면 아래 코드는 도 미 솔 도 라는 음이 출력될 것이다.
int function(void)
{
int * pSound = 0x3000;
*pSound = 1;
*pSound = 2;
*pSound = 3;
*pSound = 1;
}
그러나 컴파일러가 이 코드를 최적화 시켜버리면 결과적으로 *pSound = 1; 만 적용되어 '도'음만 출력시킬 것이다.
이러한 문제를 해결하기 위해서 volatile 키워드를 사용한다.
int function(void)
{
int volatile * pSound = 0x3000;
.....
}
이렇게 volatile 키워드를 사용하여 포인터를 선언할 경우, 해당 포인터를 기반으로 하는 모든 연산에 대해 최적화를 고려하지 않게 된다.
volatile의 두 번째 의미는 메모리에 직접 연산하라는 의미이다.
int function(void)
{
int * pSound = 0x3000;
SleepUntil(3, 35, 12); // 3시 35분 12초까지 프로그램 실행을 멈춘다.
* pSound = 2;
.....
}
SleepUntil은 가상의 함수이다. 이 함수는 3시 35분 12초까지 실행을 멈추게 한다고 가정하자. 그리고 시간이 다 되어 함수를 빠져나와 *pSound = 2;가 실행되어 '미' 음이 출력되어야 한다. 그런데 캐쉬 매니저가 값을 메모리(오디오 칩)에 저장한게 아니라 캐쉬 메모리에 저장하여 원하는 시점에서 소리를 들을 수 없게 되었다.
언젠가는 캐쉬에 저장된 데이터가 메모리에 저장이 되긴 하지만 위와 같은 문제가 발생하게 된다.
이 경우의 해결 방법으로 volatile 키워드를 사용하여 포인터를 선언하면 해당 포인터가 가리키는 메모리 공간으로 전송되는 데이터는 캐쉬되지 않는다.