출처는 데브피아 이기탁님 입니다.

압축파일안에 간단한 소스 설명과 IOCP설명이 있습니다.

저는 이걸로 기초를... ^^;

안정적인 DNS서비스 DNSEver DNS server, DNS service
Posted by 키르히아이스
,
IOCP- 윈속 프로그래밍  2002년 08월 21일 | 03시 05분

이 글은 제가 얼마 전에 프로그램 세계에 연재했던 글입니다. 다음 3회 연재 글 중에서 마지막 회에 해당합니다. 1,2회에 해당하는 글은 이 책에서 찾아보실 수 있습니다.

  • 2002/2 - 1. 윈속이란 ? - 간단한 에코우 서버/클라이언트 프로그램 만들기
  • 2002/4 - 2. 멀티스레드 윈속 서버 프로그램으로 업그레이드 하기
  • 2002/5 - 3. IOCP 윈속 서버 프로그램

    이번 회에는 지난 회에서 멀티스레드 윈속 서버 프로그램을 IOCP(Input Output Completion Port)를 이용하는 것으로 변경해보도록 하자. 전에 서버 프로그래밍에 관한 필자의 연재기사에서 수차례 IOCP를 언급한 바 있었는데 이제서야 설명을 하게 되었다.

    지난 회에 만들어본 멀티스레드 윈속 서버 프로그램의 문제점은 사용자의 수가 많아지면 스레드의 동적 생성과 스레드간의 잦은 컨텍스트 스위칭으로 인한 오버헤드가 크다는 점이었다. 이러한 점을 극복하기 위해 도입된 것이 바로 IOCP이다. 방금 설명한 것처럼 이는 멀티스레드 프로그래밍에서 유용하게 사용할 수 있으며 그 중에서도 소켓이나 파일, 메일슬롯, 파이프와 같은 입출력 관련 프로그램에서 유용하게 사용할 수 있다.

    필자는 IOCP를 파일 I/O가 많은 응용프로그램과 네트웍 I/O가 많은 윈속 프로그램에서 사용해봤는데 그냥 단순한 멀티스레드 프로그램을 작성하는 것보다 괜찮은 성능을 가짐을 알 수 있었다. 부하가 그리 크지 않다면 IOCP를 사용하나 사용하지 않으나 성능상에 큰 차이가 없다. 하지만 부하가 클 경우에는 (예를 들어 윈속 서버 프로그램이라면 현재 접속 사용자수가 많을 경우에는) 상당한 차이를 가져온다는 점을 잘 새겨두기 바란다. 하지만 파일 I/O가 아주 빈번한 응용프로그램에서는 IOCP를 사용한 멀티스레드 프로그램이나 그냥 멀티스레드 프로그램이나 성능에 있어 별 차이가 없다. 그 이유는 스레드로 인한 오버헤드보다 파일 I/O 자체로 인한 오버헤드가 더 크기 때문이었다.

    단, IOCP가 무슨 마법처럼 시스템이 가진 하드웨어 제약조건 이상으로 많은 수의 사용자를 처리할 수 있도록 해주거나 하는 것은 아니란 점을 명심하기 바란다. 부하가 많은 시점에 그냥 멀티스레드 프로그래밍을 하는 것보다 더 좋은 성능을 보일 뿐이다. 획기적으로 좋은 성능을 보이거나 하는 마술과 같은 것은 아니란 것이다. 또한 IOCP는 NT 4.0, 2000, XP에서만 사용가능하다는 점도 알아두기 바란다.

    먼저 IOCP라는 것이 그리 이해하기 쉬운 편은 아니고 이해해서 사용하기는 더욱 어렵다는 점을 밝히고 싶다. 겁먹으라고 하는 소리는 아니고 잘 이해가 안되어도 필자 탓을 하거나 자신의 머리탓(?)을 하지말고 한번 더 읽어보라는 의미에서 하는 말이다. 참고문헌 2>와 3>에 필자가 처음 IOCP를 공부할 때 봤던 책과 인터넷 기사를 적어두었다. 참고하기 바란다. 또, 마이크로소프트 플랫폼 SDK의 예제 프로그램 중에 보면 윈속에서 IOCP를 어떻게 사용할 수 있는지 보여주는 간단한 예가 있다. 참고문헌 4에 적었다. 사실 이번 연재에서 작성한 서버 예제 프로그램도 이 것을 바탕으로 작성되었다. 클라이언트 예제 프로그램은 사실 지난 회와 동일하다. 그렇기 때문에 클라이언트 프로그램에 대해서는 다루지 않겠다.

    1. IOCP의 필요성 ?

    IOCP가 왜 필요한지 알아보려면 기존 멀티스레드 프로그래밍의 제한점을 먼저 이해해야 한다.

    많은 수의 스레드 생성으로 인한 오버헤드 : 확장성의 제한

    동시에 여러 사용자를 처리할 수 없는 프로그램을 서버 프로그램이라고 부를 수 없을 것이다. 서버 프로그램이 되려면 동시에 여러 사용자로부터의 요구를 처리할 수 있어야 하고 그렇게 하기 위해서 스레드를 사용할 수 밖에 없다. 결론적으로 진정한 다중 사용자용 서버 프로그램을 짜본 사람이라면 동시 사용자 처리를 위해 누구나 스레드를 사용하고 있을 것이다. 대부분의 경우 지난 회에 살펴본 예제 프로그램처럼 현재 접속 중인 사용자의 수만큼 스레드를 만드는 방식을 취하게 된다. 즉 사용자마다 그 요구를 처리하기 위한 전담 스레드를 생성하는 것이다.

    하지만 이 방식의 문제점 중의 하나는 바로 현재 접속 중인 사용자의 수가 늘어날 경우에 발생한다. 스레드의 생성은 당연히 자원의 사용을 가져온다. 어느 수 이상으로 스레드가 생성되면 프로그램의 성능이 오히려 전체적으로 저하된다. 이유는 너무 많은 스레드가 생성되면 아무래도 그로 인해 자원이 많이 필요하게 되고 또 그 많은 스레드들간의 컨텍스트 스위칭으로 인해 실제 CPU가 어떤 일을 하는 시간보다 컨텍스트 스위칭하는데 상당한 시간을 보내게 되기 때문이다. 예를 들어 CPU의 수보다 스레드의 수가 많다면 사실 스레드간의 컨텍스트 스위칭으로 인한 오버헤드가 있다고 볼 수 있다. (사실 CPU의 수만큼 스레드의 수가 존재하는 것이 이상적이지만 이는 사실상 불가능한 일이다. 이는 만들고자 하는 응용프로그램의 특성에 따라 굉장히 달라질 수 있다.)

    IOCP는 이러한 단점을 극복하기 위해 하나의 스레드가 하나 이상의 사용자로부터의 요구를 처리할 수 있도록 해준다. 그렇다고 하나의 스레드만을 생성하는 것은 아니다. 여러 개의 스레드를 생성하지만 한 스레드가 한 사용자만을 전담하는 방식은 아니라는 것이다. 즉, 실행되는 스레드의 수를 적게 해서 이로 인한 컨텍스트 스위칭의 수를 줄이는 것이다. 이것이 가능하려면 이제 뒤에서 살펴볼 것처럼 프로그램내에서 I/O시에 비동기 방식을 사용해야 한다.

    비동기 I/O는 서버 프로그래밍의 필수

    서버 프로그램에서 성능 향상을 위해서 사용할 수 있는 다른 하나의 테크닉은 비동기(Asynchronous) I/O를 사용하는 것이다. 이를 이용하면 동시에 여러 개의 I/O 작업을 수행할 수 있는데 이는 어디까지나 작업의 시작만 비동기로 가능하다는 것이지 작업이 끝나는 부분은 즉, I/O 결과를 받는 부분은 동기화가 되어야 한다는 것이다. 만일 비동기 I/O의 결과를 그냥 무시해도 좋은 프로그램이라면 또다른 이야기가 되겠지만 아마 대부분의 프로그램에서는 비동기 I/O를 수행하고 그 결과를 살펴봐야 할 것이다.

    비동기 I/O에는 여러가지 방식이 존재한다. 간략히 참고 1에 윈도우에서 지원되는 비동기 I/O 방식을 나열해 보았다. 당연한 이야기이지만 이러한 비동기 I/O 방식은 특히 시간이 오래 걸리는 작업을 할 때 적합한 방식이다. 이러한 비동기 I/O 방식은 IOCP와 결합되었을 때 최적의 성능과 확장성을 자랑한다. 다시 정리해서 말하자면 비동기 I/O의 성능은 I/O가 끝났을 때 그 결과를 어떻게 확인하느냐에 달려 있는데 IOCP는 이러한 비동기 I/O를 가장 효율적으로 사용할 수 있게 해준다.


    참고 1. 윈도우의 비동기 I/O

    윈도우에서는 다양한 방식의 비동기 I/O를 제공한다 (사실 너무 다양한 방법을 제공한다.) 여기서는 간략히 언급하기로 하겠다. 다음에 기회가 닿으면 파일 I/O 관련 연재 기사를 다뤄볼 생각인데 그 때 자세히 언급하기로 하겠다.

    1> 오버랩드 I/O를 사용하기.

    예로 파일 I/O를 들어보자. 파일을 오픈할 때 CreateFile API를 사용하는데 이 때FILE_FLAG_OVERLAPPED를 인자로 주면 오버랩드 I/O를 수행할 수 있다. ReadFile과 WriteFile을 사용하여 I/O를 수행하게 되는데 이 함수들은 실행이 끝날 때까지 기다리지 않고 바로 리턴한다(비동기 I/O니까 당연한 이야기이지만). 이 때 마지막 인자로 OVERLAPPED 구조체를 사용하는데 여기에 이벤트(지난 회에 설명한 바 있다)를 지정하도록 되어있다. 작업이 끝나면 이 이벤트로 시그널이 가게 된다. 이벤트를 사용하는 대신에 함수의 실행이 끝났는지를 검사하기 위해 GetOverlappedResult 함수를 호출할 수도 있다. 참고로 ReadFile이나 WriteFile과 같은 함수는 꼭 파일 I/O에 사용되는 것이 아니란 점도 알아두기 바란다. 소켓에서 데이터를 읽고 쓰는데도 사용할 수 있다.

    2> 콜백 함수 사용하기

    기본적으로는 1<의 방식과 갖다. 다만 이벤트를 사용하는 대신에 콜백 함수를 지정해서 작업이 끝나면 그 함수를 호출하도록 하는 것이다. 이때는 ReadFile, WriteFile과 같은 함수 대신에 ReadFileEx와 WriteFileEx와 같은 함수를 사용해야 한다. 이 함수들은 인자 중의 하나로 콜백 함수의 주소를 받아들이도록 되어있다.

    3> IOCP 사용하기

    사실 IOCP를 비동기 I/O 작업 방식이라고도 할 수 있는데 이에 대해서는 이 기사의 뒷부분에서 자세히 살펴볼 것이다.


    지금까지 살펴본 것과 같은 기존의 멀티스레드 서버 프로그래밍의 문제점을 해결하기 위해 만들어진 것이 바로 IOCP이다. 기본적으로 IOCP는 비동기 I/O 작업을 지원하면서 적은 수의 스레드로 최대한의 요청을 처리하기 위한 방법이란 점이라고 이해하면 된다. 너무 많은 스레드가 동시에 동작함으로 인한 문제를 해결하면서 비동기 I/O 작업시 결과를 체크해야 하는 문제를 해결함으로써 서버 프로그램의 성능을 극대화하는 것이다.

    2. IOCP란 ?

    IOCP란 특정 입출력 객체(예를 들면 파일, 소켓, 메일 슬롯 등등)와 관련된 일종의 I/O 작업 결과 큐라고 생각할 수 있다. 좀더 자세히 설명하자면 먼저 IOCP 객체가 별도로 생성되어야 한다. 그 다음에 이 객체와 입출력 객체 중의 하나가 연결되어야 한다. 다음으로 이 입출력 객체에 비동기 I/O 작업이 수행되면 운영체제가 이 큐에 그 비동기I/O의 결과를 집어넣게 된다.

    또한 이 큐는 하나 이상의 스레드와 연관지어지게 된다 (스레드의 수는 비동기 I/O의 특성에 따라 달라지게 된다. 만일 I/O가 오래 걸리는 것이라면 스레드의 수는 적어도 관계없다. 하지만 I/O가 시간이 아주 조금밖에 안 걸리는 것이라면 스레드의 수는 많아야 한다). 운영체제는 큐에 결과가 있고 관련 스레드들 중에서 놀고 있는 놈이 있으면 그 스레드가 결과를 받아서 다음 작업을 수행할 수 있게 해준다. 즉, IOCP와 관련되어 동작할 수 있는 스레드를 미리 여러 개 만들어 놓고 이 중에서 필요에 따라 놀고 있는 것을 가져다 큐에서 비동기 I/O 결과를 읽어가도록 하는 것이다. 참고로 한 IOCP 객체는 동시에 여러 입출력 객체와 연관지어질 수 있다.

    자 이러한 과정을 코드를 통해 좀더 자세히 살펴보자. 그림 1을 참고하기 바란다. 본 기사의 서버 예제 프로그램의 코드를 바탕으로 설명하겠다.

    < 그림 1. IOCP의 동작 >

    1> IOCP의 생성

    먼저 첫번째 절차는 IOCP를 생성하는 것이다. 이는 CreateIoCompletionPort 함수를 통해 가능하다. 이 같은 함수를 이용해 입출력 객체와 IOCP를 연관짓는데 사용할 수 있다. 다음은 IOCP를 일단 생성하는 예(CreateIoCompletionPort 함수의 첫번째 인자로 INVALID_HANDLE_VALUE를 지정해야 한다. 이 함수에 대한 보다 상세한 설명은 참고 2를 보기 바란다)이다. 생성의 결과는 HANDLE로 리턴된다.

      g_hIOCP = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);
      if (NULL == g_hIOCP) 
      {
        printf("CreateIoCompletionPort failed: %d\n", GetLastError());
        CleanUp();
      }
    

    2> IOCP 큐에서 결과를 읽어들일 스레드 생성

    앞서 이야기한 것처럼 IOCP와 연관된 입출력 객체에 비동기 I/O를 수행하면 그 결과가 IOCP 큐에 쌓인다고 하였다. 이 큐에서 결과를 읽어들이는 일을 수행하는 스레드를 만들어야 한다. 다른 스레드 생성과 특별히 다를 것은 없다. 단 생성할 스레드의 수는 CPU수 X 2로 되어있다. 이는 마이크로소프트에서 권장하는 방식이다. 응용프로그램에 따라 이것이 적당할 수도 있고 훨씬 더 많은 스레드가 필요할 수도 있다. 이를 위해서 GetSystemInfo라는 함수를 이용해서 현재 시스템의 CPU수를 알아내는 코드가 들어있다.

    #define MAX_WORKER_THREAD	16
    
    DWORD g_dwThreadCount;
    unsigned int g_hThreads[MAX_NUMBER_OF_THREADS];
    
    …
    SYSTEM_INFO         systemInfo;
     DWORD dwThreadId;
    
    GetSystemInfo(&systemInfo);
    g_dwThreadCount = systemInfo.dwNumberOfProcessors * 2;
      …
    for (DWORD dwThread = 0;dwThread < g_dwThreadCount; dwThread++)
    {
      g_hThreads[dwThread] = _beginthreadex(NULL, 0, EchoThread, 
                                 g_hIOCP, 0, &dwThreadId);
      If (g_hThreads[dwThread] == NULL)
      {
        printf(“%d번째 스레드 생성에 실패했습니다.\n”, dwThread);
      }
    }
    

    위에서 볼 수 있는 것처럼 스레드의 생성에는 _beginthreadex 함수를 사용하였다. 스레드 함수는 EchoThread이며 스레드 함수의 인자로는 IOCP 핸들을 넘긴다. EchoThread 함수의 자세한 내용은 5>에서 살펴볼 것이다.

    3> IOCP와 입출력 객체의 연결

    다음은 이 IOCP와 입출력 객체를 연결하는 부분이다. 입출력 객체는 반드시 비동기 I/O 모드로 오픈되어야 한다. 연결된 객체에 대한 비동기 오버랩드 I/O 결과가 이 IOCP 큐에 들어간다. 예를 들어 소켓과 IOCP를 연결하는 간단한 예를 보면 다음과 같다.

    SOCKET sh;
    
    sh = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_IP, NULL, 
                 0, WSA_FLAG_OVERLAPPED);…
    if (sh != INVALID_SOCKET)
    {
    	CreateIoCompletionPort((HANDLE)sh, g_hIOCP, (ULONG_PTR)0, 0);
    

    먼저 소켓을 생성할 때 socket 함수를 사용한 것이 아니라 WSASocket 함수를 사용하였고 마지막 인자로 WSA_FLAG_OVERLAPPED가 지정되었다. 그 다음에 앞에서와 같은 CreateIoCompletionPort 함수를 사용하고 그 첫번째 인자로 소켓의 값을 지정하면 된다. 즉, CreateIoCompletionPort 함수는 IOCP의 생성에도 사용되고 생성된 IOCP와 입출력 객체를 연결하는데도 사용된다. 참고 2에서 CreateIoCompletionPort 함수의 세 번째 인자 설명을 보면 알 수 있겠지만 세 번째 인자가 가장 중요한 역할을 한다.

    앞서 잠깐 언급했던 것처럼 IOCP는 여러 개의 입출력객체와 동시에 연관지어질 수 있다. 예를 들어 여러 개의 소켓이 하나의 IOCP와 연관될 수 있다. 즉 그러한 소켓들에 대해 이루어지는 비동기 작업의 결과는 모두 그 하나의 IOCP 큐로 들어간다는 것이다. 그러기 때문에 IOCP 큐에서 비동기 작업 결과를 읽어들일 때 이 결과가 도대체 어느 입출력 객체로부터 온 것인지를 구분할 수 있는 방법이 있어야 한다. 이 때 CreateIoCompletionPort 함수의 세 번째 인자로 지정했던 값이 구분하는 역할을 담당한다. 뒤에서 살펴보겠지만 IOCP 큐에서 결과를 읽어들일 때 사용하는 함수는GetQueuedCompletionStatus라는 것이다. 이 함수의 세 번째 인자로 앞서 CreateIoCompletionPort 함수에서 지정했던 값이 넘어오게 되어 있다. 예제 프로그램에서는 입출력 객체마다 다음과 같은 구조체를 생성하고 이를 IOCP 객체와 연관지을 때 세번째 인자로 지정할 것이다.

    // IOCP와 연관되는 소켓마다 할당되는 구조체
    typedef struct _PER_SOCKET_CONTEXT 
    {
        SOCKET                 Socket;
        PPER_IO_CONTEXT       pIOContext;  
    } PER_SOCKET_CONTEXT, *PPER_SOCKET_CONTEXT;
    

    위에서 Socket은 클라이언트가 하나 연결될 때마다 부여되는 소켓이다. pIOContext는 이 소켓과의 입출력 작업에 사용되는 메모리 버퍼와 각종 구조체를 모아둔 구조체로 이 소켓내에서 벌어지는 입출력 작업의 상태를 나타낸다고 생각하면 된다. 다음과 같이 정의되어 있다.

    #define MAX_BUFF_SIZE       8192
    // 소켓에 대한 입출력 작업에 사용되는 구조체
    typedef struct _PER_IO_CONTEXT 
    {
        WSAOVERLAPPED        Overlapped;
        char                     Buffer[MAX_BUFF_SIZE];
        WSABUF                 wsabuf;
        int                       nTotalBytes;
        int                       nSentBytes;
        IO_OPERATION           IOOperation;
    } PER_IO_CONTEXT, *PPER_IO_CONTEXT;
    

    먼저 첫번째 필드인 Overlapped는 사실 ReadFile, WriteFile과 같은 함수를 이용해서 수행하는 비동기 I/O에서 사용하는 OVERLAPPED 구조체와 동일한 것이다. typedef로 이름만 바꾸었을 뿐이다. 뒤에서 WSARecv와 WSASend를 이용해서 비동기 I/O를 해볼 텐데 그 때 이 필드가 사용된다. 그 함수들을 호출할 때 로컬 변수로 사용하면 안 될까 생각할 수도 있는데 이 변수는 작업이 끝날 때까지 접근이 가능해야 하기 때문에 이렇게 글로발하게 별도로 잡아두는 것이다. (로컬 변수로 잡고 그걸 인자로 비동기 함수를 호출하면 그 변수가 선언한 블럭을 벗어날 경우 그 로컬 변수는 더 이상 유효하지 않다. 이런 문제를 해결하기 위함이다)

    사실 이 구조체는 의도적으로 WSAOVERLAPPED 타입의 필드로부터 시작한다. 비동기 I/O 작업에 사용되는 WSASend, WSARecv함수의 경우 인자 중에 WSAOVERLAPPED 타입의 변수를 받아들이는 인자가 있다. 또한 비동기 I/O가 끝나고 그 결과를 IOCP 큐에서 읽어들일 때 앞서 사용했던WSAOVERLAPPED 타입의 변수를 그대로 받아볼 수 있다.

    사실 Overlapped가 이 구조체의 첫 번째 필드이기 때문에 이 필드의 주소나 이 구조체의 주소나 동일하다. WSASend와 WSARecv를 이용해 비동기 I/O를 개시할때 이 구조체의 Overlapped 필드의 주소를 넘기면 사실 이것이PER_IO_CONTEXT 타입 변수의 주소를 넘긴 것이나 다름없다. 그렇게 해서 비동기 I/O의 결과를 큐에서 꺼낼 때 현재 작업의 상태를 알 수 있는 것이다. 앞서 이야기한 것처럼 PER_IO_CONTEXT 구조체는 현재 비동기 I/O 작업의 상태를 나타낸다.

    두 번째 필드인 Buffer는 읽기/쓰기 작업을 할때 사용할 메모리 영역이다. 세 번째 필드인 wsabuf는 읽기/쓰기 작업시 데이터의 시작 포인터와 데이터 크기를 지정하는데 사용되는 구조체이다. WSASend와 WSARecv 함수의 인자로 필요하다. 네 번째 인자인 nTotalBytes는 쓰기 작업시 전송해야할 데이터의 양을 나타낸다. 다섯 번째 인자인 nSendBytes는 지금까지 전송된 데이터의 양을 나타낸다. 마지막 인자인 IOOperation은 다음과 같이 정의된 열거자로서 현재 소켓에 대해 진행 중인 작업의 종류를 나타낸다.

    typedef enum _IO_OPERATION 
    {
        ClientIoRead, // 읽기 작업 진행 중
        ClientIoWrite  // 쓰기 작업 진행 중
    } IO_OPERATION, *PIO_OPERATION;
    

    이제 이를 바탕으로 예제 프로그램의 코드를 살펴보자. 다음에서 볼 수 있는 것처럼 클라이언트로부터의 요청이 들어오기를 대기하다가 요청이 들어오면 그로 인해 생성되는 소켓을 인자로 위의 작업을 수행하는UpdateCompletionPort라는 함수를 별도로 만들었다.

      SOCKET                 sdAccept = INVALID_SOCKET;
      PPER_SOCKET_CONTEXT lpPerSocketContext = NULL;
    
      while (g_bEndServer == FALSE) 
      {
        // 클라이언트가 들어오기를 대기한다.
        sdAccept = WSAAccept(g_sdListen, NULL, NULL, NULL, 0);
        if (SOCKET_ERROR == sdAccept) 
        {
          printf("WSAAccept: %d\n", WSAGetLastError());
          CleanUp();
        }
        printf("클라이언트가 하나 들어왔습니다\n.");
        // 만들어진 sdAccept 소켓에 앞서본 PER_SOCKET_CONTEXT 구조체를 할당한다.
        // 그리고나서 이를 IOCP 객체와 연결한다. 두 번째 인자로는 이제 일어날 작업의     
        // 종류를 명시한다. 에코우 서버이므로 첫 번째 할 작업은 클라이언트로부터 
        // 데이터를 읽는 것이기 때문에 ClientIoRead를 명시한다.
        lpPerSocketContext = UpdateCompletionPort(sdAccept, ClientIoRead, TRUE);
        if (NULL == lpPerSocketContext) 
        {
          CleanUp();
        }
        // …
    

    UpdateCompletionPort 함수의 내용은 다음과 같다. 첫 번째 인자로 지정된 소켓을 바탕으로 앞서본 PER_SOCKET_CONTEXT 구조체를 할당한다. 이것과 소켓을IOCP 객체와 연결한다. 두 번째 인자로는 이제 이 소켓에 일어날 작업의 종류를 명시한다. 에코우 서버이므로 첫 번째 할 작업은 클라이언트로부터 데이터를 읽는 것이기 때문에 ClientIoRead를 명시한다.

    // 첫번째 인자로 명시된 소켓을 IOCP에 연결짓는다.
    PPER_SOCKET_CONTEXT UpdateCompletionPort(SOCKET sd, IO_OPERATION ClientIo)
    {
      PPER_SOCKET_CONTEXT lpPerSocketContext;
    
      // PER_SOCKET_CONTEXT를 할당하는데 CtxtAllocate 함수를 사용한다.
      lpPerSocketContext = CtxtAllocate(sd, ClientIo);
      if (lpPerSocketContext == NULL) 
        return NULL;
    
      // 할당된 구조체와 소켓을 g_hIOCP에 연결한다.
      g_hIOCP = CreateIoCompletionPort((HANDLE)sd, g_hIOCP, 
             (DWORD)lpPerSocketContext, 0);
      if (NULL == g_hIOCP) 
      {
        printf("CreateIoCompletionPort: %d\n", GetLastError());
        if (lpPerSocketContext->pIOContext)
          free(lpPerSocketContext->pIOContext);
        free(lpPerSocketContext);
        return(NULL);
      }
    
      // 이 구조체를 링크드 리스트에 보관한다. 
      CtxtListAddTo(lpPerSocketContext);
      return(lpPerSocketContext);
    }
    

    위의 코드를 보면 PER_SOCKET_CONTEXT 타입의 구조체를 할당하기 위해서 CtxtAllocate라는 함수를 사용하고 있다. 이 함수에 대해서는 뒤에서 다시 설명할 텐데 구조체를 할당하고 초기화하는 일을 담당한다. 그 다음에 CreateIoCompletionPort 함수를 이용해서 이 구조체와 소켓을 IOCP에 연결한다. 마지막으로 이렇게 생성된 구조체를 전체적으로 관리하기 위해서 CtxtListAddTo 함수를 호출한다. 이 함수 역시 뒤에서 다시 설명하겠다.


    참고 2. CreateIoCompletionPort

    이 함수의 원형은 다음과 같다.

    HANDLE CreateIoCompletionPort(HANDLE FileHandle, 
       HANDLE ExistingCompletionPort, 
       ULONG_PTR CompletionKey, 
       DWORD NumberOfConcurrentThreads);
    

    첫 번째 인자인 FileHandle은 IOCP의 대상이 되는 입출력 객체의 핸들이어야 한다. 이 객체는 반드시 오버랩드 I/O 모드로 오픈된 것이어야 한다. 만일 이 인자의 값이 INVALID_FILE_HANDLE로 주어지고 두 번째 인자의 값이 NULL이 되면 이 함수의 리턴값은 새롭게 생성된 IOCP의 핸들이 된다. 이 때 세번째 인자의 값은 무시된다.

    두 번째 인자인 ExistingCompletionPort는 IOCP에 대한 핸들을 지정하기 위해 사용된다. 이 경우 첫번째 인자의 값은 입출력 객체의 핸들이 되어야 하며 이 둘은 연결되게 된다. 그런 경우 이 함수는 두번째 인자로 지정된 IOCP 핸들을 그대로 다시 리턴한다.

    세 번째 인자인 CompletionKey는 IOCP와 연결된 입출력 객체에 특정한 포인터라고 할 수 있다. 한 IOCP에는 여러 개의 입출력 객체가 동시에 연관될 수 있기 때문에 이 값을 통해 어느 객체로부터의 I/O 결과인지를 구분할 수 있다. 따라서 여러 개의 입출력 객체를 사용할 경우 이 인자는 아주 중요한 역할을 하게 된다.

    마지막 인자인NumberOfConcurrentThreads는 이 IOCP에 연관지어지는 스레드의 최대 수를 지정하는데 사용된다. 0을 주면 시스템의 자원이 허용하는 한 스레드가 계속 만들어지게 된다.


    4> 비동기 I/O의 수행

    앞 절차에서 소켓이 제대로 IOCP에 연결이 되고 나면 이제 그 소켓에 대해 비동기 I/O 작업을 수행해야 한다. 소켓의 경우, WSASend와 WSARead를 호출하면 그 결과는 g_hIOCP라는 것이 가리키는 큐안에 쌓이게 된다. 다음과 같은 함수들이 비동기 I/O 결과를 IOCP큐에 넣는다.

  • ReadFile, WriteFile
  • WSASend, WSARecv
  • ConnectNamedPipe
  • DeviceIoControl
  • LockFileEx
  • ReadDirectoryChanges
  • TransactNamedPipe
  • WaitCommEvent

    예제 프로그램에서는 UpdateCompletionPort 함수의 호출이 성공적으로 끝난 후에 클라이언트에서 보내는 데이터를 받기 위해서 WSARead 함수를 한번 호출한다. 참고로 다시 한번 이야기하자면 이 서버 프로그램은 에코우 서버이기 때문에 클라이언트가 보낸 데이터를 그대로 다시 클라이언트로 전송한다.

        lpPerSocketContext = UpdateCompletionPort(sdAccept, ClientIoRead);
        if (NULL == lpPerSocketContext) 
        {
          CleanUp();
          return 1;
        }
    
        // 소켓에 비동기 읽기를 수행한다. 
        nRet = WSARecv(sdAccept, &(lpPerSocketContext->pIOContext->wsabuf), 1, 
                    &dwRecvNumBytes, &dwFlags,
                    &(lpPerSocketContext->pIOContext->Overlapped), NULL);
        if (nRet == SOCKET_ERROR && (ERROR_IO_PENDING != WSAGetLastError())) 
        {
          printf("WSARecv Failed: %d\n", WSAGetLastError());
          CloseClient(lpPerSocketContext);
        }
      } //while
    

    위의 WSARecv 함수 호출에서 6번째 인자를 눈여겨 보기 바란다. WSAOVERLAPPED 구조체의 변수를 지정하는데 PER_IO_CONTEXT의 Overlapped 필드를 넘기고 있다. 3>에서 설명한 것처럼 이는 사실 pIOContext의 주소를 넘기는 것과 동일한 효과를 갖는다.

    아무튼 WSARecv로 인한 읽기 작업이 완료되면 이는 IOCP 큐에 들어간다. 이를 읽어들이는 작업은 앞에서 만든 스레드들에서 수행한다. 이 함수는 비동기 함수이기 때문에 바로 리턴하고 그리고나서 코드는 다시 while 루프로 선두로 가서 다른 클라이언트로부터의 연결을 대기한다.

      while (g_bEndServer == FALSE) 
      {
        // 클라이언트가 들어오기를 대기한다.
        sdAccept = WSAAccept(g_sdListen, NULL, NULL, NULL, 0);
        …
    

    즉, main 함수는 초기화 작업을 하고 난 뒤부터는 클라이언트로부터의 소켓연결이 맺어지기를 기다렸다가 만들어지면 이를 IOCP와 연결한 뒤에 WSARecv를 한번 호출하는 일만 한다. 실제 작업은 모두 스레드에서 이루어진다.

    참고로 WSASend와 WSARecv의 함수 원형을 살펴보자.

    int WSARecv(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, 
           LPDWORD  lpNumberOfBytesRecvd, LPDWORD lpFlags, 
           LPWSAOVERLAPPED lpOverlapped, 
           LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);
    
    int WSASend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, 
           LPDWORD lpNumberOfBytesSent, DWORD dwFlags, 
           LPWSAOVERLAPPED lpOverlapped, 
           LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine);
    

    이 두 함수는 비슷한 인자를 많이 갖고 있다. 먼저 모두 첫번째 인자는 소켓 핸들이다. 두 번째 인자는 WSABUF라는 구조체에 대한 포인터로 보낼 데이터에 대한 정보이거나 데이터를 받을 버퍼에 대한 정보이다. WSABUF는 다음과 같이 버퍼의 시작 주소와 버퍼의 크기를 지정하는 두개의 필드로 구성되어 있다.

    Typedef struct __WSABUF
    {
    u_long len; // 버퍼 크기
      char FAR *buf; // 버퍼 시작 주소
    } WSABUF, FAR *LPWASBUF;
    

    이 두 번째 인자로는 WSABUF 배열의 주소를 지정할 수도 있다. 그 경우 차례로 여러 버퍼의 데이터를 전송하거나 (WSASend의 경우) 받은 데이터를 여러 버퍼로 옮기는 역할(WSARecv의 경우)을 한다. 세 번째 인자는 이 두 번째 인자가 가리키는 WSABUF 변수의 수를 나타낸다. 배열을 지정했을 경우에는 그 크기를 이 인자로 지정해주면 된다. 배열이 아니라면 그냥 1을 지정하면 된다. 여기서 한가지 알아야 할 점은 이 두 함수 모두 지정한 크기만큼 입출력이 종료된 다음에 리턴되는 것이 아니란 점이다. WSARecv 같은 경우에는 읽어올 데이터가 생기면 지정된 크기와 관계없이 바로 작업을 종료한다. WSASend의 경우에는 소켓 버퍼가 꽉 차서 데이터를 지정된 크기만큼 보낼 수 없으면 일단 보낼 수 있는 만큼 보내고 만다.

    네 번째 인자는 각기 실제로 전송된 데이터(WSASend의 경우)와 실제로 읽어들인 데이터(WSARecv의 경우)의 크기가 들어간다. 그런데 이 함수들을 예제 프로그램에서처럼 비동기 모드로 사용할 경우에는 이 인자로 리턴되는 값은 함수 자체의 리턴값이 0인 경우에만 의미가 있다. 0인 경우는 바로 작업이 끝난 경우이다. 함수가 바로 끝나지 않을 경우에는 SOCKET_ERROR가 리턴되고 이 때 GetLastError 함수를 호출해보면 그 값이 WSA_IO_PENDING일 것이다.

    다섯 번째 인자는 약간 복잡한데 일단 대부분 0이 리턴되거나 (WSARecv의 경우) 0이 지정(WSASend의 경우)된다고 알아두기 바란다. 여섯 번째 인자는 WSAOVERLAPPED 구조체에 대한 포인터를 지정하는 부분이다. IOCP를 사용하는 경우에는 hEvent 필드의 값은 반드시 NULL이 지정되어야 한다. 마지막 인자는 콜백함수를 지정하는데 사용된다. 이 콜백함수의 원형은 다음과 같다.

    void CALLBACK CompletionROUTINE(DWORD dwError, DWORD cbTransferred,
        LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags);
    

    만일 여섯 번째 인자와 마지막 인자가 모두 NULL이면 이 함수들은 동기 모드로 동작한다. 여섯 번째 인자와 마지막 인자가 모두 지정되면 작업이 종료되었을 때 마지막 인자로 지정된 함수가 호출된다. 여섯 번째 인자만 지정되고 첫 번째 인자로 지정된 소켓이 IOCP와 연결되어 있으면 이 함수의 결과는 IOCP 큐에 들어간다. 사실 이 두 함수의 인자들을 제대로 이해할 수 있다면 윈도우 운영체제의 입출력 함수는 다 이해했다고 봐도 무방하다.

    5> 비동기 I/O 결과 읽기

    앞서 수행된 비동기 I/O의 결과를 읽어들이려면 GetQueuedCompletionPort라는 함수를 이용해야 한다. 이 함수 원형에 대한 설명은 참고 3에 있다. 이 함수는 IOCP 큐안에 읽어들일 비동기 I/O 결과가 있으면 이를 읽어가지고 리턴한다. 읽어올 것이 없으면 읽어올 것이 생길 때까지 리턴하지 않는다. 다음 코드처럼 이 함수는 무한루프안에서 계속적으로 호출되는 것이 일반적이다.

    While (1)
    {
      GetQueuedCompletionStatus(…);
      // 읽어들인 결과를 바탕으로 다음 일을 수행한다.
      …
    }
    

    예제 프로그램과 같은 에코우 서버에서는 특정 소켓에 대해 읽기 작업이 완료된 결과를 읽어들였으면 이를 비동기로 쓰는 작업을 하고, 쓰기 작업이 완료된 결과를 읽어들였으면 다시 비동기로 읽기 작업을 수행한다. 앞서 이야기한 것처럼 GetQueuedCompletionPort 함수의 세 번째 인자로는 현재 이 소켓에 대해 따로 할당된PER_SOCKET_CONTEXT 구조체의 포인터가 리턴되고 이 것의 pIOContext 필드를 보면 현재 진행중인 작업의 상태를 알 수 있다. pIOContext의IOOperation 필드의 값이ClientIoRead이면 지금 큐에서 읽어온 작업이 읽기 작업의 결과인 것이고 ClientIoWrite이면 쓰기 작업인 것이다.

    위의 코드를 좀더 예제 프로그램에 맞게 고쳐보면 다음과 같은 식이다.

    While (1)
    {
      GetQueuedCompletionStatus(…);
      // 읽어들인 결과를 바탕으로 다음 일을 수행한다.
      만일 읽어들인 결과가 읽기 작업이면
        읽어들인 데이터를 그대로 다시 서버로 보낸다 (물론 비동기 I/O)
      만일 읽어들인 결과가 쓰기 작업이면
        만일 앞서 쓰기 요청한 것이 다 전송되지 않았으면
          전송안 된 부분만 다시 전송한다
        다 전송되었으면
          읽기 비동기 작업을 소켓에 수행한다.
    }
    

    참고 3. GetQueuedCompletionStatus

    이 함수의 원형은 다음과 같다.

    BOOL GetQueuedCompletionStatus(
        HANDLE CompletionPort,       
        LPDWORD lpNumberOfBytes, 
        PULONG_PTR lpCompletionKey,
        LPOVERLAPPED *lpOverlapped,
        DWORD dwMilliseconds);
    

    첫 번째 인자인 CompletionPort로는 앞서 생성된 IOCP 객체의 핸들을 지정한다.

    두 번째 인자로는 지금 읽어온 I/O 작업의 결과로 읽거나 쓴 데이터의 크기가 바이트 단위로 지정된다. 즉 이 인자의 값은 운영체제에서 지정한다.

    세 번째 인자인 lpCompletionKey역시 운영체제에 의해 채워져 리턴되는 값이다. CreateIoCompletionPort 함수로 IOCP 객체를 생성할 때 세 번째 인자로 지정한 값이 여기로 리턴된다. 앞서 이야기한 것처럼 한 IOCP 객체로 둘 이상의 입출력 디바이스를 처리할 수 있기 때문에 이를 구분하는 값이 여기로 지정된다고 생각하면 된다.

    네 번째 인자인 lpOverlapped 역시 운영체제에 의해 값이 지정되는데 이는 한 입출력 디바이스내에서 각각의 입출력 작업을 구별하는 역할을 한다. 이 값은 사실 앞서 비동기 작업에서 사용된 OVERLAPPED 구조체의 주소가 그대로 들어온다. 그렇기 때문에 비동기 I/O 작업시에 OVERLAPPED 구조체를 스택에 있는 것을 사용하면 안 되고 각 작업마다 서로 다른 OVERLAPPED 구조체가 사용되어야 하는 것이다.

    마지막 인자인dwMilliseconds는 IOCP 큐에 결과가 없을 경우 얼마나 더 대기하다가 리턴할 것인지를 밀리세컨드 단위로 지정한다. 만일 타임아웃이 나서 리턴할 경우에는 GetQueuedCompletionStatus 함수의 리턴값은 FALSE가 되고 네 번째인자로는 NULL이 지정된다. 읽어올 것이 생길 때까지 대기하도록 하고 싶으면 이 인자로 INFINITE를 지정하면 된다.

    위의 플로우를 염두에 두고 이제 예제 프로그램의 스레드 코드를 실제로 살펴보자. 주석을 자세히 달아놓았으므로 주석과 함께 코드를 살펴보기 바란다.

    DWORD WINAPI EchoThread (LPVOID WorkThreadContext)
    {
      // 앞서 스레드 생성시 스레드 함수의 인자로 IOCP 핸들을 지정했었다.
      // 인자를 IOCP 핸들로 캐스팅한다.
      HANDLE hIOCP = (HANDLE)WorkThreadContext;
      BOOL   bSuccess = FALSE;
      int      nRet;
      LPOVERLAPPED    lpOverlapped = NULL;
      PPER_SOCKET_CONTEXT lpPerSocketContext = NULL;
      PPER_IO_CONTEXT     lpIOContext = NULL; 
      WSABUF buffRecv;
      WSABUF buffSend;
      DWORD  dwRecvNumBytes = 0;
      DWORD  dwSendNumBytes = 0;
      DWORD  dwFlags = 0;
      DWORD  dwIoSize;
        
      while (TRUE) 
      {
        // IOCP 큐에서 비동기 I/O 결과를 하나 읽어온다.
        bSuccess = GetQueuedCompletionStatus(hIOCP, &dwIoSize, 
                 (LPDWORD)&lpPerSocketContext, &lpOverlapped,INFINITE);
        if (!bSuccess) 
          printf("GetQueuedCompletionStatus: %d\n", GetLastError());
    
        // CleanUp 함수에 의해서 스레드의 강제 종료 명령이 내려지면.. 
        if (lpPerSocketContext == NULL)  return 0;
        if (g_bEndServer) return 0;
     
        // 클라이언트와의 소켓 연결이 끊어졌으면…
        if (!bSuccess || (bSuccess && (0 == dwIoSize))) 
        {
          // lpPerSocketContext를 메모리에서 제거한다.
          CloseClient(lpPerSocketContext); 
          continue;
        }
    
        /* 앞서 WSASend와 WSARecv에 의해 I/O 작업을 할 때 넘겼던 WSAOVERLAPPED    
    타입의 변수가 사실은 PER_IO_CONTEXT 타입의 시작이기도 하므로 이를 캐스팅하
    여 사용가능하다. */
        lpIOContext = (PPER_IO_CONTEXT)lpOverlapped;
        switch (lpIOContext->IOOperation) // 끝난 작업 종류가 무엇인가 ?
        {
            case ClientIoRead: // 읽기 작업인가 ?
            // --------------------------------------------
            // 받은 것을 그대로 보낸다. 즉, 다음 작업은 쓰기 작업이다.
            // --------------------------------------------
            printf("%s를 받았고 이를 재전송합니다.\n.", lpIOContext->wsabuf.buf);
              lpIOContext->IOOperation = ClientIoWrite; // 이제 쓰기 작업이 진행됨을 표시
              // 얼마큼 전송할 것인지 명시한다. 받은 만큼 보낸다. 이는 상태를 기록하기
              // 위함이지 WSASend 함수와는 관련없다.
              lpIOContext->nTotalBytes = dwIoSize; 
              // 전송된 데이터 크기. 아직 보낸 것이 없으므로 0
              lpIOContext->nSentBytes  = 0;
            // WSASend에게 보낼 데이터의 포인터와 크기를 지정한다.
            // 받은 데이터가 이미 lpIOContext->wsabuf.buf에 있다.
            lpIOContext->wsabuf.len  = dwIoSize; // 크기 지정
            dwFlags = 0;
            nRet = WSASend(lpPerSocketContext->Socket,
                  &lpIOContext->wsabuf, 1, &dwSendNumBytes,
                  dwFlags, &(lpIOContext->Overlapped), NULL);
            if (SOCKET_ERROR == nRet && (ERROR_IO_PENDING != WSAGetLastError())) 
            {
              printf("WSASend: %d\n", WSAGetLastError());
              CloseClient(lpPerSocketContext);
            }
            break;
    
          case ClientIoWrite: // 쓰기 작업인가 ?
            // ----------------------------------------------------
            // 전송이 다 되었는지 확인한다. 다 전송되지 않았으면 아직 전송되지 
            // 않은 데이터를 다시 보낸다. 다 전송되었으면 WSARecv를 호출해서
            // 다시 받기 모드로 진입한다.  
            // --------------------------------------------
            lpIOContext->nSentBytes  += dwIoSize; // 전송된 데이터 크기 업데이트
            dwFlags = 0;
            if (lpIOContext->nSentBytesnTotalBytes) // 다 전송되지 않았으면
            {
              // 마저 전송해야 하므로 아직 보내기모드
              lpIOContext->IOOperation = ClientIoWrite;
              // -----------------------
              // 전송되지 않은 부분을 보낸다. 
              // -----------------------
              // 버퍼 포인터를 업데이트하고
              buffSend.buf = lpIOContext->Buffer + lpIOContext->nSentBytes;
              // 보내야할 데이터의 크기를 남은 데이터의 크기만큼으로 줄인다.
              buffSend.len = lpIOContext->nTotalBytes - lpIOContext->nSentBytes;
              nRet = WSASend (lpPerSocketContext->Socket,
                         &buffSend, 1, &dwSendNumBytes,
                         dwFlags, &(lpIOContext->Overlapped), NULL);
              // SOCKET_ERROR가 리턴된 경우에는 반드시 WSAGetLastError의 리턴값이
              // ERROR_IO_PENDING이어야 한다.
              if (SOCKET_ERROR == nRet && (ERROR_IO_PENDING != WSAGetLastError())) 
              {
                printf ("WSASend: %d\n", WSAGetLastError());
                CloseClient(lpPerSocketContext);
              }
            }
            else // 데이터가 전부 전송된 경우
            {
              // 다시 이 소켓으로부터 데이터를 받기 위해 WSARecv를 호출한다.
              lpIOContext->IOOperation = ClientIoRead; 
              dwRecvNumBytes = 0;
              dwFlags = 0;
              buffRecv.buf = lpIOContext->Buffer; // 수신버퍼 지정
              // 읽어들일 데이터 크기 지정. 사실 이 크기만큼 데이터를 읽어들여야 
              // 그 결과가 IOCP큐에 들어가는 것은 아니다.  이 크기 이상 안 
              // 읽어들일 뿐이고 데이터가 이용가능한 만큼 IOCP큐에 넣는다.
              buffRecv.len = MAX_BUFF_SIZE;
              nRet = WSARecv(lpPerSocketContext->Socket,
                            &buffRecv, 1, &dwRecvNumBytes,
                            &dwFlags, &(lpIOContext->Overlapped), NULL);
              // SOCKET_ERROR가 리턴된 경우에는 반드시 WSAGetLastError의 리턴값이
              // ERROR_IO_PENDING이어야 한다.
              if (SOCKET_ERROR == nRet && (ERROR_IO_PENDING != WSAGetLastError())) 
              {
                printf ("WSARecv: %d\n", WSAGetLastError());
                CloseClient(lpPerSocketContext);
              }
            }
            break;
          } //switch
        } //while
        return(0);
    }
    

    자 이상으로 IOCP가 어떤 식으로 동작하는지 알아보았다. 단계별로 설명과 코드를 잘 살펴보면 어떻게 동작하는지 더 쉽게 이해할 수 있을 것이다.

    3. 예제 프로그램의 기타 코드 설명

    예제 프로그램에서 설명이 안 된 코드는 서버와 연결된 클라이언트의 리스트를 관리하는 함수들(CtxtAllocate, CtxtListFree, CtxtListAddTo, CtxtListDeleteFrom)과 청소 함수(CleanUp, CloseClient), 대기 소켓 생성함수(CreateListenSocket)등이다. 대기 소켓 생성 함수는 이미 지난 연재에서 살펴본 내용(사실 socket 대신에 WSASocket을 호출하는 부분만 다르다)이기 때문에 여기서는 다른 함수들에 대해서만 알아보겠다.

    클라이언트 리스트 관리 함수들

    접속하는 클라이언트가 생길 때마다 이는g_CtxtList에 기록된다. 이는CptrList 타입의 링크드 리스트 클래스이고 이 변수로의 접근은 모두g_CriticalSection이란 크리티컬 섹션에 의해 한번에 한 스레드로 제한된다.

    CtxtAllocate는 인자로 지정된 소켓에 PER_SOCKET_CONTEXT 구조체를 하나 할당하고 그 구조체를 초기화한 다음에 이를 리턴한다. 할당에 실패하면 NULL을 리턴한다. PER_SOCKET_CONTEXT 구조체의 IO_PER_CONTEXT 타입의 필드인 pIOContext의 필드를 초기화하는 부분을 눈여겨 봐두기 바란다.

    PPER_SOCKET_CONTEXT CtxtAllocate(SOCKET sd, IO_OPERATION ClientIO)
    {
      PPER_SOCKET_CONTEXT lpPerSocCon;
    
      lpPerSocCon = (PPER_SOCKET_CONTEXT)malloc(sizeof(PER_SOCKET_CONTEXT));
      if (lpPerSocCon)
      {
        lpPerSocCon->pIOContext = (PPER_IO_CONTEXT)
            malloc(sizeof(PER_IO_CONTEXT));
        if (lpPerSocCon->pIOContext) 
        {
          lpPerSocCon->Socket = sd;
          memset(&lpPerSocCon->pIOContext->Overlapped, 
             0, sizeof(OVERLAPPED));
          lpPerSocCon->pIOContext->IOOperation = ClientIO;
          lpPerSocCon->pIOContext->nTotalBytes = 0;
          lpPerSocCon->pIOContext->nSentBytes = 0;
          lpPerSocCon->pIOContext->wsabuf.buf = lpPerSocCon->pIOContext->Buffer;
          lpPerSocCon->pIOContext->wsabuf.len = MAX_BUFF_SIZE;
        }
        else 
        {
          free(lpPerSocCon);
          lpPerSocCon = NULL;
        }
      }
      return(lpPerSocCon);
    }
    

    나머지 세 함수들은 간단하다. CptrList 클래스를 사용해본 이라면 이 함수들의 소스를 이해하기가 아주 쉬울 것이다. 여기서는 CtxtListAddTo와 CtxtListDeleteFrom 함수만 살펴보겠다.

    // g_CtxtList에 lpPerSocketContext가 가리키는 항목을 추가한다
    VOID CtxtListAddTo (PPER_SOCKET_CONTEXT lpPerSocketContext)
    {
      EnterCriticalSection(&g_CriticalSection);
      g_CtxtList.AddTail(lpPerSocketContext); // 리스트의 끝에 붙인다.
      LeaveCriticalSection(&g_CriticalSection);
      return;
    }
    
    // g_CtxtList에서 lpPerSocketContext가 가리키는 항목을 제거한다.
    VOID CtxtListDeleteFrom(PPER_SOCKET_CONTEXT lpPerSocketContext)
    {
      EnterCriticalSection(&g_CriticalSection);
      if (lpPerSocketContext)
      {
        POSITION pos = g_CtxtList.Find(lpPerSocketContext);
        if (pos)
        {
          g_CtxtList.RemoveAt(pos);
          if (lpPerSocketContext->pIOContext)
            free(lpPerSocketContext->pIOContext);
          free(lpPerSocketContext);
        }
      }
      LeaveCriticalSection(&g_CriticalSection);
      return;
    }
    

    청소 함수들

    여기서는 CleanUp 함수의 코드를 보기로 하겠다. 이 함수를 프로그램이 종료될 때 호출되는 함수로 모든 스레드가 종료되기를 기다렸다가 클라이언트 리스트에 할당되었던 자료구조들을 제거하고 최종적으로 IOCP와 대기 소켓을 제거하는 일을 수행한다.

    void CleanUp()
    {
        if (g_hIOCP)        
        {
            // 스레드를 강제 종료하도록 한다. 
            // 참고 4와 EchoThread의 if (lpPerSocketContext == NULL)를 같이 보기 바란다.  
            for (DWORD i = 0; i < g_dwThreadCount; i++)
                PostQueuedCompletionStatus(g_hIOCP, 0, 0, NULL);
        }
    
        // 모든 스레드가 실행을 중지했는지 확인한다.
        if (WAIT_OBJECT_0 != WaitForMultipleObjects( g_dwThreadCount,  g_hThreads,
                         TRUE, 1000))
            printf("WaitForMultipleObjects failed: %d\n", GetLastError());
        else
            for (DWORD i = 0; i < g_dwThreadCount; i++) // 스레드 핸들을 모두 닫는다.
            {
                if (g_hThreads[i] != INVALID_HANDLE_VALUE) CloseHandle(g_hThreads[i]);
                    g_hThreads[i] = INVALID_HANDLE_VALUE;
            }
        // g_CtxtList에 들어있는 클라이언트들을 모두 제거한다.
        CtxtListFree();
        // IOCP를 제거한다.  
        if (g_hIOCP)    
        {
            CloseHandle(g_hIOCP);
            g_hIOCP = NULL;
        }
        // 대기 소켓을 제거한다.
        if (g_sdListen != INVALID_SOCKET) 
        {
            closesocket(g_sdListen); 
            g_sdListen = INVALID_SOCKET;
        }
    
        DeleteCriticalSection(&g_CriticalSection); // 크리티컬 섹션을 제거한다.
        WSACleanup(); // 윈속 라이브러리를 해제한다.
    }
    


    참고 4. PostQueuedCompletionPort

    앞에서 설명한 것처럼 이 함수는 IOCP 큐에 마치 비동기 작업이 끝나서 그 결과가 큐에 들어가는 것처럼 흉내내는 기능을 한다. 그렇기 때문에 이 함수의 인자들을 보면 GetQueuedCompletionStatus 함수에 있는 것과 동일하다. 이 함수의 원형은 다음과 같다.

    BOOL PostQueuedCompletionStatus(
    HANDLE CompletionPort,
    DWORD dwNumberOfBytesTransferred,
      ULONG_PTR dwCompletionKey, 
    LPOVERLAPPED lpOverlapped);
    

    첫 번째 인자인 CompletionPort로는 지금 만들어내는 I/O 작업의 결과가 들어갈 IOCP 객체의 핸들을 지정한다.

    두 번째 인자인 dwNumberOfBytesTransferred는 GetQueuedCompletionStatus 함수의 두 번째 인자로 넘어갈 값을 지정한다.

    세 번째 인자인 dwCompletionKey는 두 번째 인자와 마찬가지로 GetQueuedCompletionStatus 함수의lpCompletionKey 인자로 들어갈 값을 지정하는데 사용된다.

    네 번째 인자인 lpOverlapped는 앞서 인자들과 마찬가지로 GetQueuedCompletionStatus 함수의 네 번째 인자로 들어갈 OVERLAPPED 구조체의 값을 넘긴다.

    이 함수가 성공적으로 인자로 지정된 값들을 IOCP 큐에 넣으면 0이 아닌 값이 리턴된다. 실패시에는 0이 리턴되며 이 때는 GetLastError 함수를 호출해서 에러의 원인을 찾아볼 수 있다.

    예제 프로그램의 실행 화면은 그림 2와 같다.

    < 그림 2. 예제 프로그램의 실행화면 >

    이 것으로 IOCP에 대한 장황한 설명을 마치겠다. 아마 이해하기가 그리 쉽지 않을 것이다. 필자의 경우에도 이를 이해하는데 상당한 시간을 소모했으며 위의 예제 프로그램을 바탕으로 실제 환경하에서 동작하는 프로그램을 만드는데도 상당한 시간을 보냈다. 이해하기는 어렵지만 IOCP는 스레드을 최대한으로 활용할 수 있도록 해주는 메커니즘이다. 특히 소켓으로 다중 사용자의 요구를 처리해야 하는 프로그램을 만들어야 한다면 IOCP는 최적의 솔루션이 아닌가 싶다.

    참고문헌
    1. INFO: Design Issues When Using IOCP in a Winsock Server (Q192800) - http://support.microsoft.com/default.aspx?scid=kb;EN-US;q192800
    2. Programming Server-Side Applications for Microsoft Windows 2000, Chapter 2 Devico I/O and Interthreaded Communication
    3. Writing Windows NT Server Applications in MFC Using I/O Completion Ports - http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnpic/html/msdn_servrapp.asp
    4. UNBUFCPY, SOCKSRV – Microsoft Platform SDK IOCP 윈속 예제 프로그램
    5. Windows Sockets 2.0: Write Scalable Winsock Apps Using Completion Ports - http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnmag00/html/Winsock.asp

    파일다운로드 : iocp_source.zip

  •  

    출처 : http://www.winapiprogramming.com/

    안정적인 DNS서비스 DNSEver DNS server, DNS service
    Posted by 키르히아이스
    ,

    소리바다에서 한게임까지 TCP/IP 소켓 프로그래밍 에서...


    • TCP에 대한 이야기

      TCP 프로토콜은 가상으로 연결된 상태입니다.  그렇기 때문에 서버와 클라이언트 프로그램 모두에 신경을 써야 합니다.

      그리고, 1회 전송한 패킷이 한 번에 전송되리라고 기대하서는 안됩니다.  또한, 여러번에 걸쳐 전송한 패킷이 역시 한 번에 전송될 수 있습니다.

      TCP가 동작하는 방식은, 우리에게 보여지지 않습니다.  중요한 것은, 연결이 된 이후부터 연결을 닫을 때까지 전송한 데이터가 모두 하나의 데이터라는 점입니다.

      가령, 엄청나게 큰 버퍼(100MB 정도)를 할당하고, 앞에서부터 차례대로 채워나간다고 생각하면 됩니다.  변수를 하나 생성할 때마다 앞에서부터 차곡차곡 채워 넣습니다.  변수를 읽어 올 때는 어디서부터 어디까지가 하나의 변수인지 확인이 필요합니다.  모든 변수가 동일한 크기를 갖는 것이 아니기 때문입니다.  따라서 TCP에서는 어디서부터 어디까지가 의미있는 하나의 패킷인지 확인하는 작업이 필수입니다.

       

      중요한 것은 전송하는 측이나 수신 측 모두 두 개의 버퍼를 사용하는 것이 핵심입니다.  소켓 라이브러리는 전송 또는 수신을 위해 자체적으로 별도의 버퍼를 사용합니다.  그렇기 때문에 send함수를 호출했다고 해서 패킷이 실제로 상대 소켓으로 전달되었다고 가정해서는 안 됩니다.  send함수는 소켓 라이브러리의 버퍼로 데이터를 옮겨놓는 순간 반환합니다.

       

      이 것을 기억합시다.  TCP 프로토콜에서 연결이 구축되어 있는 동안 전송되는 패킷은 모두 연속되어 있다.  패킷을 처리할 때는, 각각의 패킷 길이만큼 잘라서 처리해야 합니다.

    • 패킷의 구성

      패킷은 크게 두 부분으로 나뉩니다.  이 두 부분은 소켓 라이브러리가 자동으로 구성해 주는 것이 아니고, 프로그래머가 프로그램의 성격에 맞게 구성해야 합니다.  패킷의 앞 부분에는 헤더가 오고, 뒷 부분에는 데이터가 옵니다.  헤더에 들어가는 내용은 다 다릅니다.  그러나, 반드시 포함되어야 하는 요소가 패킷 전체의 길이입니다.  없다면, 패킷을 해당 패킷에 맞게 잘라낼 수 없습니다.

       

      패킷 헤더는 일반적으로 어떠한 패킷이던지 동일한 길이로 구성합니다.  그래야 패킷을 분석하기가 쉽습니다.   대체적으로 패킷 헤더에는 패킷 전체 길이와 식별자를 넣는 것이 좋습니다.  다음은 패킷의 예제입니다.

         

      패킷전체길이

      길이 : 4 (int)

      식별자 (ID)

      길이 : 4 (int)

      헤더

     

      패킷 전체 길이

      길이 : 24

      식별자

      FILE_LOGIN

      회원 식별자

      길이 : 8 (char[8])

      암호

      길이 : 8 (char[8])

      로그인

       

      패킷 전체 길이

      길이 : 80

      식별자

      FILE_REQUEST

      회원 식별자

      길이 : 8 (char[8])

      암호

      길이 : 64 (char[64])

      파일 요청

       

      패킷 전체 길이

      길이 : 파일길이 + 8

      식별자

      FILE_TRANSFER

      파일 내용

      길이 : 가변

      파일 전송

       

      위의 패킷을 사용하는 시나리오를 말하자면 다음과 같습니다.

      클라이언트는 먼저 서버에 접속하고, 이 때 로그인 패킷을 사용함.  소리바다 등에서 파일을 검색한 후, 다운로드할 파일을 다른 회원에게 요청함.  이 때 파일 요청 패킷을 사용.  파일 요청을 받은 패킷은 파일을 요청한 회원에게 전송하고, 이 때 파일 전송 패킷을 사용함.  각각의 패킷은 모두 패킷 헤더를 갖고 있고, 패킷 헤더의 앞에는 전체 패킷의 길이가 들어 있음.  패킷을 수신했을 때의 처리는, 헤더의 두 번째 부분인 식별자에 따라 분기가 일어남.  로그인 패킷이라면 이미 로그인한 회원인지를, 파일 요청이라면 파일이 존재하는지 검사할 수 있습니다.  패킷 식별자에 들어 있는 영어 단어는 식별자를 define 문으로 정의해 놓은 상수들입니다.

       

      이 부분에서 제일 중요한 것은 패킷의 활용 부분입니다.  그리고 반드시 패킷의 맨 앞에 패킷 길이를 알려주도록 합시다.

       

    • 패킷에 관하여

      패킷이 다음과 같이 구성되어 있다고 가정합시다.

      파일명

      파일 데이터

      1번 패킷

      파일명

      파일 데이터

      2번 패킷

      파일명

      파일 데이터

      3번 패킷

      파일명

      파일 데이터

      4번 패킷

       

      모두 4개의 패킷이 있습니다.  이 패킷을 1번부터 순차적으로 전송을 합니다.  하지만 목적지에 4번이 먼저 도착할 수 있습니다.

      왜냐하면, 패킷은 라우터라는 기계를 거쳐서 목적지까지 가는데, 이 라운터라는 녀석이 판단하기에, 가장 빠를 것 같은 경로로 보내기 때문에, 3번 패킷보다 4번 패킷이 먼저 도착할 수 있습니다.  그렇기 때문에 저 패킷에 패킷의 번호를 나타내는 부분이 추가되어야 합니다.  또한, 프로그램에서 전송하는 패킷의 크기와 시스템이 전송하는 패킷의 크기가 다를 수 있습니다.  그러면, 결국 우리가 전송한 패킷은 여러 개의 패킷으로 분할될 수도 있고, 여러 번에 걸쳐 전송한 패킷이 하나의 패킷으로 결합될 수도 있습니다.  그러므로, 여러 개의 패킷이 합쳐져서 한 번에 전송될 경우, 우리는 각각의 패킷을 구분할 수 있어야 합니다.  그렇기 때문에 한가지 더 추가되어야 합니다.  위에서 얘기했던 패킷의 길이 부분이 필요합니다.

      위의 내용을 적용해서 패킷을 구성하면 다음과 같습니다.

      길이

      파일명

      번호[1]

      파일 데이터

      1번 패킷

      길이

      파일명

      번호[2]

      파일 데이터

      2번 패킷

      길이

      파일명

      번호[3]

      파일 데이터

      3번 패킷

      길이

      파일명

      번호[4]

      파일 데이터

      4번 패킷

       

      위의 경우는 특수한 경우이고, 대부분의 경우는 [길이] 필드와 [데이터] 필드만 있으면 패킷의 구성이 가능합니다.  그리고 이에 추가적으로 거의 필수에 가까운 것이, 데이터를 구분할 수 있는 식별자 입니다.

    • 패킷 전송

      패킷을 전문적으로 전송하는 함수가 윈도우 소켓 라이브러리 버젼 2에 새롭게 추가되었습니다.  이름은 TransmitPackets입니다.

      #include "stdafx.h"
      #include <WINSOCK2.H>
      #include <mswsock.h>
      #include <cassert>

      int _tmain(int argc, _TCHAR* argv[])
      {
          WSADATA wsaData;
          WSAStartup(MAKEWORD(2, 2), &wsaData);

          SOCKET ClientSocket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP);

          sockaddr_in ServerAddr;
          ZeroMemory(&ServerAddr,
      sizeof(ServerAddr));

          ServerAddr.sin_family       = AF_INET;
          ServerAddr.sin_addr.s_addr  = inet_addr(
      "211.254.138.79");
          ServerAddr.sin_port         = htons(50000);

          connect(ClientSocket, (sockaddr*) &ServerAddr,
      sizeof(ServerAddr));

          printf(
      "서버에 연결되었습니다..\n");

          
      //-------------------------------------------------
          // TransmitPackets 함수에 전달할 인자를 구축합니다.
          //-------------------------------------------------

          
      //함수 인자
          char pData[][30] = {"안녕하세요!", "김정훈입니다."};                // 원격지 소켓으로 전송될 실제 데이터 배열.

          TRANSMIT_PACKETS_ELEMENT pPacketElems[2];                           
      // 패킷 유형과 패킷 데이터에 대한 정보를 저장하고 있는 구조체 배열.
          ZeroMemory(&pPacketElems, sizeof(pPacketElems));

          
      int nPacketCount = sizeof(pPacketElems) / sizeof(pPacketElems[0]);

          
      // 배열 각각의 요소를 전송 데이터로 설정합니다.
          for(int i = 0; i < nPacketCount; i++)
          {
              pPacketElems[i].dwElFlags   = TP_ELEMENT_MEMORY;                
      // TP_ELEMENT_MEMORY -> 메모리에 있는 버퍼를 전송한다고 알려줌.
              pPacketElems[i].cLength     = lstrlen(pData[i]) + 1;            // 버퍼의 길이
              pPacketElems[i].pBuffer     = pData[i];                         // 버퍼 포인터.
          }

          
      //-------------------------------------------------
          // TransmitPackets 함수의 포인터를 구한 다음,
          // TransmitPackets 함수를 실제로 호출하는 부분.
          //-------------------------------------------------

          
      // 함수 포인터
          DWORD                   dwBytesReturned;

          
      // TransmitPackets 함수를 가리키는 WSAID_TRANSMITPACKETS GUID를 변수에 저장.
          // 포인터가 넘어가야 하기 때문에, GUID를 직접 사용하지는 못합니다.
          // GUID는 오직 하나 밖에 존재하지 않는 유일한 값을 말합니다.
          // 이 GUID 값은 mswsock.h 파일에서 찾으실 수 있습니다.
          GUID                    guid = WSAID_TRANSMITPACKETS;

          
      // 우리가 사용할 TransmitPackets 함수의 자료형입니다.
          // 사실 TransmitPackets 함수는 실제로 존재하는 함수가 아니고, 제가 만든 함수 이름입니다.
          // 헤더 파일에 함수 선언이 존재하는 것이 아니라, 프로금애 실행되는 동안에 직접 함수 포인터를 구해서 사용하기 때문입니다.
          // 그래서, 이름 대신 함수의 자료형인 LPFN_TRANSMITPACKETS 자료형이 존재합니다.
          LPFN_TRANSMITPACKETS    TransmitPackets;

          
      /*
          위의 LPFN_TRANSMITPACKETS의 원형은 다음과 같습니다.
          typedef BOOL (PASCAL FAR * LPFN_TRANSMITPACKETS) (
          SOCKET                      hSocket,
          LPTRANSMIT_PACKETS_ELEMENT  lpPacketArray,
          DWORD                       nElementCount,
          DWORD                       nSendSize,
          LPOVERLAPPED                lpOverlapped,
          DWORD                       dwFlags
          );
          */


          
      // ConnectEx, TransmitPackets 함수 등의 확장 함수는 WSAIoctl 함수를 호출해서 해당 함수의 포인터를 구해야 합니다.
          int nRet = WSAIoctl(
              ClientSocket,                           
      // 소켓 핸들
              SIO_GET_EXTENSION_FUNCTION_POINTER,     // 제어 코드로 확장 함수를 가리키는 제어 코드.
              &guid,                                  // 제어 코드와 관련된 값으로 확장 함수 일 경우, 해당 함수를 가리키는 GUID 값.
              sizeof(GUID),                           // 세번째 인자의 크기
              &TransmitPackets,                       // 함수 포인터를 반환 받을 버퍼.
              sizeof(LPFN_TRANSMITPACKETS),           // 다섯 번째 인자의 크기
              &dwBytesReturned,                       // 버퍼에 반환된 데이터 크기
              NULL,                                   // 중첩 구조체
              NULL);                                  // 완료 함수 포인터.

          assert( nRet != SOCKET_ERROR);

          TransmitPackets(
              ClientSocket,                           
      // 소켓 핸들
              pPacketElems,                           // 패킷 배열의 시작 주소
              nPacketCount,                           // 배열의 크기
              0, NULL, 0);

          closesocket(ClientSocket);
          WSACleanup();
          
      return 0;
      }


    2003년 8월 26일 화요일

    안정적인 DNS서비스 DNSEver DNS server, DNS service
    Posted by 키르히아이스
    ,

     

    source : http://www.gosu.net/GosuWeb/Article-detail.aspx?ArticleCode=1127

     

     
    TCP 성능 case study

     

    키워드

    TCP 성능 향상

    난이도

    초급

    작성일

    2006-02-10 오전 10:19:12

     
      
       

    요약설명

    windows tcp/ip의 내부 동작 방식을 이해함으로써 보다 좋은 성능을 낼 수 있다.
    본 문서는 MSDN의 INFO: Design Issues - Sending Small Data Segments Over TCP w/Winsock를 정리한 문서입니다.


     


    오전 10:05 2006-01-31

    조경민 bro@shinbiro.com

    TCP 성능 향상

    [TCP 성능 case study]

     

    선행 지식

     

     Piggy-back

    request - response 통신 중 일때 수신측에서 잘 받았다는 ACK를 따로 전송하지 않고 response 보내기 시에 header의 ACK 필드를 사용하여 ACK를 response 패킷에 함께 보내는 방식

     

     Nagle 알고리즘

    작은 양의 데이터를 여러번 send() 호출로 보내는 경우 네트워크 효율이 좋지 못하여 send()를 여러번 호출해도 일정 기간동안 지연해서 TCP 스택의 보내기 버퍼에 쌓아둔 후 한번에 보내는 알고리즘

     

    INFO: Design Issues - Sending Small Data Segments Over TCP w/Winsock

    --------------------------------------------------------------------

     

    데이터 패킷을 받게 되면 잘 받았다고 TCP 스택은 ACK 메시지를 보내게 되는데, Microsoft TCP 스택은 200ms delay 타이머가 다운 카운드 되게 된다.

     

    - delay timer가 expire되기 전에 두번째 데이터 패킷을 받게 되면 ACK를 보낸다.

    - (두번째 데이터 패킷이 도착되거나 delay timer가 expire되기) 전에 데이터를 또 보내 게 되면 ACK는 데이터 패킷에 piggy-back(등에 엎혀서)되어 함께 보내진다.

    - delay timer가 expire되면 ACK가 보내진다.

     

    작은 데이터 패킷들로 네트웍이 혼잡해지는 것을 피하기 위해서 Microsoft TCP 스택은 Nagle 알고리즘을 기본적으로 사용하게 되는데, 작은 데이터를 여러번 send 호출하면 작은 데이터 들은 TCP 스택 보내기 버퍼에 쌓이게 되고 이전에 보낸 데이터 패킷이 상대방에서 받아졌다는 ACK가 나올때 까지 지연되었다가 한번에 보내진다.

    아래는 nagle 알고리즘이 적용안되는 예외 상황이다.

    - 만일 스택에 Maximum Transmission Unit (MTU)보다 큰 데이터 버퍼가 쌓였다면 상대측으로 부터 ACK를 기다리지 않고 즉시 full-sized packet(한 패킷)을 보내게 된다.

    - TCP_NODELAY 소켓 옵션이 적용되어 Nagle 알고리즘이 비활성화되면 작은 데이터 패킷들은 지연없이 바로 상대방으로 보내진다. 따라서 각 작은 데이터 send 마다 즉각즉각 ACK가 날아온다. 하지만 한번에 쌓아서 보낼 수 없어 효율적이지 못하다.

     

    애플리케이션 상에서 성능을 최적화하기 위해서 Winsock은 애플리케이션에서 send 호출시 Winsock kernel 보내기 버퍼에 복사해 둔다. 그런 후, tcp 스택은 Nagle 알고리즘같은 학습된 지능적인 방식으로 네트워크 상에 패킷을 실제 보내야 할 시기를 결정한다.

    SO_SNDBUF 옵션으로 할당된 윈속 커널 보내기 버퍼의 크기(기본 8K)를 변경할 수 있다.

    대부분 경우, 애플리케이션에서 보내기 완료라는 것은 애플리케이션 send 호출로 데이터 버퍼가 윈속 tcp 스택 보내기 커널 버퍼에 복사되었다는 것을 알려주는 것일 뿐이며, 네트워크 상에 패킷이 발생되었다는 것을 알려주는 것은 아니다. 이 보내기 완료의 의미가 다른 딱 하나의 상황은 SO_SNDBUF를 0으로 설정하여 Winsock 버퍼링을 비활 성화한 경우이다.

     

    Winsock은 다음 규칙에 따라 애플리케이션에게 보내기가 완료되었음을 알려준다.

    - 소켓 보내기가 아직 SO_SNDBUF 보내기 버퍼 할당 크기내에 있으면 윈속은 애플리케이션에서 send한 데이터를 보내기 버퍼에 복사하여 쌓아두고 애플리케이션에게 보내기가 완료되었다고 알려준다.

    - 소켓이 SO_SNDBUF 범위 이상을 보내려 하고, 이전에 쌓아진(buffered) 하나의 send가 스택 커널 버퍼에 있다면, 윈속은 애플리케이션 send 데이터를 복사해 오고, 애플리케이션에게 보내기 완료를 알려준다.

    - 소켓이 SO_SNDBUF 범위를 넘어 있고, 이전에 쌓아진 하나 이상의 send가 스택 커널버퍼에 있다면 윈속은 애플리케이션 send로 부터 데이터를 복사한다. 윈속은 소켓이 SO_SNDBUF에 다시 데이터를 채울 수 있을때까지 애플리케이션에게 보내기 완료를 발생시키지 않는다. 즉 SO_SNDBUF가 0이 아닌 상황에서 스택 보내기 버퍼가 꽉차지 않았다면 애플리케이션 send()는  스택 보내기 버퍼에 복사하여 쌓아둔 후 보내기가 완료되었다고 말한다.

     

    Case Study 1

    TCP 클라이언트가 10000 레코드를 TCP 서버에 데이터베이스 저장을 위해서 보낸다.

    레코드의 크기는 20바이트에서 100바이트로 가변적이다. 다음과 같이 애플리케이션이 짜여져있다.

     

    - 클라이언트는 blocking send만 하며, server는 blocking recv만 한다.

    - 클라이언트는 SO_SNDBUF를 0으로 하여 하나의 데이터 버퍼에 각 레코드들을 보낸다.  (TCP 스택 버퍼를 사용하지 않고 하나의 사용자 버퍼로 각 레코드를 담아 여러번 send()를 한다.)

    - 서버는 loop를 돌면서 recv()를 호출한다. 200바이트 버퍼를 recv()에 넣어 각 recv()호출로 각 레코드를 받는다.

     

    성능: 테스트결과 서버로 일초에 5개의 레코드만 보낼 수 있었다. 총 10000 레코드를 보내는데 최대 976K 바이트의 데이터(10000*100/1024)를 보내는 것인데 30분이상이 소요되었다.

     

    분석: 클라이언트에서 TCP_NODELAY 옵션을 설정하지 않아 TCP 스택은 Nagle 알고리즘에 따라 다른 send() 패킷을 네트웍상에 바로 보내지 않고 ACK를 기다리고 ACK가 오면 쌓인 버퍼를 보내게 된다. 그러나 SO_SNDBUF = 0으로 클라이언트가 winsock 버퍼링을 사용하지 않는다 하였으므로 10000 send() 호출은 각각 네트웍 상으로 보내져야 하고 매번 ACK를 받아야 한다.

    각 ACK는 아래 서버의 TCP 스택에서 발생되는 일로 200ms 지연이 된다.

     

    - 서버는 패킷을 받고 200ms delay timer를 다운 카운트 한다.

    - 서버는 어떤 것도 response할 것이 없기 때문에 ACK은 piggyback되지 못한다.

    - 서버에서 delay timer가 expire되어 ACK가 클라이언트로 전송된다.

     

    향상: 두가지 설계적인 문제가 있다. 첫째로 delay timer 문제가 있는데 클라이언트는 200ms지연 내에 서버로 두개이상의 패킷을 보낼 필요가 있다. 클라이언트는 기본적으로 Nagle 알고리즘을 사용하기 때문에 SO_SNDBUF를 0으로 설정하지 않게 하여 기본 윈속 버퍼링을 사용하게 하면 된다.

    TCP 스택이 Maximum Transmission Unit(MTU)보다 큰 버퍼로 쌓이게 되면 상대측으로 ACK를 기다리지 않고 즉시 한번에 full-sized 패킷을 보내게 된다.

    두번째로 이런 설게에서 작은 레코드들 마다 send()를 호출하였다. 작은 데이터를 보내는 것은 매우 효율적이지 못하다. 이런 경우 각 데이터 레코드를 100바이트로 pad(작은 데이터도 더미 데이터를 붙여) 만들어 한번에 80 레코드들을 send() 시킬 수 있다. 얼마나 많은 레코드를 보냈는지 서버에서 알 수 있도록 클라이언트는 레코드 묶음을 보내기 전에 고정 크기의 헤더에 레코드 갯수를 담아 보낼 수 있다.

     

    Case Study 2

    윈속 TCP 클라이언트는 두개의 TCP 연결을 연 후, 상품 견적 서비스를 제공하려 한다.

    첫번째 연결은 서버로 상품 번호를 보내는 명령 채널로 사용되고, 두번째 연결은 상품 견적을 받는 데이터 채널로 사용된다. 두 채널은 연결된 후, 클라이언트는 서버로 상품 번호를 보내고 데이터 채널에서 데이터가 올때까지 기다리게 된다. 처음 상품 견적이 받아진 후에야 서버로 다음 상품 번호를 보낸다. 클라이언트와 서버는 모두 SO_SNDBUF와 TCP_NODELAY 옵션을 설정하지 않았다.

     

    성능: 테스트 결과 초당 5개의 견적을 받을 수 있었다.

     

    분석: 이 설계에서는 한번에 하나의 상품 견적 요청만 가능하다. 첫번째 상품 번호가 명령채널을 통해 서버로 보내지고 response는 서버에서 클라이언트로 데이터 채널을 통해 바로 보내진다. 그러면 클라이언트는 즉시 두번째 상품 번호 요청을 보내기 위해 윈속 보내기 버퍼에 복사된다.

    그러나 클라이언트 TCP 스택은 커널 버퍼에 있는 요청을 바로 보내지 못한다. 첫번째 명령 채널로 보낸 send에 대한 ACK가 아직 안왔기 때문이다. 서버 명령 채널의 200ms delay timer 후 expire되어 첫번째 상품 번호 요청에 대한 ACK를 클라이언트에게 보내게 된다. 그러면 두번째 견적 요청은 200ms 지연후 서버로 보내지게 된다. 두번째 상품 번호에 대한 견적 응답은 데이터 채널로 바로 오게 되는데 이 당시는 클라이언트 테이터 채널의 delay timer가 expire되었기 때문이다.

    서버는 이전 견적 응답에 대한 ACK를 받은 것이다. 클라이언트는 200ms 동안 두번째 상품 견적 요청을 보내지 못하여 클라이언트의 delay timer의 expire에 의해 ACK가 서버로 보내지게 된 것이다.

    결과적으로 클라이언트는 두번째 견적 응답을 받고 다른 견적 요청을 하지만 같은 방식으로 지연이 이뤄진다.

     

    향상: 두개의 연결 채널을 맺는 것은 여기서 필요치 않다.만일 한개의 연결만 맺고, request, response를 하였다면 request에 대한 ACK는 response에 piggyback되어 함께 돌아올 수 있다.

    더 향상된 방식으로 클라이언트는 여러개의 요청을 한번에 보내고 서버도 역시 여러개의 응답을 한번에 보낼 수 있다. 만일 두개의 단방향 채널 설계가 필요하다면 두 채널에 TCP_NODELAY 옵션을

    주어 작은 패킷을 이전 패킷의 ACK가 올때까지 기다리게 하지 말고 바로 바로 보낼 수 있게 하면된다.

     

    권고: 위 두가지 경우는 꾸며진 상황이다. 최악의 상황을 설명하기 위해서이다.

     

    - 만일 데이터 세그먼트가 time critical하지 않다면 애플리케이션은 하나의 큰 데이터 블럭으로 묶은 다음 한번의 send()를 하라. 너무 크지 않은 버퍼여야 하는데 8K이하면 항상 효율적이다.

    윈속 보내기 버퍼가 MTU보다 크다면 보내기 버퍼내 한번에 보낼 수 있는 패킷 크기안의 여러 full-sized 패킷을 보내고 마지막 패킷은 남겨진다. 마지막 패킷을 제외하고는 200ms delay timer 지연을 받지 않는다. 마지막 패킷이 홀수 번째 패킷이면 지연된 ACK 알고리즘과 관련되고 보내기 스택 끝에 MTU 범위를 넘는 다른 보내야할 블럭이 있다면 이는 Nagle 알고리즘으로 처리된다.

    - 가능하다면 단방향 데이터 흐름의 연결을 피해야한다. Nagle과 지연된 ACK 알고리즘의 악영향을 받게 된다.

    - 만일 작은 데이터 세그먼트를 즉시 보내야 한다면 TCP_NODELAY 옵션을 보내는 쪽에서 설정한다.

    - 만일 윈속이 보내기 완료를 알려주는 것이 네트워크 상에 패킷이 실제 보낸 것이라는 것을  보장하길 원치 않는다면 SO_SNDBUF를 0으로 설정하지 말아야 한다. 즉, SO_SNDBUF를 0으로 설정한다면 네트웍상으로 패킷을 보낸 것을 보내기 완료로 윈속이 알려준다는 의미이다.

    SO_SNDBUF가 0이 아닌 값이면 보내기 완료는 TCP 스택 버퍼에 복사되어 쌓인것을 의미한다.

    보내기 버퍼의 8K 버퍼 크기는 대부분의 상황에서 잘 동작되도록 설정된 값이라 더 나은 성능을 보이는 상황 테스트를 하지 않은 이상 변경하지 말아야 한다.

    SO_SNDBUF를 0으로 하는 것은 벌크 데이터 전송에 성능 이득을 준다. 최대 효율을 위해서 더블 버퍼링(하나 이상의 보내기 버퍼를 묶음)과 Overlapped I/O를 함께 사용해야 한다.

    - 만일 데이터 전달이 신뢰성을 보장받지 않아도 된다면 UDP를 사용하라.

     

    참고

    Common Performance Issues in Network Applications Part 1: Interactive Applications

    이 문서에서 TCP같은 Stream형 프로토콜의 Overlapped I/O를 사용하는 경우만 SO_SNDBUF = 0 를 추천하고 있다.

     

     

    정리

    windows tcp/ip의 내부 동작 방식을 이해함으로써 보다 좋은 성능을 낼 수 있다.

    본 문서는 MSDN의 INFO: Design Issues - Sending Small Data Segments Over TCP w/Winsock를 정리한 문서입니다.


    안정적인 DNS서비스 DNSEver DNS server, DNS service
    Posted by 키르히아이스
    ,