[뇌를 자극하는 윈도우즈 시스템 프로그래밍] 13장 쓰레드 동기화 기법1
https://www.hanbit.co.kr/store/books/look.php?p_code=B7673779595
뇌를 자극하는 윈도우즈 시스템 프로그래밍
이 책은 거의 모든 개발자가 궁금해 하면서도 또한 상당히 어려워하는 컴퓨터 구조, 운영체제, 시스템 프로그래밍의 내용 중 꼭 필요한 부분만 간추려서 담았다. 컴퓨터 구조와 운영체제에 대한
www.hanbit.co.kr
해당 책을 읽고 정리한 글입니다.
개요
이전 12장에서 언급한 것처럼, 쓰레드는 공유자원에 접근할 때
동기화가 필요하다.
동기화는 두 가지 관점에서 생각해볼 수 있다.
1. 실행순서의 동기화: 쓰레드가 어떤 순서로 실행되는 지를 제어2. 메모리 접근의 동기화: 여러 쓰레드가 동시에 공유 변수에 접근할 때 가시성과 원자성을 보장하는 것
쓰레드의 실행 순서와 메모리 동기화는 하나가 보장된다고 같이 되는 것은 아니다.
또한 동기화는 크게 두 가지 방법을 통해 구현할 수 있다.1. 유저 모드 동기화: 커널 오브젝트를 사용하지 않는 동기화 기법이다. 커널 모드로 전환을 하지 않으므로 비용이 덜 사용되지만, 커널 오브젝트처럼 많은 기능을 사용할 수 없다.
2. 커널 모드 동기화: 커널(Windows)에서 제공하는 동기화 커널 오브젝트를 사용하는 방법이다. 커널 오브젝트의 기능을 사용할 수 있지만, 모드 전환 비용이 소모된다.
Critical Section
Critical Section은 메모리 접근의 동기화가 필요한 영역을 의미한다.이는 배타적 접근이 요구되는 공유 자원에 접근하는 코드 블록이다.
static int total = 0;
DWORD WINAPI ThreadProcAdd(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
++total;
}
return 0;
}
DWORD WINAPI ThreadProcSub(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
--total;
}
return 0;
}
int wmain(int argc, WCHAR* argv[]) {
DWORD dwThreadID[2];
HANDLE hThread[2];
hThread[0] =
CreateThread(
NULL, 0,
ThreadProcAdd, NULL,
0, &dwThreadID[0]
);
hThread[1] =
CreateThread(
NULL, 0,
ThreadProcSub, NULL,
0, &dwThreadID[1]
);
if (hThread[0] == NULL || hThread[1] == NULL)
{
wprintf(_T("Thread creation fault! \n"));
}
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
wprintf(L"total: %d\n", total);
CloseHandle(hThread[0]);
CloseHandle(hThread[1]);
return 0;
}
12장에서 예시로 사용했던 해당 코드의 경우 ++total, --total을 하는 코드 블록이 critical section이다.
만약 critical section에서 Thread의 동시 접근을 막는다면 이러한 문제는 해결될 수 있다.
위에서 언급했던 유저모드, 커널모드 동기화를 사용해 동시 접근을 막는다.Windows에서1. Critical Section 기반 동기화2. Interlocked 함수 기반 동기화3. Mutex 기반 동기화4. Semaphore 기반 동기화5. Named Mutex 기반 동기화6. Event 기반 동기화
방법이 있다.여기서 1~2는 유저모드 나머지는 커널모드 동기화 이며, Named Mutex는 프로세스 간 동기화, Event는 실행순서 동기화에 주로 사용된다는 특징이 있다.
유저 모드 동기화
커널 모드로의 전환은 많은 비용을 소모하기 때문에 꼭 필요하지 않다면 유저모드 동기화를 하는 것이 좋다.
Critical Section 기반 동기화
이 방법은 락을 획득을 경쟁하고 획득한 하나의 쓰레드만 해당 영역 내의 코드를 실행할 수 있는 방법이다.
먼저 CRITICAL_SECTION 오브젝트를 만들고 이를 초기화 해야 한다.
typedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo;
//
// The following three fields control entering and exiting the critical
// section for the resource
//
LONG LockCount;
LONG RecursionCount;
HANDLE OwningThread; // from the thread's ClientId->UniqueThread
HANDLE LockSemaphore;
ULONG_PTR SpinCount; // force size on 64-bit systems when packed
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-initializecriticalsection
InitializeCriticalSection 함수(synchapi.h) - Win32 apps
중요한 섹션 개체를 초기화합니다.
learn.microsoft.com
InitializeCriticalSection을 사용해 초기화 하면 된다.
CRITICAL_SECTION이 C API라서 이렇게 명시적 초기화가 필요하다.
만약 std::mutex(내부적으로 critical_section사용) 를 사용한다면 c++함수이므로 생성자가 알아서 초기화 해준다.
Critical Section은 사용할 때 EnterCriticalSection을 사용해 명시적으로 내가 락을 획득할 것을 알리고, LeaveCriticalSection을 통해 명시적으로 락을 반환함을 알린다.
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-entercriticalsection
EnterCriticalSection 함수(synchapi.h) - Win32 apps
지정된 임계 영역 개체의 소유권을 기다립니다. 함수가 호출 스레드가 소유권을 부여받는 시기를 반환합니다.
learn.microsoft.com
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-leavecriticalsection
LeaveCriticalSection 함수(synchapi.h) - Win32 apps
지정된 중요 섹션 개체의 소유권을 해제합니다.
learn.microsoft.com
Windows의 CRITICAL_SECTION은 재귀적 락이 가능하기 때문에 EnterCriticalSection을 여러번 했다면 그만큼 LeaveCriticalSection을 해야 한다.
int total = 0;
CRITICAL_SECTION hCriticalSection;
DWORD WINAPI ThreadProcAdd(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
EnterCriticalSection(&hCriticalSection);
++total;
LeaveCriticalSection(&hCriticalSection);
}
return 0;
}
DWORD WINAPI ThreadProcSub(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
EnterCriticalSection(&hCriticalSection);
--total;
LeaveCriticalSection(&hCriticalSection);
}
return 0;
}
int wmain(int argc, WCHAR* argv[]) {
DWORD dwThreadID[2];
HANDLE hThread[2];
InitializeCriticalSection(&hCriticalSection);
timeBeginPeriod(1);
DWORD start = timeGetTime();
hThread[0] =
CreateThread(
NULL, 0,
ThreadProcAdd, NULL,
0, &dwThreadID[0]
);
hThread[1] =
CreateThread(
NULL, 0,
ThreadProcSub, NULL,
0, &dwThreadID[1]
);
if (hThread[0] == NULL || hThread[1] == NULL)
{
wprintf(_T("Thread creation fault! \n"));
}
WaitForMultipleObjects(2, hThread, TRUE, INFINITE);
DWORD end = timeGetTime();
wprintf(L"total: %d\n", total);
wprintf(L"time: %d", end - start);
CloseHandle(hThread[0]);
CloseHandle(hThread[1]);
return 0;
}
이전의 코드에 크리티컬 섹션을 추가했다.
추가로 해당 코드의 실행 시간을 비교해보기 위해 timeGetTime을 사용해서 출력해보았다.

결과는 이전과 다르게 0으로 정상 출력되는 것을 볼 수 있다.
Interlocked 기반 동기화
인터락 함수는 원자적 연산을 보장해주는 함수이다.
원자적(atomic)연산이라는 것은 CPU에서 한번에 원자적으로 실행될 수 있는 연산이다.
https://www.ibm.com/docs/en/aix/7.2.0?topic=services-atomic-operations
Atomic Operations
Atomic operations are sequences of instructions that guarantee atomic accesses and updates of shared single word variables. This means that atomic operations cannot protect accesses to complex data structures in the way that locks can, but they provide a v
www.ibm.com
따라서 CPU에서 해당 연산을 제공하지 않는다면 사용할 수 없다.
물론 대부분 현대 CPU에서는 제공한다.
https://learn.microsoft.com/ko-kr/windows/win32/sync/interlocked-variable-access
Interlocked 변수 액세스 - Win32 apps
애플리케이션은 여러 스레드에서 공유하는 변수에 대한 액세스를 동기화해야 합니다.
learn.microsoft.com
이제 예시에서 total 증감을 인터락 계열 함수로 바꿔보겠다.
DWORD WINAPI ThreadProcAdd(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
//++total;
InterlockedIncrement(&total);
}
return 0;
}
DWORD WINAPI ThreadProcSub(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
//--total;
InterlockedDecrement(&total);
}
return 0;
}
이를 실행해보면 total: 0이 출력되는 것을 확인할 수 있다.


디버깅을 통해 어셈블리를 확인해보면 lock inc라는 원자적 CPU명령을 하고 있음을 확인할 수 있다.
volatile
추가적으로 volatile이라는 키워드도 동기화에서 유용하게 사용된다.
일반적으로 속도상 이점으로 최적화 컴파일을 키고 프로그램을 서비스한다.

하지만 동기화를 위해 작성한 코드가 최적화 컴파일의 대상이 되어, 문제가 되는 경우가 생길 수 있다.
이 경우에 volatile키워드를 사용한다.
volatile을 붙인 변수의 경우 최적화 컴파일의 대상이 되지않는다.
c#과는 다르게 c++에서는 volatile이 해당 기능 외에는 다른 기능이 없다.
volatile을 붙여도 여전히 캐시에서 데이터를 가져오므로 메모리에 직접 연산한다는 말은 비약이 있는 표현이다.
커널 모드 동기화
커널의 기능이 필요하다면 커널 모드 동기화를 해볼 수 있다.
뮤텍스 기반 동기화
https://learn.microsoft.com/ko-kr/windows/win32/api/synchapi/nf-synchapi-createmutexa
CreateMutexA 함수(synchapi.h) - Win32 apps
명명되거나 명명되지 않은 뮤텍스 개체를 만들거나 엽니다. (ANSI)
learn.microsoft.com
Mutex는 커널 오브젝트이므로 SECURITY_ATTRIBUTES를 설정할 수 있다.
bInitialOwner를 통해 해당 뮤텍스를 생성하는 쓰레드가 우선적으로 Mutex를 소유하도록 만들 수 있다.
lpName을 설정해 NamedMutex를 만들고 싶다 NULL인경우 이름 없이 만들어진다.
Mutex가 획득 가능한 상태를 signaled, 없는 상황을 non-signaled라고 한다.
Mutex는 Kernel Object이기 때문에 WaitForSingleObject를 획득 용도로 사용할 수 있다.
그리고 ReleaseMutex를 통해 해당 Mutex를 반환할 수 있다.
DWORD WINAPI ThreadProcAdd(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
WaitForSingleObject(hMutex, INFINITE);
++total;
ReleaseMutex(hMutex);
}
return 0;
}
DWORD WINAPI ThreadProcSub(LPVOID lpParam)
{
for (DWORD i = 0; i <= 100000; ++i)
{
WaitForSingleObject(hMutex, INFINITE);
--total;
ReleaseMutex(hMutex);
}
return 0;
}

결과는 동일하지만 이전 유저모드들과 다르게 실행시간이 매우 늘어났음을 볼 수 있다.
실제로 테스트 해보아도 비슷할 것이다.
그만큼 커널모드로의 전환 비용이 크다는 것을 알 수 있다. (물론 예시에서 뮤텍스를 매우 빠르게 획득 반환을 반복해서 이기도 하다)
세마포어 기반 동기화
Semaphore는 간단하게는 Usage Count가 있는 Mutex라고 보면 된다.
https://learn.microsoft.com/ko-kr/windows/win32/api/winbase/nf-winbase-createsemaphorea
CreateSemaphoreA 함수(winbase.h) - Win32 apps
명명되거나 명명되지 않은 세마포 개체를 만들거나 엽니다. (CreateSemaphoreA)
learn.microsoft.com
하지만 Mutex와는 다르게 Semaphore는 여러 쓰레드가 소유가능하므로 소유권 개념이 없다.
A Thread가 획득하고 B Thread가 반환이 가능하다는 것이다.
이전과 같이 WaitForSingeObject를 통해 Semaphore를 획득할 수 있으며,
ReleaseSemaphore를 통해 반환한다.
나머지는 생략