시스템프로그래밍 13장 - 쓰레드 동기화 기법1

김주현·2021년 10월 17일
0

시스템 프로그래밍

목록 보기
13/21
  1. 쓰레드 동기화란 무엇인가

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

여기서 우리가 말하고자 하는 동기화는 일치한다는 의미에서의 동기화가 아니라 순서에 있어서 질서가 지켜지고 있음을 의미하는 동기화 이다.

실행순서의 동기화

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

메모리 접근에 대한 동기화

한 순가에 하나의 쓰레드만 접근해야 하는 메모리 영역이 존재한다. 대표적으로 데이터와 힙 영역이다. 데이터 영역에 할당된 변수를 둘 이상의 쓰레드가 동시에 접근할 떄에는 문제가 발생하고 만다.
앞서 _beginthreadex 함수를 소개하면서 메모리에 동시접근할 때 어떠한 문제가 발생하는지 설명하였다.

즉 이렇게 "메모리 접근에 있어서 동시접근을 막는 것 또한 쓰레드의 동기화에 해당한다"

이 두가지 형태의 쓰레드 동기화가 구분 짓기 애매모호할수 있다. 두 가지 모두가 실행에 있어서 순서를 중요시하기 때문이다. 그러나 차이가 나는 부분은 "상황"이다.

"실헹순서의 동기화"를 설명하면서 소개한 상황은 실행,혹은 접근의 순서가 이미 정해져 있는 상황이다. 만약에 A,B,C 쓰레드가 존재하고, 실행 혹은 접근의 순서는 반드시 a->b->c 라고 정의한다면, 이 순서는 반드시 지켜져야 하는 것이다.

"메모리 접근의 동기화"는 실행의 순서가 중요한 상황이 아니고, 한 순간에 하나의 쓰레드만 접근하면 되는 상황을 의미한다. 만약에 세 개의 쓰레드 a,b,c가 존재한다면 어떠한 순서로 실해되건 중요치않다.
메모리에 동시접근하는 문제점만 발생하지 않으면 된다.

참고로 실행순서를 동기화한다는 것은 쓰레드의 메모리 접근순서를 동기화하기 위한 경우가 대부분이다. 따라서 "실행순서의 동기화"역시 메모리 접근과 관련이 있다. 다만 개념적으로 메모리 접근의 측면보다는, 메모리에 접근하는 쓰레드의 실행순서가 강조되어야 하는 부분이기에 "실행순서의 동기화"라는 표현을 사용한 것이므로 이 부분에 있어서 오해나 혼란이 없어야겠다.

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

동기화 기법이 실행순서 동기화를 위한 기법과 메모리 접근 동기화를 위한 기법으로 나뉘는것은 아니지만 상황에 따라 어울리는 동기화 기법은 존재한다.

Windows에서 제공하는 동기화 기법은 제공하는 주체에따라 크게 유저 모드 동기화와 커널 모드 동기화 기법으로 나눌수있다.

유저 모드 동기회

동기화가 진행되는 과정에서 커널의 힘을 빌리지않는(커널 코드가 실행되지 않는) 동기화 기법이다. 따라서 동기화를 위해서 커널 모드로의 전환이 불필요하기 때문에 성능상에 이점이 있다. 다만 그만큼 기능상의 제한도 있다.

커널 모드 동기화

커널에서 제공하는 동기화 기능을 활용하는 방법이다. 따라서 동기화에 관련된 함수가 호출될 때마다 커널 모드로의 변경이 필요하고, 이는 성능의 저하로 이어지게 된다. 하지만 그만큼 유저 모드 동기화에서 제공하지 못하는 기능을 제공받을 수 있다.

02 . 임계 영역 접근 동기화

이번 절에서는 "메모리 접근의 동기화"에 대해서 공부하겠다.
메모리 영역의 접근을 동기화한다는 것은 "임계 영역의 접근을 동기화"하겠다는 뜻으로 해석할 수 있다. 그렇다면 임계영역은 무엇인가?

-임계 영역에 대한 이해

전역변수에 접근하는 연산을 둘 이상의 쓰레드가 동시에 실행할 경우 문제가 발생할 수 있음. 이러한 문제를 일으키는 코드 블록을 가리켜 임계영역이라고한다.

즉 문제의 원인이 될 수 있는 코드의 블록을 가리켜 임계영역이라고 하는 것이지,전역변수에 할당된 메모리 공간을 가리켜 임계 영역이라고 하는 것은 아니다.

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

위와 같은 문제점의 해결책은 임계영역의 동시접근을 막는 것이다. 즉 동기화 기법을 통해서 임계영역은 한 순간에 하나의 쓰레드만 실행될 수 있도록 제한하면 된다. 다행히도 Windows는 아주 다양한 동기화 기법을 제공한다. 그렇다고 무턱대고 하나 골라서 사용하는 것은 적절치 않다. 우선 사용 가능한 동기화 기법의 종류부터 살펴보자.

특별히 용도가 정해져 있는 것은 아니지만. 목적에 적합한 동기화 기법을 사용하면 간결하고도 정확한 코드가 나온다.

03 . 유저 모드의 동기화

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

-크리티컬 섹션 기반의 동기화

크리티컬 섹션의 동기화 방식의 핵심은 열쇠를 얻은 자만이 화장실에 들어 갈 수있다는 것이다. 이 기법을 적용하는 데 있어서 필요한 함수들을 소개하겠다.

크리티컬 섹션 기반의 동기화를 사용하려면 크리티컬 섹션 오브젝트라는 것을 만들고 초기화해야한다. 크리티컬 섹션 오브젝트는 자료형 CRITICAL_SECTION의 변수를 말하는데, 이를 화장실 열쇠 정도로 생각하면 좋겠다.

크리티컬 섹션 오브젝트를 선언한 다음에는 다음 함수를 통해서 반드시 초기화 과정을 거쳐야 한다. 초기화 과정을 통해서 크리티컬 섹션 오브젝트는 사용 가능한 상태가 된다. 화장실 앞에 열쇠를 걸어 놓는 행위로 생각하자.

void InitializeCriticalSection (
LPCRITICAL_SECTION lpCriticalSection // 초기화하고자 하는 크리티컬 섹션 오브젝트의 주소값을 인자로 전달한다.
);

열쇠를 걸어 놓았으니 화장실에 들어가기 위해서는 열쇠를 사용해야 한다. 따라서 우리에게 필요한 액션은 두 가지이다. 하나는 화장실에 들어가기 위해서 열쇠를 획득하는 행위이고, 또 하나는 화장실에서 나온 후 열쇠를 제자리에 걸어 두는 행위이다.

void EnterCriticalSection (
LPCRITICAL_SECTION lpCriticalSection // 임계영역에 진입하기 위해 필요한 크리티컬 섹션 오브젝트의 주소값을 인자로 전달한다. 만약에 누군가(다른 쓰레드)에 의해서 이미 이 함수가 호출된 상태라면,호출된 함수는 블로킹된다. 그리고 열쇠가 반환디면 블로킹 상태에 있던 함수는 빠져 나오게 된다. 이 함수의 호출에 성공하고 임계 영역으로 들어갔을 때 이를 호출한 쓰레드가 크리티컬 섹션 오브젝트를 획득했따고 표현한다.
);

void LeaveCriticalSection (
LPCRTICAL_SECTION lpCriticalSection
);

lpCriticalSection : 임계 영역을 빠져 나오고 나서 호출하는 함수이다. 화장실에 열쇠를 다시 걸어 놓는 역할을 한다. 만약에 EnterCriticalSection 함수를 호출하고 블로킹 상태에 놓인 쓰레드가 있다면, 이 함수 호출로 인해서 블로킹 상태에서 빠져나와 임계 영역으로 진입하게된다. 이 함수 호출이 완료되었을 때, 이를 호출한 쓰레드가 크리티컬 섹션 오브젝트를 반환했다고 표현한다.

위 두함수는 다음과 같은 형태로 사용된다.

임계 영역이 결정되면 위와 같이 진입 이전에 "EnterCriticalSection" 함수를 호출하고,빠져 나온후에 "LeaveCriticalSection" 함수를호출해서 이 영역은 한 순간에 하나의 쓰레드만 실행할 수 있도록 구성하는 것이 바로 크리티컬 섹션 동기화 기법의 핵심이다.

끝으로 초기화 함수가 호출되는 과정에서 할당된 리소스들이 존재하는데, 다음 함수를 이용하여 이를반환해야 한다.

void DeleteCriticalSection(
LPCRITICAL_SECTION lpCriticalSection // 반환하고자 하는 크리티컬 섹션 오브젝트의 주소값을 인자로 전달한다.
);

-인터락 함수 기반의 동기화

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

LONG InterlockedIncrement (
LONG volatile* Added
);

Addend : 값을 하나 증가시킬 32비트 변수의 주소값을 전달한다. 둘 이상의 쓰레드가 공유하는 메모리에 저장된 값을 이함수를 통해서 증가시킬 경우, 동기화된 상태에서 접근하는 것과 동일한 안정성을 보장받을 수 있다.

LONG InterlockedDecrement (
LONG volatile* Added
);

위함수와 반대로 증가대신 감소시킬때 사용된다.

두함수는 원자적접근,즉 한 순간에 하나의 쓰레드만 접근하는 것을 보장해 주는 함수이다. 따라서 모든 쓰레드가 이 함수들을 통해서 값을 하나 증가혹은 감소 시킬경우,임계역영역문제는 발생하지 않는다. 이러한 인터락 함수들도 유저 모드 기반으로 동작하기 때문에 속도가 상당히 빠르다.

두 함수의 선언에 포함되어 있는 키워드 volatile는 c,c++ 언어의 ANSI표준 키워드이며 크게 두가지 의미를 지닌다.
"최적화를 수행하지 마라"
"메모리에 직접 연산하라"

04 . 커널 모드 동기화

커널모드동기화는 모드전환때문에 느리지만,유저 모드 동기화가 제공해 주지 못하는 기능을 제공받을 수 있다.

  • 뮤텍스 기반의 동기화

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

HANDLE CreateMutex (
LPSECURITY_ATTRIBUTES lpMutexAttributes
BOOL bInitialOwner
LPCTSTR lpName
);

lpMutexAttributes : 프로세스를 생성할때,보안 속성을 지정했던 것을 기억할 것이다. 프로세스도 커널 오브젝트이고, 뮤텍스도 커널 오브젝트이다. 따라서 프로세스와 마찬가지로 보안 속성을 지정해 줄 수 있다. 앞서 우리는 새로운 프로세스 생성 시 핸들을 상속여부를 결정하는데 이 전달인자를 활용하였다.

binitialOwner : 열쇠에 해당하는 크리티컬 섹션 오브젝트는 생성 후 초기화되고 나면, 누구든지이 열쇠를 먼저 소유하는 쓰레드(EnterCriticalSection 함수를 먼저 호출하는 쓰레드가)가 임계 영역에 접근할 수 있는 권한을 얻었다. 그러나 뮤텍스는 뮤텍스 오브젝트를 생성하는 쓰레드에게 기회를 먼저 줄 수 있다. 크리티컬 섹션처럼 먼저 차지하는 사람이 임자가 되게 할 수도 있고(FALSE를 전달할 경우), 뮤텍스를 생성하는 쓰레드가 먼저 기회를 얻을 수도 있다(TRUE를 전달할 경우)

lpName : 뮤텍스에 이름을 붙여주기 위해 사용한다. 이름은 널 문자로 끝나는 문자열로 지정하면 된다. 이름으 주었을 때 생성되는 뮤텍스를 가리켜 Named Mutex라 표현한다.

반환타입은 핸들 -> 뮤텍스가 커널 오브젝트임을 의미 -> 뮤텍스는 커널 레벨 동기화 기법이다.

뮤텍스는 크리티컬 섹션 오브젝트와 달리 초기화 함수의 호출이 필요없음. 위 함수를 호출하는 과정에서 필요한 모든 초기화가 이뤄지기 때문

"보통 커널 오브젝트는 Non-Signaled 상태에 놓여 있다가,특정 상황이 되면 Signaled 상태가 된다. 그런데 이 특정 상황이라는 것은 커널 오브젝트에 의존적이다."

그렇다면 뮤텍스는 어떠한 경우에 Signaled 상태가 되겠는가. 뮤텍스는 열쇠에 비유되므로 누군가가 열쇠를 취득했을 때 Non-Sinaled 상태가 되고, 취득한 열쇠를 반환했을 때 Signaeld 상태가 될 것이다. 이러한 특성을 이용해서 동기화를 하게된다.

"뮤텍스는 누군가에 의해 획득이 가능할 때 Signaled 상태에 놓인다."

뮤텍스는 커널 오브젝트이고 또 누군가에 의해 획득이 가능할 때 Signaled 상태에 놓인다. 따라서 WaitForSingleObject 함수를 임계 영역 진입을 위한 뮤텍스 획득이 용도로 사용 가능하다. 반먄에 뮤텍스를 반활할 때에는 다음 함수를 이용해서 반환하게 된다. 물론 뮤텍스는 다시 Signaled 상태가 된다.

BOOL ReleaseMutex (
HANDLE hmutex //반환할 뮤텍스의 핸들을 인자로 전달한다.Non-Signaled 상태에 있는 뮤텍스 오브젝트는 Signaled 상태가 된다.
)

한가지 더 기억할 사항은 WaitForSingleObject 함수의 자동 되돌림 특성이다. 이 함수는 인자로 전달된 핸들의 커널 오브젝트가 Signaled 상태가 되어서 반환하는 경우, 해당 커널 오브젝트를 Non-Signaled 상태로 변경해 버린다.

쓰레드는 임계 영역에 들어가기에 앞서 뮤텍스를 획득해야 한다. 따라서 뮤텍스 핸들을 인자로 전달하면서 WaitForSingleObject함수를 호출한다. 만약에 뮤테긋가 획됙 가능한 상태라면 Signaled 상태에 있을 것이고 때문에 뮤텍스를 획됙하면서 임계영역에 진입한다.

뮤텍스는 커널 오브젝트이므로 CloseHandle 함수를 호출하면서 핸들을 반환하면 된다.

-세마포어 기반의 동기화

뮤텍스는 세마포어의 일종 - 차이는 세마포어는 카운트 기능이 존재
(임계 영역에 접근 가능한 쓰레드 개수를 조절하는 기능)

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

HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes // 보안속성을 지정하기 위한 매개변수

LONG lInitialCount // 이 값을 기반으로 임계 영역에 접근 가능한 쓰레드의 개수를 제한한다.

Long lMaximumCount // 세마포어가 지닐 수 있는 값의 최대 크기. 1일 경우 뮤텍스와 동일한 기능을 하는 바이너리 세마포어. lInitialCount로 전다되는 값보다 커야함

LPCTSTR lpName // 세마포어에 이름을 붙이기 위해 사용.

);

세마포어는 카운트를 지니고 lInitialCount에 의해 초기 카운트가 결정 카운트가 0일경우 Non-Signaled 상태에 놓이게되고, 1 이상인 경우 Signaled 상태에 있게 된다. 그리고 세마포어의 핸들을 인자로 전달하면서 WaitForSingleObject 함수를 호출할 경우, 그 값이 하나씩 감소하면서 함수를 반환한다.

임계영역을 빠져 나온 쓰레드는 ReleaseSemaphore 함수를 호출해야 한다. 이 함수는 세마포어 카운트를 증가시키는 역할을 한다.

BOOL ReleaseSemaphore (
HANDLE hSemaphore // 반환하고자 하는 세마포어의 핸들을 인자로 전달한다.
LONG lReleaseCount // 증가시킬 카운트 값의 크기를 결정. 만약에 세마포어 생성 시 결정한 최대 카운트 값을 넘겨서 증가시킬 것을 요구하는경우에는 카운트는 변경되지 않고 FALSE만 반환한다.

LPLONG lpPreviousCount // 변경되기 전 세마포어 카운트 값을 저장할 변수를 지정한다.

-이름있는 뮤텍스 기반의 프로세스 동기화

뮤텍스는 커널 오브젝트이므로, 프로세스 A의 요청에 의해서 생성되었다고해도 커널이 관리 하는 오브젝트이므로 프로세스 B도 접근이 가능하다.

다만, 핸들의 유효성이라는 한 가지 문제점이 있다. 핸들과 핸들 테이블의 소유 및 유효성에 관련된 내용을 다시 한번 상기하여 보자, 핸들 테이블은 커널 오브젝트와 이를 지칭하는 핸들값에 대한 정보를 담고 있는 테이블인데, 이는 각각의 프로세스 별로 독립적이다.

위 그림을 보면 프로세스 B는 프로세스 A가 생성한 뮤텍스에 접근이 불가능하다. 왜냐하면 프로세스 A를 통해서 만들어진 커널 오브젝트인 관계로, 프로세스
B의 핸들 테이블에는 이에 대한 정보가 없기 때문이다.

결국 이러한 문제해결을 위해서, 뮤텍스에 이름을 붙여주기로 한것이다. 이 이름은 Windows라는 운영체제내에서 유일한 이름이다. 때문에 이 이름을 통해서 Windows가 관리하고 있는 커널 오브젝트에 접근 가능한 핸들 정보를 얻을 수 있다. 다음 예제를 함께보자.

오픈 뮤텍스 함수는 이미 생성되어 있는 이름있는 뮤텍스의 핸들을 얻기 위해 사용되는 함수이다.

HANDLE OpenMutex(
DWORD dwDesiredAccess // 이름있는 뮤텍스로의 접근권한을 지정하는 것이다. 전달인자로 MUTEX_ALL_ACCESS을 전달해서 접근할 수 있는 권한을 요청해야 한다.
hInheritHandle // 핸들의 상속 유뮤를 결정하기 위한 전달인자이다.

LPCTSTR lpName //얻고자 하는 핸들 정보의 커널 오브젝트 이름을 전달한다. 여기로 전달하는 이름과 일치하는 이름을 지나는 뮤텍스가 존재한다면, 이 뮤텍스의 핸들이 반환된다. 물론 핸들 테이블에 이에 대한 정보도 추가된다.    

);

세마포어의 경우 세마포어를 획득하는 쓰레드와 반환하는 쓰레드가 달라도 문제가되지 않는다. 그러나 뮤텍스의 경우에는 문제가된다.

"뮤텍스는 획득한 사람이(쓰레드가) 직접 반환하는 것이 원칙이다. 본인만이 반환할 수 있다. 그러나 세마포어와 그 이외의 동기화 오브젝트는 마치 도서 대여점처럼 대신 다른 쓰레드가 반환해 줘도 문제되지 않는다.

쓰레드 A가 뮤텍스를 반환하지않고 종료된 경우 Windows는 쓰레드의 상태와 뮤텍스의 상태를 예의 주시하기 때문에 이러한 문제가 발생했음을 인식한다. 그리고는 더 이상 정상적인 방법으로 반환이 불가능한 뮤텍스를 대신 반환해주고, 다음 대기자인 쓰레드 B가 뮤텍스를 소유할 수 있도록 도와준다. 이때 쓰레드 B는 WAIT_ABANDONED 값을 반환받게된다.

이것만은 알고 갑시다

  1. 유저 모드 동기화와 커널 모드 동기화의 차이점 그리고 장단점

유저 모드 동기화는 커널의 힘을 빌리지 않는 단순한 라이브러리 기반의 동기화이다. 따라서 커널 모드로의 전환이 불필요하기 때문에 성능상에 큰 이점이 있다. 다만 그만큼 기능상의 제한도 있따. 반면 커널 모드 동기화는 커널에 의해 이뤄지는 동기화이다. 라이브러리 형태로 제공되는 함수를 호출하지만 실제로 동기화라는 일을 하는 대상은 커널이다. 따라서 유저 모드 동기화에서 제공하지 못하는 기능을 제공받을 수 있다.

  1. 임계영역의 의미

임계 영역을 메모리로 오해하는 경우가 있는데 임계 영역은 메모리를 의미하는 것이 아니다. 임계 영역은 둘 이상의 쓰레드가 동시에실행하면 문제가 발생하는 프로그램상의 코드 일부를 의미하는 것이다.

  1. 뮤텍스와 세마포어의 차이점 및 유사점

뮤텍스와 세마포어는 유사하다. 실제로 뮤텍스는 세마포어의 일종이다. 차이가 있다면 세마포어는 카운터를 지닐 수 있다는 것이다. 카운트를 지닐 수 있다는 것은 임계영역에 진입할 수 있는 쓰레드의 개수를 늘리거나 줄일 수 있다는 뜻이다. 임계 영역에 진입할 수 있는 쓰레드를 하나로 제한할 경우 세마포어의 카운트를 1로 둬야 하는데, 이렇게 세마포어의 카운트를 1로 두면 이를 뮤텍스라 할 수 있다.

  1. volatile 키워드

volatile 키워드는 다음 두가지 의미를 지닌다.
-최적하를 수행하지 마라.
-메모리에 직접 연산하라(혹은 캐쉬하지 마라).

0개의 댓글