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

한빛출판네트워크

IT/모바일

C# 쓰레드 이야기 - 12. 식사하는 철학자

한빛미디어

|

2002-05-08

|

by HANBIT

33,069

지난 2주 동안 Mutex(뮤텍스)에 대해 설명했으며, 식사하는 철학자 문제를 소개했다. 그리고 이벤트에 대해서 설명했다. Mutex를 사용하여 자원을 동기화하는 방법을 살펴봤으며, 이벤트를 사용하여 쓰레드간에 신호를 주고 받음으로서 쓰레드간에 동기화하는 방법을 살펴봤다. 이번시간에는 WaitHandle 클래스와 함께 식사하는 철학자 문제의 해결책을 알아보기로 하자.
사실, 필자의 심경으로는 밥 쳐먹고 몽상하는 게 전부인데다가, 이 위생 관념도 없는 집단은 남이 쓰던 포크도 마다 않고 쓰는 똘아이 집단을 거창하게 "식사하는 철학자"라고 소개하는 것은 뭔가 불합리하다고 생각한다. -_-

WaitHandle 클래스

Mutex를 이용한 동기화와 식사하는 철학자에 대해서 설명하기 전에 WaitHandle 클래스부터 설명하는 것으로 시작하자. 9. 임계 영역에서 동기화 클래스 계층구조를 보여준 적이 있다. 기억이 안나는 분들을 위해서 다시 소개하면 다음과 같다.
System.Object
   System.MarshalByRefObject
      System.Threading.WaitHandle
         System.Threading.AutoResetEvent
         System.Threading.ManualResetEvent
         System.Threading.Mutex
즉, 뮤텍스나 이벤트 모두 WaitHandle 클래스에서 파생된 클래스라는 것을 알 수 있다. WaitHandle은 공유 자원에 대한 배타적 접근 허용을 위해서 제공되는 클래스다. 다시 말해서, WaitHandle은 커널 객체에 대한 동기화를 제공하는 Win32 API에 대한 래퍼 클래스다. WaitHandle 클래스는 추상 클래스이므로 동기화 객체를 위해서 클래스를 상속시킬 수 있다.

WaitForSingleObject, WaitForMultipleObjects와 같은 Win32 API에 대한 래퍼 역할을 하며, 각각 WaitHandle.WaitOne, WaitHandle.WaitAny, WaitHandle.WaitAll로 정의되어 있다.

MsgForMultipleObjects, WaitForInputIdle, WaitForDebugEvent에 대한 래퍼는 제공하지 않는다(이들 Win32 API는 System.Threading.Process.EnterDebugMode, System.Threading.Process.LeaveDebugMode, System.Threading.Process.WaitForInputIdle, System.Threading.Process.WaitForExit에서 찾을 수 있다).

WaitHandle 클래스 주요 멤버
이름
타입
설명
Handle IntPtr 운영 체제 핸들을 가져오거나 설정한다.
WaitTimeout int 대기 핸들이 일어나기 전에 WaitAny의 제한 시간이 초과했음을 나타낸다.
Close void 핸들이 갖고 있는 자원을 해제한다.

WaitHandle.WaitOne은 WaitHandle에서 신호를 받을 때 까지 쓰레드를 대기하도록 한다. WaitOne()의 오버로드된 목록은 다음과 같다.
public virtual bool WaitOne();
public virtual bool WaitOne(int, bool);
public virtual bool WaitOne(TimeSpan, bool);
첫번째는 자원에 대한 핸들을 얻을 때 까지 무한히 대기한다. 두번째와 세번째는 지정된 시간까지 대기할 것을 지정하며, 지정된 시간 동안 자원에 대한 핸들을 얻지 못하면 false를 반환한다. 핸들을 얻었는지의 여부에 따라서 bool 값 true/false를 반환한다.

대기 시간이 두 가지 종류가 있는데 int 타입은 밀리 초 단위로 대기시간을 지정하며, 지정할 수 있는 시간의 범위는 32 비트 정수형과 같다. TimeSpan은 64비트 정수형이며 1 TimeSpan은 100 Tick(틱)과 같은 시간을 나타낸다. 따라서 보다 정교한 시간 제어가 필요하다면 TimeSpan을 쓰도록 하지만, 대부분의 경우에 밀리초 단위로 설정한다.

세번째 요소는 핸들을 기다리기 전에 동기화 도메인을 빠져나올지를 지정한다. 대부분의 예제에서 동기화 도메인을 사용하지 않기 때문에 false를 지정하면 된다.

WaitHandle.WaitAny(WaitHandle [])은 WaitHandle[]과 같이 지정된 배열에 있는 요소들 중에 어떤 하나의 핸들을 얻을 때 까지 대기한다. 오버로드된 목록은 다음과 같다.
public static int WaitAny(WaitHandle[]);
public static int WaitAny(WaitHandle[], int, bool);
public static int WaitAny(WaitHandle[], TimeSpan, bool);
WaitOne과 마찬가지로 두 번째와 세번째는 모두 지정된 시간까지 대기할 것을 지정하며, 지정된 대기 시간 동안 자원에 대한 핸들을 얻지 못하면 false를 반환한다.
한글 VS.NET에 있는 MSDN을 보면 WaitHandle.WaitAny에 대한 설명이 "지정된 배열의 모든 요소가 신호를 받기를 기다립니다."와 같이 되어 있는데, 이는 모호한 설명이다. WaitHandle[]에 2개의 핸들을 지정했을 때, 2개의 핸들 중에 하나를 받으면 쓰레드 대기상태(WaitSleepJoin)가 해제되고 작업을 진행한다(Suspended 또는 Running).

영문 VS.NET에 있는 MSDN에는 "Waits for any of the elements in the specified array to receive a signal."와 같이 되어 있으며, 원문에 대한 오역이라는 것을 알 수 있을 것이다. WaitAny[]에 대해서는 예제에서 설명할 것이다.

WaitHandle.WaitAll(WaitHandle [])은 WaitHandle[]과 같이 지정된 배열의 모든 요소에 대한 핸들을 얻을 때 까지 대기한다. 오버로드된 목록은 다음과 같다.
public static int WaitAll(WaitHandle[]);
public static int WaitAll(WaitHandle[], int, bool);
public static int WaitAll(WaitHandle[], TimeSpan, bool);
WaitHandle[]과 같이 지정된 요소에 대한 모든 핸들을 얻을 때 까지 대기하는 점을 제외하면 WaitAny()와 동일하다.

이외에 다른 WaitHandle 멤버는 모두 다른 클래스에서 파생된 것이다. Mutex는 WaitHandle에서 파생되었으므로 위에 설명한 모든 메소드를 제공한다.

WaitHandle에 대해서 알아보았으니 Mutex와 WaitHandle을 어떻게 이용하는지 예제에서 살펴보도록 하자. 여기서 소개할 예제는 MSDN에도 있으며 설명하기에 가장 좋기에 여기에 소개한다.(사실은 저작권 문제도 있을 것이고, 조금 마음에 들지 않는 부분도 있어서 읽기 쉽도록 다시 작성해봤다. 겉모양은 달라도 로직은 같다.)
이름 : MutexWait.cs

using System;
using System.Threading;

public class AppMain
{
  static Mutex gM1;
  static Mutex gM2;

  static AutoResetEvent are1 = new AutoResetEvent(false);
  static AutoResetEvent are2 = new AutoResetEvent(false);
  static AutoResetEvent are3 = new AutoResetEvent(false);
  static AutoResetEvent are4 = new AutoResetEvent(false);

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

    Thread.CurrentThread.Name = "Primary Thread";

    Thread thread1 = new Thread(new ThreadStart(ap.DoSomething1));
    Thread thread2 = new Thread(new ThreadStart(ap.DoSomething2));
    Thread thread3 = new Thread(new ThreadStart(ap.DoSomething3));
    Thread thread4 = new Thread(new ThreadStart(ap.DoSomething4));

    gM1 = new Mutex(true);
    gM2 = new Mutex(true);

    AutoResetEvent[] IAutoResetEvent = new AutoResetEvent[4];
    IAutoResetEvent[0] = are1;
    IAutoResetEvent[1] = are2;
    IAutoResetEvent[2] = are3;
    IAutoResetEvent[3] = are4;

    thread1.Start();
    thread2.Start();
    thread3.Start();
    thread4.Start();

    Console.WriteLine("-------- " + Thread.CurrentThread.Name + " - owns gM1 and gM2 " );

    Thread.Sleep(3000);

    Console.WriteLine("-------- " + Thread.CurrentThread.Name + " - releases gM1");
    gM1.ReleaseMutex();
    Thread.Sleep(2000);
    
    Console.WriteLine("-------- " + Thread.CurrentThread.Name + " - releases gM2");
    gM2.ReleaseMutex();
    Thread.Sleep(2000);

    WaitHandle.WaitAll(IAutoResetEvent);
    Console.WriteLine("All threads just have finished");
  }

  public void DoSomething1()
  {
    Console.WriteLine("DoDomething1 started, Mutex.WaitAll(Mutex[])");

    Mutex[] IMutex = new Mutex[2];

    IMutex[0] = gM1;
    IMutex[1] = gM2;

    Mutex.WaitAll(IMutex);

    Console.WriteLine("DoSomething1 finished, Mutex.WaitAll(Mutex[])");
    are1.Set();
  }

  public void DoSomething2()
  {
    Console.WriteLine("DoDomething2 started, gM1.WaitOne()");

    gM1.WaitOne();

    Console.WriteLine("DoSomething2 finished, gM1.WaitOne()");
    are2.Set();    
  }

  public void DoSomething3()
  {
    Console.WriteLine("DoDomething3 started, Mutex.WaitAny(Mutex[])");
    
    Mutex[] IMutex = new Mutex[2];

    IMutex[0] = gM1;
    IMutex[1] = gM2;

    Mutex.WaitAny(IMutex);
    
    Console.WriteLine("DoSomething3 finished, Mutex.WaitAny(Mutex[])");
    are3.Set();    
  }

  public void DoSomething4()
  {
    Console.WriteLine("DoDomething4 started, gM2.WaitOne()");
    
    gM2.WaitOne();

    Console.WriteLine("DoSomething3 finished, gM2.WaitOne()");
    are4.Set();    
  }
}
예제를 모두 입력하고 컴파일 한 결과는 다음과 같다.

 
using System;
using System.Threading;

public class AppMain
{
  static Mutex gM1;
  static Mutex gM2;

  static AutoResetEvent are1 = new AutoResetEvent(false);
  static AutoResetEvent are2 = new AutoResetEvent(false);
  static AutoResetEvent are3 = new AutoResetEvent(false);
  static AutoResetEvent are4 = new AutoResetEvent(false);
콘솔로 작성할 것이고, 쓰레드 응용 프로그램이므로 네임 스페이스 System과 System.Threading을 사용한다. 클래스에서 Mutex와 Event를 static으로 선언한다. static으로 선언하면 인스턴스 수준에서 자원을 공유할 수 있게 된다. 쓰레드 지역 저장소(Thread Local Storage, TLS)에 대해서는 나중에 설명할 것이다.

두 개의 Mutex를 사용하여 WaitOne, WaitAny, WaitAll이 어떻게 동작하는지 살펴볼 것이고, 4개의 쓰레드의 작업이 끝났다는 것을 알기 위해 이벤트를 사용한다.
    gM1 = new Mutex(true);
    gM2 = new Mutex(true);

    AutoResetEvent[] IAutoResetEvent = new AutoResetEvent[4];
    IAutoResetEvent[0] = are1;
    IAutoResetEvent[1] = are2;
    IAutoResetEvent[2] = are3;
    IAutoResetEvent[3] = are4;
Mutex를 생성하는데 true를 사용하면 현재 Mutex를 생성하는 쓰레드가 초기 소유권을 갖게 된다. 여기서는 Main 쓰레드에서 생성하므로 Main 쓰레드가 뮤텍스 gM1, gM2에 대한 소유권을 갖게 된다.

이벤트에 대한 배열을 IAutoResetEvent로 선언한다. IAutoResetEvent는 다른 모든 이벤트들에 대한 컨테이너 역할을 하기 때문에 I를 접두어로 사용하여 명명하였다.(실제로 실행 코드를 분석해보면 IAutoResetEvent는 다른 이벤트들에 대한 참조만을 갖고 있으며, 단순한 컨테이너 역할만 한다는 것을 알 수 있다.)
    thread1.Start();
    thread2.Start();
    thread3.Start();
    thread4.Start();

    Console.WriteLine("--------- " + Thread.CurrentThread.Name + " - owns gM1 and gM2 " );
4개의 쓰레드를 시작하고 Mutex gM1과 gM2의 소유권이 메인 쓰레드에 있다는 것을 콘솔에 출력한다.
    Thread.Sleep(3000);
모든 쓰레드가 시작된 다음에 Main 쓰레드를 3초동안 대기시킨다. 3초 동안 대기되는 동안 각각의 쓰레드는 시작하지만, 어떤 뮤텍스도 소유할 수 없으므로 대기상태가 된다는 것을 보여주기 위한 것이다.
    Console.WriteLine("--------- " + Thread.CurrentThread.Name + " - releases gM1");
    gM1.ReleaseMutex();
    Thread.Sleep(2000);
뮤텍스 gM1을 해제한다는 메시지를 콘솔에 출력하고 ReleaseMutex()를 호출하여 해제한다. 뮤텍스 gM1을 해제한 다음에 무슨 일이 일어나는지 알아보기 위해 2초동안 대기한다.
    Console.WriteLine("--------- " + Thread.CurrentThread.Name + " - releases gM2");
    gM2.ReleaseMutex();
    Thread.Sleep(2000);
마찬가지로 뮤텍스 gM2를 해제하고 무슨 일이 일어나는지 알아보기 위해 2초 동안 대기한다.
    WaitHandle.WaitAll(IAutoResetEvent);
    Console.WriteLine("All threads just have finished");
}
WaitHandle을 이용하여 모든 이벤트가 신호상태가 될 때 까지 기다린다. 실제로 모든 쓰레드가 종료되기 때문에 이 조건은 바로 만족되고 메시지가 콘솔에 출력된다.
  public void DoSomething1()
  {
    Console.WriteLine("DoDomething1 started, Mutex.WaitAll(Mutex[])");

    Mutex[] IMutex = new Mutex[2];

    IMutex[0] = gM1;
    IMutex[1] = gM2;

    Mutex.WaitAll(IMutex);

    Console.WriteLine("DoSomething1 finished, Mutex.WaitAll(Mutex[])");
    are1.Set();
}
첫번째 쓰레드 thread1에서 실행하는 메소드 DoSomething1은 두 개의 뮤텍스를 소유할 때만 일을 처리한다. 결과화면에서 알 수 있는 것처럼 thread1이 제일 먼저 시작했지만 뮤텍스를 가장 마지막에 소유하므로 완료(finished) 메시지가 가장 마지막에 출력되는 것을 알 수 있다. 결과에서 알 수 있는 것처럼 WaitAll은 배열에 지정된 모든 요소에 대한 핸들을 가질 때 까지 대기한다. 마지막에 are1.Set()은 thread1의 작업이 끝났음을 알려주는 것이다. are1.Set()은 이벤트를 비신호상태에서 신호상태로 변경한다. 이벤트에 대해서 기억나지 않는다면 이전 글, 11. 이벤트를 참고하기 바란다.
  public void DoSomething2()
  {
    Console.WriteLine("DoDomething2 started, gM1.WaitOne()");

    gM1.WaitOne();

    Console.WriteLine("DoSomething2 finished, gM1.WaitOne()");
    are2.Set();    
  }
thread2에서 실행하는 메소드 DoSomething1은 gM1.WaitOne()을 사용해서 뮤텍스 gM1을 이용할 수 있게 될 때 까지 기다린다. 결과화면에서 알 수 있는 것처럼 Main이 뮤텍스 gM1의 소유권을 해제한 다음에 가장 먼저 실행된다는 것을 알 수 있다. 마찬가지로 실행이 끝난 다음에는 이벤트 are2를 신호상태로 설정한다.
  public void DoSomething3()
  {
    Console.WriteLine("DoDomething3 started, Mutex.WaitAny(Mutex[])");
    
    Mutex[] IMutex = new Mutex[2];

    IMutex[0] = gM1;
    IMutex[1] = gM2;

    Mutex.WaitAny(IMutex);
    
    Console.WriteLine("DoSomething3 finished, Mutex.WaitAny(Mutex[])");
    are3.Set();    
  }
thread3에서 실행하는 메소드 DoSomething3은 IMutex에 지정된 뮤텍스 gM1과 gM2를 기다린다. 여기서 WaitAny를 사용하였으므로 배열에 지정된 핸들 중에 먼저 사용할 수 있는 핸들이 있으면 그 핸들에 대한 소유권을 얻고 쓰레드의 대기 상태를 해제한다. 결과화면에서 알 수 있는 것처럼 Main에서 뮤텍스 gM1을 해제했을 때 gM1에 대한 소유권을 얻고 대기상태를 해제한다는 것을 알 수 있다.

IMutex는 실제로 뮤텍스를 생성하는 것이 아니라 뮤텍스에 대한 컨테이너 역할을 하기 때문에 필자는 인터페이스를 뜻하는 I를 사용하였다. 앞에서 얘기한 것처럼 실제로 생성된 코드를 살펴보면 뮤텍스 gM1, gM2에 대한 참조만을 갖고 있는 컨테이너라는 것을 알 수 있다.
  public void DoSomething4()
  {
    Console.WriteLine("DoDomething4 started, gM2.WaitOne()");
    
    gM2.WaitOne();

    Console.WriteLine("DoSomething3 finished, gM2.WaitOne()");
    are4.Set();    
  }
thread4에서 실행하는 메소드 DoSomething4는 gM2.WaitOne()을 사용하는 것에서 알 수 있는 것처럼 뮤텍스 gM2를 얻을 수 있을 때 까지 대기한다. 결과화면에서 알 수 있는 것처럼 Main 쓰레드에서 뮤텍스 gM2의 소유권을 해제한 다음에 실행되는 것을 알 수 있다.

이것으로 위 예제의 전체 소스를 살펴보았다. 그러면 위에서 살펴본 예제와 Wait 메소드에 대해서 알아보도록 하자.

예제에서 알 수 있는 것들

위 예제에 대해서 이제 몇 가지를 더 생각해 보도록 하자. 먼저 각각의 쓰레드들은 뮤텍스를 이용할 수 있을 때 까지 기다린다. Main 쓰레드에서 뮤텍스 gM1을 해제했을 때 gM1.WaitOne을 사용하는 DoSomething2와 Mutex.WaitAny를 사용하는 DoSomething3가 경쟁하게 된다. 또한 첫번째 쓰레드가 실행하는 DoSomething1이 가장 먼저 시작하므로, 뮤텍스를 가장 먼저 기다리게 된다. 그렇다면 Main에서 gM1을 해제했을 때 가장 먼저 gM1을 소유해야하는 쓰레드는 첫번째 쓰레드가 아닐까? 가장 큰 의문이 하나 더 있다. Main에서는 뮤텍스를 소유하고 있다가 명시적으로 ReleaseMutex를 사용하여 뮤텍스의 소유권을 해제하여 다른 쓰레드들이 뮤텍스를 이용할 수 있도록 하였다. 다른 쓰레드에서는 뮤텍스를 해제하는 메소드를 호출하지 않는데 어떻게 다른 쓰레드들이 사이좋게 뮤텍스를 사용할 수 있을까? (^^;)

실제로 WaitAll은 모든 자원을 동시에 이용할 수 없으면 자원을 소유하지 않는다. 따라서 Main이 처음에 gM1을 해제했을 때 DoSomething1에서는 gM1을 소유하지 않는다. 마찬가지로 WaitOne과 WaitAny는 어떤 것이든 하나의 핸들에 대한 소유권을 얻으면 동작하기 때문에 문제없이 동작한다. 따라서 Main이 뮤텍스 gM1의 소유권을 해제했을 때, gM1.WaitOne을 실행하는 DoSomething2와 WaitAny를 실행하는 DoSomething3중에 먼저 gM1의 소유권을 얻은 쪽이 실행된다.(콘솔 응용 프로그램이 단순해서 아마도 항상 같은 결과가 나오겠지만)

마찬가지로 Main에서 뮤텍스 gM2를 해제했을 때 gM2.WaitOne을 실행하는 DoSomething3과 WaitAll을 실행하는 DoSomething1이 경쟁하게 된다. 왜냐하면 gM2가 해제될 때는 WaitAll에 지정된 두 뮤텍스 gM1과 gM2 모두 사용할 수 있기 때문이다.

마지막으로 쓰레드들에서 모두 뮤텍스를 해제하지 않았는데 어째서 모두 사이좋게 뮤텍스를 가질 수 있었는가? 라는 질문을 생각해보자.

Win32 API에는 WaitForSingleObject와 WaitForMultipleObjects가 있는데 왜 닷넷에서는 WaitOne, WaitAny, WaitAll과 같이 세 가지로 나뉘어 진 것인가? 라는 질문에 대한 답도 될 것 같다. 이들 Win32 API들은 모두 프로세스나 쓰레드 객체에 대해서 부작용이 있다. 10개의 쓰레드가 WaitForSingleObject를 호출하고, 동일한 객체에 대한 핸들을 얻을 때 까지 기다린다고 하자. 다른 쓰레드가 이 객체을 반환할 때, 객체는 "이제 사용할 수 있음!"을 뜻하는 신호 상태가 된다. 그러면 이 객체를 기다리던 10개의 쓰레드가 동시에 일어나서 작업을 수행한다.

반면에, 뮤텍스, 세마포어, 이벤트와 같은 객체들에 대해서 WaitForSingleObject와 WaitForMultipleObjects는 위와 같은 상황에서 객체가 "이제 사용할 수 있음!"을 뜻하는 신호 상태가 되었을 때, 한 쓰레드가 깨어나면 객체를 바로 "흑, 난 이미 임자가 있어요!"를 뜻하는 비신호 상태로 바꿔버린다.(이벤트는 닷넷에서 AutoResetEvent를 뜻한다)

이와 같은 이유 때문에 Win32 API에는 약간의 모호함이 있었고, 닷넷에서는 이것을 WaitOne, WaitAny, WaitAll로 명확하게 나누었다는 것을 알 수 있다. 그리고 모든 WaitHandle을 이용하는 공유 자원은 한 쓰레드가 소유권을 갖게 되면 즉시 비신호 상태로 바꿔버린다.(Reset)

지금까지 WaitOne, WaitAny, WaitAll을 살펴보았다. 만약에 여러 개의 공유 자원이 있고, 여러 개의 쓰레드는 반드시 2개(또는 그 이상)의 공유 자원을 갖고 있을 때 실행해야 한다면 어떻게 제어할까? 이 문제에 대한 답을 알아보기 위해 이번 회의 본론인 식사하는 철학자 문제를 살펴보자.

식사하는 철학자

식사하는 철학자 문제는 5명의 철학자(쓰레드)가 5개의 공유 자원(포크)중에 2개의 공유 자원을 가져야만 식사를 할 수 있는 것을 말한다. 식사하는 철학자 문제는 10번째 기사에서 자세히 설명했으므로, 여기서는 바로 이를 구현한 예제에 대해서 설명하도록 하자.

이 예제는 먼저 Table 클래스가 있고, 5개의 포크(뮤텍스)를 갖고 있다. 각각의 철학자를 뜻하는 Philosopher 클래스는 이 테이블 클래스를 이용하여 포크를 얻어서 식사도 하고, 생각을 하기도 한다. 각각의 식사 시간과 생각하는 시간을 불규칙적으로 하기 위해 Random 클래스를 사용하여 1부터 100 사이의 임의의 시간을 이용하도록 했다.
이름 : dining.cs

using System;
using System.Threading;

namespace hanbit
{

class Table
  {

    static Mutex gM1 = new Mutex(false);
    static Mutex gM2 = new Mutex(false);
    static Mutex gM3 = new Mutex(false);
    static Mutex gM4 = new Mutex(false);
    static Mutex gM5 = new Mutex(false);

    static Mutex[] gFork = new Mutex[5];

    static bool bContinue;

    public bool Continue
    {
      get
      {
        return bContinue;
      }

      set
      {
        bContinue = value;
      }

    }

    public void Stop()
    {
      bContinue = false;
    }

    public Table()
    {
      bContinue = true;

      gFork[0] = gM1;
      gFork[1] = gM2;
      gFork[2] = gM3;
      gFork[3] = gM4;
      gFork[4] = gM5;
    }

    public void GetForks(int threadID)
    {
      Mutex[] IFork = new Mutex[2];
      IFork[0] = gFork[threadID];
      IFork[1] = gFork[(threadID + 1) % 5];

      WaitHandle.WaitAll(IFork);

    public void DropForks(int threadID)
    {
      gFork[threadID].ReleaseMutex();
      gFork[(threadID + 1) % 5].ReleaseMutex();

    }
  }

  class Philosopher
  {
    Random rand = new Random(DateTime.Now.Millisecond);

    private int ThreadID;
    private Table aTable;

    public Philosopher(int threadId, Table table)
    {
      this.ThreadID = threadId;
      this.aTable = table;
    }


    private void Think()
    {
      Console.WriteLine(Thread.CurrentThread.Name + " Thinking.");
      Thread.Sleep(rand.Next(1, 200));
    }

    private void Eat()
    {
      Console.WriteLine(Thread.CurrentThread.Name + " Eating.");
      Thread.Sleep(rand.Next(1, 200));
    }

    public void Philosophize()
    {
      while (aTable.Continue)
      {
        aTable.GetForks(ThreadID);
        //Eat
        this.Eat();
        aTable.DropForks(ThreadID);
        //Think
        this.Think();
      }
    }
  }

    ~Philosopher()
    {
    }
  
  }

class AppMain
{
static void Main(string[] args)
{
Table table = new Table();
      Philosopher[] IPhil = new Philosopher[5];
      Thread[] IThread = new Thread[5];

      for (int loopctr = 0; loopctr < 5; loopctr++)
      {
        IPhil[loopctr] = new Philosopher(loopctr, table);
        IThread[loopctr] = new Thread(new ThreadStart(IPhil[loopctr].Philosophize) );
        IThread[loopctr].Name = "Philosopher " + loopctr;
        IThread[loopctr].Start();
      }

      Thread.Sleep(5000);
      table.Stop();

      Thread.Sleep(1000);
      Console.WriteLine("Primary Thread ended.");
      Console.WriteLine("Press any key to return.");
      Console.Read();
}
}
}
예제를 컴파일하고 실행하면 결과는 다음과 같을 것이다.

소스 코드를 살펴보도록 하자
  class Table
  {

    static Mutex gM1 = new Mutex(false);
    static Mutex gM2 = new Mutex(false);
    static Mutex gM3 = new Mutex(false);
    static Mutex gM4 = new Mutex(false);
    static Mutex gM5 = new Mutex(false);

    static Mutex[] gFork = new Mutex[5];

    static bool bContinue;

    public bool Continue
    {
      get
      {
        return bContinue;
      }

      set
      {
        bContinue = value;
      }

    }
Table 클래스를 선언하고, 5개의 뮤텍스를 생성한다. 생성하는 쓰레드에서 뮤텍스의 소유권을 갖도록 할 것이 아니므로 false를 사용하여 생성한다. gFork는 5개의 뮤텍스에 대한 컨테이너로 사용되고, 식사하는 철학자 문제에서 포크를 뜻한다. bContinue는 식사하는 철학자 프로그램을 계속 실행할지를 결정하기 위해 사용한다.
    public void Stop()
    {
      bContinue = false;
    }

    public Table()
    {
      bContinue = true;

      gFork[0] = gM1;
      gFork[1] = gM2;
      gFork[2] = gM3;
      gFork[3] = gM4;
      gFork[4] = gM5;
    }
Stop은 bContinue를 false로 설정하고, 식사하는 철학자 프로그램을 끝내기 위해 호출한다. Table 생성자에서는 bContinue = true로 설정하고, 각각의 포크를 컨테이너 gFork에 넣어둔다.
    public void GetForks(int threadID)
    {
      Mutex[] IFork = new Mutex[2];
      IFork[0] = gFork[threadID];
      IFork[1] = gFork[(threadID + 1) % 5];

      WaitHandle.WaitAll(IFork);
    }

    public void DropForks(int threadID)
    {
      gFork[threadID].ReleaseMutex();
      gFork[(threadID + 1) % 5].ReleaseMutex();

    }
GetForks와 DropForks는 철학자가 테이블에서 포크를 갖는 것(뮤텍스에 대한 핸들을 얻는 것)과 포크를 테이블에 내려 놓는 것(뮤텍스에 소유권을 해제하는 것)을 나타낸 것이다. IFork[0]에는 철학자의 왼쪽에 있는 포크를, IFork[1]에는 철학자의 오른쪽에 있는 포크를 들도록 한 것이다. 마찬가지로 식사가 끝나면 철학자의 왼쪽에 있는 포크를 내려놓고, 그 다음에 오른쪽에 있는 포크를 내려놓도록 한 것이다.
class Philosopher
  {
    Random rand = new Random(DateTime.Now.Millisecond);

    private int ThreadID;
    private Table aTable;

    public Philosopher(int threadId, Table table)
    {
      this.ThreadID = threadId;
      this.aTable = table;
    }
이것은 철학자 클래스이며, 식사하는 시간과 생각하는 시간을 임의의 숫자로 할당하기 위해 Random 클래스를 사용한다. 항상 다른 숫자를 얻기 위해 시스템 시간을 토대로하여 난수를 생성하도록 한다. 철학자 클래스 생성자는 쓰레드 ID를 갖고 있으며, 사용할 테이블을 지정하도록 하고 있다.
    private void Think()
    {
      Console.WriteLine(Thread.CurrentThread.Name + " Thinking.");
      Thread.Sleep(rand.Next(1, 200));
    }

    private void Eat()
    {
      Console.WriteLine(Thread.CurrentThread.Name + " Eating.");
      Thread.Sleep(rand.Next(1, 200));
    }
각각의 철학자는 생각하는 것과 식사하는 것을 하므로, 이를 묘사하는 두 개의 함수를 정의한다. 각각의 함수는 임의의 시간 동안 생각하고 식사할 수 있도록 하기 위해 1에서 200사이의 숫자만큼 대기하도록 한다.
    public void Philosophize()
    {
      while (aTable.Continue)
      {
        aTable.GetForks(ThreadID);
        //Eat
        this.Eat();
        aTable.DropForks(ThreadID);
        //Think
        this.Think();
      }
    }
실제로 철학자의 역할을 반복하는 함수다. Table.Continue가 true인 동안은 무한히 반복해서 실행한다. 포크를 들고 식사를 하고, 포크를 내려놓고 다시 식사하는 과정을 반복한다.
class AppMain
{
static void Main(string[] args)
{
Table table = new Table();
      Philosopher[] IPhil = new Philosopher[5];
      Thread[] IThread = new Thread[5];

      for (int loopctr = 0; loopctr < 5; loopctr++)
      {
        IPhil[loopctr] = new Philosopher(loopctr, table);
        IThread[loopctr] = new Thread(new ThreadStart(IPhil[loopctr].Philosophize) );
        IThread[loopctr].Name = "Philosopher " + loopctr;
        IThread[loopctr].Start();
      }

      Thread.Sleep(5000);
      table.Stop();

      Thread.Sleep(1000);
      Console.WriteLine("Primary Thread ended.");
      Console.WriteLine("Press any key to return.");
      Console.Read();
}
}
Main 클래스로 다섯 명의 철학자가 사용할 table을 만들고, 5명의 철학자를 IPhil [] 컨테이너에 할당한다.

루프를 돌면서 각각의 철학자에게 Pholosopher.Philosophize()를 쓰레드로 실행하도록 위임하고, 철학자에게 이름을 부여하고, 쓰레드를 시작한다. Thread.Sleep(5000) 부분은 이 식사하는 철학자 문제를 5초 동안 실행하라는 의미를 갖는다. 5초 동안 실행한 다음에는 식사하는 철학자 문제를 끝내기 위해 table.Stop()을 호출하여 table.Continue를 false로 설정한다. 쓰레드는 Philosophize를 실행중이기 때문에 바로 모든 쓰레드가 종료되지 않는다. 따라서 Thread.Sleep(1000)을 두어서 종료 메시지를 처리하기 전에 잠시 동안 기다린다. Console.Read()를 사용하여 사용자가 아무키나 입력할 때 까지 대기한다.

아마도, 위 식사하는 철학자 문제에서 철학자들이 제대로 두 개의 포크를 들고서 식사하는지 궁금할지도 모르겠다. 궁금한 독자들은 GetForks를 다음과 같이 수정한다.
    public void GetForks(int threadID)
    {
      Mutex[] IFork = new Mutex[2];
      IFork[0] = gFork[threadID];
      IFork[1] = gFork[(threadID + 1) % 5];

      WaitHandle.WaitAll(IFork);

      Console.WriteLine("Handle 1 - " + IFork[0].Handle + "  Handle 2 - " + IFork[1].Handle );
    }
각각의 핸들을 화면에 출력하도록 해서 어떤 핸들을 갖고 있는지 확인하면 된다.

사실대로 말하자면 위 예제에서는 반드시 철학자의 오른쪽과 왼쪽에 있는 포크를 들지는 않는다. 정확히는 테이블 가운데에 5개의 포크가 있고, 식사가 끝나면 다시 테이블 가운데에 2개의 포크를 돌려놓는다.

포크를 기다리는 방법

위 철학자 문제에서 포크를 기다리는 방법은 실제로 여러가지가 있다. 위 방법은 그 중에 한 가지에 불과하다.

메소드 위임을 통해서 두 개의 포크를 받을 수 있는 방법도 있다. 이 방법은 IAsyncResult.AsyncWaitHandle.WaitOne을 사용하는 방법도 있을 수 있으며, 위의 GetForks 구현대신에 WaitHandle.WaitOne 메소드를 오버라이드하여 두 개의 포크를 기다리도록 할 수도 있다.(메소드 이름이 뜻하는 것과는 달리 WaitTwo의 의미가 되겠지만 말이다)아니면 WaitHandle을 상속하여 다른 Wait 함수와 함께 WaitTwo를 구현하는 방법도 있을 수 있다. 그러나 위와 같이 컨테이너를 사용하여 WaitAll을 호출하는 것이 가장 괜찮은 방법인 것 같다.(더 좋은 방법들도 있겠지만, 필자는 그만큼 알지는 못한다) IAsyncResult의 경우에는 멀티 쓰레드와 비동기 웹 서비스를 구현하는데 자주 사용하므로 자세히 알아두면 좋을 것이다.

디버깅에 대하여

멀티 쓰레드 응용 프로그램을 디버깅하자는 것은 아니다. 실제로 어떤 것들이 실행되는지 자세히 조사하고 싶다면 디버거를 이용해서 확인할 수 있다. 닷넷 프레임워크는 두 개의 훌륭한 디버거를 제공한다. 관심있는 독자는 찾아보기 바란다. 각각의 디버거의 자세한 사용법은 MSDN이나 밑에 있는 참고 자료를 참고하기 바란다.

디버거를 사용하려면 코드를 컴파일 할 때 다음과 같이 하도록 한다.
csc /debug:full dining.cs
이와 같이 하면 디버깅 정보가 함께 생성된다.

콘솔에서 이용할 수 있는 디버거는 여러분이 시스템에 닷넷 프레임워크를 설치한 방법에 따라 다르다. 닷넷 프레임워크만 받아서 설치한 경우에는 C:\Program Files\Microsoft.NET\FrameworkSDK\bin에 있으며, VS.NET에 있는 윈도우 구성요소 업데이트(Windows Component Update)에서 설치한 경우에는 C:\Program Files\Microsoft Visual Studio .NET\FrameworkSDK\bin에 있다. 실행 파일명은 cordbg.exe이다.
b 56
go
p vars
p
reg
이와 같이 56번 라인에 중단점(break point)을 설정하고, 실행(go)하고, 변수 vars의 값을 출력하거나(p vars), 현재 프로세스의 값을 볼 수 있다(p). 현재 쓰레드에 대한 스택 상태를 보고 싶다면 reg를 사용하면 볼 수 있다. 주의할 것은 이러한 디버거를 이용하려면 반드시 /debug:full을 사용하여 디버깅해야한다.

화면에서 볼 수 있는 것처럼 p와 reg를 사용해서 자세한 명령을 볼 수 있으며, p위에는 현재 실행한 곳의 코드를 볼 수도 있다. (cordbg) 콘솔에서 ?를 입력하면 보다 자세한 명령을 볼 수 있다.

닷넷 프레임워크에 들어있는 두번째 디버거는 DbgCLR.exe다. 이것 역시 닷넷 프레임워크를 설치한 방법에 따라 위치가 다르다.

C:\Program Files\Microsoft.NET\FrameworkSDK\GuiDebug 또는 C:\Program Files\Microsoft Visual Studio .NET\FrameworkSDK\GuiDebug에 있다.

이것은 완전한 GUI 환경을 제공하는 디버거다. 디버그 | 디버깅할 프로그램을 선택해서 디버깅할 프로그램을 선택할 수 있으며, 이미 실행중인 프로그램을 디버깅할 수 있는 Process Attach Debugging은 도구 | 프로세스 디버그에서 사용할 수 있다. 주로 프로세스 디버깅은 실행중인 웹 서비스나 ASP.NET에 대한 프로세스 aspnet_wp.exe를 디버깅할 때 유용하게 사용할 수 있다.

화면에서 볼 수 있는 거처럼 콘솔 응용 프로그램의 경우에 콘솔 창이 따로 나타나지만 모든 출력은 출력 창에 나타나는 것을 볼 수 있으며, 중단된 시점의 각 변수들의 값을 일목 요연하게 볼 수 있다는 것을 알 수 있다. 주의할 점이 있는데, 멀티 쓰레드 응용 프로그램의 경우에 중단점에서 디버거가 멈춰 있어도 계속해서 실행되기 때문에 잠시 머뭇거린 사이에 응용 프로그램이 종료된다. 따라서 Main에서 Thread.Sleep을 충분히 늘려놓고 사용법을 탐색해 보거나, 아니면 다른 응용 프로그램으로 먼저 연습해 보기 바란다. 지역 창에서 gM1 - gM5까지의 트리와 IFork의 트리를 충분히 펼쳐보면 각각의 핸들(Handle) 값을 볼 수 있으며, 현재 IFork에서 어떤 gM?에 해당하는 핸들을 갖고 있는지 알 수 있다. 또한 코드 창 위에 보면 실행중인 프로그램, 쓰레드, 스택 프레임이 무엇인지 알 수 있다. CLR 디버거는 CLR 환경에 대해서만 디버깅할 수 있는 단점은 있지만 닷넷 프레임워크만으로도 충분히 훌륭하게 디버깅할 수 있다는 것을 알 수 있다.

VS.NET이 있다면 VS.NET에 있는 디버거를 사용하는 것도 좋다. VS.NET의 디버거는 비CLR 환경뿐만 아니라 멀티 쓰레드 응용 프로그램에 대한 실시간 스택 트레이스를 기록해 준다. 끝으로 관심 있다면 ildasm.exe를 이용해서 파일 | 덤프에서 전체 실행 코드를 IL 코드로 덤프할 수 있으니 이 둘을 비교해 보는 것도 재미있을 것이다.

마치며

조금은 정신이 없었을 지도 모르겠다. 이번시간에는 비교적 긴 두 개의 예제를 살펴보았다. 뮤텍스, 이벤트를 살펴보았으며, Wait 함수들이 갖는 의미에 대해서 살펴보았다. 실제로 위에 작성된 예제는 매우 깨지기 쉽다. try … catch를 이용한 예외 처리를 전혀하지 않았다. 실제 응용 프로그램을 작성한다면 보다 세심한 주의가 필요할 것이다. 다음 시간에는 지금까지 미뤄왔던 가장 기본적인 동기화 클래스인 Interlocked에 대해서 알아보고, Monitor와 Mutex의 주요 멤버들을 정리해보도록 하자. (앞으로 갈 길이 멀다. ^^;) 궁금한 사항이나 의견이 있다면 한빛미디어 관리자(webmaster@hanbitbook.co.kr) 앞이나 프로그래밍 Q&A 게시판에 올려주기 바란다. 늘 하는 얘기지만 필자가 별로 아는 것도 없고, 틀린 곳도 많을 것이다.
TAG :
댓글 입력
자료실

최근 본 상품0