본문으로 바로가기

ThreadPool and Socket Programming

category Development/C# 2010.02.05 12:22
비동기 프로그래밍은 내부적으로 쓰레드를 이용한다. 그런데 비동기 호출을 할 때마다 새로운 쓰레드를 생성해서 작업을 하게 되면, 많은 비동기 호출이 일어날 때에는 쓰레드의 수가 너무 많아져서 오히려 컨텍스트 스위칭(context switching)하는 데 시간이 더 걸리는 현상이 일어난다. 이러한 현상을 해결하기 위해서는 적절한 쓰레드 수를 유지하는 것이 해결 방법이라 할 수 있을 것이다. 닷넷에서는 그러한 관리 방법으로 쓰레드 풀이라는 것을 이용한다. 이를 이용해 시스템의 CPU 사용량에 따라 항상 적절한 쓰레드 수를 유지시켜 준다.

쓰레드 풀이란
먼저 풀(pool)의 사전적인 의미는 스위밍 풀(swimming pool)처럼 물 웅덩이, 저수지라는 뜻이 있다. 다른 뜻으로는 카 풀(car pool)처럼 공동으로 이용하는 것이라는 뜻이 있다. 여기서는 두 번째의 공동으로 이용한다는 의미이다. 카 풀이라는 것이 에너지 절약을 위해서 이웃끼리 통근 시간 같은 때에 차를 같이 이용하는 것을 말한다. 쓰레드 풀도 이와 비슷한 것으로 쓰레드들이 시스템의 효율성을 높이기 위하여 집합적으로 모여 있는 것을 쓰레드 풀이라고 부른다.

쓰레드 풀은 쓰레드 생성 요청이 있을 때마다 그 쓰레드를 바로 생성하는 것이라 일단 큐에 그 쓰레드를 넣어 두었다가 쓰레드 풀이 그 요청을 처리할 수 있는 여유가 있을 때 큐에서 하나씩 꺼내서 처리를 한다. 닷넷 환경에서는 기본적으로 쓰레드 풀 안에서의 최대 25개의 쓰레드를 넣어 둘 수 있다. 이를 그림으로 나타내면 <그림 1>과 같다.

<그림 1> 쓰레드 풀

쓰레드 풀의 사용 방법
닷넷에서 쓰레드 풀을 이용하기 위해서는 쓰레드 풀 클래스를 이용하면 된다. <리스트 1>은 쓰레드 풀 클래스의 메쏘드들이다.

 <리스트 1> 쓰레드 풀 클래스

{
     // Constructors
     // Methods
     public static bool BindHandle(IntPtr osHandle);
     public virtual bool Equals(object obj);
     public static void GetAvailableThreads(ref Int32 workerThreads,
          ref Int32 completionPortThreads);
     public virtual int GetHashCode();
     public static void GetMaxThreads(ref Int32 workerThreads,
          ref Int32 completionPortThreads);
     public Type GetType();
     public static bool QueueUserWorkItem(
          System.Threading.WaitCallback callBack);
     public static bool QueueUserWorkItem(
          System.Threading.WaitCallback callBack, object state);
     public static System.Threading.RegisteredWaitHandle
          RegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, int millisecondsTimeOutInterval,
          bool executeOnlyOnce);
     public static System.Threading.RegisteredWaitHandle
          RegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, UInt32 millisecondsTimeOutInterval,
          bool executeOnlyOnce);
     public static System.Threading.RegisteredWaitHandle
          RegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, long millisecondsTimeOutInterval,
          bool executeOnlyOnce);
          public static System.Threading.RegisteredWaitHandle
          RegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, TimeSpan timeout, bool executeOnlyOnce);
     public virtual string ToString();
     public static bool UnsafeQueueUserWorkItem(
          System.Threading.WaitCallback callBack, object state);
     public static System.Threading.RegisteredWaitHandle
          UnsafeRegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, int millisecondsTimeOutInterval,
          bool executeOnlyOnce);
     public static System.Threading.RegisteredWaitHandle
          UnsafeRegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, UInt32 millisecondsTimeOutInterval,
          bool executeOnlyOnce);
     public static System.Threading.RegisteredWaitHandle
          UnsafeRegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, long millisecondsTimeOutInterval,
          bool executeOnlyOnce);
     public static System.Threading.RegisteredWaitHandle
          UnsafeRegisterWaitForSingleObject(
          System.Threading.WaitHandle waitObject,
          System.Threading.WaitOrTimerCallback callBack,
          object state, TimeSpan timeout, bool executeOnlyOnce);
     } // end of System.Threading.ThreadPool

<리스트 1>을 보면 거의 모든 멤버가 static이고 public constructor가 없음을 볼 수 있을 것이다. 이는 닷넷에서는 하나의 프로세스당 한 개의 풀만을 허용하기 때문이다. 즉 모든 비동기 호출은 같은 하나의 풀을 통해 이뤄진다. 따라서 제 3자의 컴포넌트가 새로운 풀을 만들어서 기존의 풀과 함께 돌아감으로써 생기는 오버헤드를 줄일 수 있는 것이다. 쓰레드 풀의 큐에 새로운 쓰레드를 추가시키려면 다음과 같은 메쏘드를 이용한다.

public static bool QueueUserWotkItem ( WaitCallBack callBack ,object state);

우선 WaitCallBack이라는 대리자를 이용하여 처리할 함수를 등록하고, state를 이용하여 함께 넘길 파라미터를 지정해 준다.

public delegate void WaitCallBack( object state );

WaitCallBack 대리자의 형식이 반환 값은 없고, 인자로는 state 하나만을 받는 형식이다. 따라서 쓰레드를 사용할 함수는 이와 같은 signature를 가져야 한다. 이를 이용하여 0부터 3까지 출력하는 3개의 작업을 만들어 보자. 이를 실행하면 다음과 같이 보통 일반 쓰레드를 이용하는 것과 비슷한 결과 화면을 볼 수 있다. 3개의 작업이 동시에 이뤄지고 있다.

1번 작업 : 0
2번 작업 : 0
1번 작업 : 1
3번 작업 : 0
2번 작업 : 1
1번 작업 : 2
3번 작업 : 1
2번 작업 : 2
1번 작업 : 3
3번 작업 : 2
2번 작업 : 3
1번 작업 끝
3번 작업 : 3
2번 작업 끝
3번 작업 끝

이번에는 Thread.IsThreadPoolThread이라는 속성을 이용하여 정말 쓰레드 풀을 이용하고 있는지와, 현재 쓰레드의 고유 번호를 나타내주는 메쏘드인 GetHashCode를 이용하여 그 값을 확인해 보자.

 <리스트 2> 0부터 3까지 출력하는 4개의 작업

class Class1
{
     [STAThread]
     static void Main(string[] args)
     {
          WaitCallback callBack;

          callBack = new WaitCallback(Calc);
          ThreadPool.QueueUserWorkItem(callBack,1);
          ThreadPool.QueueUserWorkItem(callBack,2);
          ThreadPool.QueueUserWorkItem(callBack,3);
    
          Console.ReadLine();
     }
     static void Calc(object state)
     {
          for(int i= 0; i < 4; i++)
     {
          Console.WriteLine(“{0}번 작업: {1}”,state,i);
          Thread.Sleep(1000);
     }
          Console.WriteLine(“{0}번 작업 끝”,state);
     }
}

<리스트 2>에 <리스트 3>과 같은 코드를 추가한다. 결과는 다음과 같다.

Main thread. Is Pool thread:False, Hash : 2
1번 작업 thread. Is Pool thread:True, Hash : 7
1번 작업 : 0
2번 작업 thread. Is Pool thread:True, Hash : 8
2번 작업 : 0
1번 작업 : 1
3번 작업 thread. Is Pool thread:True, Hash : 9
3번 작업 : 0
2번 작업 : 1
1번 작업 : 2
3번 작업 : 1
2번 작업 : 2
1번 작업 : 3
3번 작업 : 2
2번 작업 : 3
1번 작업 끝
3번 작업 : 3
2번 작업 끝
3번 작업 끝

즉 메인 쓰레드는 쓰레드 풀에서 하는 작업이 아니며, 나머지는 쓰레드 풀 내에서 작업하고 있음을 볼 수 있을 것이다. 그리고 각자 다른 해시코드를 가지고 있으므로 각자 새로운 쓰레드를 생성해서 작업하고 있는 것이다. 이는 현재 CPU 사용량에 여유가 있었기 때문에 각자 하나씩의 쓰레드를 생성해서 작업을 한 것이다.

 <리스트 3> 쓰레드 풀 확인 방법

// 메인 부분에 추가
Console.WriteLine(“Main thread. Is Pool thread:{0}, Hash: {1}”,
Thread.CurrentThread.IsThreadPoolThread,
Thread.CurrentThread.GetHashCode());

// 쓰레드 작업 부분에 추가
Console.WriteLine(“{0}번 작업 thread. Is Pool thread:{1}, Hash: {2}”,
state,
Thread.CurrentThread.IsThreadPoolThread,
Thread.CurrentThread.GetHashCode());

만약 CPU 사용량이 많아져서 컨텍스트 스위칭 시간이 더 걸릴거라 판단되면, 다른 쓰레드들은 큐에서 대기하다가 기존 작업이 끝나고 그 쓰레드를 재사용해서 작업을 하게 된다. 이에 대한 예를 보자. CPU 사용량을 높이려면 다음과 같은 함수를 추가한다.

int ticks = Environment.TickCount;
while( Environment.TickCount - ticks < 500 );

Environment.TickCount 속성은 마지막 리부팅한 후부터의 시간을 millisecond 단위로 리턴해 준다. Thread.Sleep(1000)이라는 부분 대신 이 함수를 넣고 실행해 보면 <화면 1>과 비슷한 결과를 볼 수 있다.

<화면 1> CPU 사용량을 높인 후의 쓰레드 푸 작동 화면

<화면 1>을 보면 CPU 사용량이 100%임을 확인할 수 있다. 그리고 결과를 보면 3번 작업이 1번 작업과 같은 해시코드를 사용하고 있다. 즉 같은 쓰레드를 재사용하고 있는 것이다. 그래서 1번 작업이 끝난 후에, 1번 작업이 쓰던 쓰레드를 3번 작업이 다시 사용하고 있는 것이다. 이처럼 쓰레드 풀이라는 것은 현재 시스템의 상황에 따라 적절히 쓰레드 개수를 유지시켜 줌으로써 효율성을 높이고 있다. 그럼 이제 정말 비동기 호출이 쓰레드 풀을 이용하는지 확인해 보자.

 <리스트 4> 비동기 호출 확인하기

class Class1
{
     public static void Calc()
     {

          Console.WriteLine(“Is pool:{0}”, Thread.CurrentThread.
               IsThreadPoolThread);
          for(int i=1; i < 10 ; i++)
     {
               for(int j=0; j < 10000000 ; j++) {} // 많은 계산을 요구하는 작업
               Console.WriteLine(“SubWork :{0}”,i);
     }
}

[STAThread]
static void Main(string[] args)
{
     SubWork d = new SubWork(Calc);
     d.BeginInvoke(null,null);
    
     for(int i=0; i < 10 ; i++)
     {
          for(int j=0; j < 10000000 ; j++) {} // 많은 계산을 요구하는 작업
          Console.WriteLine(“MainWork:{0}”,i);
     }
}

<리스트 4>는 지난 시간에 했던 예제이다. 그곳에 단지 쓰레드 풀임을 확인할 수 있는 문장을 하나 추가했을 뿐이다. 결과는 다음과 같다.

Is pool:True
SubWork : 1
MainWork : 0
SubWork : 2
MainWork : 1
SubWork : 3
MainWork : 2
SubWork : 4
MainWork : 3
MainWork : 4
SubWork : 5
SubWork : 6
MainWork : 5
SubWork : 7
MainWork : 6
SubWork : 8
MainWork : 7
SubWork : 9
MainWork : 8
MainWork : 9

이제 비동기 호출이 쓰레드 풀을 이용하는 것이 확실해졌다. 그러면 모든 비동기 호출은 이와 같이 델리게이트를 만들어서 해야 할까? 그것은 아니다. 우리가 매번 비동기 호출을 위해서 델리게이트를 만들어야 한다면 그것 또한 귀찮은 일일 것이다.

그래서 닷넷에서는 미리 비동기 호출을 위한 함수들을 마련해 두고 있다. Begin×××, End×××로 표기되는 메쏘드들이 비동기 호출을 위해 미리 만들어 둔 함수들이다. 델리게이트의 BeginInvoke도 이와 같은 연장선에 보면 될 것이다. 우리는 소켓을 이용한 비동기 통신 방법에 대해 알아볼 것이므로 소켓과 관련된 비동기 함수들을 살펴볼 것이다. 그전에 소켓의 기본 개념부터 설명하겠다.

소켓이란?
일반적인 의미로 소켓이란, 전구의 소켓처럼 꽂는 구멍을 말한다. 즉 다른 것과 연결시켜 주는 구멍이다. 컴퓨터에서의 소켓도 이와 비슷한 의미이다. 네트워크의 다른 대상과 정보를 교환하기 위한 구멍인 것이다. 일종의 네트워크 자료 교환을 위한 파이프라인(pipeline)으로 생각하면 된다.

일반적으로 네트워크에서 정보를 주고받기 위한 주소로 IP 어드레스라는 것을 사용한다. 그런데 이 주소는 대개 하나의 컴퓨터에 한 개의 주소가 할당된다. 그런데 네트워크 정보 교환은 하나의 컴퓨터뿐만 아니라 여러 컴퓨터와 정보를 주고받아야 하므로 하나의 IP 주소로는 이 정보를 어디로 보내야 하는지 구분할 수 없다.

그래서 포트(Port)라는 개념을 쓴다. 이는 항구라는 뜻으로 각 네트워크 정보들이 통신하는 입구인 것이다. 일반적으로 HTTP는 80 포트를 사용하고, FTP는 21 포트를 사용한다. 그래서 어떤 한 컴퓨터에 네트워크 데이터를 보내더라도 포트 번호가 다르므로, HTTP용 데이터와 FTP용 데이터가 각각 제 자리를 찾아가는 것이다. 이를 그림으로 나타내면 <그림 2>와 같다.

<그림 2> 포트의 개념

일반적으로 포트 번호는 0∼65535까지 쓸 수 있지만 0∼1024번까지는 80번이나 21번처럼 미리 정해진 포트 번호를 사용하므로 사용자가 임의의 포트 번호를 사용하려면 그 이상의 번호를 사용하면 된다.

<그림 2>를 보면 포트에 소켓이 연결되어 있음을 볼 수 있을 것이다. 특히 서버쪽을 보면 하나의 포트에 여러 개의 소켓이 달려있음을 볼 수 있을 것이다. 이는 다중의 클라이언트가 하나의 포트로 접속하기 때문이다. 각 클라이언트마다 이들의 데이터를 맡아서 중개해주는 파이프라인(소켓)이 따로 있어야 하기 때문에 하나의 포트에 여러 개의 소켓이 달려 있는 것이다.

그런데 여기서 한 가지 의문점이 있을 수 있다. 하나의 포트에 여러 개의 네트워크 데이터들이 몰려들어 올텐데 서버는 이를 어떻게 구분해서 각자의 전담 파이프라인(소켓)으로 보내주는 것일까? 이는 TCP/IP의 헤더를 보면 쉽게 해결이 된다.

<표 1> TCP/IP 헤더

<표 1>을 보면 IP 프로토콜의 헤더에는 보내는 곳과 받는 곳의 IP 주소가 들어 있다. 한편 TCP 헤더에는 보내는 곳과 받는 곳의 포트 번호가 들어 있다. 이들 4가지의 정보는 서로의 데이터를 확실히 구분하는 기준이 되므로, 서버측에서는 이 헤더를 보고 각자에 맞는 소켓으로 데이터를 보내주는 것이다.

다시 <그림 2>를 보면 서버측의 포트 번호는 지정되어 있는 반면에 클라이언트측의 포트 번호는 일관성 없이 중구난방으로 아무 번호나 할당되어 있음을 볼 수 있을 것이다. 그 이유는 클라이언트 입장에서는 데이터를 보내야 하는 서버측의 포트 번호는 알아야 하지만 자신의 포트 번호는 그냥 비어있는 아무 번호나 써도 상관없다. 굳이 자신의 포트 번호를 미리 정하지 않아도 되는 것이다. 그래서 클라이언트가 서버로 연결할 때, 자신의 남는 포트 번호 중 아무나 한 개를 할당해서 소켓과 연결시켜 주는 것이다.

소켓의 구현 과정
소켓은 <그림 3>과 같은 일련의 과정을 거쳐 작업이 진행된다. 먼저 서버측에서는 소켓을 생성하고 그 소켓을 특정 포트 번호와 연결(bind)시킨다. 그리고 상대방으로 연결이 오기를 허락하는 듣기(listen) 작업을 수행한다. 그러다가 클라이언트가 접속을 하게 되면 서버는 이를 받아들이고(accept) 새로운 소켓을 만들어서 그 새로운 소켓이 계속 통신을 담당하게 하고 자신은 다시 듣기(lisetn)상태로 들어간다.

<그림 3> 소켓의 구현 과정

그런데 이 때 한 가지 주의할 것이 있다. 하나의 포트에는 한 개의 소켓만 bind할 수 있다는 것이다. 여기서 조심해야 할 것이 bind라는 말이다. 하나의 포트에 여러 개의 소켓이 있을 수는 있지만 bind는 오직 한 개만 된다. 하나의 포트에 두 개의 소켓을 bind하려 하면 에러가 나면서 bind가 실패하게 된다.

그럼 왜 bind는 하나만 되는 것일까? 그 이유는 앞에서 보았듯이 데이터를 구분할 방법이 없기 때문이다. 데이터를 구분할 때 TCP/IP 헤더를 보고 구분한다고 했다. 그런데 하나의 포트에 두 개 이상의 소켓이 bind되면 이들 데이터를 구분할 방법이 없는 것이다.

예를 들어 <그림 2>에서 80번 포트에 HTTP와 FTP용 소켓 두 개를 bind시켰다고 해보자. 그러면 서버는 포트로 들어오는 패킷의 TCP/IP 헤더 정보를 보고 데이터를 구분하는데 그 헤더에는 IP와 포트 번호밖에 없다. 그래서 이 패킷이 HTTP용인지 FTP용인지 구분할 방법이 없는 것이다. 그래서 하나의 포트번호에는 하나의 소켓만 bind할 수 있다. 그러면 이제 실제로 간단한 소켓 통신 프로그램을 만들어 보자.

 <리스트 5> 서버소켓 예제

[STAThread]
static void Main(string[] args)
{
     Socket listeningSocket = new Socket(AddressFamily.InterNetwork,
          SocketType.Stream, ProtocolType.Tcp);

     IPAddress hostadd = Dns.Resolve(“localhost”).AddressList[0];
     IPEndPoint EPhost = new IPEndPoint(hostadd, 7000);

     listeningSocket.Bind(EPhost);

     listeningSocket.Listen( 10 );
     Console.WriteLine(listeningSocket.LocalEndPoint +
          “에서 접속을 listening하고 있습니다.”);
     Socket newSocket;

     while(true)
     {
          newSocket = listeningSocket.Accept(); // blocking
         
          Console.WriteLine(newSocket.RemoteEndPoint.ToString() +
          “에서 접속하였습니다.”);
     byte[] msg = ENCODING.Default.GetBytes(“접속해 주셔서 감사합니다.”);
     int i = newSocket.Send(msg);
     }
}

간단한 소켓 예제
<리스트 5>는 서버 소켓 예제이다. 먼저 TCP 방식의 소켓을 생성하고 7000번 포트에 bind한 후 listen하고 있다. 그러다가 클라이언트가 접속을 하게 되면, 클라이언트의 주소를 표시해 주고 메시지를 전송해 주고 있다.

 <리스트 6> 클라이언트

[STAThread]
static void Main(string[] args)
{
Socket s = new Socket(AddressFamily.InterNetwork,
SocketType.Stream, ProtocolType.Tcp);
IPAddress hostadd = Dns.Resolve(“localhost”).AddressList[0];
IPEndPoint EPhost = new IPEndPoint(hostadd, 7000);
s.Connect(EPhost); // blocking
if ( s.Connected == true)
{
byte[] bytes = new byte[1024];
s.Receive(bytes); // blocking
Console.WriteLine(ENCODING.Default.GetString(bytes));
s.Shutdown(SocketShutdown.Both);
s.Close();
}
}

<리스트 6>은 클라이언트의 코드이다. 클라이언트는 특정 포트와 bind할 필요가 없으므로 connect할 때 자동으로 임의의 포트가 할당된다. 서버로의 접속이 성공하면 메시지를 받아서 화면에 표시해 준다. 그럼 이제 이들의 결과 화면을 보자.

◆ 서버 화면
127.0.0.1:7000에서 접속을 listening하고 있습니다.
127.0.0.1:3912에서 접속하였습니다.

◆ 클라이언트 화면
접속해 주셔서 감사합니다.

서버 화면을 보면 클라이언트 측에서는 임의의 포트 번호에 소켓을 할당해서 접속하고 있다는 것을 확인할 수 있을 것이다. 그러면 이제 정말 하나의 포트에 여러 개의 소켓이 존재하는지 보자. 먼저 클라이언트를 두 개 실행시켜 서버에 접속하도록 하자. 다음은 서버 화면이다.

127.0.0.1:7000에서 접속을 listening하고 있습니다.
127.0.0.1:3916에서 접속하였습니다.
127.0.0.1:3917에서 접속하였습니다.

두 개의 클라이언트를 실행시켜서 7000번 포트에 두 개의 클라이언트가 접속을 했다. 이제 netstat -a라는 명령어를 ‘명령프롬프트’창에서 입력해 네트워크 상태를 확인해 보자.

C:\>netstat -a

Active Connections

Proto Local Address Foreign Address State
TCP 한용희:7000 한용희:0 LISTENING
TCP 한용희:7000 한용희:3916 CLOSE_WAIT
TCP 한용희:7000 한용희:3917 CLOSE_WAIT

앞의 화면에서 다른 부분은 생략하고, 우리가 보기를 원하는 화면만 표시를 했다. 현재 로컬 컴퓨터의 7000번 포트의 상태를 보면 listening하는 상태가 있고, 이미 연결된 두 개의 정보가 나온다. 모두 같은 7000번 포트에 연결된 것들이다. 이로써 하나의 포트에 여러 개의 소켓이 있을 수 있다는 것을 확인할 수 있을 것이다.

이제 소켓에 대한 궁금증을 풀었다. 그런데 앞의 예제를 응용해서 게임 서버로 만들기에는 무리가 있다. 왜냐하면 accept할 때나 receive할 때 블러킹이 걸려서 다른 일을 하지 못하기 때문이다. 그러므로 우리가 지금껏 익혀온 비동기 호출을 이용해서 이 문제를 해결해 보자.

비동기 소켓 통신을 이용해 블러킹 해결
앞서 닷넷에서는 델리게이트를 따로 이용하지 않고서도 미리 준비된 Begin×××와 End×××를 이용해서 비동기 프로그래밍을 할 수 있다고 했다. 이를 이용해 앞서 만든 예제에 적용해 보자(<리스트 7>).

 <리스트 7> 비동기 통신으로 작성한 서버

class Class1
{
     static void AcceptCallBack(IAsyncResult ar)
     {
          Socket listener = (Socket)ar.AsyncState;
          Socket newSocket = listener.EndAccept( ar );
         
          Console.WriteLine(newSocket.RemoteEndPoint.ToString() +
               “에서 접속하였습니다.”);
          byte[] msg = ENCODING.Default.GetBytes(“접속해 주셔서 감사합니다.”);
          int i = newSocket.Send(msg);

     }
[STAThread]
static void Main(string[] args)
{
     Socket listeningSocket = new Socket(AddressFamily.InterNetwork,
          SocketType.Stream, ProtocolType.Tcp);

     IPAddress hostadd = Dns.Resolve(“localhost”).AddressList[0];
     IPEndPoint EPhost = new IPEndPoint(hostadd, 7000);
     listeningSocket.Bind(EPhost);

     listeningSocket.Listen( 10 );
     Console.WriteLine(listeningSocket.LocalEndPoint +
          “에서 접속을 listening하고 있습니다.”);

     while(true)
     {
          IAsyncResult ar = listeningSocket.BeginAccept (
               new AsyncCallback(AcceptCallBack), listeningSocket);
          // non-blocking
          ar.AsyncWaitHandle.WaitOne();
          }
     }
}

먼저 예제에서 블러킹이 되었던 accept 부분을 비동기 함수인 BeginAccept로 바꾸었을 뿐 결과는 동일하다. 만약 이 프로그램을 윈도우폼으로 만들었다면 accept할 때 윈도우가 움직이는 것을 보면 확실히 블러킹되지 않았다는 것을 확인할 수 있을 것이다. 그러나 여기서는 간결한 예제를 위해서 콘솔 프로그램으로 만들었다.

<리스트 8>은 클라이언트를 비동기 방식으로 수정한 것이다. 이번에는 connect와 receive 두 개를 비동기 방식으로 만들었다. 결과는 먼저 예제와 동일하다. 이 예제들은 간단하기 때문에, 별 어려움이 없을 것이라 생각한다. 그러면 이 비동기 통신이 쓰레드 풀을 이용하는지 직접 확인해 보고 쓰레드 풀에 남아 있는 쓰레드의 갯수에 대해 알아보자.

 <리스트 8>비동기 통신을 이용한 클라이언트

class Class1
{
     static byte[] bytes = new byte[1024];
     static void ConnectCallBack(IAsyncResult ar)
     {
          Socket s = (Socket)ar.AsyncState;

     if ( s.Connected == true)
     {
          s.BeginReceive(bytes, 0, bytes.Length, SocketFlags.None,
               new AsyncCallback( ReceiveCallBack) , s);
          // non-blocking
     }
}
static void ReceiveCallBack(IAsyncResult ar)
{
     Socket s = (Socket)ar.AsyncState;

     int nLength = s.EndReceive(ar);
     if ( nLength > 0 ) // 0보다 작다면 접속이 끊어진 것이다.
     {
          Console.WriteLine(ENCODING.Default.GetString( bytes ) );
     }
}

[STAThread]
static void Main(string[] args)
{
     Socket s = new Socket(AddressFamily.InterNetwork,
          SocketType.Stream, ProtocolType.Tcp);
    
     IPAddress hostadd = Dns.Resolve(“localhost”).AddressList[0];
     IPEndPoint EPhost = new IPEndPoint(hostadd, 7000);

     s.BeginConnect(EPhost, new AsyncCallback(ConnectCallBack) , s);
     // non-blocking

     Console.ReadLine();
     s.Shutdown(SocketShutdown.Both);
     s.Close();
     }
}

I/O completion ports
<리스트 8>에 다음과 같은 코드를 추가해서 <리스트 9>와 같이 현재 쓰레드의 상태에 대해 알아보자. ShowThreadInfo()라는 함수를 만들었다. 이는 현재 쓰레드의 해시코드, 쓰레드 풀인지 여부, 그리고 남아있는 쓰레드 풀의 여분 갯수를 표시한다. 앞서 쓰레드 풀은 시스템에 따라 적절한 쓰레드 갯수를 유지시켜 준다고 했다.

 <리스트 9> 쓰레드의 상태를 알아보기 위한 코드

static void ShowThreadsInfo()
{
     int workerThreads, completionPortThreads;
    
     Console.WriteLine(“Thread HashCode: {0}”,
          Thread.CurrentThread.GetHashCode());
     Console.WriteLine(“Is Thread Pool? : {0}”,
          Thread.CurrentThread.IsThreadPoolThread);

     ThreadPool.GetAvailableThreads(out workerThreads,
          out completionPortThreads);
     Console.WriteLine(“Available Threads”);
     Console.WriteLine(“WorkerThreads: {0}, CompletionPortThreads: {1}”,
          workerThreads, completionPortThreads);
     Console.WriteLine();
}

static void ConnectCallBack(IAsyncResult ar)
{
     Socket s = (Socket)ar.AsyncState;
     ShowThreadsInfo();
     if ( s.Connected == true)
     {
     s.BeginReceive(bytes, 0, bytes.Length, SocketFlags.None,
          new AsyncCallback( ReceiveCallBack) , s);
          // non-blocking
     }
     Thread.Sleep(2000);
}

static void ReceiveCallBack(IAsyncResult ar)
{
     Socket s = (Socket)ar.AsyncState;

     ShowThreadsInfo();

     int nLength = s.EndReceive(ar);
     if ( nLength > 0 ) // 0보다 적다면 접속이 끊어진 것이다.
     {
          Console.WriteLine(ENCODING.Default.GetString( bytes ) );
     }
}

기본적으로 25개가 최대인데 쓰레드 풀에서 쓰레드를 하나씩 돌릴 때마다 이 최대 수치는 줄어들게 된다. 이를 표시해 주는 함수가 GetAvailableThreads라는 함수이다. 앞에서 Connect의 콜백 함수의 경우 비동기 호출 후 바로 끝나는 것을 막기 위해서 2초간 잠시 잠을 재웠다. 어떤 결과가 나올 것인가? 그냥 생각하기로는 connect에서 쓰레드 하나 쓰고 receive에서 쓰레드 하나 쓰니 남아있는 쓰레드 갯수는 23개가 돼야 할 것이다. 과연 그럴까?

Thread HashCode : 30
Is Thread Pool? : True
Available Threads
WorkerThreads : 24, CompletionPortThreads : 25

Thread HashCode : 33
Is Thread Pool? : True
Available Threads
WorkerThreads : 24, CompletionPortThreads : 24

쓰레드 풀인 것은 확인이 됐고 문제는 남아있는 쓰레드 개수이다. 비동기 호출인 receive를 했는데도 WorkerThread 개수가 변함이 없다. 대신 completionPortThread에서 숫자가 하나 줄었다. 왜 이런 현상이 일어나는 것일까?
그것은 또 다른 쓰레드 풀을 사용했기 때문이다.

앞에서 프로세스당 하나의 쓰레드 풀이 존재한다고 했는데 사실 하나가 더 존재한다. 그것은 바로 I/O 전용으로 또 하나의 쓰레드 풀, 즉 I/O completion 포트용 쓰레드 풀이다. 이는 I/O 작업 전용의 쓰레드 풀로서 I/O 작업을 완료했는지 안 했는지에 대한 체크를 담당하게 된다. 그럼 왜 I/O 전용 쓰레드 풀을 사용하는 것일까? 이것을 쓰는 것이 성능이 더 좋기 때문이다.

그러나 이 기능을 사용하려면 Winsock에서 이 기능을 지원해야만 한다. 그래서 앞 프로그램을 윈도우 95나 윈도우 98에서 실행하면 이들 운영체제의 Winsock에는 이 기능이 없기 때문에 닷넷에서는 자동으로 I/O completion 포트용 쓰레드 풀 대신에 workerThread를 이용해서 처리를 하게 해 준다.

그러나 윈도우 NT/2000/XP의 경우 Winsock2가 설치돼 있는데 이 Winsock2가 IOCP 기능을 지원하므로 별도의 IOCP용 쓰레드 풀을 가동해서 일을 처리하게 된다. 과거 비주얼 C++로 IOCP를 구현하려면 복잡하게 코딩을 해야 했으나 닷넷에서는 손쉽게 비동기 호출 중, 네트워크 I/O 관련 함수를 호출하면 자동으로 IOCP를 이용하게 되어 있어 보다 손쉽게 코딩을 할 수 있다.

Deadlocks
비동기 함수를 이용하는 데 있어 한 가지 주의 사항이 있다. <리스트 10>을 보자.

 <리스트 10> Deadlocks

class ConnectionSocket
{
     public void Connect()
     {
          IPHostEntry ipHostEntry = Dns.Resolve( “localhost”);
          IPEndPoint ipEndPoint = new IPEndPoint(
               ipHostEntry.AddressList[0], 7000 );

          Socket s= new Socket( ipEndPoint.AddressFamily,
               SocketType.Stream, ProtocolType.Tcp );
          IAsyncResult ar = s.BeginConnect(ipEndPoint, null, null);
          s.EndConnect(ar);
          Console.WriteLine(“비동기 호출 완료”);
     }
}

class Class1
{
[STAThread]
static void Main(string[] args)
{
     for(int i=0; i < 30 ; i++)
     {
          ThreadPool.QueueUserWorkItem(new WaitCallback(PoolFunc) );
     }
     Console.WriteLine(“ThreadPool큐에 30개 적재”);
     Console.ReadLine();
}
static void ShowThreadsInfo()
{
     int workerThreads, completionPortThreads;

     Console.WriteLine(“Thread HashCode: {0}”
          ,Thread.CurrentThread.GetHashCode());
     ThreadPool.GetAvailableThreads(out workerThreads,
     out completionPortThreads);
          Console.WriteLine(“WorkerThreads: {0},
          CompletionPortThreads: {1}”,
     workerThreads, completionPortThreads);
     Console.WriteLine();
}

static void PoolFunc(object state)
{
     ShowThreadsInfo();
    
     ConnectionSocket connection = new ConnectionSocket();
     connection.Connect();
     }
}

먼저 비동기 호출을 하는 connectionSocket이라는 클래스를 만들었다고 하자. 그런데 어떤 사람이 이 클래스를 쓰면서 이를 쓰레드 풀 내에서 호출하기로 했다. 그래서 그는 30개 쓰레드를 연속으로 만들고 이를 쓰레드 풀에 적재하였다. 그리고 각 쓰레드가 비동기 호출을 하는 이 클래스를 사용하였다. 서버는 먼저 만든 서버를 그대로 이용하기로 하자. 어떤 결과가 나올 것인가? 다음 결과를 보자.

ThreadPool큐에 30개 적재
Thread HashCode : 3
WorkerThreads : 24, CompletionPortThreads : 25
Thread HashCode : 18
WorkerThreads : 23, CompletionPortThreads : 25

Thread HashCode : 19
WorkerThreads : 22, CompletionPortThreads : 25

Thread HashCode : 1
WorkerThreads : 21, CompletionPortThreads : 25

Thread HashCode : 20
WorkerThreads : 20, CompletionPortThreads : 25

Thread HashCode : 21
WorkerThreads : 19, CompletionPortThreads : 25
Thread HashCode : 22
WorkerThreads : 18, CompletionPortThreads : 25

Thread HashCode : 23
WorkerThreads : 17, CompletionPortThreads : 25

Thread HashCode : 24
WorkerThreads : 16, CompletionPortThreads : 25
Thread HashCode : 25
WorkerThreads : 15, CompletionPortThreads : 25

Thread HashCode : 26
WorkerThreads : 14, CompletionPortThreads : 25

Thread HashCode : 29
WorkerThreads : 11, CompletionPortThreads : 25

Thread HashCode : 30
WorkerThreads : 10, CompletionPortThreads : 25

Thread HashCode : 31
WorkerThreads : 9, CompletionPortThreads : 25

Thread HashCode : 32
WorkerThreads : 8, CompletionPortThreads : 25

Thread HashCode : 33
WorkerThreads : 7, CompletionPortThreads : 25
Thread HashCode : 34
WorkerThreads : 6, CompletionPortThreads : 25
Thread HashCode : 35
WorkerThreads : 5, CompletionPortThreads : 25

Thread HashCode : 36
WorkerThreads : 4, CompletionPortThreads : 25

Thread HashCode : 17
WorkerThreads : 3, CompletionPortThreads : 25

Thread HashCode : 4
WorkerThreads : 2, CompletionPortThreads : 25

Thread HashCode : 5
WorkerThreads : 1, CompletionPortThreads : 25

Thread HashCode : 7
WorkerThreads : 0, CompletionPortThreads : 25

실행을 해 보면 프로그램이 멈춰버릴 것이다. WorkerThread가 0이 되면서 프로그램이 더 이상 작동 안 하는 데드록(deadlock) 현상이 일어난다. 왜 이런 현상이 일어나는 것일까? 먼저 어떤 사용자가 쓰레드 풀을 사용하면서 30개의 쓰레드를 쓰레드 풀에 적재를 했다. 쓰레드 풀의 기본적인 최대치는 25인데 한꺼번에 30개의 쓰레드를 적재해 버린 것이다.

그래서 이미 쓰레드 풀은 포화 상태가 되었다. 그런데 비동기 호출인 BeginConnect를 하려고 큐에 적재를 했는데, 이미 차지하고 있는 쓰레드 풀에서 빈 공간이 나올 기미가 안 보이는 것이다. 이미 connect 함수에서는 EndConnect 함수를 이용해 비동기 호출이 끝나기를 블럭되면서 기다리고 있는데 끝나질 않으니 한없이 기다리게 된다. 그렇다고 끝날 수도 없다. 이미 쓰레드 풀은 포화 상태이기 때문에 더 이상의 비동기 호출이 끼어들 자리가 없기 때문이다.

이 문제를 해결하기 위해서는 BeginConnect라는 비동기 호출을 동기 호출 함수로 바꿔주거나 처음 쓰레드 30개를 적재할 때, 한꺼번에 적재하지 말고 비동기 함수가 실행될 여지를 남겨주기 위해서 앞의 for문에서 Thread.Sleep(1000);이라는 문장을 주어 잠시 기다려 주면 비동기 호출이 실행될 여지가 있어서 데드록이 발생하지 않는다. 이러한 현상은 일반적으로 쓰레드 풀 내의 쓰레드가 비동기 호출이 끝나기를 기다릴 때 발생한다. 그러므로 쓰레드 풀과 비동기 호출을 같이 쓸 때는 주의해야 한다.

게임 서버 소개
지금까지 소개한 내용을 가지고 본격적으로 온라인 게임 서버를 만들어 보겠다.

네트워크 데이터 통신 방법
플래시와 소켓 통신을 하는데, 데이터 통신 방법은 단순하게 문자열로 보내고 받는 방법을 택하였다. 원래 플래시에는 XMLSocket이라는 것을 제공한다. 이는 XML 데이터를 위한 소켓으로 데이터를 XML 방식으로 보내야만 하는 것이다. 그러나 게임과 같이 속도가 중요한 프로그램에서는 XML로 데이터를 처리하면 이를 파싱하는 데 오버헤드가 있어 바람직하지 않다. 그래서 플래시의 XMLSocket을 이용하기는 하지만 이를 파싱하지 않고 데이터를 콤마로 구분한 문자열로 보내서 쓰는 방법을 택하였다.

이때 주의할 것은 플래시의 XMLSocket은 맨 마지막에 문자열의 끝임을 나타내주는 ‘\0’ 표시가 있어야 제대로 받아들인다. 그래서 서버에서 데이터를 전송할 때 데이터의 끝에 ‘\0’을 추가해 주었다. 서버에서 네트워크 데이터를 보낼 때는 보통 서버에 접속한 모든 사용자에게 데이터를 전송하는데 자기 자신을 포함하는 경우가 있고, 자기 자신을 제외한 나머지에게 데이터를 전송할 경우가 있어 브로드캐스트(broadcast) 함수를 두 가지로 만들었다.

<그림 4> 로그인 부분

<그림 5> 대기실 부분

사용자 처리 방법
각 사용자마다 이를 담당하는 user 클래스를 따로 만들었다. 이 클래스의 멤버는 <리스트 11>과 같다.

 <리스트11> User 클래스

class User
{
    private Socket m_sock; // Connection to the user
    private byte[] m_byBuff = new byte[50]; // Receive data buffer
    public string m_sID; // ID 이름
    public string m_sTank; // 탱크 종류
    public string m_sTeam; // 팀 종류
    public int m_nLocation; // 방에서의 자신의 위치
    public Point m_point; // 자신의 위치
}

각 사용자마다 자신의 네트워크 데이터를 처리할 소켓을 가지고 있고, 자신의 각종 정보를 가지고 있다. 메인에서는 이들을 arraylist로 유지해 새로운 사용자가 들어올 때마다 리스트에 추가해 준다.

방 관리
본 게임 서버에는 방이 하나밖에 없다. 최초에 들어온 사람이 방장이 되는 것이다. 이렇게 만든 이유는 간단하게 만들기 위해서이다. 본 게임 서버를 소개하는 목적이 소스를 이해하는 데 있으므로 가능한 최소한의 기능만 구현하여 소스 코드 크기를 줄였다. 아마 이 소스를 분석해 보면 쉽게 여러 개의 방도 만들 수 있을 것이다. 방이 하나밖에 없으므로 이미 게임중이면 다른 사용자가 들어오지 못하게 하였다.

<그림 6> 게임 시작전 초기화 부분

<그림 7> 게임중 부분

패킷 정보
서버와 클라이언트가 정보를 주고 받기 위해서는 서로 약속된 정보를 주고 받아야 한다. 이때 패킷의 처음 부분에는 이 패킷의 종류를 나타내는 정보를 담고, 나머지에 데이터를 담았다. 게임의 상태를 크게 세 부분으로 나눌 수 있는데 처음 로그인 부분, 대기실 부분, 게임 시작 전 초기화 부분, 게임 중 부분으로 나눌 수 있다. 이 세 부분에서 주고받는 패킷 정보는 <그림 4~7>과 같다.

플래시 MX를 이용한 게임 완성
이번 연재에서는 쓰레드 풀을 이용한 비동기 프로그래밍과 소켓의 개념에 대해 알아 보고 최종적으로 게임 서버를 완성했다. 크게 네 가지 주제로 다시 한 번 정리하자면, 첫 번째는 쓰레드 풀에 대한 개념이다. 쓰레드 풀은 다수의 쓰레드를 운영하는 데 있어, 많은 쓰레드를 교환하는 데 있어 생기는 오버헤드를 해결할 수 있는 것이다. 이 쓰레드 풀의 핵심 개념은 바로 재사용이다.

두 번째 소켓은 한 마디로 네트워크 프로그래밍을 하는 데 있어 마치 파일을 조작하듯 좀더 쉽게 접근할 수 있도록 도와주는 도구라고 할 수 있다. 세 번째는 IO completion 포트에 대해 알아 봤다. 이는 Winsock2에서 제기된 기능으로써, 대량의 네트워크 접속을 처리하는 데 있어 쓰레드 풀을 이용하여 각각의 네트워크 접속을 효과적으로 처리하는 것을 말한다. 마지막으로는 간단한 게임 서버를 완성해 보았다. 다음 호에서는 플래시 MX를 이용해 게임의 클라이언트 부분을 완성해 볼 것이다. @

'Development > C#' 카테고리의 다른 글

문화권 식별자  (0) 2010.07.21
객체 직렬화(Serialization) 역직렬화(Deserialization)  (0) 2010.06.03
ThreadPool and Socket Programming  (0) 2010.02.05
GAC(Global Asembly Cash)  (0) 2009.12.08
C# 3.0 Preview: Extension Method와 나머지  (0) 2009.12.08
프로퍼티(get,set)  (0) 2009.12.08

댓글을 달아 주세요