[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 13. 쓰레드 동기화 기법 1

2023. 11. 5. 15:10·독서/[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ]

01. 쓰레드 동기화란 무엇인가?

 

두 가지 관점에서의 쓰레드 동기화

보통 동기화라 하면 무엇인가를 일치시켜 주는 것이라고 생각을 한다. 그러나 우리가 말하고자 하는 동기화는 쓰레드의 관점에서 순서가 잘 지켜지고 있음을 의미한다. 또한, 이러한 쓰레드의 동기화는 크게 두 가지로 나눠서 생각할 수 있다.

 

실행 순서의 동기화

경우에 따라 쓰레드의 실행 순서가 중요한 경우가 있다. 예를 들어, A 쓰레드가 계산한 결과를 B 쓰레드가 받아서 출력하는 경우, A 쓰레드가 반드시 먼저 실행되어야 한다. 즉, 쓰레드의 실행 순서를 정의하고, 이 순서에 반드시 따르도록 하는 것이 쓰레드 동기화이다.

 

메모리 접근에 대한 동기화

데이터 영역과 힙 영역과 같이 한순간에 하나의 쓰레드만 접근해야 하는 메모리 영역이 존재한다. 데이터 영역에 할당된 변수를 둘 이상의 쓰레드가 동시에 접근할 때에는 문제가 발생하고 만다. 즉, 메모리 접근에 있어서 동시 접근을 막는 것 또한 쓰레드의 동기화이다.

 

정리하자면, 다음과 같다.

“실행 순서의 동기화”는 실행, 혹은 접근의 순서가 이미 정해져 있고, 이를 지키는 것이 중요하다.

 “메모리 접근의 동기화”는 순서와 상관없이 한순간에 하나의 쓰레드만 접근하는 것이 중요하다.

 

추가적으로, 실행 순서를 동기화하는 것은 쓰레드의 메모리 접근 순서를 동기화하기 위한 경우가 대부분이다.

 

쓰레드 동기화에 있어서의 두 가지 방법

Windows에서는 다양한 동기화 기법을 제공하며, 상황에 따라 적절한 동기화 기법이 존재한다. 또한, Windows에서 제공하는 동기화 기법은 크게 두 가지로 나뉜다. 하나는 유저 모드 동기화(User Mode Synchronize) 기법이고, 하나는 커널 모드 동기화(Kernel Mode Synchornize) 기법이다.

 

유저 모드 동기화

동기화 과정에서 커널의 힘을 빌리지 않는(커널 코드가 실행되지 않는) 동기화 기법이다. 이에 따라, 커널 모드로의 전환이 이뤄지지 않는다. 즉, 성능적으로 이점이 있다. 그러나, 기능상의 제한이 따른다.

 

커널 모드 동기화

커널에서 제공하는 동기화 기능을 활용한다. 이에 따라, 커널 모드로의 전환이 빈번하게 일어나기 때문에 성능 저하로 이어지지만, 다양한 기능을 제공받을 수 있다.


02. 임계 영역(Critical Section) 접근 동기화

이번 chapter에서는 주로 “메모리 접근의 동기화”를 다룬다. 메모리 영역의 접근을 동기화한다는 것은 “임계 영역의 접근을 동기화” 하겠다는 것과 마찬가지이다.

 

임계 영역(Critical Section)에 대한 이해

임계 영역이란, Operating System 과목에서도 언급되었지만, 둘 이상의 쓰레드가 동시에 접근할 가능성이 있는 코드 블록을 의미한다. 즉, 문제의 원인이 될 수 있는 코드 블록을 임계 영역이라 하는 것이지, 변수에 할당된 메모리 공간을 의미하지 않는다.

정리하자면, 다음과 같다.

임계 영역은 한순간에 하나의 쓰레드만 접근하는 것이 요구되는 공유 리소스(ex 전역변수)에 접근하는 코드 블록을 의미한다.

 

이러한 문제점을 해결하기 위해 동기화 기법을 사용한다. Windows에서 제공하는 동기화 기법은 다음과 같다.

 

  • 크리티컬 섹션(Critical Section) 기반의 동기화 – 메모리 접근
  • 동기화뮤텍스(Mutex) 기반의 동기화 – 메모리 접근 동기화
  • 이름있는 뮤텍스(Named Mutex) 기반의 프로세스 동기화 – 프로세스 간 동기화
  • 이벤트(Event) 기반의 동기화 – 실행 순서 동기화
  • 세마포어(Semaphore) 기반의 동기화 – 메모리 접근 동기화
  • 인터락 함수(Interlocked Family Of Function) 기반의 동기화 – 메모리 접근 동기화

 

위에서 크리티컬 섹션, 인터락 함수 기반의 동기화는 유저 모드 동기화에 해당하며, 나머지는 커널 모드 동기화에 해당된다.


03. 유저 모드의 동기화 (Synchronization In User Mode)

유저 모드의 동기화는 앞서 언급했듯이, 커널 모드로의 전환이 불필요하기 때문에 성능상의 이점을 얻을 수 있으며, 커널 모드 동기화에 비해 활용 방법도 단순하다.

 

크리티컬 섹션(Critical Section) 기반의 동기화

이는 앞서 언급했던 임계 영역을 의미하는 Critical Section 과는 다른 것을 의미한다. 흔히 이 동기화 기법을 소개할 때 화장실 열쇠에 비교한다. 핵심은 열쇠를 얻은 사람만이 화장실에 들어갈 수 있다는 것이다. 다음은 이 기법을 사용하는 데 있어서 필요한 함수들을 소개한다.

CRITICAL_SECTION gCriticalSection; //critical section obj

VOID InitializeCriticalSection(
	LPCRITICAL_SECTION lpCriticalSection
);

VOID EnterCriticalSection(
    LPCRITICAL_SECTION lpCriticalSection
);

VOID LeaveCriticalSection(
    LPCRITICAL_SECTION lpCriticalSection
);

VOID DeleteCriticalSection(
    LPCRITICAL_SECTION lpCriticalSection
);

 

각 함수의 기능은 함수의 이름에서 직관적으로 알 수 있으며, 매개변수 lpCriticalSection은 크리티컬 섹션 오브젝트의 주소값을 의미한다.

 

크리티컬 섹션 기반의 동기화를 위해 크리티컬 섹션 오브젝트의 생성 및 초기화가 필요하다. 이는 CRITICAL_SECTION 자료형의 변수로, 화장실의 열쇠 역할을 한다. 사용 방법은 다음과 같다.

 

CRITICAL_SECTION gCriticalSection; //critical section obj
InitializeCriticalSection(&gCriticalSection); //object 초기화
EnterCriticalSection(&gCriticalSection); //임계영역 진입
//
//임계 영역
//
LeaveCriticalSection(&gCriticalSection); //임계영역 탈출
DeleteCriticalSection(&gCriticalSection); //object 반환

 

인터락 함수(Interlocked Family Of Function) 기반의 동기화

전역으로 선언된 변수 하나의 접근 방식을 동기화하는 것이 목적이라면, 이러한 용도로 특화된 인터락 함수를 사용하는 것도 나쁘지 않다. 이는 함수 내부적으로 한순간에 하나의 쓰레드에 의해서만 실행되도록 동기화되어 있다. 대표적인 인터락 함수는 다음과 같다.

LONG InterlockedIncrement(
	LONG volatile* Addend
);

LONG InterlockedDecrement(
	LONG volatile* Addend
);

 

InterlockedIncrement 함수와 InterclokedDecrement 함수는 원자적 접근(Atomic Access), 즉 한순간에 하나의 쓰레드만 접근하는 것을 보장해 주는 함수이다. MS에서는 위에서 소개한 인터락 함수 외에도 값을 원하는 만큼 증가 및 감소시키는 함수 등 다양한 인터락 함수를 제공한다.

이러한 인터락 함수들은 유저 모드 기반으로 접근하기 때문에 속도가 빠르다.

 

위의 두 함수 선언에 포함되어 있는 키워드 volatile라는 키워드는 C, C++ 언어의 ANSI 표준 키워드로, 크게 두 가지 의미를 지닌다. 그 중 하나는 다음과 같다.

“최적화를 수행하지 마라”

 

컴파일러는 프로그래머가 작성한 코드를 컴파일 단계에서 코드의 최적화를 진행한다. 예를 들어 다음과 같은 코드가 있다고 가정하자.

int a(void)
{
	int a = 1;
	a = 2;
	a = 3;
	cout << a;
}

 

이는 컴파일러에 의해 다음과 같은 코드로 최적화된다.

int a(void)
{
	int a = 3;
	cout << a;
}

 

이러한 경우, 중간의 변수의 값을 변경하는 과정이 유의미한 과정일 경우에 문제가 발생하게 된다.

 

두 번째 의미는 다음과 같다.

“메모리에 직접 연산하라”

 

코드가 실행되는 과정에서 성능 향상을 위해 동작 중인 캐쉬 매니저가 값을 메모리에 저장하는 것이 아니라, 캐쉬 메모리에 저장하는 경우가 있다. 하지만, 메모리에 저장되어야 의미가 있는 코드일 경우, 언젠가 캐쉬에 저장된 데이터가 메모리에 저장되므로 실행은 되겠지만, 원하는 시점에 실행되지 않는다는 것이 문제이다.

volatile로 선언하면 해당 데이터는 캐쉬되지 않는다.

 

정리하자면 volatile는 전달되는 포인터를 이용해서 함수 내부적으로 최적화를 진행하지 않으며, 해당 포인터가 가리키는 메모리 영역을 캐쉬하지 않는다.


04. 커널 모드의 동기화 (Synchronization In Kernel Mode)

이제는 커널 모드에서 동작하는 커널 모드 동기화 기법에 대해 이야기할 것이다. 이는 Windows 커널 레벨에서 제공해 주는 동기화 기법이기 때문에, 유저 모드 동기화가 제공해 주지 못하는 기능을 제공받을 수 있다.

 

뮤텍스(Mutex) 기반의 동기화

뮤텍스 기반의 동기화 기법의 경우에는 열쇠에 비유할 수 있는 것이 뮤텍스 오브젝트이고, 이는 크리티컬 섹션 오브젝트와 달리 다음 함수를 통해서 만들어진다.

 

HANDLE CreateMutex(
	LPSECURITY_ATTRIBUTES lpMutexAttributes,
	BOOL bInitialOwner,
	LPCSTR lpName
);

lpMutexAttributes: 프로세스를 생성할 때처럼 보안 속성을 지정해 준다.

bInitialOwner: 뮤텍스는 오브젝트를 생성하는 쓰레드에게 먼저 기회를 줄 수 있다. 크리티컬 섹션처럼 먼저 차지하는 사람이 임자가 되게 할 수도 있고(FALSE), 뮤텍스를 생성하는 쓰레드가 먼저 기회를 얻을 수도 있다(TRUE).

lpName: 뮤텍스에 이름을 붙여주기 위해 사용한다. 이름을 주었을 때 생성되는 뮤텍스를 가리켜 Named Mutex라 한다.

 

위 함수의 반환형을 보면, HANDLE 임을 알 수 있다. 즉, 뮤텍스가 커널 오브젝트임을 의미한다. 따라서, 이를 통해 뮤텍스가 커널 레벨 동기화 기법임을 확인할 수 있다.

또한, 뮤텍스는 커널 오브젝트이므로, Signaled와 Non-Signaled 상태를 가진다. 이는 뮤텍스를 열쇠에 비유하자면, 누군가가 열쇠를 취득했을 때 Non-Signaled 상태가 되고, 취득한 열쇠를 반환했을 때 Signaled 상태가 된다. 이러한 상태 변환은 다음과 같은 함수를 통해 이루어진다.

 

BOOL ReleaseMutex(
	HANDLE hMutex
);

 

이와 더불어 WaitForObject 함수를 이용한다. 이 함수는 인자로 전달된 핸들의 커널 오브젝트가 Signaled 상태가 되어서 반환하는 경우, 해당 커널 오브젝트를 Non-Signaled 상태로 변경한다. 따라서 다음과 같은 구성이 된다.

 

또한, CloseHandle을 통해 운영체제에 의한 소멸을 진행할 수 있다.

 

추가적으로, WaitForSingleObject 함수는 다양한 용도로 사용되기 때문에 다음과 같이 래핑(Wrapping)을 통해 사용한다. 이는 코드에 의미를 명확하게 알 수 있다. 또한, 성능과 관련해서 이는 컴파일러가 코드 최적화를 진행할 때 함수의 호출 부분을 몸체 부분으로 대체시키므로, 성능에 영향은 없다.

 

DWORD AcquireMutex(HANDLE mutex)
{
	return WaitForSingleObject(mutex, INFINITE);
}

 

세마포어(Semaphore) 기반의 동기화

보통 세마포어는 뮤텍스와 상당히 유사하다고 말한다. 이는 “세마포어 중에서 단순화된 세마포어(바이너리 세마포어)를 가리켜 뮤텍스라 한다”라고 말할 정도이다. 따라서, 엄밀히 말하면 뮤텍스는 세마포어의 일종이다.

둘의 차이는 카운트 기능에서 비롯된다. 뮤텍스는 카운트 기능이 없지만, 세마포어에는 카운트 기능이 존재한다. 이러한 카운트 기능은 임계 영역에 접근 가능한 쓰레드의 개수를 의미한다.

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

 

HANDLE CreateSemaphore(
	LPSECURITY_ATTRIBUTES lpSemaphoreAttributes,
	LONG lInitialCount,
	LONG lMaximumCount,
	LPCSTR lpName
);

lpSemaphoreAttributes: 보안 속성을 지정하기 위한 매개변수이다.

lInitialCount: 세마포어는 값을 지니는데, 이 값을 기반으로 임계 영역에 접근 가능한 쓰레드의 개수를 제한한다.

lMaximumCount: 세마포어가 지닐 수 있는 값의 최대 크기를 지정한다. 이 값이 1일 경우 뮤텍스와 동일한 기능을 한다. 기본적으로 lInitialCount보다 커야 한다.

lpName: 세마포어에 이름을 붙인다.

 

lInitialCount에 의해 초기 카운트가 경정되고, 카운트가 0일 경우 Non-Signaled 상태에 놓이게 되며, 1 이상인 경우 Signaled 상태에 있게 된다.

WaitForSingleObject 함수를 호출할 경우 그 값이 하나씩 감소하며 함수를 반환한다. 따라서 세마포어를 생성할 때 초기 카운트를 10으로 놓을 경우 WaitForSingleObject 함수를 열한 번째 호출 시 세마포어 카운트가 0인 관계로 블로킹 상태가 된다.

 

다음은 임계 영역을 빠져나온 쓰레드가 호출하는 ReleaseSemaphore 함수이다.

BOOL ReleaseSemaphore(
	HANDLE hSemaphore,
	LONG lReleaseCount,
	LPLONG lpPreviousCount
);

hSemaphore: 반환하고자 하는 세마포어의 핸들을 전달한다.

lReleaseCount: 반환은 카운트의 증가를 의미한다. 이 전달인자를 통해 증가시킬 값의 크기를 결정할 수 있다. 2를 전달할 경우 카운트가 2 증가한다. 보통은 1을 전달한다.

lpPreviousCount: 변경되기 전 세마포어 카운트 값을 저장할 변수를 지정한다. 필요 없다면 NULL을 전달한다.

 

이름있는 뮤텍스(Named Mutex) 기반의 프로세스 동기화

뮤텍스는 커널, 즉 운영체제 소유이기 때문에 다음과 같은 형태의 동기화가 가능하다.

위처럼 서로 다른 프로세스 영역에 존재하는 쓰레드가 뮤텍스를 이용해 동기화될 수 있다. 뮤텍스는 커널 오브젝트이므로, A와 B 프로세스 모두 접근 가능하다. 그러나, 프로세스 A에서 뮤텍스를 생성했을 경우, 뮤텍스에 관한 정보는 프로세스 A 핸들 테이블에만 존재하고, B의 테이블에는 없다. 따라서 B는 접근할 수 없게 된다. 이때 사용되는 것이 Named Mutex이다. 이는 Windows 운영체제 내에서 유일한 이름으로, 이를 통해 Windows가 관리하고 있는 커널 오브젝트에 접근 가능한 핸들 정보를 얻을 수 있다.

 

뮤텍스의 소유와 WAIT_ABANDONED

WaitForSingleObject 함수의 반환값을 보면, WAIT_ABANDONED라는 것이 있다. 만약 A, B, 쓰레드가 있고, 세마포어 오브젝트 C가 있다고 가정하자. A 쓰레드가 C의 카운트를 하나 감소시켰다면, 다시 증가시키는 것은 A 쓰레드일 것이다. 하지만, 이는 뮤텍스에서만 지켜져야 하는 제약사항이다. 세마포어의 경우 세마포어를 획득하는 쓰레드와 반환하는 쓰레드가 달라도 문제가 되지 않지만, 뮤텍스에서는 문제가 된다.

만약 쓰레드 A가 뮤텍스 C를 획득한 뒤, 뮤텍스를 반환하지 못하고 갑작스레 종료되어 버렸다. 이 경우 Windows에서는 이러한 상황을 파악하고, 뮤텍스를 대신 반환해 준다. 다음 대기자인 쓰레드 B는 WAIT_ABANDONED 값을 반환받게 된다.

이러한 WAIT_ABANDONED의 반환 자체는 오류가 아니지만, 프로그램 코드에도 문제가 없음을 의미하는 것은 아니다.


참고 자료:

윤성우. 『뇌를 자극하는 윈도우즈 시스템 프로그래밍』.한빛미디어, 2007.

'독서 > [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ]' 카테고리의 다른 글

[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 15. 쓰레드 풀링(Pooling)  (1) 2023.11.11
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 14. 쓰레드 동기화 기법 2  (2) 2023.11.06
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 12. 쓰레드의 생성과 소멸  (2) 2023.10.29
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 11. 쓰레드의 이해  (3) 2023.10.27
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 10. 컴퓨터 구조에 대한 세 번째 이야기  (3) 2023.10.08
'독서/[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ]' 카테고리의 다른 글
  • [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 15. 쓰레드 풀링(Pooling)
  • [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 14. 쓰레드 동기화 기법 2
  • [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 12. 쓰레드의 생성과 소멸
  • [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 11. 쓰레드의 이해
coding-l7
coding-l7
  • coding-l7
    coding-l7rl0
    coding-l7
  • 글쓰기 관리
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 기타
      • 유니티
        • OfficeWorkerRunning
      • 프로그래밍 언어 N
        • C N
        • C#
        • C++
      • CS
        • 컴퓨터 구조
        • 운영체제
      • 물리 기반 시뮬레이션
        • 기초
        • Cloth Simulation
        • Fluid Simulation
      • 코딩 테스트
        • 프로그래머스
        • 백준
      • 독서
        • [ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ]
        • [ 혼자 공부하는 컴퓨터 구조 + 운영체제 ]
        • [ CUDA 기반 GPU 병렬 처리 프로그래밍 ]
      • 영어
        • Basic Grammar In Use
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃허브
    • 포트폴리오
  • 공지사항

  • 인기 글

  • 태그

    narrow range filter screen-space fluid rendering
    fluid simulation
    C언어
    상수
    bilateral blur
    screen-space rendering
    wave simulation
    입자 기반 방법
    GLSL
    screen space fluid rendering
    정수 승격
    컴퓨터 구조
    물리 기반 시뮬레이션
    jump table
    유체 시뮬레이션
    OpenGL
    collision
    Flip
    그리드 기반 방법
    시스템 프로그래밍
    surface turbulence
    fluid implicit particle
    액체 시뮬레이션
    position based dynamics
    실수
    cloth simulation
    명령어
    파동 난류
    pbd
    RAM
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.3
coding-l7
[ 뇌를 자극하는 윈도우즈 시스템 프로그래밍 ] Chapter 13. 쓰레드 동기화 기법 1
글쓰기
상단으로

티스토리툴바