본문 바로가기
Programming/열혈 TCP, IP 소켓 프로그래밍(저자 윤성우)

Ch 20. Windows에서의 쓰레드 동기화

by minjunkim.dev 2020. 8. 14.

    모든 내용은 [윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어] 를 기반으로 제 나름대로 이해하여 정리한 것입니다. 다소 부정확한 내용이 있을수도 있으니 이를 유념하고 봐주세요!


# 유저모드와 커널모드

- 윈도우 운영체제의 연산방식(프로그램 실행방식)을 가리켜 "이중 모드연산" 방식이라 함.

- 유저모드 : 응용 프로그램이 실행되는 기본모드. 물리적인 영역으로의 접근이 허용되지 않으며,

접근할 수 있는 메모리의 영역에도 제한이 따른다.

- 커널모드 : 운영체제가 실행될 때의 모드. 메모리뿐만 아니라, 하드웨어의 접근에도 제한이 따르지 않는다.

- 그러나, 응용 프로그램의 실행과정에서 항상 유저모드에만 머무는 것이 아니라, 유저모드와 커널모드를 오가며 실행하게 됨.

- 두 가지 모드를 정의하는 이유 : "안전성을 높이기 위함"

- 쓰레드와 같이 커널 오브젝트의 생성을 동반하는 리소스의 생성을 위해서는,

유저모드(응용 프로그램에서 리소스 생성을 요청) => 커널모드(리소스의 생성) => 유저모드(응용 프로그램의 나머지 부분을 이어서 실행)

위와 같은 모드 변환의 과정을 기본적으로 거쳐야 함.

- 리소스의 생성 뿐만 아니라 커널 오브젝트와 관련된 모든 일은 커널모드에서 진행됨.

- 모드의 변환 역시 시스템에 부담이 되는 일이기에, 빈번한 모드의 변환은 성능에 영향을 줄 수 있음.

 

# 유저모드 동기화

- 유저모드상에서 진행되는 동기화

- 운영체제의 도움 없이 응용 프로그램 상에서 진행되는 동기화

- 속도가 빠르고(커널모드로의 전환이 불필요하기 때문), 사용방법도 간단

- 운영체제의 도움을 받지 않는 동기화 기법이기에 기능이 제한적

 

# 커널모드 동기화

- 커널모드상에서 진행되는 동기화

- 유저모드 동기화에 비해 제공되는 기능이 더 많고, Dead-lock에 걸리지 않도록 타임아웃의 지정이 가능

- 서로 다른 프로세스에 포함되어 있는 두 쓰레드간의 동기화도 가능

(운영체제가 소유하고 관리하는 커널 오브젝트를 기반으로 하기 때문)

- 모드간의 빈번한 변환이 불가피하기 때문에 성능에 있어서의 제약이 따름

 

# Dead-lock

- 임계영역으로의 진입을 대기중인, 블로킹 상태에 있는 쓰레드가 이를 빠져나오지 못하는 상황

- 매우 다양한 상황에서 발생함

- 대부분의 경우, 원인의 파악조차 쉽지 않을 정도로 애매한 상황에서 Dead-lock은 발생함


# CRITICAL_SECTION 기반의 동기화(유저모드 동기화 기법 중 하나)

- "CRITICAL_SECTION 오브젝트(이하 CS 오브젝트)"라는 것을 생성해서 이를 동기화에 활용함

- 이는 커널 오브젝트가 아니며, 대부분의 다른 동기화 오브젝트와 마찬가지로

이는 임계영역의 진입에 필요한 일종의 Key(열쇠)로 이해할 수 있음

- 임계영역의 진입을 위해서는 CS 오브젝트를 얻어야 하고,

반대로 임계영역을 빠져나갈 때에는 얻었던 CS 오브젝트를 반납해야 함

 

# CS 오브젝트의 초기화 및 소멸과 관련된 함수

#include <windows.h>

void InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
void DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

- lpCriticalSection : init... 함수에서는 초기화 할 CRITICAL_SECTION 오브젝트의 주소값 전달, 반면 Del... 함수에서는 해제할 CRITICAL_SECTION 오브젝트의 주소값 전달

 

- LPCRITICAL_SECTION 매개변수 : CRITICAL_SECTION의 포인터형

- DeleteSectionCriticalSection 함수는 CRITICAL_SECTION 오브젝트를 소멸하는 함수가 아니라, CRITICAL_SECTION 오브젝트가 사용하던 리소스를 소멸시키는 함수

 

# CS 오브젝트의 획득(소유) 및 반납에 관련된 함수

#include <windows.h>

void EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);
void LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

- lpCriticalSection : 획득(소유) 및 반납할 CRITICAL_SECTION 오브젝트의 주소값 전달

 

# CS 오브젝트를 이용한 동기화 예시

 

Sync_CS_win.c

#include <stdio.h>
#include <windows.h>
#include <process.h>

#define NUM_THREAD 50

/* thread의 main 함수 */
unsigned WINAPI threadInc(void* arg);
unsigned WINAPI threadDes(void* arg);

long long num=0; // thread들이 접근하는 전역변수
CRITICAL_SECTION cs; // CS 오브젝트

int main(int argc, char* argv[])
{
    HANDLE tHandles[NUM_THREAD]; // thread의 커널 오브젝트의 핸들
    int i;
    
    InitializeCriticalSection(&cs); // CS 초기화
    for(i=0;i<NUM_THREAD;++i) // NUM_THREAD 만큼 쓰레드 생성 및 실행
    {
    	if(i%2)
        	tHandles[i]=(HANDLE)_beginthreadex(NULL,0,threadInc,NULL,0,NULL);
        else
        	tHandles[i]=(HANDLE)_beginthreadex(NULL,0,threadDes,NULL,0,NULL);
    }
    
    WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INTINITE); // 모든 쓰레드기 종료할때까지 블로킹
    DeleteCriticalSection(&cs); // CS 관련 리소스 소멸
    printf("result: %lld \n", num);
    
    return 0;
}

unsigned WINAPI threadInc(void* arg)
{
    int i;
    
    EnterCriticalSection(&cs); // CS 획득
    for(i=0;i<50000000;++i)
    	num+=1;
    LeaveCriticalSection(&cs); // CS 반납
    
    return 0;
}

unsigned WINAPI threadDes(void* arg)
{
    int i;
    
    EnterCriticalSection(&cs); // CS 획득
    for(i=0;i<50000000;++i)
    	num-=1;
    LeaveCriticalSection(&cs); // CS 반납
    
    return 0;
}

- CS 획득 및 반납의 과정은 생각보다 시간이 걸리는 작업이므로, 항상 상황에 따라 적절한 임계영역 설정이 필요하다.(예시에서는 빠른 결과 확인을 위해 반복문 전체를 임계영역으로 잡았다.)


# 커널모드 동기화 기법 : Event, Semaphore, Mutex 커널 오브젝트 기반의 동기화가 존재

 

1. Mutex(Mutual Exclusion) 커널 오브젝트 기반 동기화

- CS 오브젝트 기반의 동기화와 유사

- Mutex 커널 오브젝트 역시 열쇠에 비유해서 이해할 수 있음

 

# Mutex 커널 오브젝트의 생성에 관련된 함수

#include <windows.h>

HANDLE CreateMutex(
LPSECURITY_ATTRIBUTES lpMutexAttributes, BOOL bInitialOwner, LPCTSTR lpName);
// 성공시 생성된 Mutex 커널 오브젝트의 핸들, 실패시 NULL 반환

- lpMutexAttributes : 보안관련 특성 정보의 전달, 디폴트 보안설정을 위해서 NULL 전달

- bInitialOwner : TRUE 전달시, 생성되는 Mutex 커널 오브젝트는 이 함수를 호출한 쓰레드의 소유가 되면서 non-signaled 상태가 된다. 반면 FALSE 전달시, 생성되는 Mutex 커널 오브젝트는 소유자가 존재하지 않으며 signaled 상태로 생성된다.

- lpName : Mutex 커널 오브젝트에 이름을 부여할 때 사용된다. NULL을 전달하면 이름없는 Mutex 커널 오브젝트가 생성된다.

 

- Mutex 커널 오브젝트는 소유자가 없는 경우에 signaled 상태가 된다.

 

# Mutex는 커널 오브젝트이기 때문에 다음 함수의 호출을 통해서 소멸이 이뤄진다.

#include <windows.h>

BOOL CloseHandle(HANDLE hObject);
// 성공시 TRUE, 실패시 FALSE 반환

- hObject : 소멸하고자 하는 커널 오브젝트의 핸들 전달

 

- 위 함수는 커널 오브젝트를 소멸하는 함수이기 때문에, Semaphore와 Event의 소멸에도 사용됨.

 

# Mutex의 획득과 반납에 관한 함수

- 획득은 WaitForSingleObject의 함수호출을 통해서 이루어짐

#include <windows.h>

BOOL ReleaseMutex(HANDLE hMutex);
// 성공시 TRUE, 실패시 FALSE 반환

- hMutex : 반납할, 다시 말해서 소유를 해제할 Mutex 커널 오브젝트의 핸들 전달

 

- Mutex는 소유되었을 때 non-signaled 상태가 되고, 반납되었을 때(소유되지 않았을 때) signaled 상태가 되는 커널 오브젝트

- WaitForSingleObject 함수는 커널 오브젝트의 상태가 signaled 상태인지 확인하는 함수이므로, 이를 통해 Mutex의 소유여부를 확인할 수 있음

- WaitForSingleObject 함수의 호출결과는

1) 호출 후 블로킹 상태 : Mutex 커널 오브젝트가 다르 쓰레드에게 소유되어서 현재 non-signaled 상태인 경우

2) 호출 후 반환된 상태 : Mutex 커널 오브젝트의 소유가 해제되었거나 소유되지 않아서 signaled 상태인 경우

- Mutex는 WaitForSingleObject 함수가 반환될 때, 자동으로 non-signaled 상태가 되는 'auto-reset 모드' 커널 오브젝트 => WaitForSingleObject 함수는 결과적으로 Mutex를 획득(소유)할 때 호출하는 함수가 됨

 

# Mutex 기반의 임계영역 보호를 위한 코드

WaitForSingleObject(hMutex, INFINITE);
// 임계영역의 시작
....
// 임계영역의 끝
ReleaseMutex(hMutex);

1) Mutex가 해제되었거나 소유되지 않아서 signaled 상태인 경우, WaitForSingleObject 함수가 반환을 하므로 임계영역으로 진입. 이 때, Mutex는 non-signaled 상태로 바뀜(auto-reset 모드) => Mutex가 소유됨

2) ReleaseMutex 함수 호출에 의해 Mutex를 반납. 이 때, Mutex는 signaled 상태로 바뀜.

 

# Mutex 커널 오브젝트를 이용한 동기화 예시

 

SyncMutex_win.c

#include <stdio.h>
#include <windows.h>
#include <process.h>

#define NUM_THREAD 50

/* thread의 main 함수 */
unsigned WINAPI threadInc(void* arg);
unsigned WINAPI threadDes(void* arg);

long long num=0; // thread들이 접근하는 전역변수
HANDLE hMutex; // Mutex 커널 오브젝트의 핸들

int main(int argc, char* argv[])
{
    HANDLE tHandles[NUM_THREAD]; // thread의 커널 오브젝트의 핸들
    int i;
    
    hMutex=CreateMutex(NULL,FALSE,NULL); // Mutex 커널 오브젝트를 소유자 없이 signaled 상태로 생성
    for(i=0;i<NUM_THREAD;++i) // NUM_THREAD 만큼 쓰레드 생성 및 실행
    {
    	if(i%2)
        	tHandles[i]=(HANDLE)_beginthreadex(NULL,0,threadInc,NULL,0,NULL);
        else
        	tHandles[i]=(HANDLE)_beginthreadex(NULL,0,threadDes,NULL,0,NULL);
    }
    
    WaitForMultipleObjects(NUM_THREAD, tHandles, TRUE, INTINITE); // 모든 쓰레드가 종료할때까지 블로킹
    CloseHandle(hMutex); // Mutex 커널 오브젝트 소멸
    printf("result: %lld \n", num);
    
    return 0;
}

unsigned WINAPI threadInc(void* arg)
{
    int i;
    
    WaitForSingleObject(hMutex, INFINITE); // Mutex 획득
    for(i=0;i<50000000;++i)
    	num+=1;
    ReleaseMutex(hMutex); // Mutex 반납
    
    return 0;
}

unsigned WINAPI threadDes(void* arg)
{
    int i;
    
    WaitForSingleObject(hMutex, INFINITE); // Mutex 획득
    for(i=0;i<50000000;++i)
    	num-=1;
    ReleaseMutex(hMutex); // Mutex 반납
    
    return 0;
}

# Semaphore 오브젝트 기반 동기화

- 리눅스의 세마포어와 유사

- 둘 다 "세마포어 값"이라 불리는 정수를 기반으로 동기화가 이뤄지고, 이 값이 0보다 작아질 수 없음

- 윈도우의 세마포어 값은 세마포어 커널 오브젝트에 등록이 됨

 

# Semaphore 오브젝트의 생성에 사용되는 함수

- 소멸은 CloseHandle 함수를 이용해서 진행함

#include <windows.h>

HANDLE CreateSemaphore(
LPSECURITY_ATTRIBUTES lpSemaphoreAttributes, LONG lInitialCount, LONG lMaximumCount,
LPCTSTR lpName);
// 성공시 생성된 Semaphore 커널 오브젝트의 핸들, 실패시 NULL 전달

- lpSemaphoreAttributes : 보안관련 정보의 전달, 디폴트 보안설정을 위해서 NULL 전달

- lIntialCount : 세마포어의 초기값 지정, 매개변수 lMaximumCount에 전달된 값보다 크면 안되고, 0 이상이어야 한다.

- lMaximumCount : 최대 세마포어 값을 지정한다. 1을 전달하면 세마포어값이 0, 또는 1이 되는 바이너리 세마포어가 구성된다.

- lpName : Semaphore 커널 오브젝트에 이름을 부여할 때 사용한다. NULL을 전달하면 이름없는 Semaphore 오브젝트가 생성된다.

 

- 세마포어 값이 0인 경우 non-signaled 상태

- 세마포가 값이 0보다 큰 경우 signaled 상태

- 매개변수 lIntialCount에 0이 전달되면 non-signaled 상태로 Semaphore 커널 오브젝트 생성

 

 

# 세마포어 커널 오브젝트의 반납에 사용되는 함수

#include <windows.h>

BOOL ReleaseSemaphore(HANDLE hSemaphore, LONG lReleaseCount, LPLONG lpPreviousCount);
// 성공시 TRUE, 실패시 FALSE 반환

- hSemaphore : 반납할 Semaphore 커널 오브젝트의 핸들 전달

- lReleaseCount : 반납은 세마포어 값의 증가를 의미하는데, 이 매개변수를 통해서 증가되는 값의 크기를 지정할 수 있다. 그리고 이로 인해서 세마포어의 최대 값을 넘어서게 되면, 값은 증가하지 않고 FALSE가 반환된다.

- lpPreviousCount : 변경 이전의 세마포어 값 저장을 위한 변수의 주소값 전달, 불필요하다면 NULL 전달

 

- WaitForSingleObject 함수가 호출되면, 세마포어 값이 0보다 큰 경우(signaled 상태) 반환을 한다.

이렇게 반환이 되면 세마포어 값을 1 감소시킨다. 세마포어 값이 0이 되면 non-signaled 상태가 된다.

 

- 임계영역의 보호 코드

WaitForSingleObject(hSemaphore, INFINITE);
// 임계영역의 시작
....
// 임계영역의 끝
ReleaseSemaphore(hSemephore, 1, NULL);

 

# Semaphore 커널 오브젝트를 이용한 동기화 예시

 

SyncSema_win.c

#include <stdio.h>
#include <windows.h>
#include <process.h>

/* thread의 main 함수 */
unsigned WINAPI Read(void* arg);
unsigned WINAPI Accu(void* arg);

/* 세마포어 커널 오브젝트의 핸들 */
static HANDLE semOne;
static HANDLE semTwo;

static int num;

int main(int argc, char* argv[])
{
    HANDLE hThread1, hThread2; // 쓰레드의 핸들
    semOne=CreateSemaphore(NULL,0,1,NULL); // non-signaled 상태의 바이너리 세마포어 생성
    semTwo=CreateSemaphore(NULL,1,1,NULL); // signaled 상태의 바이너리 세마포어 생성
    /*
    이를 통해 hThread1 => hThread2 순서대로 임계영역에 진입되게 함
    */
    
    /* 쓰레드 생성 및 실행 */
    hThread1=(HANDLE)_beginthreadex(NULL,0,Read,NULL,0,NULL);
    hThread2=(HANDLE)_beginthreadex(NULL,0,Accu,NULL,0,NULL);
    
    /* 각 쓰레드가 종료할 때까지 블로킹 */
    WaitForSingleObject(hThread1,INFINITE);
    WaitForSingleObject(hThread2,INFINITE);
    
    /* 세마포어 소멸 */
    CloseHandle(semOne);
    CloseHandle(semTwo);
    
    return 0;
}

unsigned WINAPI Read(void* arg)
{
    int i;
    
    for(i=0;i<5;++i)
    {
        fputs("Input num: ",stdout);
        WaitForSingleObject(semTwo,INFINITE); // semTwo를 non-signaled로 변경
        scanf("%d",&num);
        ReleaseSemaphore(semOne,1,NULL); // semOne을 signaled로 변경
    }
    
    return 0;
}

unsigned WINAPI Accu(void* arg)
{
    int sum=0, i;
    
    for(i=0;i<5;++i)
    {
        WaitForSingleObject(semOne, INFINITE); // semOne을 non-signaled로 변경
        sum+=num;
        ReleaseSemaphore(semTwo,1,NULL); // semTwo를 signaled로 변경
    }
    printf("Result: %d \n",sum);
    
    return 0;
}

# Event 커널 오브젝트 기반 동기화

- Event 커널 오브젝트의 생성과정에서 자동으로 non-signaled 상태로 돌아가는 auto-reset 모드와 그렇지 않은 manual-reset 모드 중 하나를 선택할 수 있다는 점이 다른 동기화 오브젝트와 비교됨.

 

# Event 커널 오브젝트의 생성에 사용되는 함수

#include <windows.h>

HANDLE CreateEvent(
LPSECURITY_ATTRIBUTES lpEventAttributes, BOOL bManualReset,
BOOL bInitialState, LPCTSTR lpName);
// 성공시 생성된 Event 커널 오브젝트의 핸들, 실패시 NULL 반환

- lpEventAttributes : 보안관련 정보의 전달, 디폴트 보안설정을 위해서 NULL 전달

- bManualRest : TRUE 전달시 mamual-reset 모드 Event, FALSE 전달시 auto-reset 모드 Event 생성

- bInitialState : TRUE 전달시 signaled 상태의 Event 커널 오브젝트 생성, FALSE 전달시 non-signaled 상태의 Event 커널 오브젝트 생성

- lpName : Event 커널 오브젝트에 이름을 부여할 때 사용된다. NULL을 전달하면 이름없는 Event 커널 오브젝트가 생성된다.

 

# CreateEvent 함수의 두번째 매개변수에 TRUE가 전달되면 manual-reset 모드로 Event 커널 오브젝트가 생성되므로, WaitForSingleObject 함수가 반환한다고 해서, Event 커널 오브젝트가 자동으로 non-signaled 상태로 되돌려지지 않는다. 따라서 이러한 경우에는 다음 두 함수를 이용해서 명시적으로 오브젝트의 상태를 변경해야 한다.

#include <windows.h>

BOOL ResetEvent(HANDLE hEvent); // to the non-signaled
BOOL SetEvent(HANDLE hEvent); // to the signaled
// 성공시 TRUE, 실패시 FALSE 반환

 

# Event 커널 오브젝트를 이용한 동기화 예시

 

SyncEvent_win.c

#include <stdio.h>
#include <windows.h>
#include <process.h>

#define STR_LEN 100

/* thread의 main 함수 */
unsigned WINAPI NumberOfA(void* arg);
unsigned WINAPI NumberOfOthers(void* arg);

static char str[STR_LEN];
static HANDLE hEvent; // Event 커널 오브젝트의 핸들

int main(int argc, char* argv[])
{
    HANDLE hThread1, hThread2; // 쓰레드의 핸들
    hEvent=CreateEvent(NULL,TRUE,FALSE,NULL); // manual-reset 모드의 non-signaled 상태의 Event 커널 오브젝트 생성
    
    /* 쓰레드 생성 및 실행 */
    hThread1=(HANDLE)_beginthreadex(NULL,0,NumberOfA,NULL,0,NULL);
    hThread2=(HANDLE)_beginthreadex(NULL,0,NumberOfOthers,NULL,0,NULL);
    
    /*
    문자열을 입력받은 후에,
    Event를 signaled 상태로 변경
    */
    fputs("Input string: ",stdout);
    fgets(str,STR_LEN,stdin);
    SetEvent(hEvent);
    
    /* 각 쓰레드가 종료할 때까지 블로킹 */
    WaitForSingleObject(hThread1,INFINITE);
    WaitForSingleObject(hThread2,INFINITE);
    
    ResetEvent(hEvent); // Event를 non-signaled 상태로 변경
    CloseHandle(hEvent); // Event 소멸
    
    return 0;
}

unsigned WINAPI NumberOfA(void* arg)
{
    int i, cnt=0;
    WaitForSingleObject(hEvent, INFINITE); // 문자열을 입력 받은 후에야 함수를 반환
    for(i=0;str[i]!='\0';++i)
    {
        if(str[i]=='A')
            ++cnt;
    }
    printf("Num of A: %d \n",cnt);
    
    return 0;
}

unsigned WINAPI NumberOfOthers(void* arg)
{
    int i, cnt=0;
    WaitForSingleObject(hEvent, INFINITE); // 문자열을 입력 받은 후에야 함수를 반환
    for(i=0;str[i]!='\0';++i)
    {
        if(str[i]!='A')
            ++cnt;
    }
    printf("Num of others: %d \n",cnt-1); // 문자열에 개행문자도 포함되어 있으므로 -1
    
    return 0;
}

- 간단하게나마 둘 이상의 쓰레드가 동시에 대기상태를 빠져 나와야 하는 상황을 보임

- 이러한 상황에서 manual-reset 모드로 생성 가능한 Event 오브젝트가 좋은 선택이 될 수 있음


[출처] : 윤성우 저, "열혈강의 TCP/IP 소켓 프로그래밍", 오렌지미디어