예전에 소켓을 만들 때 비동기 처리(?)를 select() 함수를 이용해서 처리했었는데, 이 함수를 사용한 서버는 많은 동시 접속자 및 고성능에는 적합하지 않다고 해서 다른 방법을 찾아보기로 했습니다.

Blocking 이 있으면 모두 동기식입니다.

왜 적합하지 않을까❓

  1. FD_SET 비효율적인 사용
    • select() 호출 때마다 파일 디스크립터 배열을 다시 세팅해서 전달
  2. polling 방식
    • select() 함수 내부에서는 파일 디스크립터를 순회하며 감지함
    • 접속한 클라이언트가 많아질수록 성능이 떨어짐
  3. 파일 디스크립터 최대 개수
    • 허용하는 최대 개수가 1024개

따라서 고성능 서버에서는 사용되지 않는다.

🔬 IOCP

💡I/O 완성 포트는 다중 프로세서 시스템에서 여러 비동기 I/O 요청을 처리하기 위한 효율적인 스레딩 모델, 많은 동시 비동기 I/O 요청을 처리하는 프로세스는 I/O 요청을 수신할 때 스레드를 만드는 것보다 미리 할당된 스레드 풀과 함께 I/O 완료 포트를 사용하여 더 빠르고 효율적으로 수행할 수 있습니다. - MSDN

WindowsI/O 완료 처리를 커널이 할 수 있도록 만들어 놨고, 그걸 사용하기만 하면 됩니다. 👍

🚀 핵심

IOCP 관련


  1. CreateIOCompletePort(): I/O 완료 포트를 만들고 지정된 파일 핸들에 연결하거나, 아직 파일 핸들에 연결되지 않은 I/O 완료 포트를 만들어 나중에 연결할 수 있습니다.
  2. GetQueuedCompletionStatus(): 지정된 I/O 완료 포트에서 I/O 완료 패킷을 큐에서 제거하려고 시도합니다. 대기 중인 완료 패킷이 없으면 함수는 완료 포트와 연결된 보류 중인 I/O 작업이 완료될 때까지 기다립니다.
  3. PostQueuedCompletionStatus(): I/O 완료 패킷을 I/O 완료 포트에 게시합니다.

비동기 소켓 관련


  1. WSASocket(): 비동기 소켓을 생성
  2. WSARecv()/WSASend(): 비동기 송/수신 처리
  3. OVERLAPPED: 비동기 처리 식별자

WSAIoctl(): AcceptEx() 함수를 불러올 때 사용

💡 OVERLAPPED 는 비동기 요청할 때마다 생성 및 초기화하여 같이 요청을 보낸다.
I/O 요청이 완료되어 Callback 되었을 때 어떤 I/O 요청인지 구별할 수 있게 해줍니다.
동적으로 생성하면 오버헤드가 있기 때문에 메모리풀을 사용한다고 합니다. 😄

⌨️ 실사용 예

IOCP 핸들을 생성하고 스레드풀을 미리 만들어 놓습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Server.cc
...
bool ServerImpl::init(size_t workerCount) {
    bool result = false;
    do {
        if(WSAStartup(MAKEWORD(2, 2), &_wsa) != 0) {
            break;
        }

        // 처음 서버를 초기화할 때 IOCP 핸들을 만듭니다.
        _iocp = CreateIoCompletionPort(
            INVALID_HANDLE_VALUE, NULL,0, 0);

        // 만들지 못했다면 NULL을 반환합니다.
        if(_iocp == NULL) {
            break;
        }

        // 위 설명대로 미리 스레드풀을 만들어 효율적으로 처리합니다.
        for(size_t i = 0; i < workerCount; i++) {
            try {
                _threads.emplace_back(&ServerImpl::worker, this);
            } catch(...) {
                break;
            }
        }
...

스레드풀(worker())에서는 I/O 완료를 기다립니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Server.cc
...
int ServerImpl::worker() {
    DWORD bytesTransferred = 0;     // 전송된 바이트 수
    ULONG_PTR completionKey = 0;    // Completion Key (소켓 등록 시 전달했던 값)
    LPOVERLAPPED overlapped = NULL; // OVERLAPPED 구조체 포인터
    BOOL ret = FALSE;

    for(;;) {
        // IO 완료가 있을 때까지 대기
        ret = GetQueuedCompletionStatus(
            _iocp,
            &bytesTransferred,
            &completionKey,
            &overlapped,
            INFINITE
        );

        // IO 완료가 되었으면 처리 시작
        if(overlapped == NULL && completionKey == NULL) {
            // 스레드 종료 신호
            break;
        }

...

클라이언트가 연결을 요청하면 accept 후 미리 만들어 놓았던 acceptSock 에 담겨서 오게 됩니다.
이 때 연결된 소켓은 다시 IOCP 에 등록하여 송/수신 완료 신호를 받게 됩니다.

⚠️ 이 예제에서는 accept() 함수 또한 비동기 처리를 위해 IOCP에 등록해서 사용했습니다.
따라서 Accept I/Oworker()로 옵니다.

IOCP 에 클라이언트를 등록한 후 비동기 수신을 위해 WSARecv() 를 요청하고, 다음 클라이언트를 accept하기 위해 AcceptEx()를 다시 호출한다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Server.cc
...
switch(ov->ioType) {
    case(IO_TYPE::ACCEPT):
    {
        // 세션 생성
        ACCEPT_OVERLAPPED_CONTEXT *acceptOv = reinterpret_cast<ACCEPT_OVERLAPPED_CONTEXT *>(ov);
        Session *newSession = new SessionImpl(acceptOv->acceptedSock);

        // 연결이 되었으면 IOCP에 연결된 소켓을 등록
        registerSoketToIOCP(newSession);
...
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Server.cc
...
bool ServerImpl::registerSoketToIOCP(Session *session) const {
    if(session) {
        // worker에서 세션을 식별할 수 있도록 completionKey에 session 세팅
        ULONG_PTR completionKey = reinterpret_cast<ULONG_PTR>(session);

        // IOCP 에 연결된 소켓 등록
        if(CreateIoCompletionPort(
            (HANDLE)session->getSocket(),
            _iocp,
            completionKey,
            0) == _iocp) {
            return true;
        }
    }
    
    return false;
}
...

프로그램이 종료될 때 스레드도 종료될 수 있도록 신호를 보냅니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Server.cc
...
void ServerImpl::destroy() {
    closeListenSock();

    // 스레드 종료 시그널
    // 워커 수 만큼(IO 대기) CompletionKey 및 Overlapped 에 NULL을 전달
    for(std::thread &th : _threads) {
        PostQueuedCompletionStatus(_iocp, 0, NULL, NULL);
    }

    for(std::thread &th : _threads) {
        if(th.joinable()) {
            th.join();
        }
    }
    _threads.clear();

    // IOCP 핸들 해제
    if(_iocp != NULL) {
        CloseHandle(_iocp);
    }

    WSACleanup();
}

정리

  1. Windows 고성능 서버 구현 시 select()가 아닌, 커널의 I/O 완료 프로세스인 IOCP 를 사용한다.
  2. Linux 에서는 epoll 을 사용하여 고성능 서버를 구현하고 epollI/O 완료 신호가 아닌, I/O 준비 신호가 온다.
  3. 고정된 길이의 데이터를 받더라도 TCP 는 데이터가 끊겨 들어올 수 있기 때문에 처리를 해야함
    • I/O 완료 신호라 끊겨서 들어오더라도 소켓 버퍼에서 꺼내서 스레드에 던짐 (4KB 를 받겠다고 해도 3 KB 를 받을 수 있음)
    • 동기로 처리했을 때는 MSG_PEEK 를 이용해서 용량이 차지 않으면 꺼내지 않았었는데, 고민해 봐야 함
  4. Session 에서 읽기 및 쓰기를 비동기로 구현해야 함
  5. 어려움