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

한빛출판네트워크

한빛랩스 - 지식에 가능성을 머지하다 / 강의 콘텐츠 무료로 수강하시고 피드백을 남겨주세요. ▶︎

IT/모바일

C# 쓰레드 이야기: 10. 뮤텍스(Mutex)

한빛미디어

|

2002-03-27

|

by HANBIT

41,128

저자: 한동훈

지난 시간에는 임계영역(Critical Section)에 대해서 살펴보고, 동기화를 위해 사용할 수 있는 Monitor 클래스와 lock()에 대해서 살펴보았다. 이번에는 동기화에 유용하게 사용할 수 있는 뮤텍스에 대해서 살펴보도록 하자.

신호 메커니즘(Signaling Mechanism)

멀티 쓰레드 시스템에서 쓰레드를 동기화하기 위해 신호 메커니즘(Signaling Mechanism)을 사용한다. 여러 개의 쓰레드가 사용하는 공유 자원이 손상되는 것을 막기 위하여 동기화를 사용한다. 동기화를 위해 사용하는 객체를 동기화 객체라 한다. 이러한 동기화 객체는 사용할 수 있는 상태를 알려줄 수 있다. 이러한 상태를 알려주는 것을 시그널링(Signaling)이라 한다. 동기화 객체가 사용가능할 때를 시그널되었다(Signaled)라고 하며, 동기화 객체가 사용중일 때를 시그널되지 않았다(Nonsignaled)라고 한다.
Signaling Mechanism - 흔히들 시그널링 메커니즘이라고 얘기하며, 필자는 신호 메커니즘이라는 용어로 사용한다.
Signaled - 대부분의 도서들이 Signaled 원문으로 표시하거나 시그널 되었다로 표시하고 있으나 이러한 용어는 매우 모호하다. 필자는 신호상태(Signaled)로 사용한다.
Nonsignaled - Signaled와 마찬가지로 필자는 비신호상태(Nonsignaled)로 사용한다.

하나의 쓰레드가 공유 자원을 사용하고 있을 때 공유 객체는 비신호상태가 된다. 다른 쓰레드가 공유 자원을 사용하려고 하면 비신호상태이기 때문에 공유 자원이 이미 사용중이라는 것을 알 수 있다. 따라서 공유 자원을 사용할 수 있게 될 때 까지 기다린다. 공유 자원의 사용이 다 끝나면 공유 자원이 반환된다. 공유 자원이 반환되면 다른 쓰레드에게 공유 자원이 사용가능한 상태임을 알리고(Signaling), 신호상태(Signaled)가 된다.

식사하는 철학자(Dining Philosophers)

쓰레드에 대한 설명에서 빠지지 않는 고전적인 문제가 있으니, 그것을 식사하는 철학자 문제라 한다. 이 문제는 다음과 같다. 하나의 테이블에 다섯명의 철학자가 앉아있다. 테이블에는 5개의 스파게티 접시가 있고, 5개의 포크가 있다. 스파게티는 매우 미끄럽기 때문에 식사를 하려면 2개의 포크를 사용해야한다. 철학자는 생각하는 것과 식시하는 것, 두 가지 일만 할 수 있다. 철학자는 생각을 하다가 배가 고프면 식사를 한다.

이 문제에서 철학자가 쓰레드를 뜻하는 것임은 잘 알 수 있을 것이다. 철학자가 식사를 하려고 할 때 이용할 수 있는 포크가 있으면 먼저 오른쪽 포크를 든다. 그 다음에 왼쪽 포크를 이용할 수 있는지 확인하고 남아있는 포크가 있으면 왼쪽 포크를 집어서 식사를 한다. 루프를 돌면서 다섯 개의 포크를 이용할 수 있는지 확인하는 것도 하나의 방법이 될 것이다. 그러나 이 경우에 교착상태(deadlock)에 빠질 위험이 있다. 만약에 다섯명의 철학자가 동시에 배가 고파져서 동시에 오른쪽 포크를 집는 다면 어떻게 될까? 모든 철학자가 다른 포크를 이용할 수 있을 때까지 기다리게 되므로 철학자는 굶어죽게 된다. 이런 경우에는 하나의 포크를 집은 다음에 일정 시간 동안 다른 포크를 갖지 못한다면 다시 포크를 내려놓게 하는 방법으로 철학자가 굶어죽는 것을 막을 수 있을 것이다. 그러나 이러한 해결 방법은 비효율적이다. 다섯명의 철학자가 포크를 들고 있다가, 한 명의 철학자가 포크를 내려놓는 순간 다른 철학자가 포크를 들어서 식사한다고 생각해 보자. 세 명의 철학자는 한 손에 포크를 들고 있으며, 한 명의 철학자는 두 개의 포크를 들고 식사를 하고 있으며, 다른 한 명의 철학자는 아무 포크도 갖고 있지 않을 것이다. 실제로 다섯 개의 포크가 있으므로 최소한 두 명의 철학자가 동시에 식사를 할 수 있는데, 한 명의 철학자만 식사를 하고 있으므로 비효율적이다.

철학자 문제의 목적은 모든 철학자중에 어떤 철학자도 굶어죽는 일이 없어야한다는 것이다.

이 문제에 대한 해법은 여러가지가 있지만, 그 중에 필자가 알고 있는 것들은 다음과 같다.
  • 철학자들은 식사할 때만 테이블에 있도록 한다. 테이블에는 4명의 철학자만 있도록 한다.(N명의 철학자에 대하여 N-1명의 철학자만 테이블에 있도록 한다)
  • 식사를 하기 전에 양옆의 포크를 모두 이용할 수 있는지 확인한 다음에 짚는다.
  • 홀수번째 철학자는 오른쪽 포크를 먼저들고, 짝수번째 철학자는 왼쪽 포크를 먼저들도록 한다.
여기서 마지막 해결 방법이 획기적인 아이디어로 생각되지만, 이 세 가지 해결책은 하나의 철학자가 굶어죽는 문제를 해결하지는 못한다.

이 문제를 해결하는 가장 좋은 방법은 다음과 같다.

철학자가 생각을 하다가 배가 고프면 양쪽에 포크를 이용할 수 있는지 확인한다. 양쪽에 있는 포크를 모두 이용할 수 있으면 식사를 하지만, 그렇지 않다면 식사를 하지 않고 대기중(wait)으로 표시한다. 양쪽에 있는 포크를 모두 이용할 수 있으면 식사를 시작하고 식사중으로 표시한다. 식사가 끝나면 포크를 다시 양옆에 내려놓고 식사가 끝났다는 사실을 대기중인 철학자에게 알려준다(signaling). 여기서 대기중인 철학자는 누군가가 알려주기 전에는 절대 깨어나지 않는다. 그러나 대기중인 철학자가 아무도 없다면 식사가 끝났다는 사실을 알려줘도 아무 일도 일어나지 않는다.

멀티 쓰레드 프로그래밍에는 자주 발생하기 문제에 대한 몇 가지 동기화 방법이 있으며, 이러한 동기화 방법을 설계 패턴(Design Pattern)이라 한다. 식사하는 철학자 문제에 사용하는 방법은 상호배제(Mutual Exclusion)이며, 간단히 뮤텍스(Mutex)라 한다. 뮤텍스는 공유 자원에 대해서 상호 배타적 접근을 위해서 사용된다.

뮤텍스(Mutex)

뮤텍스는 지금까지 소개한 임계 영역이 프로세스 객체에 속한 것과는 달리 커널 객체(Kernel Object)중에 하나다. 커널 객체는 보안과 관련되어 있기 때문에 자원을 사용하기 전에 보안을 확인하기 때문에 임계 영역을 사용하는 것보다 느리다. Win32에서 뮤텍스는 Win32 커널 객체에 속하며, 닷넷에서는 닷넷 커널 객체에 속한다.(이렇게 얘기하고 있지만 닷넷에서 Mutex 클래스는 Win32 CreateMutex와 동일하다)

뮤텍스는 자원을 최소 수행 단위로 획득하기 위해 사용되며, 위 철학자 문제에서 최소 수행 단위로 획득해야하는 자원은 포크 2개가 된다. 또한 철학자들간에 식사를 위해서 대기중이거나, 포크를 다 사용했다는 사실을 알리는 것처럼 하나의 프로세스(테이블)에 있는 여러 개의 쓰레드(철학자)간에 통신을 할 수 있는 방법을 제공한다. 뮤텍스는 커널 객체이기 때문에 프로세스간에 통신을 하기 위해서 사용할 수 있다.

Mutex 클래스

Mutex 클래스는 System.Threading 네임스페이스에 있으며, 생성자는 다음과 같다.
생성자
설명
public Mutex(); 기본값으로 뮤텍스를 초기화한다.
public Mutex(bool); 호줄하는 쓰레드가 뮤텍스의 초기 소유권을 갖는지를 정한다.
public Mutex(bool, string); 뮤텍스의 이름을 정한다.
public Mutex(bool, string, bool); 뮤텍스의 초기 소유권, 뮤텍스 이름, 메소드가 뮤텍스의 초기 소유권 반환 여부

첫번째는 기본값으로 뮤텍스를 초기화하는 것이다. 두번째는 뮤텍스를 생성하는 쓰레드가 뮤텍스의 소유권을 갖도록 할 것인가를 결정한다. true로 되면 뮤텍스를 생성한 쓰레드가 소유권을 갖게된다. 뮤텍스를 이용하여 프로세스간에 동기화를 하려면 뮤텍스 이름을 이용한다. 세번째와 네번째 생성자는 뮤텍스의 이름을 지정한다. myMutex와 같은 문자열을 이름으로 사용하여 동기화할 수 있으며, 각각의 쓰레드는 같은 뮤텍스에 대해서 다른 핸들을 갖게된다. 이처럼 실제로 하나의 핸들(뮤텍스)에 대해서 다른 핸들을 갖게 되는 것을 모사 핸들 내지는 의사 핸들이라한다. 쉽게말하면 가짜 핸들이다.

식사하는 철학자 문제에서와 같이 공유 자원을 뮤텍스가 관리하도록 한다. 공유 자원이 필요한 스레드는 뮤텍스에게 공유 자원을 요청하도록 한다. 뮤텍스의 사용은 다음과 같다.
Mutex mutex = new Mutex();
mutex.WaitOne();
// 공유 자원 사용
mutex.ReleaseMutex();
WaitOne()은 하나의 자원이 이용가능해질 때까지 대기하는 것을 뜻한다. 이 메소드는 WaitHandle 클래스에서 상속받은 것이다. WaitOne(), WaitAny(), WaitAll()을 사용할 수 있다. 뮤텍스에서는 Wait를 호출한 만큼 ReleaseMutex() 호출해야한다. 그렇지 않으면 자원은 해제되지 않으며 다른 쓰레드들은 계속해서 자원이 해제되기를 기다리게 된다.

Monitor나 Mutex 사용시 주의할 점

Monitor나 Mutex를 사용하여 공유 자원에 대한 동기화를 할 때 다음과 같은 코드를 작성하지 않도록 주의한다.
public int Data
{
  get
  {
    mMutex.WaitOne();
    int lData = mData;
    return lData;
    mMutex.ReleaseMutex();
  }
}
빨간색으로 표시된 부분과같이 공유 자원에 대한 락을 획득한 상태에서 함수를 빠져나가면 자원은 해제되지 않고 교착상태에 빠지게된다. 따라서 Mutex를 먼저 해제한 다음에 return을 사용하여 값을 반환하고 함수를 빠져나가도록 해야한다. 다행히도 닷넷에서 이러한 종류의 오류를 하게되면 코드는 컴파일되지 않으며 Unreachable code detected 오류 메시지를 출력한다.

다음은 뮤텍스를 이용한 동기화 예제다.
이름 : Mutex01.cs

using System;
  using System.Threading;

  public class SharedDataObject
  {
    public SharedDataObject ()
    {
      mMutex = new Mutex ();
    }
  
    public int Data
    {
      get
      {
        Console.WriteLine("Mutex-get : " + Thread.CurrentThread.Name);

        mMutex.WaitOne();
        Thread.Sleep(100);
        int lData = mData;
        mMutex.ReleaseMutex();

        return lData;
      }

      set
      {
        Console.WriteLine("Mutex-set : " + Thread.CurrentThread.Name);
        mMutex.WaitOne();
        Thread.Sleep(2000);
        mData = value;
        mMutex.ReleaseMutex();
      }
    }
    private int mData;
    static Mutex mMutex = new Mutex(true);
  }

  public class AppMain
  {

    AppMain()
    {
      Console.WriteLine("Begin Application");
      Thread.CurrentThread.Name = "Primary Thread";

    }
    ~AppMain()
    {
      Console.WriteLine("End Application");
    }

    static void Main()
    {
      AppMain ap = new AppMain();
      ap.DoTest();
    }

    private SharedDataObject msdo = new SharedDataObject();

    private void DoTest()
    {

      // Initialize shared data.
      msdo.Data = 0;

      Thread[] threads = {
                           new Thread ( new ThreadStart(DoReading) ),
                           new Thread ( new ThreadStart(DoReading) ),
                           new Thread ( new ThreadStart(DoReading) ),
                           new Thread ( new ThreadStart(DoWriting) )
                         };

      threads[0].Name = "Read 1";
      threads[1].Name = "Read 2";
      threads[2].Name = "Read 3";
      threads[3].Name = "Write 1";

      threads[0].Start();
      threads[1].Start();
      threads[2].Start();
      threads[3].Start();

      Thread.Sleep(3000);

    }

    private void DoReading()
    {
      for ( int loopctr = 0; loopctr < 10; loopctr++)
      {
        Console.WriteLine(Thread.CurrentThread.Name + " ctr " + loopctr.ToString() + 
        " : " + msdo.Data);
      }
    }

    private void DoWriting()
    {
      Console.WriteLine("Started - DoWriting1()");

      for( int loopctr = 0; loopctr < 10; loopctr++)
      {
        Console.WriteLine("Shared Data Increased");
        msdo.Data++;
      }

      Console.WriteLine("Ended - DoWriting1()");
    }
}
공유 데이터는 SharedDataObject 클래스로 캡슐화하고 있으며, Mutex.WaitOne()을 사용하여 뮤텍스를 가져오고, 뮤텍스의 사용이 끝나면 Mutex.ReleaseMutex()를 사용하여 뮤텍스를 해제한다. 처리를 명확하게 하기 위해 get과 set에서 콘솔에 메시지를 출력하도록 하였다. get과 set 부분을 자세히 살펴보도록 하자.
get
{
  Console.WriteLine("Mutex-get : " + Thread.CurrentThread.Name);

  mMutex.WaitOne();
  Thread.Sleep(100);
  int lData = mData;
  mMutex.ReleaseMutex();

  return lData;
}
mMutex.WaitOne()은 하나의 뮤텍스를 얻기 위해 사용한다. 사용가능한 뮤텍스가 없으면 여기서 사용가능한 뮤텍스가 있을 때까지 기다리게 된다. 뮤텍스를 얻은 다음에 가상의 처리를 에뮬레이트하기 위해 Thread.Sleep을 사용하였다. 처리가 끝나면 ReleaseMutex()를 사용하여 뮤텍스를 반환한다.
set
{
  Console.WriteLine("Mutex-set : " + Thread.CurrentThread.Name);
  mMutex.WaitOne();
  Thread.Sleep(2000);
  mData = value;
  mMutex.ReleaseMutex();
}
이것은 set에 대한 부분이다. get과 크게 바른 부분은 없으며, 보다 큰 처리시간을 에뮬레이트하기 위해 Thread.Sleep(2000)을 사용하였다. 이 때문에 실제로 예제를 수행하면 3개의 읽기 쓰레드는 처리시간이 짧기 때문에 같은 시간에 데이터를 상호 배타적으로 읽지만, 처리시간이 긴 쓰기 쓰레드가 작업중일 때는 읽기 쓰레드가 시작되지 않는다는 것을 알 수 있다.

다시 말해서 쓰기 쓰레드가 작업중인 동안에 읽기 쓰레드가 수행하는 get 부분의 mMutex.WaitOne()에서 사용가능한 뮤텍스가 없기 때문에 더 이상 수행하지 않고 대기한다. 쓰기 작업이 끝나고 뮤텍스가 반환되면 대기중인 쓰레드들 중에 한 쓰레드가 뮤텍스를 획득한다. 뮤텍스를 획득한 쓰레드는 잠에서 깨어나(mMutex.WaitOne) 계속해서 처리를 하게 된다.

이 예제를 실행해보고 무슨 일이 일어나는지 이해했다면 한 가지 의문이 생길 것이다. 데이터를 쓰는 동안은 데이터를 읽지 못하도록 할 필요가 있지만, 데이터를 쓰는 중이 아니라면 읽기 동작에 굳이 동기화를 할 필요가 있을까?

이것은 생산자/소비자(Provider/Consumer) 모델 또는 독자/작가(Reader/Writer) 모델로 알려져 있다. 특히 위 예제와 같은 것을 단일 생산자/다중 소비자 모델이라고 한다. 닷넷에서는 이러한 모델에 대한 클래스를 제공하는데 ReaderWriterLock 클래스다. 이 클래스에 대해서는 다음 시간에 뮤텍스에 이어서 계속 설명하겠다.

lock()의 함정

마지막으로 지난 시간에 간단하게 다루었던 lock()에 대한 것인데 다음과 같이 사용하면 절대로 동기화되지 않는다.
int iData = 0;
public void DoSomething()
{
  lock(iData)
  {
    // do something
  }
}
위와 같은 코드는 잘 실행되며, 분명히 어떤 에러도 발생하지 않는다. 또한 어떤 쓰레드도 잠기지 않는다. 쓰레드를 잠그려 하면 값 타입(value type)인 iData는 참조 타입(reference type)으로 박싱(boxing)되기 때문에 결과적으로 iData 값은 새로운 스택에 복사된다. 때문에 위와 같은 메소드가 호출될 때마다 스택에 이 정수형의 다른 사본이 생성된다. 때문에 위와 같은 코드를 실행할 때마다 스레드는 다른 객체에 대해서 락을 하는 것이 되기 때문에 어떤 쓰레드도 블록되지 않는다. 따라서 lock()에는 레퍼런스 타입만 사용할 수 있다.(일반적으로 많이 쓰는 lock(this)등은 레퍼런스 타입이므로 문제가 되지 않는다) 이것은 다른 동기화 객체에도 해당된다.

실제로는 위와 같은 문장은 컴파일되지 않는다. 그러나 lock((object)iData)와 같이 하면 문제없이 컴파일할 수 있으며, 위와 같은 이유로 동기화는 수행되지 않는다.

System.Type 클래스의 객체는 클래스의 static 메소드를 사용하여 상호 배제를 구현할 수 있다.
class Shared
{
   public static void Add(object x) {
      lock (typeof(Shared)) {
         // do something
      }
   }
   public static void Remove(object x) {
      lock (typeof(Shared)) {
         // do something
      }
   }
}
마치며

이번에는 뮤텍스에 대해서 알아봤다. 아직 뮤텍스를 제대로 설명한 것은 아니다. 다음 시간에는 뮤텍스에 대해서 보다 자세히 알아볼 시간을 가질 것이다. WaitOne, WaitAny, WaitAll의 사용에 대해서 이해하는 시간을 가질 것이며, 이벤트에 대해서도 살펴볼 것이다. 아마도 몇 주 후에는 지금까지 살펴본 클래스들에 대해서 정리하는 시간을 갖게 될 것이다.
TAG :
댓글 입력
자료실

최근 본 상품0