• 2006-06-01

    QueueUserWorkItem()

    Windows 에서 제공하는 ThreadPooling API인 QueueUserWorkItem()에 대해서 알아보자.

    QueueUserWorkItem()

    Programming Application for Microsoft Windows 에 의하면 ThreadPooling 시 새로운 쓰레드가 추가되기 위한 factor 에는 아래와 같은 것이 있다.

    • 쓰레드가 추가된 지 몇 초가 지나면, 한번 고민해본다
    • WT_EXECUTELONGFUNCTION 이 사용될 경우, 한번 고민해본다
    • 큐잉된 작업의 개수가 일정 threadhold 를 넘어설 경우, 한번 고민해본다.

    그러나, 상세한 파라미터는 알려져 있지 않아서.. 테스트를 해 보았다.

    
    const int MAX_WORK = 1000;
    LONG WorkCounter = 0;
    int add_time = 1;
    int work_time = 10;
    int nloop = 1;
    bool bPreQueue = false;
    int pre_queue_time = 1000;
    bool bMiddleSleep = false;
    int middle_sleep_time = 1000;
    
    DWORD WINAPI MyWorkThreadProc(PVOID pParam)
    {
        MyWork* pWork = (MyWork*)pParam;
    
        Sleep(pWork->workTime);
    
        pWork->threadID = ::GetCurrentThreadId();
        pWork->output = pWork->input * 2;
    
        InterlockedIncrement( &WorkCounter );
    
        return 0;
    }
    
    void
    thread_pool_test_fixture::test_really_pooling()
    {
        for ( int k = 0 ; k  0 )
                {
                    Sleep(pre_queue_time);
                }
            }
    
            MyWork works[MAX_WORK];
            for (int i=0; i 0 )
                {
                    Sleep(add_time);
                }
    
                if ( i == 500 && bMiddleSleep && middle_sleep_time > 0 )
                {
                    Sleep( middle_sleep_time );
                }
            }
    
            while ( WorkCounter  threads;
            for (int i=0; i::iterator itr = threads.begin() ; itr != threads.end() ; itr ++ )
            {
                BOOST_MESSAGE( *itr  처리 시간 |
    | 10 | 10 | 1  | 큐잉되는 시간 == 처리 시간 |
    | 10 | 20 | 3  | 큐잉되는 시간
    
  • 2006-05-28

    boost::is_base_of

    템플릿 프로그래밍을 하다 보면, 문자열이나 사용자 정의 클래스(UDT) 같은 특정 타입에 대한 특수화(specializaiton)를 할 경우가 있다. 그 중에서 가장 헷갈리는 부분이 바로 특정 클래스의 포인터를 템플릿 인자로 받아서 특수화해야 하는 경우인데, boost typetraits 중 하나인 boost::is_base_of를 사용하면 손쉽게 해결할 수 있다.

    
    template
    void old_algorithm ( const T & value )
    {
      // do generic something
    }
    

    위와 같은 함수 템플릿에 대해서 Base 클래스의 하위 클래스 포인터를 받았을 때를 위한 특수화를 해야 할 경우, 다음과 같은 패턴을 사용하라. 핵심은 is_base_of::type 이 true_type 또는 false_type 이라는 2 개의 서로 다른 타입을 리턴하며, 이 값을 이용해서 특수화를 한다는 것이다.

    
    template
    void new_algorithm ( const T & value )
    {
      typedef typename boost::is_base_of::type is_derived_class;
    
      // call impl
      new_algorithm_impl(value,is_derived_class());
    }
    
    template
    void new_algorithm_impl ( const T & value, const boost::true_type& )
    {
      // do specialized for Base class
    }
    
    template
    void new_algorithm_impl ( const T & value, const boost::false_type& )
    {
      // do generic something
    }
    
    
  • 2006-05-25

    boost::any

    게임 개발을 하다 보면, 가끔 서로 다른 타입의 객체들을 하나의 컨테이너에서 다루어야 할 때가 있다. 만약 이 컨테이너로 하는 일이 간단하다면야 인터페이스 상속을 쓰면 되지만, 상속을 할 수 없을 정도로 타입이 다를 때도 있다. 이럴 때 사용하는 것이 바로 boost::any 클래스이다.

    boost::any는 이름 그대로 어떤(any) 것도 담을 수 있는 클래스로, void * 와 비슷한 역할을 한다. (더욱 자세한 설명은 boost 온라인 매뉴얼을 참고)

    이번에 boost::any 의 코드를 참조해서 작업을 하다 보니 몇 가지 장단점들이 눈에 띄었다.

    • 미리 any 의 내부값의 타입을 파악하고 있어야 한다. 즉, type() 을 이용해서 if-else 방식으로 비교하는 수 밖에 없다.
    • any_cast(value) 는 생각보다 타이핑하기가 귀찮다. 꺽쇠와 괄호를 다 타이핑한다고 생각해보라...
    • 디버깅시 any 내부의 placeholder 에 들어 있는 값을 찾기가 곤란하다.

    이번 주는 영식군이 한 달 동안 쇼핑하면서 작업한 객체 복제 프레임워크를 이틀 동안 인수인계 받은 후, 복제의 최소 단위인 replication value 클래스를 boost::any 의 코드를 참조해서 고치고 있다. 미리 2000라인 정도의 테스트 코드를 만들어둔 덕택에 코드 학습 리팩토링이 겨우 4일 만에 거의 끝낼 수 있었다. 물론 영식군이 계속 지나가면서 어차피 기능이 똑같다면 왜 사서 삽질을 하냐고 갈구고 있지만 그냥 못들은 척하고 중이다. 흐흐.

  • 2006-05-16

    P2P Tips

    P2P 프로그래밍에 있어서 주의해야 할 항목들을 정리해 보았다. 입문자에게 많은 도움이 되었으면 한다. (참고로 본인은 직접 P2P를 개발하기보단 동료가 삽질하는 걸 지켜본 쪽이라서, 정확하지 않은 내용이 있을 수 있음을 먼저 밝힌다)

    분석 및 검토

    게임성 분석

    만들고자 하는 게임의 다양한 속성들에 대한 분석이 최우선 단계이다. 다음 질문에 자신의 게임이 어떤 쪽인지를 잘 따져보기 바란다.

    • 최대접속자는 얼마인가? (4-8, 16-32, 64-256, Massive)
    • 카운터 스트라이크처럼 사용자들에게 핑이 빠른 서버를 선택할 수 있도록 할 것인가?
    • NPC 또는 몹이 존재하는가? 만약 그렇다면 누가 그들을 움직이게 할 것인가?
    • 승패 또는 타격 판정, 이동에 대한 검증은 누가 할 것인가?
    • dedicated server 를 지원할 것인가? 또는 클라이언트 중에서 UDP 서버를 선택할 것인가?

    토폴로지의 선택

    어떤 토폴로지를 선택하느냐에 따라서 최대 대역폭, 최대 접속자가 결정되므로, 위에서 조사한 결과에 따라서 적합한 토폴로지를 선택해야 한다. 아래의 문서를 참고하기 바란다.

    see also : 네트워크 액션 게임의 분류

    UDP 라이브러리의 선택

    토폴로지를 선택했다면 이제 UDP 라이브러리를 선택할 단계이다.

    단지 위치 정보만을 보내는 게 아닌 이상, 순수 UDP 만으로 네트워크 게임을 개발하기란 쉽지 않다. 결국은 reliable UDP 를 지원하는 외부 라이브러리를 사용하거나 직접 구현하게 될텐데, 직접 구현하는 것은 시간이 많거나 자신이 천재가 아닌 이상 그다지 좋은 선택은 아니다. 반면 어떤 라이브러리를 선택해도 깊은 이해 없이는 잘 사용하기가 힘든 것이 사실이다.

    가능하면 Unreal, Source Engine 등 네트워크 모듈이 포함된 게임 엔진을 구매하는 것을 권장하며, 그럴 수 없다면 충분한 검토 기간 하에 RakNet, OpenTNL, udt, enet 중에서 하나를 선택하기 바란다.

    디자인 및 구현, 테스트

    네트워크 모듈의 통합

    많은 UDP 라이브러리들은 보다 빠른 반응성을 위해 자체적으로 쓰레드를 사용한다. 그러나 dedicated server 가 아닌 이상, 서버쪽은 무작정 쓰레드를 남발해서는 곤란하다. 하나의 서버가 여러 개의 게임을 동시 처리해야 할 경우가 많으므로, 이럴 때에는 소스를 수정해야 할 수 있다.

    • polling vs. event-driven
    • single thread vs. multithread

    이때 폴링 방식의 클라이언트는 로딩 혹은 렌더링으로 인해 네트워크 업데이트를 방해하는 일이 없도록 주의해야 한다.

    NAT와 홀펀칭

    하드웨어의 구비
    하드웨어가 없는 상태에서의 NAT 프로그래밍은 장님 코끼리 다리 만지기와 같다.
    SNAT
    SNAT-SNAT 간에는 홀펀칭이 통하지 않는다는 사실만 기억하면 된다. 중국이 아닌 이상 SNAT를 만나기란 쉽지 않다. 정 귀찮다면 C/S구조로 가는 것도 나쁘지 않다. GPGStudy 포럼을 잘 검색하면 어떤 라우터가 SNAT인지를 찾을 수 있다.
    N+1
    어떤 NAT는 새로운 매핑을 위해 포트 번호를 점차 증가시키기도 한다. 그러나 라우터 제조 업체마다 다르므로 일반적으로는 알 수 없다고 봐야 한다. Timeout 30-40초마다 패킷을 밖으로 쏘지 않을 경우 매핑을 삭제하는 공유기가 존재한다.
    패킷 릴레이
    직접적인 통신이 되지 않는 peer 에게 패킷을 보낼 수 있는 유일한 방법이다. 문제는 릴레이는 핑이 2배 느려지는 것이 보통이므로 데드 레커닝 및 보간 기능으로 잘 극복해야 한다. 릴레이가 끊기는 경우 실시간으로 다른 루트를 찾아주면 금상첨화.
    사설 네트워크
    같은 네트워크에 있는 사용자들 끼리는 굳이 public 주소로 접속할 필요는 없다. 상대방의 (public,private) 모두로 핑을 쏜 다음, 응답이 오면 그 중 빠른 넘을 실제 주소로 선택하라. 이때 둘 다 될 경우라면 대체로 private를 사용하는 편이 좋다. 물론 본인 확인은 필수. 방화벽 내부에 있는 사람들끼리 플레이할 경우 public 으로 통신하면 괴로워진다.
    다단계 NAT
    중국은 다단계 NAT로 악명이 높다. 최소한 2단계로 NAT를 배치한 후 각 노드마다 클라이언트를 두고 내부/외부와 통신이 잘 되는지를 확인할 것. 그렇게 열심히 테스트해도 안되는 환경이 분명 존재하므로, 처음부터 릴레이를 기본 기능으로 포함시킬 것.
    사용자 서버
    네트워크 비용을 줄이려면 특정 클라이언트가 서버 역할을 하도록 하라. 단 Drop-out시 게임을 중단하고 새 서버를 찾는 기능을 구현해야 한다. 물론 보안 문제는 책임질 수 없다;;

    대역폭 관리

    모뎀 지원
    해외에는 아직도 모뎀을 많이 사용하고 있다. 따라서 모뎀 유저가 방에 들어올 경우 최대 접속자를 줄이든가 대역폭에 맞게 데이터를 실시간 관리하는 기능이 필요하다.
    MTU
    게임에서의 최대 패킷 크기를 항상 알고 있을 것. 특히 리스트 방식으로 전송되는 데이터들(방 목록, 사람 목록, 게임 목록)을 유의할 것.
    순간 대역폭
    체크 어떤 공유기(D-Link)는 순간 대역폭을 초과할 경우 reset 되기도 한다.

    공유기, 방화벽, 포트 포워딩

    (D)DOS 공격
    어떤 공유기는 외부로부터 갑자기 연결이 많이 들어올 경우 DOS 혹은 DDOS 로 간주해서 연결을 차단하기도 한다.
    XP 방화벽
    Windows XP의 방화벽에 자동 등록하기는 필수. 단 사용자에게 허가를 받지 않고 몰래 방화벽을 끌 경우 해외에서는 고소당할 수 있다.
    백신 호환성 체크
    일부 백신들은 정상적인 어플리케이션도 바이러스나 웜으로 간주하기도 한다. 따라서 유명 백신들을 설치한 상태에서 툴과의 호환성을 체크한 후 문제가 있다면 매뉴얼을 홈페이지에 등록해야 한다.
    포트 포워딩
    portforward.com이나 WOW처럼 공유기에서 포트포워딩을 하는 방법을 홈페이지에 기술할 것. 가능하면 클라이언트에 네트워크 진단 도구를 탑재하거나, 웹 기반 진단 페이지를 만들기를 권장한다.
    주의 깊은 포트의 선택
    해외의 일부 회선 업체는 자신들의 자체 서비스(VoIP)를 위해 특정 UDP 포트를 임의로 막기도 한다. 가능한한 다른 게임 및 어플리케이션과 충돌하지 않는 포트를 선택할 것.
    서버 방화벽
    네트워크 관리자에게 사용하는 포트에 대한 문서를 넘기고 수시로 업데이트 해줄 것. (특히 해외 서비스일 경우는 필수) 허용된 포트의 끝언저리에서 실수로 인한 문제가 종종 생기곤 한다.

    기타

    • 공유기의 타입 찾기 : STUN
    • Drop-In : 게임 도중 새로운 접속자가 들어올 때
    • 연결 끊김 감지 : 게임 도중 연결이 끊길 경우 얼마나 빨리 감지할 수 있을까?
    • 데이터 복제, 보안, 통계 수집
  • 2006-05-16

    IOCP Tips

    I/O completion port 를 개발할 때 주의해야 할 사항들.

    1-Recv Rule

    하나의 소켓에 대해서 특정 순간에 1개 이하의 입력만이 요청되어야 한다는 규칙을 1-recv 규칙이라고 부른다. 물론 버퍼를 여러 개 준비한 다음 미리 요청해두는 N-recv 방식도 그럴듯하지만, 요청한 순서대로 항상 완료되지는 않는다는 소문(?)에 그다지 많이 사용되고 있지는 않은 듯하다.

    그런데, 입력 재요청은 GQCS()를 호출하는 입출력 쓰레드에서 할 수도 있지만, 로직 처리 쓰레드에서 하는 경우도 많다. MMORPG가 아닌 경우에 한정해서 후자의 방식을 권장하는 편인데, 코드는 약간 복잡하지만 버퍼 복사 및 동기화 부담도 줄어들기 때문이다. 다음 두 예제를 살펴보자.

    # ex1
    class ideal_session:
    def end_read(self,buf,len):
    """callback for input completion. called by i/o thread"""
    while 1:
    msg = self.get_msg() # parse & copy!!
    if msg:
    msgq.append((self,msg))
    self.begin_read()
    
    def ideal_process_sessions():
    """logic process thread"""
    while 1:
    session, msg = msgq.pop()
    if session and msg:
    msg_handler(session,msg)
    
    # ex2
    class real_session:
    def end_read(self,buf,len):
    """callback for input completion. called by i/o thread"""
    sessions.append(self) # only notify
    
    def real_process_sessions():
    """logic process thread"""
    while 1:
    session = sessions.pop()
    if session:
    while 1:
    msg = session.get_msg() # parse but no copy!
    if not msg: break
    msg_handler(session,msg)
    session.begin_read()
    

    [예제1]에서는 입출력 쓰레드에서 메시지 파싱 - 큐잉 - 입력 재요청이 이루어지고 로직 처리 쓰레드는 메시지큐로 동작하는데, 전체적으로 코드는 단순하지만 메모리 복사 및 할당이 많아진다. 또한 입력 버퍼가 가득 찬 후의, 다음 입력 재요청을 과연 어디서 할 것인지도 애매하다. 반면, [예제2]에서는 대부분의 작업을 로직 처리 쓰레드에 위임시킴으로써 입출력 처리 쓰레드의 부하를 최소화하면서, 버퍼 복사 역시 극단적으로 줄일 수 있게 된다.

    단, [예제1]이 입력 완료 후 재요청이 더 빠르지만, 전체적인 입출력 처리를 감안한다면 그다지 빠르다고 보기는 힘들 것으로 추측한다.

    1-Send Rule

    입력과 달리 출력 관리 방법에 대해서는 많은 이견이 존재한다.

    N-send 는 보통 출력 버퍼 관리를 하기보다는 필요할 때마다 즉시 보내는 것을 의미하는데, 테스트해 본 결과 출력 완료시 bytes_transferred != bytes_written 인 경우를 거의 못봤다는 이야기도 종종 들린다. 그러나 만에 하나 예외 상황이 발생한다면 TCP 스트림이 꼬이게 되므로 1-send 로 구현하는 것을 보통 권장한다. 좀 더 자세한 설명은 GPGStudy에서 sparrowhawk님이 쓰신 글이 글을 참고하도록 할 것.

    고전적인 출력 버퍼 관리 방식에서는 큰 출력 버퍼를 마련해 두고, 보낼 데이터를 복사한 후 한꺼번에 flush 하게 된다. 여기에 버퍼 복사를 줄이기 위해 큰 버퍼 대신 버퍼의 리스트로 구현한 것을 바로 gather-send 라고 하는데, 대부분이 broadcast 인 게임 서버에서는 Lock과 참조카운팅, 메모리 풀링을 함께 사용해야 하기 때문에, unicast가 많은 미들웨어에 한정해서 사용해야 할 것이다. 가능하면 출력 버퍼의 관리 방식을 손쉽게 교체할 수 있는 프레임워크를 만들거나 사용하는 것을 추천한다.

    출력 완료 처리 및 재요청은 당연히 로직 처리 쓰레드의 몫이 된다. 1-Recv Rule과 같은 방식으로 하면 간단하므로 따로 예제는 준비하지 않았다.

    NumberOfCurrentThresds

    CreateIoCompletionPort() 의 마지막 파라미터인 NumberOfConcurrentThreads 에 대한 MSDN의 설명을 보자.

    Maximum number of threads that the operating system allows to concurrently process I/O completion packets for the I/O completion port. If this parameter is zero, the system allows as many concurrently running threads as there are processors in the system.
    
    Although any number of threads can call the GetQueuedCompletionStatus function to wait for an I/O completion port, each thread is associated with only one completion port at a time. That port is the port that was last checked by the thread.
    
    When a packet is queued to a port, the system first checks how many threads associated with the port are running. If the number of threads running is less than the value of NumberOfConcurrentThreads, then one of the waiting threads is allowed to process the packet. When a running thread completes its processing, it calls GetQueuedCompletionStatus again, at which point the system can allow another waiting thread to process a packet.
    
    The system also allows a waiting thread to process a packet if a running thread enters any wait state. When the thread in the wait state begins running again, there may be a brief period when the number of active threads exceeds the NumberOfConcurrentThreads value. However, the system quickly reduces the number by not allowing any new active threads until the number of active threads falls below the specified value.
    

    즉 이 파라미터는 GQCS()에서 리턴되어 I/O completion 을 처리하는 쓰레드의 최대 동시 실행 개수를 의미한다. GQCS()를 호출하는 사용자 쓰레드의 개수에는 제한이 없지만, IOCP 자체에서 리턴시키는 개수를 이 파라미터로 제한하게 된다. 따라서 이 개수가 커지면 CPU 의 부하가 심해지므로 보통 0 을 넣어 자동화하거나 CPU 개수의 2배를 사용한다.

    리소스 관리

    Network Programming for Microsoft Windows 2000에 나오는 리소스 관련 부분을 적당히 번역해봤다.

    IOCP 를 사용한 Windows 서버를 개발할 때에는 locked pages 제한과 non-paged pool 제한에 유의해야 한다. 그 중 locked pages 제한이 non-paged pool 제한보다는 덜 위험하고 해결하기도 더 쉽다.

    Locked Pages Limit

    모 든 Overlapped I/O 에 있어서 WSABUF 의 메모리 공간은 locked 된다. 메모리가 lock 되면, 이넘들은 page out 되지 않는다. OS 레벨에서 locked page 의 최대 제한이 존재한다. 이 제한에 도달하면 이후의 Overlapped I/O 에서는 WSANOBUFS 에러가 리턴된다.

    이때, 최대 연결의 개수가 중요한 서버에서는 0 bytes overlapped I/O 기법을 사용할 수 있다. 이 기법에서는, 버퍼를 제공하지 않으므로 locked 되는 메모리가 발생하지 않는다. (읽기 요청이 완료될 때 nonblocking receive 를 하는 방식이다.) 단, 클라이언트와 서버간의 통신 방식에 따라서 좋은 방법을 골라야 한다. 만약 클라이언트가 데이터를 열라 보낸다면, 잦은 Overlapped Input 호출이 있을테니..

    (WSABUF[0].buf 가 가리키는 메모리 공간에 lock 이 걸리는 것은 당연하다만, OVERLAPPED 구조체가 존재하는 메모리에도 마찬가지일까? 윗 글에 따르면 그건 아닌 듯한데.. 냠..)

    또 중요하게 고려해야 하는 것은, 시스템의 페이지 사이즈이다. lock 을 걸 때에는 페이지 단위로 걸게 되는데, x86 에서의 페이지 크기는 4K 의 배수이다. (조사해보니 x86 에서는 4K 였다) GetSystemInfo() 를 사용하면 페이지 크기를 알 수 있다.

    Non Paged Pool Limit

    non-paged pool 제한은 보다 위험하고, 복구하기도 어렵다. non-paged pool 은 항상 물리적 메모리에 존재하고, 절대 page out 되지 않는 메모리 영역이다. 커널과 각종 드라이버들이 이 영역에 속한다. 또한 각 소켓 역시 non-paged pool 에 소켓 상태를 저장하기 위한 약간의 메모리를 소비하게 된다. 특정 주소에 bind 될 경우, 주소 정보를 위한 추가적인 메모리가 더 필요하다. 소켓이 연결되면 remote address 관련된 메모리도 필요해진다. 모두 합하면, 연결된 소켓은 2k 정도, accept/AcceptEx 로 리턴된 소켓은 1.5k 정도의 non-paged pool 공간을 차지한다. (accepted 소켓은 단지 리모트 주소만 필요하기 때문에 약간 더 작다) 또한, 각 overlapped I/O 연산은 500 바이트 정도의 메모리 공간을 필요로 한다. (500 바이트는 혹시 OVERLAPPED 구조체를 위한 공간이 아닐까?)

    일 반적으로 non-paged pool 은 물리적 메모리의 1/4 이며, W2k 에서는 256M, NT4 에서는 128M 의 최대 제한을 가진다. 256M 의 경우라면 5만 정도의 연결을 처리할 수 있지만, Accept/Send/Receive Overlapped I/O 의 개수가 제한된다는 점에 주의하라. accepted 소켓당 1.5k 이라고 하면, 소켓 자체로만 75M( 50000 x 1.5k )를 차지하며, 0 bytes receive 기법을 사용한다면 25M의 메모리를 차지한다.

    만 약 시스템의 non-paged pool 이 한계에 도달하면, 운이 좋다면 WSANOBUFS 에러를 리턴하지만 그렇지 않으면 crash 될 것이다. 이를 복구하거나 예측(혹은 모니터링)한다는 것은 거의 불가능하기 때문에 개발자 스스로 최대 동시 접속수라든지, 최대 overlapped 연산의 숫자 등을 테스트해볼 필요가 있다. 만약 WSAENOBUFS 에러를 만난다면 연결을 닫는다든지 해서 줄이는 것이 좋을 것이다...

    see also:

    • http://pl.atyp.us/content/tech/servers.html