Part15. Tutorial3-동기화(Synchronization) 기능을 추가해 보자

원문 :  http://kkamagui.springnote.com/pages/368015

 

들어가기 전에...

0.시작하면서...

 앞서 멀티 태스킹 기능을 추가하면서 동기화가 얼마나 중요한지 느꼈을 것이다. 잘 모르겠다면 커널 크래쉬 화면을 한번 더 보고 오자. 단순히 3개의 태스크만 실행되는 스케줄러를 구현하는데도 동기화 문제가 발생하니, 윈도우와 같이 수백개의 태스크가 동작하는 OS에서 동기화는 PC의 CPU 만큼이나 중요한 문제다.

 효율적인 동기화를 위해 몇가지 동기화 오브젝트를 설계하고 구현해보자.

 

1.동기화 객체(Synchronization Object)

 이번에 구현해볼 동기화 객체는 세머포어(Semaphore)와 뮤텍스(Mutex)이다. 사실 뮤텍스는 카운트가 1인 바이너리 세머포어와 기능이 비슷하기 때문에 세머포어를 구현하고 그것을 이용해서 뮤텍스를 구현하도록 하자. 뮤텍스는 소유의 개념이 포함되어있어 바이너리 세마포어와는 차이가 있지만  세부적인 내용은 넘어가도록 하자(궁금한 사람은 구글링을 @0@)/~)

 윈도우 프로그래밍을 어느정도 해본 사람, 혹은 스레드 프로그래밍을 어느정도 해본 사람이라면 세머포어와 뮤텍스에 대해서 이미 알고 있을 것이다.

 

 그래도 혹여나 모르는 사람들이 있을까봐 간단히 용도를 설명하겠다.

 여러 태스크에서 하나의 공유된 자원(글로벌 변수 or IO 장치 등등)에 접근하게되면 서로 경쟁을 하게 된다. 이 공유된 자원을 순차적으로 사용하고 반납하게 되면 자원에 동시에 접근했을 때 발생하는 문제를 해결할 수 있고, 태스크 간의 "순차적인 사용"을 컨트롤 하기위해 세머포어나 뮤택스를 사용한다.

 앞서 Tutorial에서 멀티태스크의 기능을 구현할 때 스케줄러의 예를 보면 잘 알 수 있다. 글로벌 구조체인 gs_stScheduler가 태스크 간에 공유된 자원이었고 무차별하게 접근할때 커널 크래쉬가 발생했다. 이것을 kLock()과 kUnlock() 메소드를 사용하여 태스크 간의 접근을 컨트롤하였고 스케줄러가 정상적으로 동작할 수 있었다. 잘 기억이 안나는 사람은 Part14. Tutorial2-멀티 태스킹(Multi Tasking) 기능을 추가해 보자를 다시 보도록 하자.

 

2.세머포어(Semaphore) 설계

 세머포어는 크게 카운팅 세머포어(Counting Semaphore)와 바이너리 세머포어(Binary Semaphore)로 나뉘어진다. 두 세머포어의 차이점은 자원에 진입할 수 있는 태스크의 수가 단 한개만 가능한가, 아니면 n개가 가능한가 차이밖에 없다. 세머포어의 사용법은 동기화가 필요한 영역에 진입할 때 On()을 하고 작업을 완료하면 Off()를 호출하는 방식으로 사용하며 On과 Off 사이의 코드는 세머포어의 종류에따라 하나의 태스크만 실행가능하거나 n개의 태스크만 실행할 수 있다.

 세머포어를 깃발과 방으로을 생각하면 이해하기가 좋다. 어떤 방에 들어가는데, 방에 들어가면 입구에 깃발을 하나 올리고 방에서 나오면 깃발을 하나 내린다. 만약 내가 들어갈 차례인데 깃발이 전부 올려져 있으면 누군가 나와서 깃발을 내릴 때까지 기다렸다가 들어가는 방식이다.

 카운팅 세머포어를 구현하기 위해서는 진입 가능한 태스크의 최대 개수, 현재 진입한 태스크의 수를 포함하는 세머포어 자료구조가 필요하다. 이를 아래와 같이 선언하자.

  1. // Semaphore 구조체
    typedef struct semaphoreStruct
    {
        BOOL bLock;
        int iMaxTask;
        int iCurTask;
    } SEMAPHORE, * PSEMAPHORE;

 중요하게 볼 부분은 bLock 부분인데, 세머포어의 변수들 또한 공유되는 자원이므로 이 자원을 수정할 수 있는가 판단하는 플래그가 필요하다. 이 플래그가 없으면 어떻게 될까? 세머포어의 변수를 설정하고 비교하는 부분 역시 여러 태스크가 접근할 것이므로 역시 엉망이 될 것이다(커널 크래쉬를 잊지말기를 바란다).

 

3.구현

3.1 세머포어(Semaphore) 구현

 위에서 세머포어에 대해 간략히 설계했으므로 이제 코드를 작성해 보자. iMaxTask 변수는 최대로 진입 가능한 태스크의 수로 사용하고 iCurTask는 현재까지 진입한 태스크의 수로 사용한다면, 초기화/On/Off 코드는 아래와 같이 쓸 수 있다.

  1. /**
        Semaphore를 초기화 한다.
    */
    void InitSemaphore( SEMAPHORE* pstSema, int iMaxTask )
    {
        kMemSet( pstSema, 0, sizeof( SEMAPHORE ) );
        pstSema->iMaxTask = iMaxTask;
    }
  2. /**
        Semaphore를 점유한다.
    */
    BOOL OnSemaphore( SEMAPHORE* pstSema )
    {
        // 세머포어 변수에 접근하기위해 접근이 가능한지를 확인하고 접근 가능하면
        // 접근 금지를 설정한 후 변수를 본다.
        while( 1 )
        {
            if( kLock( &( pstSema->bLock ) ) == FALSE )
            {
                SwitchTask();
                continue;
            }
  3.         if( pstSema->iCurTask + 1 <= pstSema->iMaxTask )
            {
                break;
            }
            else
            {
                kUnlock( &( pstSema->bLock ) );
                SwitchTask();
            }
        }
  4.     pstSema->iCurTask++;
  5.     // 세버포어 변수에 대한 접근 금지를 해제한다.
        kUnlock( &( pstSema->bLock ) );
  6.     return TRUE;
    }
  7. /**
        Semaphore를 해제한다.
    */
    BOOL OffSemaphore( SEMAPHORE* pstSema )
    {
        // 세머포어 변수에 접근하기위해 접근이 가능한지를 확인하고 접근 가능하면
        // 접근 금지를 설정한 후 변수를 본다.
        while( 1 )
        {
            if( kLock( &( pstSema->bLock ) ) == FALSE )
            {
                SwitchTask();
                continue;
            }
  8.         if( pstSema->iCurTask > 0 )
            {
                break;
            }
            else
            {
                kUnlock( &( pstSema->bLock ) );
                SwitchTask();
            }
        }
  9.     pstSema->iCurTask--;
  10.     // 세버포어 변수에 대한 접근 금지를 해제한다.
        kUnlock( &( pstSema->bLock ) );
  11.     return TRUE;
    }

  초기화하는 함수는 쉽게 이해가될테니 넘어가고 OnSemaphore()OffSemaphore() 함수를 보자. 낯이 익은 함수가 보이지 않는가? kLock()kUnlock() 함수가 여러번 나오는데, 이 함수는 Atomic하게 동작하는 함수로 아래와 같은 역할을 한다.

  • BOOL kLock( BYTE* pbFlag ) : Atomic Operation으로 pbFlag의 값이 0이면 1로 설정하고 1을 리턴하고, 1이면 0을 리턴
  • BOOL kUnlock( BYTE* pbFlag ) : Atomic Operation으로 pbFlag의 값이 1이면 0으로 설정하고 1을 리턴하고, 0이면 0을 리턴

 위의 함수 설명과 세머포어 코드를 같이 보면 전체적인 흐름을 이해하는데 문제가 없을 것이다. 위의 코드를 순서대로 나열하면 아래와 같다.

  1. kLock()을 호출하여 내가 세머포어 변수를 수정할 수 있는지 확인한다.
  2. TRUE가 리턴되면  다른 태스크가 세머포어 변수를 수정하고 있지 않으므로 세머포어 값을 변경한다.
  3. 변경이 끝나면 kUnlock()을 호출하여 세머포어 변수를 수정할 수 있음을 설정한다.
  4. FALSE가 리턴되면 다른 태스크가 세머포어 변수를 수정하고 있으므로 TRUE가 리턴될 때까지 대기한다.

 

 여기서 잠깐... 세머포어를 구현하는데 꼭 필요한 것이 Atomic Operation이라는 것을 알았다. 그러면 어떻게 Atomic Operation을 구현할 수 있을까? Atomic함을 보장하기위해서 필요한 것은 무엇일까?

 그것은 명령을 수행할 때 도중에 인터럽트되지 않고 처리를 끝내는 것이다. 아래와 같이 인터럽터를 Disable 함으로써 간단히 Atomic Operation을 구현할 수 있다(Single CPU라고 가정한다).

  1. ;  BYTE *pbFlag : 옛날버전
    ;     인터럽터를 Disable 시키고, 플래그를 검사하여
    ;  플래그의 값이 0 이면 1로 증가시키고 1을 리턴하고,
    ;  플래그의 값이 1 이면 0을 리턴한다.
    _kLockOld:
      push    ebp
  2.   mov     ebp, esp
      push    ebx
      pushfd
  3.   ; 플래그의 포인터를 얻는다.
      mov     ebx, dword [ss:ebp + 8]
      ; 인터럽터 Disable
      cli
      mov  al, byte [ds:ebx]
      cmp  al, 0
      ; 일단 FALSE를 셋팅해 놓고
      mov  eax, 0x00000000
      jne  kLockExit
  4.   mov  byte [ds:ebx], 0x01
      mov  eax, 0x01
  5.  kLockExit:
      popfd
      pop     ebx
      pop     ebp
      retn
  6. ;  BYTE *pbFlag : 옛날 버전
    ;     인터럽터를 Disable 시키고 플래그를 검사하여
    ;  플래그의 값이 1 이면 0로 감소시키고 1을 리턴하고
    ;  플래그의 값이 0 이면 0을 리턴한다.
    _kUnlockOld:
      push    ebp
  7.   mov     ebp, esp
      push    ebx
      pushfd
  8.   ; 플래그의 포인터를 얻는다.
      mov      ebx, dword [ss:ebp + 8]
      ; 인터럽터 Disable
      cli
      mov  al, byte [ds:ebx]
      cmp  al, 1
      ; 일단 FALSE를 셋팅해 놓고
      xor      eax, eax
      jne  kUnLockExit
  9.   mov  byte [ds:ebx], 0
      mov  eax, 0x01
  10.  kUnLockExit:
      popfd
      pop  ebx
      pop      ebp
      retn

  주석에 표시된대로 더 이상 사용되지 않는다(프레임워크 처음 릴리즈시 버전). 위에서 보듯 인터럽트를 불가로 설정하고 다시 인터럽터 관련 플래그(EFLAG) 레지스터를 복원하는 방식으로 Atomic을 보장했다. 하지만 이 방법의 문제점은 너무 자주 인터럽트를 불가로 설정하기 때문에 인터럽트 처리가 지연될 수 있다.

 Intel Architecture Manual의 Volume 2 Instruction Set을 보면 Atomic한 처리를 위한 명령어들이 나와있다. lock 명령과 xchg, cmpxchg 명령이 그것이다.

 cmpxchg mem, reg 명령어의 역할은 아래와 같다(자세한 설명은 Intel Architecture Volume 2, Instruction Set 문서를 참고하도록 하자).

  • memal 레지스터의 값이 같음 : reg의 값을 mem에 복사를 하고 ZF 플래그를 1로 설정
  • memal 레지스터의 값이 다름 : regmem의 값을 복사하고 ZF 플래그를 0로 설정

 

 아래는 lock, cmpchg를 이용해서 수정한 코드다.

  1. ;  BYTE *pbFlag :
    ;  플래그의 값이 0 이면 1로 증가시키고 1을 리턴하고,
    ;  플래그의 값이 1 이면 0을 리턴한다.
    _kLock:
      push    ebp
      mov     ebp, esp
      push ebx
  2.   mov  al, 0
      mov  ah, 1
  3.   mov  ebx, dword [ss:ebp + 8 ]
      ; 메모리의 값이 al과 같으면 ah를 메모리에 넣고 zf를 1로 셋팅
      lock cmpxchg byte [ds:ebx], ah
      je  LOCKSUCCESS
      mov  eax, 0
      jmp  LOCKEND
  4. LOCKSUCCESS:  
      mov  eax, 1
      jmp  LOCKEND
  5. LOCKEND:
      pop ebx
      pop ebp
      retn
  6. ;  BYTE *pbFlag : 옛날 버전
    ;     인터럽터를 Disable 시키고 플래그를 검사하여
    ;  플래그의 값이 1 이면 0로 감소시키고 1을 리턴하고
    ;  플래그의 값이 0 이면 0을 리턴한다.
    _kUnlock:
      push ebp
      mov ebp, esp
      push ebx
  7.   mov  al, 1
      mov  ah, 0
  8.   mov  ebx, dword [ss:ebp + 8 ]
      ; 메모리의 값이 al과 같으면 ah를 메모리에 넣고 zf를 1로 셋팅
      lock cmpxchg byte [ds:ebx], ah
      je  UNLOCKSUCCESS
      mov  eax, 0
      jmp  UNLOCKEND
  9. UNLOCKSUCCESS:  
      mov  eax, 1
      jmp  UNLOCKEND
  10. UNLOCKEND:
      pop ebx
      pop ebp
      retn

 

3.2 KShell.c/h 수정

 자 그럼 이제 세머포어를 사용해보자. kShell.c 파일을 아래와 같이 수정한다.

  1. SEMAPHORE gs_stSema;      <= 세머포어 구조체 정의
  2. /**
        KShell 의 Main
    */
    void Shell()
    {
        InitScheduler();
        InitSemaphore( &gs_stSema, 1 );   <= 세머포어 초기화, 하나의 태스크만 실행가능하도록 설정
  3.     // 새로운 태스크 등록
        AddTask( EdgeDraw );
        EnableScheduler( TRUE );
       
        ShellLoop();
  4.     while( 1 );
    }
  5. /**
        글로벌 변수에서 값을 읽어서 문자를 찍어주는 함수
    */
    int gs_iX = 0;
    int gs_iY = 0;
    void Print( BYTE bCh )
    {
        int i;
  6.     printchxy( gs_iX, gs_iY, bCh );
       
        // 약간의 Delay를 위해 사용
        kIdle();
  7.     printchxy( gs_iX, gs_iY, ' ' );
    }
  8. /**
        테두리를 그려주는 태스크
    */
    void EdgeDraw( void )
    {
        int i;
        int j;
        BYTE bCh;
        int k;
        char vcBuffer[ 8 ];
        int iTID;
  9.     i = 0;
        j = 0;
        bCh = 0;
        iTID = GetCurrentTID();
       
        kDToA( vcBuffer, iTID );
  10.     for( k = 0 ; k < 50000 ; k++ )
        {
            printxy( 0, 23 - iTID, "=EdgeDraw Task Work=" );
            printxyn( 20, 23 - iTID, vcBuffer, 8 );
  11.         // 콘솔 테두리를 돌면서 .을 찍는다.
            for( i = 30 ; i < 79 ; i++ )
            {
                // Semaphore 대기
                OnSemaphore( &gs_stSema );
                gs_iX = i;
                gs_iY = 23 - iTID;
                Print( bCh );
                bCh++;
                OffSemaphore( &gs_stSema );
                SwitchTask();
            }
        }
    }

  위에 파란색 부분을 보면 Print() 함수가 새로 정의되었는데, 글로벌 변수 gs_iX, gs_iY에서 값을 읽어 화면에 한 문자를 출력했다가 일정시간 뒤에 다시 공백으로 지우는 역할을 하는 함수이다. EdgeDraw()에서 수정된 부분은 OnSemaphore()/OffSemaphore() 함수를 사용하고 그 안에 글로벌 변수 X,Y에 값을 설정한 후 Print() 함수를 호출한 부분이다.

 각 태스크가 공유 자원인  gs_iXgs_iY에 접근하여 값을 설정하고 Print() 함수를 호출하여 다시 공유자원의 값을 사용하는 구조이다. 이것을 빌드하여 이미지를 만든 다음 Virtual Box에서 실행한 상태에서 starttask 명령을 3번정도 입력하여 화면을 지켜보자.

 멀티세머포어사용.PNG

<Start Task With Semaphore>

  위와 같은 화면이 표시될 것이다. starttask를 3번 사용하여 총 4개의 task를 생성하였고 각각의 태스크는 문자를 보여주고 지워졌다가 다시 다른 문자를 보여주는 아주 깔끔한 화면을 보여준다. 세머포어가 동시에 수행될 수 있는 태스크의 수를 1개로 하여 생성되었기때문에 글로벌 변수에 값을 설정하고 화면에 값을 출력하기까지 다른 태스크가 끼어들지 못하여 이러한 결과가 나온 것이다.

 

 하지만  OnSemaphore()와 OffSemaphore() 함수를 주석처리하고 다시 실행해보자.

멀티세머포어사용안함.PNG

<Start Task Without Semaphore>

 

 절대 일부러 합성한 화면이 아님을 미리 알려둔다. 글로벌 변수에 값을 설정하고 Print() 함수를 호출하는 과정에서 중간 중간에 태스크 스위칭이 되어 다른 태스크가 끼어든 결과이다. 정상적으로 문자가 출력되지 않고 또한 제대로 지워지지도 않는다.

 예제로 들기에는 약간 부족한 면이 있지만 이것으로 세머포어의 역할이나 구현에 대해서 어느정도 감을 잡았으리라 생각된다.

 

4.뮤텍스(Mutex)

 뮤텍스는 바이너리 세머포어와 비슷하다. 다만 다른점은 뮤텍스는 소유의 개념이 있어서 On 시에 태스크 ID를 저장해 두고 Off 시에 태스크 ID를 비교해서 On을 수행한 태스크만 처리가 가능하다.

 

 그럼 어떤 방식으로 뮤텍스를 구현할 수 있을까?  아래와 같이 구현하면 된다.

  • OnMutex() : 위에서 보았던 세머포어 구조체에 TID 필드를 추가하고 OnMutex() 함수를 만든뒤 바이너리 세머포어와 동일하게 구현한다. 그리고 마지막에 TID를 보관한다.
  •  OffMutex() : 가장  먼저 TID를 비교하여 OnMutex()를 실행한 태스크인지 비교한뒤 바이너리 세머포어의 Off 함수와 동일하게 구현한다.

 

 크게 어렵지 않은 부분이므로 각자 구현해 보도록 하자.

 

5.마치며...

 이제 동기화 객체도 생성되었으니 멀티 태스킹 프로그래밍을 마음껏 할 수 있다(정말?? ㅡ,.ㅡ;;;). 부담없이 태스크를 만들고 돌려보자. @0@)/~

 

6.첨부

6.1 프레임워크 1.0.3 이전 

  • Asm.asm : 수정된 kLock(), kUnlock() 함수 포함
  • Custom.zip : kShell, Syncrhonize 파일 포함
  • makefile : Makefile  

 

6.2 프레임워크 1.0.3 이후

 

 

이 글은 스프링노트에서 작성되었습니다.

+ Recent posts