광고


멀티쓰레드 동기화 시스템



음... 멀티 쓰레드 동기화라는 좀 거대한 제목을 달았는데, 정리는 간략하게 ㅋ
그리고 임계 영역에 대한 설명과 CriticalSection 동기화 기법의 용어 구분을 확실하게 하기 위해 철저히 한글과 영문으로 분리 표기하겠음.
그리고 아래 등장하는 함수 원형과 세마포어의 카운트 값은 Windows OS 범위로 한정하여 설명한다.


1. 동기화의 두 가지 관점

쓰레드간 동기화는 크게 두 가지 관점으로 나눌 수 있다.
비록 거의 한 가지 관점의 목적을 달성하기 위한 동기화이지만...
  • 실행 순서의 동기화
    : 쓰레드들이 정해진 특정 순서로 실행되어야 할 때 필요하다.
    : 보통 이벤트 오브젝트를 이용한 방법이 많이 사용된다.

  • 메모리 접근의 동기화
    : 대다수 멀티 쓰레드 기반 서버 작성시 요 녀석을 해결하려고 동기화를 구현한다.
    : CriticalSection, Mutex, Semaphore 등등의 다양한 방법이 있다.
    : 이후 나올 내용은 모두 메모리 접근의 동기화의 관점에 필요한 내용들이다. 

2. 동기화 주체로 나누어 본 두 가지 방법

쓰레드 동기화의 주체가 누구이냐에 따라 크게 두 가지 방법으로 분류할 수 있다.
  • 유저 모드 동기화
    : 커널의 힘을 빌리지 않는 동기화 기법이다.
    : 따라서 동기화를 위해 커널로의 전환이 불필요하기에 성능상 이점은 있지만,
    : 그만큼의 기능상의 제한도 있다.

    : 그 종류로는 InterlockedXXX 계열 함수, CriticalSection, SpinLock

  • 커널 모드 동기화
    : 커널에서 제공하는 동기화 기능을 활용하는 방법이다.
    : 즉, 커널 오브젝트를 생성하고 이를 조작하는 함수 호출로 동기화를 진행한다.
    : 커널 오브젝트 조작 함수가 호출될 때마다 커널 모드로의 전환이 발생하므로 성능 저하가 있다.
    : 하지만, 유저 모드 동기화에 비해 더 강력한 기능들을 제공한다.

    : 그 종류로는 Mutex, Semaphore, Event
즉, 일장일단이 있으므로 처한 상황에 맞게 골라 쓰면 될 듯.

3. 임계 영역이란?

본격적으로 메모리 접근의 동기화에 대해 살펴보기 위해 이 녀석을 얘기하지 않을 수 없다.
메모리 접근의 동기화를 해야만 하는 이유가 임계 영역을 안전하게 처리하기 위함이기 때문이다.

우선, 임계 영역의 정의부터 정리하자면,
"임계 영역이란, 배타적 접근(한 순간에 하나의 쓰레드만 접근)이 요구되는 공유 리소스(ie. 전역변수)에 접근하는 코드 블록"
위에서 중요한 것은 특정 코드라고 표현하지 않은 것이다.

4. InterlockedXXX 계열 함수와 volatile keyword

만약 하나의 전역 변수를 동기화하는 것이 목적이라면, InterlockedXXX 계열 함수만으로도 동기화가 가능하다.
이 InterlockedXXX 계열 함수들은 Atomic Access, 즉 한 순간에 하나의 쓰레드만 접근하는 것을 보장해 주는 함수이다.
따라서 모든 쓰레드가 이 함수를 통해서 값을 변경하거나 증감시켜도 동기화 문제는 발생하지 않는다.

바로 다음에 소개할 CriticalSection이라는 동기화 기법도 내부적으로는 InterlockedXXX 함수를 기반으로 구현되어 있다.
그 다음 소개될 SpinLock 역시 이 계열 함수들을 사용하여 구현하는 경우가 많다.

InterlockedXXX 계열 함수들도 유저 모드 기반으로 동작하기에 속도 역시 상당히 빠르다.

참고로, InterlockedXXX 함수에 대한 MSDN 링크는 http://msdn.microsoft.com/en-us/library/ms684122(v=VS.85).aspx 로부터 하나씩 찾아나가면 된다.

그리고 InterlockedXXX 계열 함수들의 파라미터는 모두 volatile 키워드가 붙어 있다.
왜 volatile 키워드가 붙은 녀석들만 처리가 가능한지는 volatile 키워드의 특성을 알아야 한다.
이 설명은 다음 링크로 대체한다.


5. CriticalSection

1) 개론

사실, 임계 영역을 영어로 표현하면 CriticalSection이다.
위에서 굳이 한글로 임계 영역이라고 표현한 이유는 이 CriticalSection 동기화 방식과의 혼동을 피하고자 함이었다.

자, 쉬운 설명을 위해서 우리가 지켜야 할 임계 영역을 화장실이라고 표현하자.
이 화장실에 들어가기 위해서는 화장실 열쇠가 필요하다.
화장실에 아무도 없다면 열쇠를 가져가 볼일을 보고, 다시 열쇠를 제자리에 가져다 두어야 한다.
그래야만 다음 사람이 다시 열쇠를 가져가 화장실에 갈 수 있기 때문이다.

여기에서 중요한 것은 반드시 열쇠를 가져간 사람이 다시 열쇠를 제자리에 가져다 놔야 한다는 것이다.
이 얘기를 굳이 하는 이유는 추후 Semaphore에서 알 수 있을 것이다.

이것이 바로 CriticalSection의 동기화 방식이다.
핵심은 "열쇠를 가진 사람만이 화장실에 들어갈 수 있다"는 것이다.

2) 사용법

CriticalSection 동기화를 사용하기 위해서는 CriticalSection 변수 선언이 필요하다.

CRITICAL_SECTION CS;

그리고 나면, 이 CS 변수를 초기화 시켜야 한다.

void InitializeCriticalSection( LPCRITICAL_SECTION lpCriticalSection );

자, 이제 열쇠를 만들고 누구나 찾아갈 수 있는 위치에 걸어둔 것까지 했다.
이제 이 열쇠를 가지고 화장실에 들락날락 하는 방법을 살펴보자.

화장실에 가기 위해 열쇠를 가져갈 때 호출해야 하는 함수는

void EnterCriticalSection( LPCRITICAL_SECTION lpCriticalSection ); 이다.

만약, 누가 이미 열쇠를 가지고 화장실에 들어가 있다면, 그 누군가가 열쇠를 되돌려놓기 전까지 이 함수는 블로킹된다.
이 블로킹 되는 것이 중요한 포인트 중 하나가 되는데, 임계 영역에서의 CPU 소비 시간이 극히 짧다면, 즉 임계 영역에 머무르는 시간이 짧다면 이 블로킹 상태로 전환되는 것이 낭비가 될 수도 있다.

이는 스케쥴링 메커니즘을 이해하고 있다면 쉽게 납득이 갈텐데, 쓰레드가 블로킹 상태가 되면 쓰레드 컨텍스트 스위칭이 발생하기 때문이다.

따라서, 정말 살짝만 더 기다리면 되는데 굳이 쓰레드 컨텍스트 스위칭을 하기에, 블로킹 되는 것이 다소 낭비라는 것이다.
이 이야기를 굳이 길게 쓴 것은 바로 다음 소개할 SpinLock을 위함이며,
더 나아가 크리티컬 섹션의 스핀 기능을 추천하기 위함이다.

크리티컬 섹션의 스핀 기능을 이용할 때엔 아래와 같이 크리티컬 섹션을 초기화하면 된다.

BOOL InitializeCriticalSectionAndSpinCount( LPCRITICAL_SECTION lpCriticalSection, DWORD spinCount );

스핀 카운트는 0~0xFFFFFFFF의 범위 내 어떤 값이든 지정할 수 있으나, 대부분 4096 정도로 설정함을 추천한다.

이제 볼 일을 다 보고 열쇠를 제자리에 가져다 놓을 때 호출해야 하는 함수는

void LeaveCriticalSection( LPCRITICAL_SECTION lpCriticalSection ); 이다.

마지막으로 열쇠가 필요없어 졌을 때 제거하는 함수는

void DeleteCriticalSection( LPCRITICAL_SECTION lpCriticalSection ); 이다.

사실 이 CriticalSection은 가장 심플하기에 가장 보편적으로 사용되는 동기화 방식이다.


6. SpinLock

스핀락은 위 CriticalSection의 한가지 단점을 극복하는데서 착안된 동기화 기법이다.
그것은 바로 쓰레드가 임계 영역을 획득하지 못하게 되면(Lock을 못잡게 되면) 쓰레드가 블로킹되는 것이다.
쓰레드 블로킹은 이후 쓰레드 컨텍스트 스위칭을 불러오게 되며 성능의 하락을 유발시킨다.

이 단점을 극복하기 위해 스핀락은 락을 점유하지 못할 때 쓰레드가 Back-off 되어 다른 쓰레드에게 넘기는 것이 아니라, Loop를 돌면서 해당 쓰레드를 Busy-Waiting 상태로 만들어 버린다.
그러면, 쓰레드 스위칭이 발생하지 않게 되어 컨텍스트 스위칭이 발생하지 않게 되는 것이다.

하지만!!!
임계 영역에서의 작업이 다소 시간이 오래 걸리는 일이라면?
결과는 오히려 CriticalSection을 사용할 때보다 훨씬 더 안 좋아지게 된다.

곧 끝날거라 기대하며 기다리느라 쓰레드가 헛돌고 있다보면 CPU 점유율이 올라가고, 오히려 다른 쓰레드가 컨텍스트 스위칭을 하더라도 일을 더 많이 할 수 있다면 낭비가 되는 것이다.

이러한 스핀락의 단점을 해소하기 위해 아래와 같이 CriticalSection과 스핀락의 개념을 혼용하는 경우가 많다.

우선, 락을 획득하지 못하면 Busy-Waiting을 위해 스핀 카운트 만큼만 루핑한다.
그리고 스핀카운트를 다 돌았으면, Sleep()을 통해 극히 짧은 시간 쓰레드를 블로킹시킨다.
이 후 다시 쓰레드가 Running이 되면 위를 계속해서 반복하는 것이다.

이렇게 되면 무한정 BusyWaiting을 하게 되지도 않고, 굳이 쓰레드 컨텍스트 스위칭을 하지 않아도 될 경우를 많이 회피할 수 있게 된다.

물론 이 방식도 문제는 있다.
임계영역에서의 수행이 생각보다 오래 걸리는 경우엔 CriticalSection만 못한 결과가 나올 수도 있는 것이다.

따라서, 스피닝만 하는 스핀락보다는 크리티컬 섹션이 스핀락 기능을 이용하는 방식에 점점 무게가 실리고 있다.

지금까지 유저 모드 동기화 방식들에 대해 알아보았다.

앞서 얘기했듯이 커널 모드 동기화 방식들은 커널 모드로의 전환이 발생하기에 느리다.
하지만, Windows 커널 레벨에서 제공하는 동기화 기법이기 때문에, 유저 모드 동기화가 제공해 주지 못하는 기능을 제공받을 수 있다.
자, 그럼 뮤텍스와 세마포어가 어떠한 것들을 제공해주는지 어떠한 특장점이 있는지 살펴보자.


7. Mutex

CriticalSection 동기화 방식에서의 화장실 열쇠를 기억하는가?
그 열쇠가 Mutex에서는 Mutex 오브젝트이고, 이는 다음 함수를 통해 생성할 수 있다.

HANDLE CreateMutex (
LPSECURITY_ATTRIBUTES lpMutexAttributes, // Mutex도 커널 오브젝트이기에 보안 속성 지정 가능.
BOOL bInitialOwner, // Mutex를 생성한 쓰레드에게 먼저 임계 영역에 접근할 기회가 있는가?
LPCSTR lpName // Named-Mutex를 사용하려 할 때 지정해 주면 된다.
);

CriticalSection의 경우 CRITICAL_SECTION 변수를 선언하고, InitializeCriticalSection()를 통해 초기화 과정을 거쳐야 했지만, Mutex의 경우 위 함수 하나로 모든 생성/초기화 과정이 완료되었다.

위 함수의 파라미터만 봐도 CriticalSection에 비해 Mutex가 두 가지 기능이 더 많다는 것을 알 수 있다.
  • bInitialOwner
    : CriticalSection은 임계 영역에 먼저 접근한 녀석이면 누구나 권한이 있었지만,
    : Mutex는 오브젝트를 생성한 쓰레드가 접근 권한을 먼저 취할 수 있다는 것이다.
  • lpName
    : NULL이 아니면 Named-Mutex를 사용할 수 있게 된다.
    : 보통 프로세스의 중복 실행을 방지하는데 사용된다.

    ::CreateMutex(0, TRUE, L"GameServer");
    if ( ::GetLastError() == ERROR_ALREADY_EXISTS )
    {
    // 중복 실행되었으니 에러처리
    }
두 번째 파라미터를 통해 커널 오브젝트의 이름을 명명할 수 있다.
위 예제에서 프로세스 중복 실행을 예로 들면서 이름을 "GameServer"라고 간단히 지었지만,
이는 Named 커널 오브젝트를 생성함에 있어 주의를 알려주기 위한 떡밥 예제이다.

커널 오브젝트의 이름은 그 종류에 관계없이 글로벌한 네임스페이스를 가진다.
즉, Mutex를 "A"로 만들고 Semaphore를 "A"로 만들려고 하면, Semaphore 생성은 실패한다.
단, 같은 Mutex라면 생성이 아닌 기존에 만들어진 커널 오브젝트를 얻어온다. (OpenMutex와 동일하게 동작)

따라서, Named 커널 오브젝트 생성시 이름을 common 한 것이나, 일반 명사 사용시에 문제점을 뒤따를 수 있다.
최대한 구체적으로 해당 오브젝트의 특징을 모두 포함시키는 것이 안전하다.


함수의 인자에 SECURITY_ATTRIBUTES가 포함되어 있는 것을 보니, 
Mutex는 커널 오브젝트임이 확실하고, 커널 오브젝트는 Signaled와 NON-Signaled 상태를 가진다고 하였다. 
(링크 : http://sweeper.egloos.com/2814944의 챕터 6)

커널 오브젝트는 NON-Signaled 상태에 있다가 특정 상황이 되면 Signaled 상태로 바뀌는데, 이 특정 상황이라는 것은 커널 오브젝트마다 다르다.
그렇다면, Mutex의 경우 언제 Signaled 상태로 전환될까?
"Mutex는 누군가에 의해 획득이 가능할 때 Signaled 상태에 놓인다"

그렇다면, Signaled 상태를 체크하는 WaitForSingleObject 함수를 이용하여 화장실 열쇠에 해당하는 Mutex를 획득할 수 있다.
다른 쓰레드가 잡고 있는 Mutex를 반환할 때까지(즉, Signaled 상태가 될때까지) 기다리는 것이다.
즉, CriticalSection의 EnterCriticalSection() 함수와 같은 기능을 하는 것이다.

WaitForSingleObject 함수가 Signaled를 감지하는 순간 해당 쓰레드는 Mutex를 획득한 것이고 (즉, 임계영역에 들어갈 권한을 얻은) WaitForSingleObject 함수 특성상 반환이 되는 순간 Mutex는 다시 NON-Signaled 상태가 되므로, 다른 쓰레드들은 열쇠를 얻을 때까지 기다리게 되는 셈이다.

WaitForSingleObject로 쓰레드가 대기될 때에도 쓰레드는 블로킹 상태가 되므로 역시 쓰레드 컨텍스트 스위칭은 발생한다.

WaitForSingleObject 함수명이 Mutex를 얻는 동작과 네이밍 매치가 좀 어색하다 싶으면 아래와 같이 래핑 함수를 하나 만드는 것도 나쁘지 않다.
DWORD AcquireMutex( HANDLE hMutex)
{
return ::WaitForSingleObject( hMutex, INFINITE );
}

그리고 획득한 열쇠를 반납하는 함수는 

BOOL ReleaseMutex( HANDLE hMutex); 이다.

CriticalSection의 LeaveCriticalSection과 동일한 역할을 한다고 보면 된다.

마지막으로 열쇠가 필요없어졌을 때 제거하는 함수는 Mutex가 커널 오브젝트이므로,

당연히 CloseHandle 함수를 호출하면 된다.

얼핏 임계 영역에 접근을 제한하는 방법들만 보면 Mutex는 CriticalSection과 상당히 유사하다.
똑같이 Lock에 대한 소유자(Owner)가 존재하고, 소유자만이 락을 해제할 수 있다.
하지만, Mutex는 위에서 정리한 대로 CriticalSection이 갖지 못하는 부가 기능이 있으며 유저 모드 동기화 vs 커널 모드 동기화의 차이점도 존재한다.

또한, Name 명명을 이용하여 프로세스간 동기화 역시 가능하다.
(아래에 나올 Semaphore나 Event 역시 이름을 명명할 수 있는 커널 오브젝트이다)

A 프로세스에서 먼저 "AAA" named Mutex를 이용해 임계 영역에 들어가면,
B 프로세스에서 "AAA" named Mutex가 signaled 상태가 될 때까지 기다리게 할 수 있는 것이다.


8. Semaphore

보통 세마포어는 뮤텍스와 상당이 유사하다고들 얘기한다.
세마포어는 그 종류가 몇가지 되는데 그 중 하나가 뮤텍스이다.
즉, 뮤텍스는 엄밀히 얘기해 세마포어의 여러 종류 중 하나인 셈이다.

따라서, 세마포어 역시 커널 오브젝트 형태이고 나머지 처리들이 뮤텍스와 유사함을 알 수 있다.
(WaitForSingleObject를 통한 접근 대기라던가...)

그럼 세마포어는 무엇이 다르길래 뮤텍스와 별도로 존재하는 것일까?

그 차이는 바로 Count 기능이다. Count 기능?
쉽게 설명하기엔 역시 예를 들어서 설명하는 것이 최고다.

CriticalSection과 Mutex를 설명할 때엔 한번에 한명만 들어갈 수 있는 화장실과 그 열쇠에 비유했지만,
세마포어는 카운트 기능을 설명하기 위해 식당(임계 영역)과 테이블(접근 키)로 비유해보자.

유명한 식당에 테이블은 딱 10개뿐인데, 손님은 50명이 대기중이다. (한 테이블엔 한명만 앉을 수 있다고 가정)
즉, 생성된 쓰레드 수는 50개고 임계 영역에 동시 접근할 수 있는 쓰레드는 10개가 되는 셈이다.

뮤텍스는 임계 영역에 접근가능한 쓰레드 개수를 조절하는 기능이 없고, 세마포어엔 있다.
이것이 바로 카운트 기능인 것이다. 이게 뮤텍스와 세마포어의 결정적인 차이다.

다음은 세마포어를 생성하는 함수이다.

HANDLE CreateSemaphore (
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, // 세마포어도 커널 오브젝트이기에 보안 속성 지정 가능.
LONG InitialCount. // 생성시 세마포어의 카운트 값
LONG MaximumCount, // 세마포어가 가질 수 있는 최대 카운트 값. InitialCount보단 당연히 커야 함
LPCSTR lpName // Mutex와 동일하게 Named 지정 가능
);

세마포어도 커널 오브젝트이기에 Signaled, NON-Signaled 상태가 전환된다.

"세마포어는 카운트가 0인 경우 NON-Signaled 상태가 되고, 1 이상인 경우 Signaled 상태가 된다"

초기 카운트 값인 InitialCount를 10으로 잡았다면, 세마포어는 Signaled 상태이고 이 상황에서
WaitForSingleObject가 한번씩 반환될 때마다 카운트는 줄어들어 0이 되는 순간 NON-Signaled 상태가 되는 것이다.
그 이후 접근한 쓰레드들은 다른 쓰레드가 임계 영역에서 벗어나 세마포어를 반환할 때까지 블로킹 된다.

참고로, InitialCount가 1인 세마포어는 Binary Semaphore라 불리며 기능적으로는 Mutex와 동일하다.

임계 영역을 벗어난 쓰레드가 세마포어를 반환할 때 사용하는 함수는

BOOL ReleaseSemaphore (
HANDLE hSemaphore, // 세마포어 오브젝트 핸들
LONG ReleaseCount, // 반환할 세마포어 카운트. 상황에 따라서 1보다 크게 줄수도 있다.
LPLONG PreviousCount // 이전 카운트 값인데 불필요하면 NULL 때리삼
);

위 함수 호출시 ReleaseCount는 결국 세마포어 카운트 증가치가 되는데, 이 증가치와 기존 값의 합이 MaximumCount를 넘게 되면, ReleaseSemaphore는 카운트를 변경시키지 못하고 FALSE를 리턴한다.

그리고 다 쓴 세마포어 제거 역시 CloseHandle 함수를 통하면 된다.

마지막으로 CriticalSection/Mutex와 세마포어의 중요한 차이점을 설명하겠다.
이것은 아주 중요한 개념인데, 바로 Owner 개념의 유무이다.

CS/Mutex는 획득한 쓰레드(Owner)가 직접 반환하는 것이 원칙이다. 획득 쓰레드가 아니면 반환할 수 없다.
하지만, 세마포어의 경우 획득한 쓰레드와 반환 쓰레드가 달라도 문제가 되지 않는다.
즉, Owner 개념이 없는 것이다.

다시 말해 CS/Mutex의 경우 획득 쓰레드가 비정상 종료되어 버리면, 반환 문제가 발생한다.
  • CriticalSection
    : 이건 뭐 답이 없다.
    : Lock을 획득한 쓰레드를 비정상 종료 시키면 다른 쓰레드는 영원히 임계 영역에 접근하지 못한다.

    : 하지만, 다른 쓰레드가 EnterCritialSection를 호출했다고 해서 의해 영~원~히~ 블로킹 되지는 않는다.
    : 시스템에 CriticalSectionTimeout 값이 지정되어 있으며, 이 값이 초과할 때까지 반환이 이뤄지지 않으면 예외가 발생한다.
    : 이 값은 HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Session Manager 에 위치한다.
    : 기본값은 2,592,000이며 이는 대략 30일에 해당한다.

    : 즉, 임계영역은 블로킹이 지속되나, 임계영역에 진입을 시도한 쓰레드의 타임아웃은 너~무~ 길지만 영원하진 않다는 거다.

  • Mutex
    : 역시 커널 동기화 기법인가보다.
    : 커널에서 쓰레드와 Mutex 오브젝트를 지속적으로 감시하고 있는 듯~
    : 획득 쓰레드가 비정상 종료되는 순간, 다른 쓰레드의 WaitForSingleObject 함수가 바로 리턴된다.
    : 리턴값은 0x80 이며 이는 WAIT_ABANDONED에 해당한다.

    : 즉, Mutex의 경우 Owner가 반환하는 것이 원칙이나, Owner가 죽어버렸을 경우 커널이 대신 반환해 주는 것이다.

덧글

  • 최태영 2016/11/28 23:19 # 삭제 답글

    좋은 정보 올려주셔서 감사드려용~
댓글 입력 영역