본문 바로가기
C/[서적] 뇌를 자극하는 윈도우즈 시프

Chapter 14. 쓰레드 동기화 기법2 - 실행 순서 동기화

by GameStudy 2022. 2. 10.

14.1 실행 순서 동기화 기법

 

14.1-1 생산자-소비자 모델

  Note) 왜 하필 순서를 동기화하는가? 이게 중요함. 이거에 대해 얘기 해보자.

    생산자-소비자 모델은 사실 I/O 모델임. 다른 관점에서는 쓰레드 모델이다.라고도 함.

    사실 I/O 모델을 구현하기 위해서 쓰레드 모델이 도입되었다고 해도 무방할 정도.

    - 어찌되었든 I에 해당하는 입력으로는 네트워트로 부터 들어오는 입력, 콘솔 입력, ... etc

      입력된 데이터를 출력 혹은 가공해서 내가 원하는 데로 이용하는 모델이 있다고 가정 해보자.

    - 이럴 경우에 우리는 보통 쓰레드 모델을 바로 도입 시키기 보단 단순하게 구현함.

      일단 입력이 먼저 진행되고 그다음 출력이 진행됨.

      즉, 번갈아 가며 입력/출력하면 되는 것. 그냥 While loop로 구현해도됨.

    - 그러나 여기에는 문제점이 있음. 출력은 이미 구현 사이클이 정해져 있음. 

      우리가 어느정도 시간이 걸릴지 알고 있음.

      입력 데이터의 크기가 만약 10byte다하면, 10byte 데이터 출력하면 되기 때문.

      즉, 출력은 입력에 의존적임. 입력 데이터의 양만큼 출력이 이뤄짐.

      근데 또 입력은 외부 환경에 의존적임. 이말은 즉슨, 10byte 만큼씩 입력받고 

      출력하는데 전혀 문제 없이 구현해 두었는데, 외부 환경에서 갑자기 10MB가 들어온다면?

      내가 가지고 있는 시스템에서 문제가 생길 수 있음. 즉, 데이터의 손실이 발생할 수  있음.

    - 정리하자면, 10byte 입력 받고 10byte 출력하는데 문제 없는 내 시스템에

      10MB가 들어오게되면, 10byte 입력 받고 10byte 출력하는 와중에도 막 데이터가 쏟아져 들어오는 것.

      이 과정이 문제가 되는 것.

    - 그래서 실제로 I/O를 구현할 때, 입력 쓰레드와 출력 쓰레드를 둠.

      입력 쓰레드는 계속 데이터를 입력만 받음. 10byte가 오든, 10MB가 오든 전혀 상관없이

      출력용 버퍼에다가 쌓아다줌. 즉, 출력용 버퍼가 견디는 한, 출력 쓰레드가 더디게 가져가도

      전혀 데이터 손실 문제가 발생하지 않음.

    - 즉, 갑자기 어느 순간에 데이터 버스트가 일어 날 수 있다면 쓰레드를 도입함.

      그리고 그를 위한 버퍼를 준비하는 I/O 모델을 만듦.

    - 이때 입력 쓰레드를 생산자 쓰레드라 함. 출력 쓰레드를 소비자 쓰레드라 함.

      단, 생산자 쓰레드는 데이터를 생성해내는게 아님. 데이터를 제공하는 쓰레드.

    - 이때 아주 중요한건, 순서임. 생산자가 데이터를 제공하고 나서야 소비자가 데이터를 소비하는 것.

      만약 순서가 꼬여서, 생산자가 데이터를 제공하지 않은 상태. 즉 출력용 버퍼가 비어 있음.

      근데 소비자가 와서 데이터를 소비하려 든다면, 그 데이터들은 의미가 없는 쓰레기값일거임.

      바로 이러한 연유로 실행 순서 동기화가 필요한 것.

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

14.2 이벤트 기반 동기화

 

14.2-1 이벤트 기반 동기화

  Note) 이제 왜 우리가 순서를 동기화 해야하는지 알았음. 그럼 어떻게 순서를 동기화 하지?

    세마포어와 뮤텍스를 배웠기에 이건 비교적 쉬움. 시나리오는 아래와 같음.

    1. 생산자 쓰레드가 있고, 소비자 쓰레드가 있음.

      특정 영역(버퍼)에 생산자 쓰레드가 데이터를 가져다 놓으면, 소비자가 이걸 가져감.

    2. 생산자 쓰레드와 소비자 쓰레드가 만나서 이야기함. 

      생산자 쓰레드가 "데이터를 버퍼에 가져다 놓으면, 너가 그때와서 데이터를 가져가."

      근데 이때 문제가 있음. 생산자 쓰레드가 다 가져다 놓았는지, 소비자 쓰레드는 알 수 없음.

      그럼 누가 알 수 있나? 생산자 쓰레드 본인밖에 모름.

      즉, 어느 순간부터는 소비자 쓰레드가 진입해도 된다는걸 프로그래머가 결정할 수 있음.

    3. 그래서 둘이 약속을 함. 커널 오브젝트 하나 만들고, 초기에는 Non-signaled 상태로 두자.

      그리고 생산자는 데이터를 버퍼에 다 가져다 놓았을 경우에, Signaled 상태로 바꾸겠다고 약속함.

      그럼 소비자 쓰레드는 이 커널 오브젝트를 감시하고, Signaled 상태가 되면 진입하는 것.

    4. 그래서 소비자 쓰레드는 이 커널 오브젝트를 WaitForSingleObject() 함수를 통해 감시함.

      Non-signaled 상태라면 소비자 쓰레드는 Blocked 상태로 빠짐.

      생산자 쓰레드는 버퍼에 데이터를 다 가져다 놓고, SetEvent() 함수를 통해서 커널 오브젝트를

      Signaled 상태로 바꿈.

    일단, 위 과정이 이벤트 기반 동기화 모델의 Outline임. 더 깊게 들어가 보자.

 

  Note) 수동 리셋 모드 이벤트(Manual-Reset Event) Vs. 자동 리셋 모드 이벤트(Auto-Reset Event)

    - CreateEvent() 함수를 호출해서 이벤트를 생성함.

      이때, 수동이냐 자동이냐를 설정할 수 있음.

    - 수동 리셋 모드 이벤트는 이벤트 오브젝트의 Signaled 혹은 Non-signaled 상태를

      직접 조작해주지 않으면 상태가 변하지 않음.

      즉, Non-signaled -> signaled는 SetEvent() 호출로 직접 조작

      Signaled -> Non-signaled는 ResetEvent() 호출로 직접 조작

    - 자동 리셋 모드 이벤트는 이벤트 오브젝트의 Signaled 상태 설정은 직접 조작해야 함.

      다만, Signaled 상태에서 Non-signaled 상태로 되돌아가는 것은 자동임.

      즉, Non-signaled -> signaled는 SetEvent() 호출로 직접 조작

      Signaled -> Non-signaled는 WaitForSingleObject() 호출로 기다리기만 하면 됨.      

출처. 윤성우, "뇌를 자극하는 윈도우즈 시스템 프로그래밍"

 

  Note) 그럼 언제 자동쓰고 언제 수동쓰나?

      만약 A 쓰레드와 B 쓰레드가 하나의 커널 오브젝트를 기다리고 있다고 가정해 보자.

      두 케이스인데, 하나는 커널 오브젝트가 자동이고, 다른 하나는 수동.

      - 자동인 경우에는 두 쓰레드 모두가 WaitForSingleObject() 통해 대기 중에 있다가

        커널 오브젝트의 상태가 Signaled가 되면 둘 중 하나만 소비하게 됨.

        즉, 둘 중 하나만 실행의 기회를 얻게됨.

      - 수동인 경우에는 일단 커널 오브젝트를 생산자 쓰레드가 SetEvent()를 통해 Signaled 상태로 바꿈.

        그럼 두 쓰레드 모두가 실행의 기회를 얻게됨.

      - 즉, 한 순간에 하나의 쓰레드만 깨어나게 하려면 자동 리셋 모드 이벤트를 사용.

        관찰하고 있던 둘 이상의 쓰레드가 동시에 깨어나게 하려면 수동 리셋 모드 이벤트를 사용.

      

 

 

 

   Ex) 사실 근데 아래 예제는 수동이든 자동이든 문제가 되지 않는 예제.

<hide/>

/*
    StringEvent.cpp
    프로그램 설명: 1. 생산자/소비자 모델의 이해
                   2. 동기화 event에 대한 이해.
                   3. 메인 쓰레드 - 자식 쓰레드 사이의 동기화
                   4. 메인 쓰레드가 생산자 쓰레드, 자식 쓰레드가 소비자 쓰레드
*/

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <process.h>    /* _beginthreadex, _endthreadex */


unsigned int WINAPI OutputThreadFunction(LPVOID lpParam);

TCHAR string[100];
HANDLE hEvent;

int _tmain(int argc, TCHAR* argv[])
{	
    HANDLE  hThread;
    DWORD dwThreadID;

    hEvent = CreateEvent(	// event object 생성.
                NULL,		// 상속 불가.
                TRUE,		// manual-reset mode로 생성.
                FALSE,		// non-signaled 상태로 생성.
                NULL		// 이름 없는 event.
             );	
    if(hEvent==NULL){
        _fputts(_T("Event object creation error \n"), stdout); 
        return -1;
    }
    
    hThread = (HANDLE)_beginthreadex ( // [자식 쓰레드] 소비자 스레드 생성.
                        NULL, 0, 
                        OutputThreadFunction, 
                        NULL, 0, 
                        (unsigned *)&dwThreadID
                    );

    if(hThread==0) {
        _fputts(_T("Thread creation error \n"), stdout); 
        return -1;
    }	

    _fputts(_T("Insert string: "), stdout); 
    _fgetts(string, 30, stdin);

    SetEvent(hEvent);	// [메인 쓰레드]event의 state를 signaled 상태로 변경.

    WaitForSingleObject(hThread, INFINITE); // [메인 쓰레드] 메인 쓰레드가 먼저 종료되면 안되기에 기다림.
        
    CloseHandle(hEvent);	// event 오브젝트 소멸
    CloseHandle(hThread);

    return 0;
}

unsigned int WINAPI OutputThreadFunction(LPVOID lpParam)
{

  WaitForSingleObject(hEvent, INFINITE); // [자식 쓰레드]event가 signaled 상태가 되기를 기다린다.

  _fputts(_T("output string: "), stdout); 
  _fputts(string, stdout); 

  return 0;
}

 

14.2-2 수동 리셋 모드 이벤트 활용의 예

  Ex) 출력에 관련된 문제 발생

    왜냐면 두 쓰레드 모두가 하나의 이벤트 커널 오브젝트를 관찰하고 있음.

    그래서 두 쓰레드가 출력하려는 문장이 섞여 나올 수도 있음.

    즉, 임계 영역이라 함은 이전까지 메모리 접근의 문제점을 이야기 했었음.

    또한 하나의 코드 블럭만을 임계 영역이라 정의 했었음.

    근데 이 예제를 보면 코드 블럭이 분리 되어있지만 문제가 되는것.

    즉, 실행 순서 동기화는 해주었지만 콘솔에 접근하는 것은 동기화가 되지 않은 예제.

    그럼 콘솔에 접근하는 것에 접근 동기화를 시킬것이냐, 실행 순서 동기화를 시킬것이냐는

    베스트 엔서가 없음. 근데 동시에만 실행되지 않으면 될 경우에, 메모리 접근 동기화를 씀.

    결론은, 임계 영역이라는 것이 하나의 코드 블럭일수도 있지만 둘 이상의 코드 블럭이 임계영역으로 될 수도 있다.

<hide/>

/*
    StringEvent2.cpp
    프로그램 설명: manual-reset mode 동기화 적용 사례.
      이전 예제와 다르게, 하나의 쓰레드가 더 등장.
*/

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <process.h>    /* _beginthreadex, _endthreadex */


unsigned int WINAPI OutputThreadFunction(LPVOID lpParam);
unsigned int WINAPI CountThreadFunction(LPVOID lpParam); 

TCHAR string[100];
HANDLE hEvent;

int _tmain(int argc, TCHAR* argv[])
{	
    HANDLE  hThread[2];
    DWORD dwThreadID[2];

    hEvent = CreateEvent(	// event object 생성.
                NULL,		// 상속 불가.
                TRUE,		// manual-reset mode로 생성.
                FALSE,		// non-signaled 상태로 생성.
                NULL		// 이름 없는 event.
             );	
    if(hEvent==NULL){
        _fputts(_T("Event object creation error \n"), stdout); 
        return -1;
    }
    
    hThread[0] = (HANDLE)_beginthreadex (
                        NULL, 0, 
                        OutputThreadFunction, 
                        NULL, 0, 
                        (unsigned *)&dwThreadID[0]
                    );

    hThread[1] = (HANDLE)_beginthreadex (
                        NULL, 0, 
                        CountThreadFunction, 
                        NULL, 0, 
                        (unsigned *)&dwThreadID[1]
                    );




    if(hThread[0]==0 ||hThread[1]==0) 
    {
        _fputts(_T("Thread creation error \n"), stdout); 
        return -1;
    }	

    _fputts(_T("Insert string: "), stdout); 
    _fgetts(string, 30, stdin);

    SetEvent(hEvent);	// event의 state를 signaled 상태로 변경.

    WaitForMultipleObjects ( // [메인 쓰레드] 둘 이상의 쓰레드를 기다릴 때 호출하는 함수.
                2,           // 배열의 길이.
                hThread,     // 핸들의 배열.
                TRUE,        // 모든 핸들이 신호받은 상태로 될 때 리턴.
                INFINITE	 // 무한 대기.
    ); 
        
    CloseHandle(hEvent);	// event 오브젝트 소멸
    CloseHandle(hThread[0]);
    CloseHandle(hThread[1]);

    return 0;
}

unsigned int WINAPI OutputThreadFunction(LPVOID lpParam)
{

  WaitForSingleObject(hEvent, INFINITE); // event가 signaled 상태가 되기를 기다린다.

  _fputts(_T("Output string: "), stdout); 
  _fputts(string, stdout); 

  return 0;
}

unsigned int WINAPI CountThreadFunction(LPVOID lpParam)
{

  WaitForSingleObject(hEvent, INFINITE); // event가 signaled 상태가 되기를 기다린다.

  _tprintf(_T("Output string length: %d \n"), _tcslen(string)-1); 

  return 0;
}

 

  Ex) 이벤트(실행 순서 동기화) + 뮤텍스(메모리 접근 동기화)로 해결

<hide/>

/*
    StringEvent3.cpp
    프로그램 설명: event, mutex 동시 사용 사례.
*/

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>
#include <process.h>    /* _beginthreadex, _endthreadex */


unsigned int WINAPI OutputThreadFunction(LPVOID lpParam);
unsigned int WINAPI CountThreadFunction(LPVOID lpParam); 

typedef struct _SynchString
{
    TCHAR string[100];
    HANDLE hEvent;
    HANDLE hMutex; 
} SynchString;

SynchString gSynString;

int _tmain(int argc, TCHAR* argv[])
{	
    HANDLE  hThreads[2];
    DWORD dwThreadIDs[2];

    gSynString.hEvent = CreateEvent(	
                            NULL,		
                            TRUE,		
                            FALSE,		
                            NULL		
                        );	

    gSynString.hMutex = CreateMutex ( 
                            NULL,
                            FALSE,
                            NULL
                        );    

    if(gSynString.hEvent==NULL || gSynString.hMutex==NULL) {
        _fputts(_T("kernel object creation error \n"), stdout); 
        return -1;
    }


    hThreads[0] = (HANDLE)_beginthreadex (
                        NULL, 0, 
                        OutputThreadFunction, 
                        NULL, 0, 
                        (unsigned *)&dwThreadIDs[0]
                    );

    hThreads[1] = (HANDLE)_beginthreadex (
                        NULL, 0, 
                        CountThreadFunction, 
                        NULL, 0, 
                        (unsigned *)&dwThreadIDs[1]
                    );


    if(hThreads[0]==0 ||hThreads[1]==0) 
    {
        _fputts(_T("Thread creation error \n"), stdout); 
        return -1;
    }	

    _fputts(_T("Insert string: "), stdout); 
    _fgetts(gSynString.string, 30, stdin);

    SetEvent(gSynString.hEvent);	// event의 state를 signaled 상태로 변경.

    WaitForMultipleObjects ( 
                2,           // 배열의 길이.
                hThreads,     // 핸들의 배열.
                TRUE,        // 모든 핸들이 신호받은 상태로 될 때 리턴.
                INFINITE	 // 무한 대기.
    ); 
        
    CloseHandle(gSynString.hEvent);
    CloseHandle(gSynString.hMutex);
    CloseHandle(hThreads[0]);
    CloseHandle(hThreads[1]);

    return 0;
}

unsigned int WINAPI OutputThreadFunction(LPVOID lpParam)
{

  WaitForSingleObject(gSynString.hEvent, INFINITE); // event가 signaled 상태가 되기를 기다린다.
  WaitForSingleObject(gSynString.hMutex, INFINITE); // 뮤텍스 추가

  _fputts(_T("Output string: "), stdout); 
  _fputts(gSynString.string, stdout); 

  ReleaseMutex(gSynString.hMutex);

  return 0;
}

unsigned int WINAPI CountThreadFunction(LPVOID lpParam)
{

  WaitForSingleObject(gSynString.hEvent, INFINITE); // event가 signaled 상태가 되기를 기다린다.
  WaitForSingleObject(gSynString.hMutex, INFINITE); // 뮤텍스 추가

  _tprintf(_T("Output string length: %d \n"), _tcslen(gSynString.string)-1); 

  ReleaseMutex(gSynString.hMutex);

  return 0;
}

 

14.3 타이머 기반 동기화

Prologue) 이번 절의 내용은 타이머를 어떻게 쓰느냐가 아님.

  타이머와 이벤트 커널 오브젝트 사이에 어떤 의미가 있는지를 알아보는 절.

 

14.3-1 수동 리셋 타이머 Vs. 자동 리셋 타이머

  Note) 타이머도 커널 오브젝트임. 타이머가 알람 울리면 Signaled 상태가 되었음을 의미.

    즉, 타이머는 일정 시간이 지난 뒤에 Signaled 상태가 되는 커널 오브젝트.

    - 수동 리셋 타이머는 내가 직접 시간을 지정해주는 타이머

    - 자동 리셋 타이머는 내가 직접 시간을 지정해줄수도 있고, 그 뒤에 자동으로 몇초 뒤에

      알람이 울릴지도 설정 가능. ex. 10초 뒤에 알람이 울리고, 그 뒤에는 5초마다 알람이 울림.

      근데 이러면 자동으로 Non-signaled 상태로 돌아가기도 해야 함.

    - CreateWaitableTimer() 함수의 두 번째 인자가 true이면 수동, false 이면 자동.

HANDLE CreateWaitableTimer( // 타이머 커널 오브젝트 생성.
    LPSECURITY_ATTRIBUTES lpTimerAttributes,
    BOOL bManualReset,
    LPCTSTR lpTimerName
)

 

BOOL SetWaitableTimer( // 타이머 커널 오브젝트의 시간 설정.
    HANDLE hTimer,
    const LARGE_INTEGER* pDueTime,         // 초기 시간 
    LONG lPeriod,                          // 반복 시간
    PTIMERAPCROUTINE pfnCompletionRoutine, // 
    LPVOID lpArgToCompletionRoutine,
    BOOL fResume
)

 

  Ex)

<hide/>

/*
    ManualResetTimer.cpp
    프로그램 설명: 수동 리셋 타이머 오브젝트에 대한 이해.
*/

#define _WIN32_WINNT	0x0400

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


int _tmain(int argc, TCHAR* argv[])
{
    HANDLE hTimer = NULL;
    LARGE_INTEGER liDueTime;

    liDueTime.QuadPart=-100000000; // 음수를 주게끔 정의되어 있음. 현재 시간이 0초이고, 상대시간을 설정할땐 음수를 줌.
                                   // ns 단위.

    hTimer = CreateWaitableTimer(NULL, FALSE, _T("WaitableTimer"));
    if (!hTimer)
    {
        _tprintf( _T("CreateWaitableTimer failed (%d)\n"), GetLastError());
        return 1;
    }

    _tprintf( _T("Waiting for 10 seconds...\n"));

    SetWaitableTimer(hTimer, &liDueTime, 0, NULL, NULL, FALSE);

     WaitForSingleObject(hTimer, INFINITE);
    _tprintf( _T("Timer was signaled.\n") );
    MessageBeep(MB_ICONEXCLAMATION);

    return 0;
}

 

Ex)

<hide/>

/*
    PeriodicTimer.cpp
    프로그램 설명: 주기적 타이머에 대한 이해.
*/

#define _WIN32_WINNT	0x0400

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


int _tmain(int argc, TCHAR* argv[])
{
    HANDLE hTimer = NULL;
    LARGE_INTEGER liDueTime;

    liDueTime.QuadPart=-100000000;

    hTimer = CreateWaitableTimer(NULL, FALSE, _T("WaitableTimer"));
    if (!hTimer)
    {
        _tprintf( _T("CreateWaitableTimer failed (%d)\n"), GetLastError());
        return 1;
    }

    _tprintf( _T("Waiting for 10 seconds...\n"));

    SetWaitableTimer(hTimer, &liDueTime, 5000, NULL, NULL, FALSE); // 이 함수의 인자는 ms 단위.

    while(1)
      {
            WaitForSingleObject(hTimer, INFINITE);
            _tprintf( _T("Timer was signaled.\n") );
            MessageBeep(MB_ICONEXCLAMATION);
      }
    return 0;
}

 

Note) 13장: 임계영역 접근 동기화

  14장: 실행순서 접근 동기화 -> Event and Timer

  고급 소프트웨어일수록, Event와 Timer 기법이 더 중요해짐.

  왜 그러냐면, 누군가가 시키는 일만 할 경우에는 Timer가 필요 없음.

  주기적으로 소프트웨어 혼자 일을 해야 할 때 Event and Timer 기법이 중요함.

댓글