TCP-IP 소켓 프로그래밍

Overlapped IO 모델을 이해해보자

코딩봇치 2023. 9. 14. 10:48

Overlapped??


overlapped는 중첩이라는 뜻을 가지고 있다. IO를 중첩한다는 것이다.

하나의 스레드 내에서 동시에 둘 이상의 영역으로 데이터를 수신해, 입출력이 중첩되는 상황을 'IO 중첩'이라고한다.

IO중첩이 일어나기 위해선 입출력 함수가 논-블로킹 모드로 작동해야한다!

Overlapped IO의 핵심

overlapped IO는 이런 동작 원리에 있는 것이 아닌, 이 완료된 입출력의 확인방법에 있다. 논블로킹 모드로 진행되면, 완료 결과를 따로 확인해줘야한다. 윈도우에서 말하는 Overlapped IO는 입출력만을 뜻하는 게 아닌 완료를 확인하는 방법까지 포함한 것이다.

 

Overlapped 입출력 관련 함수


Overlapped IO 모델을 위해선 특별한 소켓을 사용할 수 있다.

소켓은 우리가 잘아는 그냥 SOCKET도 있지만, 윈도우 함수들을 좀 더 효율적으로 사용하기 위한 WSASocket이 있다.

WSASocket 사용하기

WSASocket은 기존 소켓과 생성 시 들어가는 인자값에 차이가 있다.

#include <winsock2.h>

SOCKET WSASocket(
int af, int type, int protocol, LPWSAPROTOCOL_INFO lpProtocolInfo, GROUP g, DWOORD dwFlags);

→ 성공 시 소켓, 실패시 INVALID_SOCKET을 반환함

세 번째 인자까지는 기본 소켓과 동일하다.

lpProtocollinfo 생성되는 소켓의 정보를 담고있는 WSAPROTOCOL_INFO 구조체 주소, 필요없으면 NULL
g 함수의 확장을 위해 있는 것, 0을 넣기
dwFlags 소켓 속성정보

Overlapped 전용 소켓을 생성하기 위해선 dwFlags에 'WSA_FLAG_OVERLAPPED'를 넣어주면 된다.

WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);

 

 Overlapped IO 진행을 위한 입/출력 함수

소켓간의 연결 과정은 일반 소켓과 차이가 없지만 송/수신에 있어선 다른 함수들이 존재한다.

Overlapped IO를 진행하기 위해 있는 송/수신 함수를 알아보자.

WSASend 함수

#include <winsock2.h>

int WSASend(
SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount,
LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRouine);

→ 성공 시 0, 실패 시 SOCKET_ERROR를 반환함.

s 소켓
- Overlapped IO 속성이 부여된 소켓이 전달 되어야 Overlapped IO모델로 진행함.
lpBuffers 전송할 데이터 정보를 지닌 WSABUF 구조체 주소 전달
- 1개도 가능하고 배열로도 가능함.
dwBufferCount 두 번째 인자로 전달된 배열의 길이
- 1개라면 1을 전달하면 됨.
lpNumberOfbytesSent 전송된 바이트 수가 저장될 변수 주소값
dwFlags 함수의 데이터 전송특성 변경 시 사용.
(MSG_OBB모드 등등)
lpOverlapped WSAOVERLAPPED 구조체 변수 주소
- Event 방법을 사용할 때 사용되는 매개변수
lpCompletionRoutine Completion Routine 함수 주소 전달
(이 함수로 전달 완료 여부 확인 가능)

IO가 비동기로 처리되니 바로 몇 바이트 전송이 완료 됐는 지 알 수 없다. 그래서 4번째 인자를 통해 입출력 후 전송된 바이트 크기를 알 수 있다는 것을 알 수 있다.

추가적으로 생소한 WSABUF, WSAOVERLAPPED 구조체가 어떤 식으로 이루어져 있는지 살펴보자.

 

WSABUF

typedef struct __WSABUF
{
	u_long len; // 데이터 크기
    char FAR* buf; // 버퍼 주소 값
} WSABUF, *LPWSABUF;

WSABUF는 버퍼의 정보를 담고 있다. 데이터 크기, 버퍼의 시작 주소 값 등을 전달해준다.

왜 사용할까?? => 나중엔 큰 버퍼를 잘게잘게 쪼게 사용하기도 할 것이다. 그럴 때 WSABUF가 유용하게 쓰일 것이다.

 

WSAOVERLAPPED

typedef struct _WSAOVERLAPPED
{
	DWORD Internal;
    DWORD InternalHigh;
    DWORD Offset;
    DWORD OffsetHigh;
    WSAEVENT hEvent;
} WSAOVERLAPPED, *LPWSAOVERLAPPED;

우리가 집중해야 하는 것은 'hEvent' 이다. Internal InternalHigh는 IO가 진행될 때 운영체제 내부적으로 사용되는 변수들이고, OFffset, OffsetHigh 역시 사용하는 곳이 예약 되어있다. 우리는 'hEvent' 멤버만 사용하게 될것이다.

 

 ※주의점

  1. Overlapped IO를 진행 할 때 WSASend 함수의 매개변수 lpOverlapped는 NULL이 아닌, 사용 가능한 구조체 변수 주소를 전달해야한다. => NULL값이 전달되면 블로킹 모드로 동작하는 일반적인 소켓으로 간주됨!
  2. WSASend 함수로 동시에 둘 이상 영역으로 데이터를 전송하는 경우엔 인자로 전달되는 OVERLAPPEDD 구조체를 별도로 구성해야한다. => IO 과정에서 운영체제에 의해 참조되고 사용되기 때문에 같은 구조체가 들어가면안됨!

WSARecv 함수

#include <winsock2.h>

int WSARecv(
SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount,
LPDWORD lpNumberOfBytesRecvd, DWORD lpFlags, LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRouine);

놀랍게도 WSASend와 차이가 없다. 수신이 송신관련으로 바꼈다고만 생각해주면 된다.

결과 확인 함수

#include <winsock2.h>

BOOL WSAGetOverlappedResult9
	SOCKET s, LPWSAOVERLAPPED lpOverlapped, LPDWORD lpcbtransfer, BOOL fWait, LPDWORD lpdwFlags);

→ 성공 시 TRUE, 실패 시 FALSE를 반환함

s Overlapped IO 진행 소켓
lpOverlapped Overlapped IO 진행 시 전달했던 WSAOVERLAPPED 구조체 변수 주소
lpcbTransfer 실제 송수신된 바이트 크기를 저장할 변수 주소
fWait 아직도 IO가 진행중일 때 대기 여부
- TRUE시 IO 완료까지 대기하고 FALSE시 false를 반환함.
lpdwFlags WSARecv함수가 호출 시 부수적인 정보(OOB 메세지 등)
- 불필요 시 NULL 전달

 

입/출력 함수 동작

WSASend 나 WSARecv를 비동기적으로 호출 했을 때 우리는 두 가지 경우의 수로 결과를 얻을 수 있다.

  1. 운 좋게 입/출력 실행 시 바로 완료한 경우
  2. 반환 후 입/출력이 나중에 완료되는 경우

운 좋게 바로 완료가 되면 매개변수로 넣은 변수로 결과값을 얻을 수 있다.

하지만 바로 완료가 되지 않을 때도 있다. 이 상황을 Pending 상황이라고 한다. 그 상황엔 WSA_IO_PENDING이라는 오류가 발생한다.(오류가 아니지만 오류로 상황을 알리는 것)

WSAGetLastError라는 마지막 오류를 반환해주는 함수를 통해 이런 코드를 작성할 수 있다.

.....

if(WSASend(_socket, &dataBuf, 1, &sendBytes, 0, &overlapped,NULL) == SOCKET_ERROR){
	if(WSAGetLastError() == WSA_IO_PENDING){
    	//Pending 상황
    }
    else{
    	// 진짜 오류 상황
    }
}

 

Overlapped IO 입출력 완료 확인 방법


이제 본격적으로 입출력을 어떤 식으로 확인할 수 있는지 알아보자. 위에서 확인 방법엔 두 가지가 존재한다.

  1.  Event활용하기
  2. Completion Routine 활용하기

두 가지 모두 차근차근 알아보자.

Event 오브젝트 활용

Event 오브젝트를 사용하면 이러한 순서가 나온다.

  1. IO 완료 시 WSAOVERLAPPED 구조체 변수가 참조하는 Event 오브젝트가 signaled 상태가 된다.
  2. IO의 결과 확인을 위해 WSAGetOverlappedResult 함수를 사용한다.

이외의 것은 WSAEventSelect 모델과 유사한 지식을 요구하니 아직 모르겠다면 이 글을 읽어보자.

활용

SOCKET hSocket;

.....

WSAEvent evObj;
WSAOVERLAPPED overlapped;

.....

evObj = WSACreateEvent();
memset(&overlapped, 0, sizeof(overlapped));
overlapped.hEvent = evObj; // 이벤트 등록

if(WSASend(hSocket, &dataBuf, 1, &sendBytes, 0, &overlapped, NULL == SOCKET_ERROR){
	if(WSAGetLastERROR() == WSA_IO_PENDING)
    {
    	WSAWaitForMultipleEvnets(1, &evObj, TRUE, WSA_INFINITE, FALSE);
        WSAGetOverlappedResult(hSocket,&overlapped, &sendbytes, FALSE, NULL);
    }
}

printf("Send %d Bytes", sendbytes);

 Completion Routine 활용

Completion Routine은 이러한 활용용도를 가지고 있다.

"Pending된 IO가 완료되면 이 함수를 호출해줘!"

하지만 중요한 작업 중 Completion Routine을 호출하면 프로그램 흐름이 엉망이 될 수 있다. 그래서 운영체제는 IO 요청 스레드가 alertable wait 상태에 놓여 있을 때만 Completion Routine를 호출한다.

 

alertable wait 상태? => 운영체제의 메세지 수신을 대기하는 스레드 상태, alertable wait 상태 진입시 각 스레드 마다 존재하는 APC큐에서 모든 routine을 실행시킴.

스레드를 alertable wait 상태로 만드는 함수에는 네 가지 정도가 있다.

  1. WaitForSingleObjectEx
  2. WaitForMultipleObjectEx
  3. WSAWaitForMultipleEvents
  4. SleepEx

Completion Routine 함수 인터페이스

completion routine에 함수를 활용하고 싶다면 함수의 인터페이스를 맞춰줘야한다.

void CALLBACK CompletionROUTINE(
	DWORD dwError, DWORD cbTransferred, LPWSAOVERLAPPED lpOverlapped, DWORD dwFlags);
dwError 오류정보
- 오류가 없다면 0이 전달됨.
cbTransferred 입출력 데이터 크기
lpOverlapped 입출력 함수 매개변수로 전달한 overlapped
dwFlags 입출력 함수로 전달된 특성 정보 or 0

void 옆 CALLBACK은 함수의 호출규약이니 꼭 삽입해주어야한다.

 

활용

if(WSARecv(hRecvSock, &dataBuf, 1, &recvBytes, &flags, &overlapped, RecvCallback
	== SOCKET_ERROR){
	if(WSAGetLastError() == WSA_IO_PENDING)
    	::SleepEx(INFINITE, TRUE); // alertable wait 상태 진입

}
else{
	printf("Recv %d Bytes", recvBytes);
}

.....

void CALLBACK RecvCallback(DWORD error, DWORD recvLen, LPWSAOVERLAPPED overlapped, DWORD flags) {
	if(error != 0){
    	// 에러 발생
        return;
    }
    
    printf("Recv CALLBACK %d Bytes", recvLen);
}

pending 되어 completion routine으로 실행됐다면 Recv CALLBACK 이 출력되고 바로 됐다면 Recv 가 출력될 것이다.

 

Event 방식과의 차이

Event 방식은 Event와 Overlapped를 일대일 매칭을 시켜줬어야했다. 다수를 처리해야한다면 정말 힘들어지지만 completion routine 방식은 alertable wait에 들어가면 모두 처리해준다.

 

※ 입/출력 함수에 들어가는 overlapped의 hEvent 멤버는 이벤트 방식일 때만 사용한다. 이 곳에 다른 데이터를 넣어서 더 많은 데이터를 Routine함수에 넣어줄 수 있다!

 

마무리


overlapped IO의 두 가지 방법에 대해 공부해봤다. overlapped IO는 한 가지 아쉬운 점이 남아있다. APC큐가 스레드 별로 있기 때문에 다른 스레드의 IO는 대신 처리해줄 수가 없어서 자신의 IO가 없다면 계속 alertable wait 모드에 남아있어야한다. 이러한 문제도 있지만, 현재는 간단한 입/출력 방법만 알아봤지만 accept도 계속 받아줘야하는 서버를 만들 경우 빈번한 alertable wait으로 인해 성능 또한 저하된다.

 

참고