본문 바로가기

윈도우즈 소켓 프로그래밍 기초

IOCP (I/O Completion Port)

동기 입출력

호출된 함수가 블로킹 상태로 놓이기 때문에

그 함수가 반환할 때 까지 CPU는 다른 일을 수행할 수 없습니다.

 

비동기 입출력

입출력 수행시 함수를 호출하자마자 바로 반환합니다.

그래서 CPU는 다른 작업을 할 수 있습니다. 

 

Select, WSAEventSelect 멀티플렉싱은 비동기 입출력과 관련이 없는 모델입니다.

비동기 입출력 함수인 WSASend, WSARecv를 활용하는 Overlapped I/O, IOCP가

WinSock의 대표적인 비동기 입출력 모델입니다.

 

Overlapped I/O vs IOCP

Winsock 계열로 만들어진 서버 모델의 90퍼센트 이상은 IOCP가 차지하고 있다고 합니다.

Overlapped I/O와 IOCP의 차이점 중 하나는 "스레드 풀링"에 있습니다.

스레드 풀링은 재사용이 가능한 스레드를 유지할 수 있는 최적화 된 기능입니다.

IOCP의 강점 중 하나는 이러한 스레드 풀링을 활용할 수 있다는 점입니다.

 

완료 루틴(Complete Routine)을 사용하는 Overlapped I/O는

입출력 작업이 끝나면 콜백 함수를 호출해주는 모델입니다.

 

TCP로 서로 송수신할 때 WSASend / WSARecv와 같은 비동기 입출력 함수를 호출하면

스레드에서 내부적으로 APC(Asynchronous Procedure Call Queue) 큐가 생성되고,

여기에 입출력이 완료된 결과를 저장합니다.

 

그리고 애플리케이션에서 지정된 타이밍, Alertable State(알림 상태)를 바탕으로

APC 큐에 저장된 입출력 정보를 참조해서 Complete Routine 함수를 호출한 다음

다시 비동기 입출력을 진행하게 되는 방식입니다.

 

그렇다면 결국 스레드에서 생성된 APC 큐를 통해서

콜백 함수 Complete Routine을 호출한다는 뜻이겠군요.

 

APC 큐는 각 스레드마다 자동으로 생성되고 파괴된다는 특징이 있습니다.

모든 스레드는 자신만의 독립적인 APC 큐를 가지고 있으므로, APC 큐의 결과는

APC 큐를 소유하고 있는 스레드에서만 확인할 수 있습니다.

 

반면에 IOCP는 이러한 제약이 없습니다.

스레드 풀을 유지하기 때문에 사용자가 생성한 IOCP 커널 객체에 접근하는

여러 스레드들을 두고 입출력 결과를 처리할 수 있습니다.

스레드 풀의 크기는 보통 머신에 설치된 프로세서 * 2만큼 생성한다고 합니다.

 

 

IOCP를 쓰려면 IOCP객체를 생성해야합니다.

CreateCompletionPort 함수로 IOCP 객체를 생성할 수 있습니다.

그리고 클라이언트 소켓과 생성한 IOCP를 서로 연결합니다.

 

첫 번째 인자는 IOCP와 연결할 HANDLE을 지정합니다.

처음에는 IOCP 객체를 생성해야하므로 INVALID_HANDLE_VALUE를 인자로 전합니다.

IOCP 객체를 생성한 후에 넘길 핸들은 Overlapped I/O를 지원하는 객체여야합니다.

 

두 번째 인자는 이미 만들어진 IOCP 핸들을 입출력 입출력 완료포트에 지정해줍니다.

여기에서도 첫 생성시에는 존재하는 IOCP 객체가 없으므로 NULL을 전달합니다.

 

이후에, 이미 만들어진 클라이언트 소켓과 IOCP를 지정하면

내부적으로 장치 리스트라는 곳에 IOCP와 연결된 것을 등록한다고 합니다.

장치 리스트에 등록된 핸들은 종료시 CloseHandle을 호출해서

장치 리스트에 등록된 핸들을 제거해줘야한다.

 

세 번째 인자는 IOCP 핸들을 key로 유저가 정의한 값입니다.

사용자가 넘기고 싶은 값을 넘기면 됩니다.

 

네 번째 인자는 한 번에 동작할 수 있는 최대 스레드 개수를 지정합니다.

IOCP 처리를 위해 몇 개의 스레드를 할당할지 지정하는 것인데

0을 넘기면 프로세서의 숫자로 지정됩니다.

 

IOCP 서버 C++ 소스 코드

#include <WinSock2.h>
#include <iostream>
#include <process.h>
#include <vector>

using namespace std;

#pragma comment(lib, "ws2_32.lib")

#define PORT			7890
#define PACKET_LENGTH		1024

typedef struct _tagSocketInfo
{
	SOCKET			hSocket;
	SOCKADDR_IN		tAddr;
}Socket_Info, *PSocket_Info;

typedef struct _tagDataInfo
{
	OVERLAPPED tOverlapped;
	char Buffer[PACKET_LENGTH];
	WSABUF wsaBuf;

	_tagDataInfo()
	{
		memset(&tOverlapped, 0, sizeof(tOverlapped));
		memset(Buffer, 0, PACKET_LENGTH);
		memset(&wsaBuf, 0, sizeof(wsaBuf));
	}

}Data_Info, *PData_Info;

unsigned int __stdcall IocpThread(void* pArg)
{
	HANDLE hComport = (HANDLE)pArg;

	DWORD dwByteTransfer = 0;
	PSocket_Info pSocketInfo = nullptr;
	PData_Info pDataInfo = nullptr;
	DWORD dwFlag = 0;

	while (true)
	{
		// 이 함수는 블로킹 함수이다.
		// 스레드를 통해서 해당 Completion Port에서 큐에 완료보고가 들어오는지 검사한다.
		// arg 1) hComport : 이 IOCP 커널 객체를 통해서 내용물을 읽겠다.
		// arg 2) &dwByteTransfer : 몇 바이트의 정보를 읽었다는 내용을 저장.
		// arg 3) (DWORD*)&pSocketInfo : IOCP 연결을 구분하는 키값이다.
		// arg 4) (LPOVERLAPPED*)&pDataInfo : OVERLAPPED 구조체 주소를 넘긴다.
		// 비동기 입출력은 완료시 완료된 OVERLAPPED 구조체의 주소가 들어간다.
		// arg 5) INFINITE : Time Out, 대기 시간을 설정한다.
		GetQueuedCompletionStatus(hComport, &dwByteTransfer, (DWORD*)&pSocketInfo, 
			(LPOVERLAPPED*)&pDataInfo, INFINITE);

		// 통신 종료시
		if (dwByteTransfer == 0)
		{
			// Completion Port와 연결된 accept소켓을 닫는다.
			closesocket(pSocketInfo->hSocket);
			delete pSocketInfo;
			delete pDataInfo;
			continue;
		}

		cout << pSocketInfo->hSocket << " Client Message : " << pDataInfo->Buffer << endl;
	}

	return unsigned int();
}

int main()
{
	WSADATA wsaData;
	WSAStartup(MAKEWORD(2, 2), &wsaData);

	/*
	CreateIoCompletionPort(
		
		// CreateIoCompletionPort : I/O Completion Port를 생성한다.

		// An open file handle or INVALID_HANDLE_VALUE.
		// 첫 생성시에는 INVALID_HANDLE_VALUE로 넘긴다.
		_In_ HANDLE FileHandle, 

		// A handle to an existing I/O completion port or NULL.
		// 이미 생성된 IOCP의 핸들을 의미. 
		// 처음에 생성할 땐 별도의 장치 핸들을 지정해서 사용하지 않을 것이므로 역시 0.
		_In_opt_ HANDLE ExistingCompletionPort, 

		// 연결하고자 하는 장치를 식별할 수 있는 값을 지정할 때 사용.
		// 여러 개의 장치가 하나의 IOCP 커널 객체에 연결되어 있을 때,
		// 각각의 장치를 이 매개변수에서 지정한 값으로 식별이 가능해진다.
		// 처음에는 새로운 IOCP 커널 객체를 생성하는 것이므로 이 값은 의미가 없으므로 0.
		_In_ ULONG_PTR CompletionKey,

		// If this parameter is zero, 
		// the system allows as many concurrently running threads 
		// as there are processors in the system.
		// 동시에 실행이 가능한 스레드 수를 의미한다.
		// 0을 넘기면 윈도우는 CPU 개수에 맞게 값을 지정해준다.
		_In_ DWORD NumberOfConcurrentThreads 
	); */

	HANDLE hComport = CreateIoCompletionPort(INVALID_HANDLE_VALUE, 0, 0, 0);

	// SYSTEM_INFO 구조체 선언
	SYSTEM_INFO tInfo;

	/*
		typedef struct _SYSTEM_INFO {
			...

		// 현재 머신에 있는 CPU에 따른 프로세서를 카운팅한다.
		// 구해진 카운트를 바탕으로 스레드를 생성할 것이다.
		// 생성할 스레드는 보통 프로세서 * 2만큼 잡는다.
		DWORD dwNumberOfProcessors; 
			...
		} SYSTEM_INFO, *LPSYSTEM_INFO;
	*/

	// 위에서 선언한 SYSTEM_INFO 구조체를 바탕으로
	// 사용할 프로세서의 개수를 구한다.
	GetSystemInfo(&tInfo);

	vector<HANDLE>	vecThread;

	// 이 프로세서의 개수를 바탕으로 스레드 풀을 구성한다.
	// 이 스레드 안에서 IOCP 큐에 변화가 있는지 감시한다. (GetQueuedCompletionStatus)
	for (DWORD i = 0; i < tInfo.dwNumberOfProcessors; ++i)
	{
		HANDLE hThread = (HANDLE)_beginthreadex(0, 0, IocpThread, (HANDLE)hComport, 0, 0);

		vecThread.push_back(hThread);
	}

	// create listen socket
	SOCKET hListen = WSASocket(PF_INET, SOCK_STREAM, IPPROTO_HOPOPTS, 0, 0, WSA_FLAG_OVERLAPPED);

	SOCKADDR_IN tListenAddr = {};
	tListenAddr.sin_family = AF_INET;
	tListenAddr.sin_port = htons(PORT);
	tListenAddr.sin_addr.s_addr = htonl(INADDR_ANY);

	// bind
	bind(hListen, (SOCKADDR*)&tListenAddr, sizeof(tListenAddr));

	// listen
	listen(hListen, SOMAXCONN);

	while (true)
	{
		SOCKET hSocket = {};
		SOCKADDR_IN tClntAddr = {};
		int iSize = sizeof(tClntAddr);

		// accept Socket 생성
		// IOCP 모델에서 main thread의 역할도 역시 accept
		// 클라이언트와 접속을 담당하는 역할을 수행한다.
		hSocket = accept(hListen, (SOCKADDR*)&tClntAddr, &iSize);

		PSocket_Info pSockInfo = new Socket_Info;
		
		pSockInfo->hSocket = hSocket;
		memcpy(&pSockInfo->tAddr, &tClntAddr, iSize);

		// 생성한 accept 소켓과 위에서 생성했던 IOCP 핸들과 묶어준다.
		// arg 3) ULONG_PTR CompletionKey : 위에서 힙 메모리에 생성한
		// 구조체의 주소를 키 값으로 IOCP 커널 객체에 연결되어 있는 연결 장치의 식별 가능.
		CreateIoCompletionPort((HANDLE)hSocket, hComport, (DWORD)pSockInfo, 0);

		PData_Info pDataInfo = new Data_Info;

		pDataInfo->wsaBuf.len = PACKET_LENGTH;
		pDataInfo->wsaBuf.buf = pDataInfo->Buffer;

		DWORD dwFlag = 0;
		DWORD dwRecvBytes = 0;

		WSARecv(hSocket, &pDataInfo->wsaBuf, 1, &dwRecvBytes, &dwFlag, &pDataInfo->tOverlapped, nullptr);
	}

	closesocket(hListen);

	WSACleanup();

	return 0;
}

 

실행 결과

클라이언트 C++ 소스 코드는 Overlapped I/O 포스팅에 있는

소스 코드와 동일합니다. (https://pony11.tistory.com/17?category=868582)

'윈도우즈 소켓 프로그래밍 기초' 카테고리의 다른 글

Overlapped I/O  (0) 2019.12.29
WSAEventSelect 입출력 모델  (0) 2019.12.29
I/O 멀티플렉싱 (fd_set, select)  (0) 2019.12.29
멀티 스레드, 크리티컬 섹션 동기화  (0) 2019.12.29
TCP / UDP 통신  (0) 2019.12.27