공부 학습

IOCP 서버 프로그래밍

Multitab 2022. 8. 2. 15:55

IOCP란? : Input/Ouptput Completion Port의 약자다. 입력과 출력의 완료를 담당할 포트를 지정해서 처리하겠다는 의미다. 입력과 출력의 완료시점에서의 통지는 overlapped(중첩 입출력)에서 처리가 되므로, 이 기술은 윈도의 중첩 입출력 기술을 확장시킨 것으로 볼 수 있다.
간단히 말하면 윈도우에서 제공하는 비동기 IO 라이브러리로 생각하면 될것이다. IOCP의 기본적인 구동 방식을 정리하자면

  1. 적당한 수의 워커 쓰레드를 생성한다. (보통 (코어수 * 2) + 1)
  2. 소켓 생성
  3. 클라이언트 접속 시도시 accept 함수 호출
  4. 연결 완료시 Complete Port 할당
  5. WSARecv함수를 호출해 입출력 디바이스에서 입출력이 완료되면 completion queue에 등록하고 워커 쓰레드에 할당한다.
    순으로 구동되는 방식이다. 일반적으로 보이는 쓰레드 풀 방식에 비해 쓰레드에 작업을 할당하는 방식을 원도우에서 자동으로 해주는 점에서 편하다고 할수 있다.

지금부터는 IOCP의 기본적인 예제 코드를 살펴보며 알아보자

#pragma comment(lib, "ws2_32")
#include <winsock2.h>
#include <Ws2tcpip.h>

#include <thread>
#include <vector>

#define MAX_SOCKBUF 1024 // 패킷의 크기
#define MAX_WORKERTHREAD 4 // 스레드에 넣을 쓰레드의 수

winsock2.h 헤더파일에는 윈도우 서버를 구축하는데 소켓 프로그래밍 라이브러리가 구축되어 있다. Ws2tcpip.h에는 winsock2에서 TCP/IP를 검색하고 사용하는데 필요한 함수들이 정의 되어 있다.

//WSAOVERLAPPED 구조체를 확장해 필요한 정보 추가
struct stOverlappedEx{
    WSAOVERLAPPED   m_wasOverlapped;    //Overlapped IO 구조체체
    SOCKET          m_socketClient;     // 클라이언트 소켓
    WSABUF          m_wsaBuf;           //Overlapped IO 버퍼
    char            m_szBuf[ MAX_SOCKBUF ]; // 데이터 버퍼
    IOOperation     m_eOperation;       //작업 동작 종류
};

//클라이언트 정보를 담는 구조체
struct stClientInfo
{
    SOCKET          m_socketClient;     //Client와 연결되는 소켓
    stOverlappedEx  m_stRecvOverlappedEx; //RECV Overlapped IO작업을 위한 변수
    stOverlappedEx  m_stSendOverlappedEx; //SEND Overlapped IO작업을 위한 변수

    stClientInfo(){
        ZeroMemory(&m_stRecvOverlappedEx, sizeof(stOverlappedEx));
        ZeroMemory(&m_stSendOverlappedEx, sizeof(stOverlappedEx));
        m_socketClient = INVALID_SOCKET;
    }
};

stClientInfo는 서버에 접속한 클라이언트에 대한 정보를 담기 위한 구조체이고 stOverlappedEx 구조체는 기본적으로 클라이언트의 정보를 담고 있는 WSAOVERLAPPED 구조체를 환장하여 정보를 담기 위한 구조체이다. 그럼 WSAOVERLAPPED 구조체는 어떤 것이며 IOCP에서 Overlapped이라는 개념은 무엇일까?

overlapped IO란?
CPU에 비해 디스크나 통신 기기는 속도가 굉장히 느리기 때문에 이러한 지연시간을 피하기 위해 IOCP에서 제공하는 비동기 입출력 방법이다. IOCP는 입출력해야할 일들을 수행하고 느린속도로 수행이 완료되면 CPU에 입출력의 완료를 통보한다. 그리고 이를 효율적으로 수행하기 위한 최적의 쓰레드 풀링방식을 포함한다.

bool StartServer(const UINT32 maxClientCount){
        CreateClient(maxClientCount);

        mIOCPHandle = CreateIoCompletionPort(INVALID_HANDLE_VALUE,NULL,NULL, MAX_WORKERTHREAD);
        if(NULL == mIOCPHandle){
            printf("[에러] CreateIoCompletionPort()함수 실패 : %d\n", GetLastError());
            return false;
        }

        bool bRet = CreateWorkThread();
        if(bRet == false){
            return false;
        }

        bRet = CreateAccepterThread();
        if(false == bRet){
            return false;
        }

        printf("서버시작\n");
        return true;
    }

위 코드에서 중요한 것은 CreateIoCompletionPort()함수이다. 뒤에 변수는 최초 생성 시에는 잘 쓰이지 않음으로 적당히 INVALID_HANDLE_VALUE,NULL,NULL, 0으로 넘기거나 맨 끝에 0은 Overlapped IO를 운영할 쓰레드의 개수임으로 별도로 지정해주거나 0을 넘기면 default로 CPU의 코어 개수만큼 할당해준다.

//Waiting Thread Queue에서 대기할 쓰레드들 생성
    bool CreateWorkThread(){
        unsigned int uiThreadId = 0;
        //WaitingThread Queue에서 대기상태로 쓰레드 생성의 권장 개수 : (cpu 개수 * 2) + 1
        for(int i = 0; i < MAX_WORKERTHREAD; i++){
            mIOWorkThreads.emplace_back([this](){ WorkThread(); });
        }

        printf("WorkerThread 시작...\n");
        return true;
   }

위에서 IOCP에서 입출력 장치의 느린 입출력을 관리할 동안 IOCP가 할당해 줬을때 실질적으로 그일을 수행할 쓰레드를 생성한다. 이때 생성되는 쓰레드는 코어의 개수의 두배 정도로 할당한다. 왜냐하면 어떤 코에에 할당된 일이 wait 상태에 있을때 다른 여분의 스레드를 코어에 할당해 주기 위해서이다.

    //CompletionPort객체와 소켓과 CompletionKey를 연결시키는 역할을 하는 함수
    bool BindIOCompletionPort(stClientInfo* pClientInfo)
    {
        auto hIOCP = CreateIoCompletionPort((HANDLE)pClientInfo->m_socketClient, mIOCPHandle, (ULONG_PTR)(pClientInfo), 0);

        if(NULL == hIOCP || mIOCPHandle != hIOCP)
        {
            printf("[에러] CreateIoCompletionPort()함수 실패 : %d\n", GetLastError());
            return false;
        }

        return true;
    }

이제 IOCP 시스템과 접속을 시도한 클라이언트의 정보를 받아와 소켓을 IOCP시스템과 소켓을 연결하는 작업을 해보자. 이 일을 해주는 것이 CreateIoCompletionPort() 수이다.

while(mIsWorkRun){
            //해당함수를 통해 WaitingThread Queue에 대기상태로 들어가게 된다.
            //완료된 Overlapped IO작업이 발생하면 IOCP Queue에서 완료된 작업을 가져와 처리한다.
            //PostQueuedCompletionStatus() 함수에 의해 사용자 메세지가 도착하면 쓰레드를 종료한다.

            bSuccess= GetQueuedCompletionStatus(mIOCPHandle,
                                                &dwIoSize, //실제로 전송된 바이트
                                                (PULONG_PTR)&pClientInfo, //CompletionKey
                                                &lpoverlapped,//Overlapped IO 객체
                                                INFINITE // 대기할시간
                                                );
            //사용자 쓰레드 메세지 종료 처리리
            if(TRUE == bSuccess && 0 == dwIoSize && NULL == lpoverlapped){
                mIsWorkRun = false;
                continue;
            }

이제 Completion Queue를 보며 IO 작업의 완료 여부를 확인하여 지속적으로 감시한다. 이후 WSASend WSARecv함수를 이용해 일을 수행한다.