메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

C# 쓰레드 이야기: 9. 임계 영역

한빛미디어

|

2002-03-26

|

by HANBIT

30,699

저자: 한동훈

지난 시간에는 동기화에 대해서 이야기 했으며, 멀티 쓰레드 환경에서 어떤 문제가 생길 수 있는지 간략히 살펴보았다. 여러 개의 쓰레드가 하나의 정수 데이터를 공유하는 것은 빈번하기 때문에 정수 데이터의 증가와 감소를 동기화할 수 있는 Interlocked 클래스가 제공된다. 이것은 동기화의 가장 간단한 형식에 속한다. 그러나 정수형이 아닌 데이터베이스에 데이터를 쓰거나 읽는 작업을 쓰레드를 이용해서 동시에 처리한다면 데이터를 저장하는 동안에는 데이터를 읽지 못하게 해야하고, 데이터를 읽는 동안에는 데이터를 쓰지 못하도록 하는 것이 필요하다. 이와 같이 하나의 쓰레드가 공유 데이터를 사용하고 있으면 다른 쓰레드들이 접근하지 못하도록 하는 것을 베타적 접근이라한다.

멀티 쓰레드에서 베타적 접근은 가장 일반적으로 사용되는 방법이며, 베타적 접근에 사용할 수 있는 것 중에 하나가 임계 영역이라는 것이다.

임계 영역(Critical Section)

임계 영역은 가장 간단하며, 속도도 빠르다. 임계 영역은 한 번에 하나의 쓰레드만 접근할 수 있도록 되어 있는 영역(Section)을 뜻한다. 임계 영역의 구조는 보통 운영체제에 의해서 숨겨져 있으며, 닷넷에서도 이점은 같다.(Win32에서 임계 영역의 구조는 winnt.h에 CRITICAL_SECTION으로 정의되어 있다)

임계 영역은 프로세스 공간에 있으며, 다른 프로세스와 직접 통신할 수 없다. 닷넷에서는 이러한 프로세스는 응용 프로그램 도메인(AppDomain)안에서 실행되며, 응용 프로그램 도메인을 사용하여 프로세스간에 데이터를 교환할 수도 있지만, 여기서는 범위를 벗어나므로 다루지 않는다.

커널 객체에 대해서 소개한 것을 기억하고 있다면 알 수 있겠지만, 임계 영역은 커널 객체가 아니다. 때문에 각각의 프로세스마다 고유의 임계 영역을 갖고 있으며, 다른 프로세스에 의해서 임계 영역을 침범할 수 없다. NT에서 프로그래밍을 하고 있다면 커널 객체를 획득하려면 적절한 권한이 필요하지만, 임계 영역은 그러한 과정이 필요없기 때문에 속도가 빠르다.

닷넷에서 동기화 객체

닷넷에서는 동기화를 위한 몇가지 객체가 제공된다. 다음은 닷넷의 주요 동기화 객체의 클래스 계층이다.
System.Object
   System.MarshalByRefObject
      System.Threading.WaitHandle
         System.Threading.AutoResetEvent
         System.Threading.ManualResetEvent
         System.Threading.Mutex
위에서 알 수 있는 것처럼 닷넷 동기화 객체는 WaitHandle 기본 클래스를 상속한 AutoResetEvent와 ManualResetEvent, Mutex 클래스가 있으며, 이들 각각의 동기화 객체는 실행시간에 WaitHandle 객체로 대표해서 처리할 수 있다는 것을 알 수 있다. 또한, WaitHandle 클래스는 Win32 동기화 핸들을 캡슐화하고 있다. AutoResetEvent와 ManualResetEvent는 쓰레드 동기화를 위해 이벤트를 사용하는 경우에 사용한다.

WaitHandle 기본 클래스는 시그널링(signaling) 메커니즘을 사용하기 때문에 멀티 쓰레드 처리에서 쓰레드 안정성(thread safety)을 갖고 있다고 한다.

위 동기화 객체 외에 처리를 대기시킬 수 있는 객체에는 Process, Thread, IOStream, AppDomain, ReaderWriterLock이 있다. Process, Thread는 앞에서 이미 다루었으며, ReaderWriterLock은 앞에서 소개한 생산자/소비자 프로그래밍 모델에 대해서 소개했는데 닷넷에서는 이것에 대해서 ReaderWriterLock으로 구현해 두었다. IOStream은 여기서 다루지 않는다.

Win32에서의 임계 영역

굳이 Win32에서의 임계 영역을 소개하려는 것은 닷넷에서는 모든 것을 개발자가 알 필요없도록 감춰두었기 때문이다. Win32에 대한 코드는 모두 C++를 사용했다. Win32에서의 임계 영역은 다음과 같은 순서로 된다.

Win32 임계 영역 의사 코드
// 임계 영역을 선언한다.
CRITICAL_SECTION cs;
// 임계 영역을 초기화한다.
InitializeCriticalSection(&cs);
// 임계 영역으로 들어간다. 여기서는 한 번에 하나의 쓰레드만 
// 임계 영역으로 들어갈 수 있으며, 이미 임계 영역에 들어간 
// 쓰레드가 있으면 다른 쓰레드는 여기서 대기한다.
EnterCriticalSection(&cs);

try
{
// 한 번에 하나의 쓰레드에 의해서만 실행될 수 있는 코드 블록이다.
}
finally
{
LeaveCriticalSection(&cs);  // 임계 영역을 빠져나온다.
}

DeleteCriticalSection(&cs);  // 임계 영역을 정리한다.
닷넷에서의 임계 영역 - Monitor

Win32 임계 영역 코드에서 볼 수 있는 것처럼 임계 영역을 선언, 초기화, 진입, 빠져나오기, 정리와 같은 번거로운 작업이 필요하다. 닷넷에서는 위와 같은 번거로운 작업을 모두 없애고 간단하게 사용하기 위해 System.Threading.Monitor 클래스를 제공한다. 이 클래스의 사용법은 다음과 같다.
Monitor.Enter(this);  // 임계 영역을 시작한다
Monitor.Exit(this);  // 임계 영역을 종료한다
닷넷에서 임계 영역을 사용하는 예제를 살펴보도록 하자.
이름 : CritSec01.cs

using System;
using System.Threading; 

public class AppMain
{
  private int counter = 0;

  public static void Main()
  {
    AppMain app = new AppMain();

    app.DoTest();
  }

  public void DoTest()
  {
    Thread t1 = new Thread( new ThreadStart(Incrementer) );
    Thread t2 = new Thread( new ThreadStart(Decrementer) );
    
    t1.Start();
    t2.Start();
  }

  private void Incrementer()
  {
    Monitor.Enter(this);

    try
    {
      while ( counter < 10 )
      {
        Console.WriteLine("Incrementer : " + counter.ToString());
        counter++;
      }
    } // end of try

    finally
    {
      Monitor.Exit(this);
    } // end of finally
  } // end of Incrementer

  private void Decrementer()
  {
    Monitor.Enter(this);
    
    try
    {
      while ( counter > 0 )
      {
        Console.WriteLine("Decrementer : " + counter.ToString());
        counter--;
      }
    } // end of try

    finally
    {
      Monitor.Exit(this);
    } // end of finally
  } // end of Decrementer
}
위 예제에서 알 수 있는 것처럼 두 개의 쓰레드를 생성하고, 각각의 쓰레드는 counter의 값을 조건에 따라 증가시키거나 감소시킨다. 여기서 counter는 두 쓰레드가 공유하는 공유 데이터가 된다. 따라서 이 값을 변경하는 코드 부분을 Monitor 클래스를 사용하여 임계 영역으로 선언하고 있다. 코드를 컴파일하고 실행하면 결과는 다음과 같을 것이다.

하나의 쓰레드가 이미 임계 영역을 사용중일 때 다른 쓰레드가 임계 영역을 사용하려면 임계 영역을 사용할 수 있게 될 때 까지 기다려야다. 만약에 쓰레드가 기다리는 것을 것을 원하지 않는다면 Monitor.Enter() 대신에 Monitor.TryEnter()를 사용하도록 한다.

Monitor.TryEnter()는 Monitor.Enter()와 비슷하지만 임계 영역이 사용중이더라도 기다리지 않고 계속해서 실행한다. Monitor.TryEnter()는 락(lock)을 설정하지 못하면 false를 반환하며, 임계 영역에 들어가지 않은 상태에서 코드를 실행한다.

위 예제에서 사용한
Monitor.Enter(this);
를 모두 다음과 같이 변경한다.
Monitor.TryEnter(this);
다시 컴파일하여 실행된 결과는 다음과 같다.

결과에서 볼 수 있는 것처럼 Decrementer 쓰레드의 첫번째 출력은 7이며, 다음 출력은 9가 되는 것을 알 수 있을 것이다.(여러 번 실행해야 위와 같은 결과를 볼 수 있으며, 실행할때마다 약간씩 다른 결과가 나타나는 것을 볼 수 있을 것이다) 위에서 설명한 것처럼 Monitor.TryEnter()는 임계 영역에 들어갔는지와 관계없이 코드를 실행한다. 따라서 Monitor.TryEnter()를 사용하려면 다음과 같이 수정해야한다. 여기서는 Decrementer()만을 수정했다.
  private void Decrementer()
  {
    // 임계 영역에 들어가지 못했다.
    if ( Monitor.TryEnter(this) == false ) 
    {
      Console.WriteLine("임계 영역에 들어가는데 실패");     
    }

    // 임계 영역에 들어간 경우
    else
    {
      while ( counter > 0 )
      {
        Console.WriteLine("Decrementer : " + counter.ToString());
        counter--;
      }
      Monitor.Exit(this);
    }
} // end of Decrementer
닷넷에서의 임계 영역 - lock()

다음은 예제에서 사용했던 Monitor 클래스 대신에 lock()을 사용하여 동기화를 수행하도록 변경한 것이다. 전체예제에서 Incrementer()와 Decrementer() 부분만 수정하였다.
  private void Incrementer()
  {
    lock(this)
    {
      while ( counter < 10 )
      {
        Console.WriteLine("Incrementer : " + counter.ToString());
        counter++;
      }
    } // end of lock
  } // end of Incrementer

  private void Decrementer()
  {
    lock(this)
    {
      while ( counter > 0 )
      {
        Console.WriteLine("Decrementer : " + counter.ToString());
        counter--;
      }
    } // end of lock
} // end of Decrementer
컴파일하여 실행하면 결과가 같다는 것을 알 수 있을 것이다. 실제로 lock()은 Monitor.Enter()와 Monitor.Exit()의 역할을 수행한다. Monitor 클래스보다 lock()이 더 사용하기 간편하다는 것을 알 수 있을 것이다. lock()은 특정 코드 블록을 잠금으로서 동기화하는데 유용하며, Monitor 클래스는 lock()보다 공유 자원에 대해서 정교한 제어가 필요할 때 사용한다. 그렇다면 이러한 Monitor 클래스와 lock()은 어떻게 구현된 것일까? 필자는 Monitor 클래스와 lock()을 C++로 구현하였다.

Win32에서 Monitor 구현

닷넷에서 사용한 Monitor 클래스를 Win32에서 구현해보도록 하자. 먼저 동기화에 대한 공통된 인터페이스를 갖고 있는 추상 클래스 SyncHandle을 만든다.
이름 : SyncHandle.h

#include 

class SyncHandle
{
protected:
  HANDLE hObject;   // Monitor 객체에 대한 핸들

public:
  SyncHandle::SyncHandle();
  SyncHandle::~SyncHandle();

  virtual bool enter(DWORD dwTimeOut = INFINITE);
  virtual bool exit() = 0;
};
hObject는 객체에 대한 핸들을 갖고 있으며, 생성자와 파괴자를 정의하고, 임계 영역에 들어가는 enter와 exit를 구현하고 있다. 여기서는 간단히 하기 위해 enter에 대해서는 오버로딩을 구현하지 않았다. 독자중에 System.Threading.Monitor 클래스 멤버들을 MSDN에서 찾아보았다면 Monitor.Enter()가 여러가지 버전으로 오버로딩되어 있다는 것을 알 수 있을 것이다. 이에 대해서는 다음에 다룰 것이다.

다음은 SyncHandle.cpp의 소스다.
이름 : SyncHandle.cpp

#include "SyncHandle.h"

SyncHandle::SyncHandle()
{
  hObject = NULL;
}

SyncHandle::~SyncHandle()
{
  if ( NULL != hObject )
  {
    ::CloseHandle(hObject);
    hObject = NULL;
  }
}

bool SyncHandle::enter(DWORD dwTimeOut)
{
  if ( WaitForSingleObject(hObject, dwTimeOut) == WAIT_OBJECT_0)
    return true;
  else
    return false;
}
파괴자에서는 객체에 대한 핸들이 있는지 확인하며, 핸들을 갖고 있다면 핸들을 정리한다. enter는 실제로 임계 영역에 들어가는 것이다. enter의 실제 구현은 WaitForSingleObject로 되어 있는데 하나의 객체에 대해서만 임계 영역에 들어갈 때까지 기다린다는 것을 의미한다. enter의 선언부분을 보면 virtual bool enter(DWORD dwTimeOut = INFINITE)이므로, 임계 영역을 획득할 때까지 무한히(INFINITE) 기다린다는 것을 뜻한다. 다시말해 Monitor.Enter()와 동일한 동작을 한다. WAIT_OBJECT_0는 대기동작이 성공한 것을 뜻한다.(대기동작의 실패는 WAIT_FAILED를 사용한다)

다음은 SyndHandle 클래스를 상속한 Monitor 클래스의 선언 파일이다.
이름 : Monitor.h

#include 
#include "SyncHandle.h"

class Monitor : public SyncHandle
{
  CRITICAL_SECTION cs;

public:
  Monitor();
  ~Monitor();

  virtual bool enter(DWORD dwTimeOut = INFINITE);
  virtual bool exit();

  bool tryEnter();
};
Monitor 클래스의 선언파일은 SyncHandle에서 상속한 enter와 exit에 대한 선언 뿐만 아니라 임계 영역에서 사용할 수 있는 tryEnter를 추가로 구현한다. 또한 임계 영역 cs를 선언하고 있는 것에 주의한다.
이름 : Monitor.cpp

#include "Monitor.h"

Monitor::Monitor()
{
// 임계 영역을 초기화한다
InitializeCriticalSection(&cs);
}

Monitor::~Monitor()
{
  // 임계 영역을 정리한다
  DeleteCriticalSection(&cs);
}

bool Monitor::enter(DWORD /*not used*/)
{
  // 임계 영역에 들어간다.
  EnterCriticalSection(&cs);
  return true;
}

bool Monitor::exit()
{
  // 임계 영역에서 빠져나온다
  LeaveCriticalSection(&cs);
  return true;
}

bool Monitor::tryEnter()
{
#if( _WIN32_WINNT >= 0x0400 )
  if ( TryEnterCriticalSection(&cs) )
    return true;
  else
#endif
    return false;

}
Monitor 클래스는 .NET의 Monitor 클래스와 큰 차이를 느끼지 못할 것이다. .NET에서 사용했던 Monitor.Enter(), Monitor.Exit(), Monitor.TryEnter()를 구현한 것이다. 실제로 임계 영역이 사용중일 때 대기하지 않도록 하려면 TryEnterCriticalSecion() Win32 API를 사용한다. 이 API의 동작은 Monitor.TryEnter()와 동일하다. #if는 C++에서 처리하는 전처리기이며, _WIN32_WINNT >= 0x0400은 TryEnterCriticalSecion() Win32 API가 윈도우 NT 4.0 이상에서만 동작하기 때문에 사용한 것이다. 윈도우 9x 계열에서는 이 API가 동작하지 않는다. 참고로 윈도우 2000은 _WIN32_WINNT 값이 0x0500이며, 윈도우 XP, .NET Server는 0x0501이다.(필자의 예상이 맞다면 닷넷에서 Monitor.TryEnter()는 윈도우 9x 계열과 NT 3.5이하에서는 동작하지 않을 것이다)

지금까지 작성한 Monitor 클래스를 이용하는 예제 프로그램을 살펴보도록하자.
이름 : CritSec01.cpp

#include 
#include 

#include "Monitor.h"

using namespace std;

// 쓰레드 함수의 원형
DWORD WINAPI incrementer(LPVOID pv);
DWORD WINAPI decrementer(LPVOID pv);

Monitor monitor;

int counter = 0;

int main()
{
  char* ps[] = {"incrementer", "decrementer"};
  DWORD threadID;
  const int nThread = 2;
  HANDLE hThreads[nThread];


  
  hThreads[0] = CreateThread( NULL,
                              0,
                              incrementer,
                              (LPVOID)ps[0],
                              0,
                              &threadID);

  hThreads[1] = CreateThread( NULL,
                              0,
                              decrementer,
                              (LPVOID)ps[1],
                              0,
                              &threadID);

  // 모든 쓰레드가 종료할 때 까지 기다린다.
  //DWORD rc = 
  WaitForMultipleObjects(nThread, hThreads, TRUE, INFINITE);

  CloseHandle(hThreads[0]);
  CloseHandle(hThreads[1]);
  return 0;
}

DWORD WINAPI incrementer(LPVOID pv)
{
  monitor.enter();

  char* ps = reinterpret_cast(pv);

  while ( counter < 10 )
  {
    counter++;
    cout << ps << " : " << counter << endl;
  }

  monitor.exit();
  return 0;
}

DWORD WINAPI decrementer(LPVOID pv)
{
  monitor.enter();

  char* ps = reinterpret_cast(pv);

  while ( counter > 0 )
  {
    counter--;
    cout << ps << " : " << counter << endl;
  }

  monitor.exit();
  return 0;
}
코드를 모두 입력했다면 저장한다. 지금까지 총 5개의 C++ 파일을 작성하였다: SyncHandle.h, SyncHandle.cpp, Monitor.h, Monitor.cpp, CritSec01.cpp

입력된 코드의 컴파일은 다음과 같이 한다. 참고로 모든 C++ 코드는 순수 C++로 작성되었으며 Borland C++나 Visual C++ 양쪽 모두에서 제대로 컴파일된다.(필자는 C++ Builder 5.0과 Visual C++ 6.0으로 테스트하였다)

C++ Builder 또는 Borland C++를 사용하고 있다면 다음과 같이 컴파일한다.
bcc32 -c SyncHandle.cpp
bcc32 -c Monitor.cpp
bcc32 CritSec01.cpp SyncHandle.obj Monitor.obj
Visual C++을 사용하고 있다면 다음과 같이 컴파일한다.
cl /c SyncHandle.cpp
cl /c Monitor.cpp
cl /EHs CritSec01.cpp SyncHandle.obj Monitor.obj
두 컴파일러 모두 c 옵션은 C++ 소스 파일을 실행파일로 만들지말고 기계어 파일(.obj)로 만들라는 것을 뜻한다. VC++은 몇가지 차이점이 있기 때문에 예외가 발생하면서 컴파일되기 때문에 예외를 화면에 출력하지 않도록 하기 위해 EHs 옵션을 사용하였다. 실행 결과는 다음과 같다.

결과에서 알 수 있는 것처럼 닷넷에서 했던것과 Win32 API를 사용하여 직접 Monitor 클래스를 구현한 것과 큰 차이가 없다는 것을 알 수 있을 것이다. 여기서는 일부러 Win32 API를 사용하여 Monitor 클래스를 직접 구현했다. 임계 영역이 무엇인지, Monitor 클래스가 어떻게 동작하는지 알고 있다면 문제가 생겼을 때 문제의 원인을 하나씩 풀어가기 쉬운 것은 당연할 것이다. 마지막으로 글이 길어지면 한빛미디어 편집진에게 혼나기 때문에 닷넷의 lock()에 대한 구현은 여기서 소개하지 않고, 소스에만 포함시켜 두었다. 관심있는 분들은 Lock.h, Lock.cpp, CritSec02.cpp를 살펴보기 바란다. C#에서의 lock()을 C++에서는 Lock l(&cs);와 같이 사용한다는 정도의 차이만이 있을 뿐이다.(Lock 클래스는 Lock.h에 정의되어 있다) 이 소스를 곰곰히 분석해보면 닷넷에서 lock()이 어떻게 임계 영역으로 설정될 수 있는 범위를 설정하고(Enter) 나올 수 있는지(Exit) 알 수 있을 것이다.

Win32 API 구현에서 보면 WaitForSingleObject Win32 API를 볼 수 있는데 이것은 세번째 글에서 다루었던 Thread.Join()에서 이미 설명한 것이다. WaitForMultipleObjects Win32 API에 대한 부분은 아직 설명하지 않았다. 닷넷에서는 이 부분이 크게 세가지로 나누어져 있다: WaitOne, WaitAny, WaitAll.

이 부분에 대해서는 나중에 설명할 것이다.

마치며

이번에는 쓰레드 동기화에서 가장 자주 사용되는 임계 영역에 대해서 살펴보았으며, 임계 영역에서 가장 많이 사용하는 방법중에서 Monitor와 lock에 대해서 살펴보았다. 다음에는 이 보다 발전적인 방법인 뮤텍스(Mutex)에 대해서 설명할 것이다. 뮤텍스는 매우 강력한 동기화 도구지만 커널 객체이기 때문에 성능이 떨어지지만 프로세스간에 동기화를 수행할 수 있다. 왜 뮤텍스를 써야하는지 다음 시간에 자세히 살펴보도록 하자. 마지막으로 멋진 동기화 코드를 소개한 Julian에게 감사드리며, Win32와 닷넷간의 차이점에 대해서 명쾌한 답변을 해준 Barak에게 감사드린다.

[소스다운] cs_thread09_source.zip
TAG :
댓글 입력
자료실

최근 본 상품0