IOCP와 Overlapped IO
IOCP(Input Output Completion port)와 Overlapped IO는 상당히 유사하다. 구동 방식은 똑같지만 완료 통지 방법이 다를뿐이다. 그러니 Overlapped IO를 먼저 알고 있다면 IOCP를 이해하는데 굉장히 도움이 될 것이다. 이 글(Overlapped를 이해해보자)을 읽고 오면 도움이 될거다!!
Overlapped의 문제점
Overlapped를 이해해보자에선 간단하게 입출력 활용법만 알아보고, 서버를 만들어보진 않았다. Overlapped IO로 서버를 구성하려면 어떤걸 먼저해야할까? 비동기 Accept를 위해 소켓을 논블로킹모드로 전환시켜야한다.
u_long on = 1;
::ioctlsocket(socket, FIONBIO, &on);
이 함수를 옵션에 'FIONBIO' 를 넣고 on/off 유무를 넣어주면 사용하면 논블로킹 모드로 전환할 수 있다.
논블로킹으로 전환하면 2가지 특성이 생긴다.
- 클라이어언트의 연결요청이 없을 때 accept함수가 호출되면 INVALID_SOCEKT이 곧바로 반환된다. 그리고 WSAGetLastError 함수를 호출하면 WSAEWOULDBLOCK이 반환된다.
- accept 함수 호출을 통해 새로 생성되는 소켓도 논블로킹 속성을 가진다.
이제 Overlapped를 사용해 간단히 서버를 만들어보자.
int main(.....)
{
......
SOCKET hLisnSock, hRecvSock;
LPWSAOVERLAPPED lpOvlp;
DWORD recvBytes;
.....
while(1)
{
SleepEx(100,TRUE) // alertable wait 상태 돌입
hRecvSock = accept(hLisnSock, (SOCKADDR*)&recvAdr, &recvAdrSize);
if(hRecvSock == INVALID_SOCKET)
{
if(WSAGetLastError() == WSAWOULDBLOCK) // 아직 연결요청 없음.
continue;
else
{
// 진짜 에러
}
}
WSARrev(hRecvSock, &wsaBuf, 1, &recvBytes, 0, lpOvLp, ReadCompRoutine);
}
}
void CALLBACK ReadCompRoutine(DWORD error, DWORD recvBytes, LPWSAOVERLAPPED overlap, DWORD flags){
......
}
while문에서 논블로킹 accept를 진행하면서 SleepEx를 통해 계속 alertable wait 상태에 들어가고 있다. 이렇게 Overlapped IO는 빈번한 alertable wait으로 성능이 저하되는 단점을 가지고 있다.
이러한 단점을 극복하려면 어떤 식으로 해야할까?
IOCP 모델
"accept 함수의 호출은 main 스레드에서 처리하고, 별도의 스레드를 추가로 생성해 클라이언트와의 입출력을 담당한다."
이것이 IOCP를 통해 진행하는 서버의 구현 방식이다!!
IOCP는 Input Output Copletion port의 약자이다.
Completion port?? => overlapped IO 모델에서 나왔던 APC큐는 스레드에 각각 존재한다고 했다. completion port를 쉽게 이해하면 공용 APC 큐라고 생각하면된다!
IOCP 관련 함수
IOCP를 위해 필요한 활동은 세 가지가 있다.
- completion port 생성
- completion port에 소켓 등록
- 완료된 IO 확인
하지만 한 가지 함수가 2가지 일을 맡고 있기에 두 가지 함수만 알아보면 된다.
CreateIoCompletionPort
CreateIoCompletionPort 함수는 completion port 생성과 소켓 등록 두 가지 일을 하는 함수다.
#include <winsock2.h>
HANDLE CreateIoCompletionPort(
HANDLE FileHandle, HANDLE ExisitingCompletionPort, ULONG_PTR CompletionKey,
DWORD NumberOfConcurrentThreas);
| 매개변수명 | 생성 시 | 등록 시 |
| FileHandle | INVALID_HANDLE_VALUE 전달 | Completion port에 등록할 소켓 |
| ExistingCompletionPort | NULL 전달 | 등록될 completion port 오브젝트 |
| CompletionKey | 0 전달 | 완료될 IO 관련 정보 전달을 위한 매개 변수 |
| NumberOfConcurrentThreads | 할당되어 IO를 처리할 스레드 수 - 2를 입력 시 2개로 제한된다. - 0 전달 시 CPU가 동시 실행 가능한 스레드 최대 수로 지정됨 |
어떤 값을 넣어도 NULL이 아니면 무시함. |
| 반환 시 | 성공 시 Completion port 핸들, 실패 시 NULL을 반환함. | |
GetQueuedCompletionStatus
Completion port에 등록되는 IO들을 확인하는 함수다.
#include <windows.h>
BOOL GetQueuedCompletionStatus(
HANDLE CompletionPort, LPWORD lpNumberOfBytes, PULONG_PTR lpCompletionKey,
LPOVERLAPPED* lpOverlapped, DWORD dwMilliseconds);
→ 성공 시 TRUE, 실패 시 FALSE를 반환함.
| CompletionPort | 완료된 IO 정보가 등록되어 있는 Completionport |
| lpNumberOfbytes | 입출력 과정에서 송수신 된 데이터 크기를 받을 변수 주소 |
| lpCompletionKey | CreateCompletionPort 함수에서 세 번째 인자로 전달된 값을 저장할 변수 주소 |
| lpOverlapped | WSASend,WSARecv 함수호출 시 전달하는 OVERLAPPED 구조체를 받을 변수 주소 |
| dwMilliseconds | Time out 정보 전달 - INFINITE 전달 시 무한 대기 |
이제 IOCP 모델을 설계해보자.
정보 전달하기
입출력이 완료되었을 때, 우리는 어떤 입출력인지 알 수 없다. 그럼 어떻게 완료됐다라는 신호만 가지고 후작업을 진행할 수 있을까?
Key 값
우리는 소켓을 Completion port에 등록할 때 세 번째 매개변수를 통해 정보를 전달할 수 있다는 것을 알고 있다. 이것을 통해 socket의 추가 정보들을 입출력이 완료될 때로 전달할 수 있다. 게임을 예로 든다면, 해당 소켓의 playerId는 몇 인지, 어떤 방에 속해 있는지 정보를 전달 할 수 있다.
Overlapped 값
입/출력함수 (WSARecv/Send)를 호출 할 때 우리는 Overlapped 오브젝트를 넣어줘야한다. 이것을 통해 입출력에 대한 정보를 전달 할 수 있다. 구조체의 주소는 구조체의 첫 멤버의 주소와 같다는 것을 잘 이용해주면 된다.
typedef struct
{
OVERLAPPED overlapped;
WSABUF wsaBuf;
char buffer[BUF_SIZE];
int rwMode; // READ or WRITE
} PER_IO_DATA, *LPPER_IO_DATA;
이 구조체를 OVERLAPPED로 캐스팅 해서 입/출력 함수에 넣어준다면, 입/출력이 완료 되었을 때 overlapped값을 다시 struct로 캐스팅하여 여러가지 정보를 얻을 수 있다.
이것은 Key값도 마찬가지다. Key값도 구조체를 통해 연결된 주소 등을 포함하여 정보를 전달할 수 있다.
typedef struct
{
SOCKET hClntSock;
SOCKADDR_IN clntAdr;
} PER_HANDLE_DATA, *LPPER_HANDLE_DATA;
IOCP 흐름 구성
이제 직접 서버를 간단히 만들어보며 흐름을 구성해보자. 중요하지 않은 변수 선언과, listen까지의 과정은 생략하겠다.
Main Thread
int main(.....)
{
.....
HANDLE hComPort;
SYSTEM_INFO
LPPER_IO_DATA ioInfo; // IO 관련 정보
LPPER_HANDLE_DATA handleInfo; // 소켓 관련 정보
.....
hComPort =CreateIoCompletionPort(INVALIDE_HANDLE_VALUE, NULL, 0, 0);
GetSystemInfo(&sysInfo);
for(int i = 0; i < sysInfo.dwNumberOfProcessors; i++) // 최대 스레드 개수만큼 스레드 생성
_beginthreadex(NULL, 0, EchoThreadMain, (LPVOID)hComPort, 0, NULL);
.....
while(1)
{
hClntSock = accept(hServSock, (SOCKADDR*)&clntAdr, &AddrLen); // 메인 스레드는 accept만 진행
handleInfo=(LPPER_HANDLE)malloc(sizeof(PER_HANDLE_DATA));
handleInfo->hClntSock = hClntSock;
memcpy(&(handleInfo->clntAdr), &clntAdr, addrLen);
CreateIoCompletionPort((HANDLE)hClntSock, hComPort, (DWORD)handleInfo,0) // Key값과 함께 소켓 등록
ioInfo = (LPPER_IO_DATA)malloc(sizeof(PER_IO_DATA));
memset(&ioInfo->overlapped),0, sizeof(OVERLAPPED));
ioInfo->wsaBuf.len = BUF_SIZE;
ioInfo->wsaBuf,buf = ioInfo->buffer;
ioInfo->reMode = READ; // #define READ 3, WRITE 5
WSARecv(handleInfo->hClntSock, &(ioInfo->wsaBuf), 1, &recvBytes, &flags, &(ioInfo-> overlapped),NULL);
}
}
메인 스레드는 블로킹 accept를 통해 accept를 받고, CompletionPort에 등록하고 Recv를 실행시켜준다.
그럼 Completion port에 등록된 스레드들은 어떤일을 할까?
DWORD WINAPI EchoThreadMain(LPVOID pComPort)
{
HANDLE hComPort = (HANDLE)pComPort;
SOCKET sock;
LPPER_HANDLE_DATA handleInfo;
LPPER_IO_DATA ioInfo;
.....
while(1)
{
GetQueuedCompletionStatus(hComPort, &bytesTrans,
(LPDWORD)&handleInfo, (LPOVERLAPPED*)&ioInfo, INFINITE);
sock=handleInfo->hClntSock;
if(ioInfo->rwMode==READ)
{
// 수신 후처리
}
else
{
// 송신 후처리
}
}
}
IO완료가 됐을 때 key와 overlapped로 전달된 정보를 가지고 후처리를 하는 역할을한다.
이로써 메인스레드는 accept를 맡고 다른 스레드들이 IO 처리를 하는 IOCP 모델을 만들어봤다.
IOCP가 성능이 나오는 이유
- 논블로킹 방식으로 IO가 진행되기 때문에, IO 작업으로 인한 시간 지연이 없다.
- IO가 완료된 핸들을 찾기위해 반복문을 구성 안해도된다.
- IO의 진행대상인 소켓의 핸들을 배열로 관리할 필요가 없다.
- IO의 처리를 위한 스레드 수를 조절할 수 있어, 적절한 스레드 수를 지정할 수 있다.
마무리
이로써 epoll과 함께 훌륭한 서버 모델로 취급 받는 IOCP 모델에 대해 알아보았다. IOCP모델은 윈도우에서만 쓸 수 있어, 여러 운영체제에 대응하기 위한 모델로는 적합하지 않을지도 모른다!
참고
'TCP-IP 소켓 프로그래밍' 카테고리의 다른 글
| Overlapped IO 모델을 이해해보자 (0) | 2023.09.14 |
|---|---|
| 비동기 Notification IO 모델을 이해해보자 (0) | 2023.09.13 |
| epoll을 이해해보자 (0) | 2023.09.12 |
| 스레드의 동기화를 이해해보자 (1) | 2023.09.11 |
| 스레드와 프로세스의 차이를 이해해보자 (5) | 2023.09.11 |