서버/네트워크 프로그래밍

입출력모델 - IOCP (Completion Port)

season97 2025. 6. 16. 14:42
728x90
반응형

IOCP (I / O Completion Port)가 왜 최종일까?

  • 이전 모델들의 단점을 모두 해결했기 때문에
  • 이벤트 모델의 단점 : 스레드와 클라이언트가 1:1로 묶여 클라이언트 수 만큼 스레드가 필요했다
  • 콜백 모델의 단점 : I/O를 시작한 스레드에서만 콜백이 실행되는 '스레드 종속성' 문제가 있었다 
  • IOCP는 적은 수의 스레드로 많은 클라이언트의 I/O를 효율적으로 처리하며, I/O 완료 작업을 특정 스레드에 종속시키지 않고 노는 스레드에게 공평하게 분배한다.

IOCP의 3대 핵삼 구성요소

세가지 핵심API함수로 동작한다

  1. CreateIoCompletionPort
    • IOCP의 핵심인 Completion Port 핸들을 생성하는 함수
    • 모든 I/O 완료 통지는 이 포트를 통해 이루어진다.
    • 또한 클라이언트 소켓이 생길 때마다 이 함수를 다시 호출하여 소켓을 Completion Port에 등록하는 역할을 함
  2. GetQueuedCompletionStatus
    • "일감 내놔" 라고 하는 함수.
    • Worker스레드가 이 함수를 호출하면, Completion 포트에 I/O 완료 작업(완료 패킷)이 등록될 때까지 대기
    • 작업이 생기면 완료된 작업의 정보와 함께 리턴되어 스레드가 일을 시작하게 한다.
  3. Worker스레드
    • GetQueuedCompletionStatus를 무한 루프로 호출하며 대기하다가, 완료 패킷을 받으면 해당 IO를 처리하는 역할을 하는 일꾼 쓰레드들 (보통 CPU코어 수의 2배 정도를 생성)

IOCP 서버의 전체적인 흐름

  1. main 스레드 (사장)
    • IOCP Completion Port(완료 포트) 생성 (CreateIoCompletionPort)
    • Worker 스레드(직원)들을 여러개 생성해서 GetQueuedCompletionStatus를 호출하며 대기시킨다
    • 자신은 accept에만 집중하며 새로운 클라이언트 연결을 받는다
    • 새 클라이언트가 접속하면 통신용 소켓을 생성하고 IOCP에 등록한다 ( CreateIoCompletionPort )
    • 등록된 클라이언트를 위해 첫번째 비동기 I/O 작업(WSARecv)을 시작하고 다시 accept 하러 간다
  2. Worker 스레드 (직원)
    • GetQueuedCompletionStatus를 통해 I/O 완료 통지를 받는다
    • 완료된 작업의 종류(Read,Write...등) 와 대상 클라이언트(Session) 정보를 확인
    • 데이터를 처리하고, 연쇄적으로 다음 WSARecv를 호출해 데이터 수신을 이어나간다
    • 클라이언트 접속이 끊긴 것을 감지하면, 해당 클라이언트의 Session과 socket등 리소스를 안전하게 정리한다

 

실습 코드 흐름 분석

핵심 자료구조 정의

enum class IO_TYPE
{
	READ,
	WRITE,
    ACCEPT,
	// ... 등등
};

// 어떤 종류의 I/O가 완료되었는지 알려주기 위한 확장 구조체
struct OverlappedEx
{
	WSAOVERLAPPED overlapped = {};
	IO_TYPE		  ioType;
	// 필요에 따라 다른 데이터 추가 가능
};

// 클라이언트 정보를 담는 구조체
struct Session
{
	SOCKET socket = INVALID_SOCKET;
	char recvBuffer[BUFSIZE] = {};
	// ...
};
  •  Session 이외에 OverlappedEx 구조체를 만들어 I/O 작업의 종류를 구분해준다
  • GetQueuedCompetionStatus는 작업이 완료되었다는 사실만 알려줄 뿐 Recv의 결과인지 Send의 결과인지는 알려주지 않기 떄문이다.

Main쓰레드

int main()
{
	// 1. 윈속 초기화, 리슨 소켓 준비 (이전과 동일)
	// ...

	// 2. IOCP 완료 포트 생성
	// 마지막 인자 0은 '최적의 스레드 수'를 OS에 맡긴다는 의미 (보통 CPU 코어 수)
	HANDLE iocpHandle = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

	// 3. Worker 스레드(직원) 생성 및 일 시키기
	const int32 threadCount = 5;
	vector<thread> workerThreads;
	for (int32 i = 0; i < threadCount; i++)
    {
    //	GThreadManager->Launch([=]() { WorkerThreadMain(iocpHandle); });
    // 내 프레임워크에선 커스텀해놔서 사용 가능한데 일단은 표준으로 사용
		workerThreads.emplace_back(WorkerThreadMain, iocpHandle);
	}
	// 4. Main 스레드는 Accept만 담당 (사장님은 영업만!)
	while (true)
	{
		// ... accept 로직 (논블로킹 처리 포함) ...
		// 성공적으로 clientSocket을 얻었다고 가정

		// 5. 새 클라이언트를 위한 Session 생성
		Session* session = new Session{ clientSocket };
		
		// 6. 소켓을 IOCP에 등록
		//    세 번째 인자(Key)가 매우 중요! 이 소켓에서 발생한 I/O의 Key로 session 포인터를 넘겨줌
		::CreateIoCompletionPort((HANDLE)clientSocket, iocpHandle, (ULONG_PTR)session, 0);

		// 7. 첫 번째 비동기 읽기(Recv) 작업 시작
		OverlappedEx* overlappedEx = new OverlappedEx();
		overlappedEx->ioType = IO_TYPE::READ;

		WSABUF wsaBuf;
		wsaBuf.buf = session->recvBuffer;
		wsaBuf.len = BUFSIZE;
		DWORD recvLen = 0;
		DWORD flags = 0;
		::WSARecv(session->socket, &wsaBuf, 1, NULL, &flags, &overlappedEx->overlapped, NULL);
	}

	// ... 스레드 종료 대기 및 정리 ...
}

 

 

 

WorkerThread

void WorkerThreadMain(HANDLE iocpHandle)
{
	while (true)
	{
		DWORD bytesTransferred = 0;
		Session* session = nullptr;       // I/O가 발생한 클라이언트 세션
		OverlappedEx* overlappedEx = nullptr; // 완료된 I/O 작업 정보

		// 1. "일감 내놔!" -> 완료된 작업이 생길 때까지 여기서 대기
		BOOL ret = ::GetQueuedCompletionStatus(iocpHandle, &bytesTransferred,
			(PULONG_PTR)&session, (LPOVERLAPPED*)&overlappedEx, INFINITE);

		// 2. 리소스 정리 (가장 중요!)
		// 클라이언트 접속 종료 또는 치명적 오류 발생
		if (ret == FALSE || bytesTransferred == 0)
		{
			cout << "Client Disconnected." << endl;
			closesocket(session->socket);
			delete session;
			delete overlappedEx; // 시작됐지만 완료되지 못한 I/O의 Overlapped 객체도 해제
			continue;
		}

		// 3. 완료된 작업 종류 판단 및 처리
		switch (overlappedEx->ioType)
		{
		case IO_TYPE::READ:
			{
				cout << "Recv Data: " << bytesTransferred << " bytes" << endl;

				// [TODO] 받은 데이터(session->recvBuffer)로 실제 게임 로직 처리
				
				// 4. 처리했으니 다음 Recv 작업을 연쇄적으로 호출
				//    - 이전 OverlappedEx는 해제하고, 다음 작업을 위해 새로 할당
				delete overlappedEx; 
				OverlappedEx* newOverlappedEx = new OverlappedEx();
				newOverlappedEx->ioType = IO_TYPE::READ;

				WSABUF wsaBuf;
				wsaBuf.buf = session->recvBuffer;
				wsaBuf.len = BUFSIZE;
				DWORD flags = 0;
				::WSARecv(session->socket, &wsaBuf, 1, NULL, &flags, &newOverlappedEx->overlapped, NULL);
			}
			break;
		
		// 추후 case IO_TYPE::WRITE: 등으로 확장 가능
		}
	}
}

 

 

 

IOCP가 좋은 이유

  • 최고의 확장성 : 클라이언트가 수백, 수천이 되어도 Worker 스레드 수는 늘릴 필요가 없다, 서버의 부하가 스레드 컨텍스트 스위칭이 아닌 실제I/O 처리에 집중된다
  • 효율적인 스레드 관리 : I/O를 시작한 스레드와 처리하는 스레드가 달라도 된다. OS가 알아서 노는 쓰레드를 꺠워서 일을 시키므로, 스레드들이 낭비되지 않는다
  • 자원 관리의 명확성 : GetQueuedCompetionStatus를 통해 I/O 완료 시점을 명확히 알 수 있으므로, 메모리 해제나 객체 소멸같은 리소스 관리 로직을 일관되게 처리할 수 있다.

※ IOCP는 복잡해보이지만, "사장님(Main)은 영업만, 직원(Worker)은 처리만" 이라는 역할 분담의 원리를 이해하면 그 구조가 한눈에 들어오게 된다. 윈도우 환경에서 고성능 서버를 만들려면 반드시 필요하니 숙지하자

728x90
반응형