XIII : 쓰레드 동기화 기법 I
뇌를 자극하는 윈도우즈 시스템 프로그래밍 을 읽고 정리한 문서입니다 ;)
쓰레드 동기화란 무엇인가
쓰레드간의 동기화 문제만 잘 처리해도 멀티 쓰레드 프로그래밍에서 발생하는 문제의 대부분을 미리 막을 수 있다.
동기화와 관련된 문제는 컴파일타임에 발생하는 오류가 아니라, 런타임에 발생하는 오류이기에 찾아내고 디버깅하기 매우 어렵기에, 미리 예측하고 동기화를 통해 확실히 방지할 줄 알아야 한다.
동기화라고 하면 보통 무엇인가를 일치시켜 주는 것이라고 통용되지만, 쓰레드에서의 동기화는 순서에 있어서 질서가 지켜지고 있음을 의미하게 된다.
- 실행순서의 동기화
- 만일 A 쓰레드가 계산한 결과를 B 쓰레드에서 사용하려고 할 때, 순서가 중요하다.
- 쓰레드의 실행순서를 정의하고, 이 순서에 반드시 따르도록 하는 것이 쓰레드 동기화이다.
- 메모리 접근에 대한 동기화
- 데이터 영역과 힙 영역처럼, 한 순간에 하나의 쓰레드만 접근해야 하는 영역이 존재한다.
- 메모리 접근에 있어서 동시접근을 막는 것 또한 쓰레드의 동기화에 해당한다.
쓰레드 동기화에 있어서의 두 가지 방법
Windows에서 제공하는 동기화 기법은 제공하는 주체에 따라 크게 두 가지로 나뉜다.
- 유저 모드 동기화
- 커널의 힘을 빌리지 않는 동기화 기법이라 속도가 빠르다.
- Critical Section 기반 동기화
- 인터락 함수 기반 동기화
- 커널 모드 동기화
- 커널모드 전환이 필요하기에 속도가 비교적 느리다.
- 유저 모드 동기화보다 기능이 풍부하다.
- 뮤텍스 기반의 동기화 / 이름있는 뮤텍스 기반의 동기화
- 세마포어 기반의 동기화
- 이벤트 기반의 동기화
임계 영역 접근 동기화
어떠한 연산을 둘 이상의 쓰레드가 동시에 진행할 경우 문제가 발생할 수 있을 때, 해당 연산 코드 블록을 가리켜 임계 영역, Critical Section이라고 한다.
전역변수에 할당된 메모리 공간을 가리켜 임계 영역이라고 하는 것은 아니다.
동기화 기법을 통해서 한 순간에 하나의 쓰레드만 실행될 수 있도록 제한하면 문제는 해결된다.
유저 모드의 동기화
유저 모드 동기화는 커널 모드 전환이 없기에, 이 기법으로도 문제 해결이 충분하다면 굳이 커널 모드 동기화를 사용할 필요가 없다.
크리티컬 섹션 (Critical Section) 기반의 동기화
대부분의 동기화 기법은 열쇠에 비유할 수 있다. 열쇠를 얻은 자만이 영역에 접근할 수 있는 것이다.
크리티컬 섹션 기반의 동기화를 사용하려면 크리티컬 섹션 오브젝트를 만들고 초기화해야 한다.
이는 자료형 CRITICAL_SECTION
의 변수를 선언함으로 시작되고, 초기화는 InitializeCriticalSection()
함수를 사용한다.
CRITICAL_SECTION myCriticalSection;
InitializeCriticalSection(&myCriticalSection);
열쇠를 획득하는 행위는 EnterCriticalSection()
함수에 해당하고, 만일 호출 시 열쇠가 없는, 즉 다른 쓰레드가 열쇠를 가지고 있는 상황이었다면 블로킹되게 된다.
열쇠를 반환하는 행위는 LeaveCriticalSection()
함수이고, 만일 이 열쇠를 기다리며 블로킹된 쓰레드가 있었다면 이와 동시에 해당 쓰레드가 깨어나게 된다.
따라서 사용 형태는 다음과 같아진다.
EnterCriticalSection(&myCriticalSection);
// 임계 영역
LeaveCriticalSection(&myCriticalSection);
끝으로 초기화 함수가 호출하는 과정에서 할당된 리소스들을 반환하기 위해서, DeleteCriticalSection()
함수를 호출하여 이를 반환해야 한다.
인터락 (Interlocked Family Of Function) 기반의 동기화
전역으로 선언된 변수 하나의 접근방식을 동기화하는 것이 목적이라면 인터락 함수를 사용하는 것도 좋은 선택이다.
인터락 함수는 내부적으로 한 순간에 하나의 쓰레드에 의해서만 실행되도록 동기화되어 있다.
LONG InterlockedIncrement(
LONG volatile* Addend
);
LONG InterlockedDecrement(
LONG volatile* Addend
);
이러한 함수는 원자적 접근을 보장해 주는 함수이다. 크리티컬 섹션 방식의 동기화 또한 내부적으로는 인터락을 기반으로 구현되어 있다.
마이크로소프트는 값을 원하는 만큼 증감시키는 함수나 64비트 변수를 대상으로 연산하는 다양한 인터락 함수를 제공한다.
volatile 키워드
volatile
이라는 키워드는 C, C++언어의 ANSI 표준 키워드이며, 두 가지 의미를 가진다.
- 최적화를 수행하지 마라
- 컴파일러는 프로그래머의 비효율적인 코드를 최대한 빠르게 동작할 수 있게 개선한다.
- 하지만 이에 따라 설계한 대로 동작하지 않는 경우가 있기에, 이를 방지하기 위함이다.
- 캐싱하지 않는다
- 단 이는 캐시메모리를 사용하지 않는다라던지, 메모리에 직접 연산하라는 뜻으로 받아들이면 안되고, 그저 레지스터에 저장하지 않는 동작을 의미한다.
커널 모드 동기화
뮤텍스 (Mutex) 기반의 동기화
뮤텍스 기반 동기화 기법의 경우에는 열쇠가 뮤텍스 오브젝트인 것이며, 이는 CreateMutex
라는 함수 호출을 통해 생성되어지는 커널 오브젝트이다.
따라서 뮤텍스는 커널 오브젝트이므로 누군가가 열쇠를 취득했을 때 Non-Signaled가 되고, 누군가 열쇠를 반환했을 때 Signaled 상태가 될 것이다.
따라서 뮤텍스에 대한 획득은 WaitForSingleObject
함수를 사용함으로서 가능하고, 뮤텍스의 반환할 때에는 ReleaseMutex
함수를 이용해서 반환하게 된다.
WaitForSingleObject
함수는 커널 오브젝트가 Signaled 상태가 되어 이를 기다린 쓰레드에게 리턴된 경우, 해당 커널 오브젝트를 다시 Non-Signaled 상태로 되돌린다.
따라서 뮤텍스 사용법은 다음과 같아진다.
뮤텍스를 생성한 뒤, 뮤텍스 핸들을 인자로 전달하면서 WaitForSingleObject
함수를 호출한다. 임계 영역에서 일을 마친 쓰레드가 빠져나오며 ReleaseMutex
함수를 호출한다. 그러면 WaitForSingleObject
로 대기하던 다른 쓰레드가 진입할 수 있게 된다.
이후 뮤텍스의 사용이 모두 끝난다면, 이는 커널 오브젝트이기 때문에 CloseHandle
을 통해 핸들을 반환하는 것으로 뮤텍스 관리가 끝난다.
WaitForSingleObject
는 커널 오브젝트의 신호를 대기하는 것 뿐이지만, 이곳저곳에서 다양한 의미로 사용되기 때문에, 함수를 래핑해서 사용하는 경우도 많다.
DWORD AcquireMutex(HANDLE mutex){
return WaitForSingleObject(mutex, INFINITE);
}
세마포어 기반의 동기화
세마포어 중에서 단순화된 세마포어가 뮤텍스이라고 말하기도 하며, 뮤텍스는 세마포어의 일종이라고 말하기도 한다.
뮤텍스와 세마포어의 차이는, 카운트 기능에 있다. 따라서, 세마포어는 뮤텍스와 동일하게 임계 영역에 대한 접근을 제한하지만, 하나의 쓰레드가 아닌 정해진 개수의 쓰레드를 허용하게 된다.
다음은 세마포어 오브젝트를 생성하는 함수이다.
HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSA,
LONG lInitialCount,
LONG lMaximumCount,
LPCTSTR lpName
);
LONG lInitialCount
: 세마포어에서 가장 중요한 전달인자로서, 이 값을 기반으로 임계 영역에 접근 가능한 쓰레드의 개수가 제한된다.LONG lMaximumCount
: 세마포어가 지닐 수 있는 값의 최대 크기를 지정한다. 이 값이 1인 경우 뮤텍스와 동일한 기능을 하는 바이너리 세마포어가 구성된다.LPCTSTR lpName
: 세마포어에 이름을 붙이기 위해 사용한다.
임계 영역에 접근하려고 하는 쓰레드는 WaitForSingleObject
함수를 호출하여 열쇠를 획득한다. 이후 임계 영역을 빠져 나온 쓰레드는 ReleaseSemaphore
함수를 호출하여 열쇠를 반환한다.
HANDLE ReleaseSemaphore(
HANDLE hSemaphore, // 반환하고자 하는 세마포어
LONG lReleaseCount, // 세마포어 카운트를 증가시킬 크기
LPLONG lpPreviousCount // 변경되기 전 세마포어 값을 받을 포인터
);
세마포어의 개수 제한은 딱히 없으나, WaitForMultipleObjects
함수가 관찰할 수 있는 최대 커널 오브젝트의 수는 MAXIMUM_WAIT_OBJECTS
가 64
로 설정되어 있기에 이를 유의해야 한다.
이름있는 뮤텍스 (Named Mutex) 기반의 프로세스 동기화
뮤텍스에 이름을 붙여 생성할 경우 이름있는 뮤텍스라 하고, 세마포어에 이름을 붙여 생성할 경우 이름있는 세마포어라고 부른다.
이러한 기법은 실제 유용한 상황이 많지는 않지만, 이름이 붙었다는 특징 덕분에 이름 없는 뮤텍스와 세마포어가 제공하지 못하는 기능을 가지고 있다.
커널 오브젝트의 핸들 테이블은 프로세스에 종속되기에, 다른 프로세스 간에는 핸들이 공유되지 않는다. 따라서, 프로세스 B는 프로세스 A가 생성한 뮤텍스에 접근이 불가능하다. 설령 핸들 값을 공유하더라도 이는 다른 프로세스에게는 사용할 수 없는 값이다.
하지만, 커널 영역에 존재하는 오브젝트이기에 방법이 있다면 쉽게 이를 공유할 수 있는데, 바로 오브젝트의 이름을 이용하는 것이다.
OpenMutex
함수를 사용해서 이름으로 뮤텍스를 찾는다면 프로세스간 뮤텍스를 공유할 수 있다.
뮤텍스의 소유와 WAIT_ABANDONED
한 쓰레드가 만일 세마포어 오브젝트의 카운트를 감소시켰다면, 그 쓰레드가 임계영역을 나갈 때 다시 세마포어 오브젝트의 카운트를 증가시키는 것이 일반적이다.
하지만 이러한 제약사항을 반드시 지켜줘야 하는것은 뮤텍스 뿐이다.
세마포어는 카운트를 감소시킨 쓰레드가 아닌 다른 쓰레드가 카운트를 증가시켜도 문제가 되지 않는다.
또한, 만일 뮤텍스가 임계 영역에서 작업을 하던 중 뮤텍스를 반환하지 않고 종료되어 버린다면, Windows는 이를 감지하고 종료된 쓰레드를 대신하여 뮤텍스를 반환해준다.
이 때 뮤텍스를 전달해 주며 반환값으로 주는 값이 WAIT_ABANDONED
이다.
하지만 이는 Windows OS가 제공해 주는 안전 장치일 뿐이지, 이를 코드 흐름의 일부로 사용해서는 안된다.
이것만은 알고 갑시다
- 유저 모드 동기화와 커널 모드 동기화의 차이점
- 유저 모드 동기화는 커널의 힘을 빌리지 않기에 성능상에 큰 이점이 있다.
- 커널 모드 동기화는 대신 다양한 기능을 제공받을 수 있다.
- 임계 영역의 의미
- 임계 영역은 메모리의 특정 영역이 아닌, 공유 데이터를 사용하게 됨으로 인해 문제를 발생시키는 코드 일부를 의미하는 것이다.
- 뮤텍스와 세마포어의 차이점
- 뮤텍스와 세마포어는 유사하지만, 세마포어는 카운트 기능을 제공한다는 점에서 차이가 있다. 세마포어의 카운트를 1로 두면 뮤텍스와 동일한 동작을 하게 된다.
- volatile 키워드의 의미
- 컴파일러 최적화를 진행하지 않는다.
- 레지스터에 캐싱하지 않는다.
'공부한 이야기 > 윈도우 OS' 카테고리의 다른 글
XV : 쓰레드 풀링 (0) | 2023.04.29 |
---|---|
XIV : 쓰레드 동기화 기법 II (0) | 2023.04.29 |
XII : 쓰레드의 생성과 소멸 (0) | 2023.04.29 |
XI : 쓰레드의 이해 (0) | 2023.04.29 |
X : 컴퓨터 구조에 대한 세 번째 이야기 (0) | 2023.04.29 |