Overlapped IO 모델
Overlapped IO 모델
이전 Asychronous Notification I/O 모델에서 비동기로 처리되었던 것은 ‘IO’가 아닌 ‘Notification(알림)‘이었다. 그러나 여기서는 IO를 비동기로 처리하는 방법에 대해 설명한다.
이 둘의 차이점을 명확히 알고 장단점 구분할 수 있어야 이후의 IOCP를 쉽게 공부할 수 있다.
IO 중첩
💡 헷갈려서 정리하는 비동기와 논블로킹 차이
동기 / 비동기 : IO 작업 A,B,C를 요청했을때, 응답 순서가 A,B,C가 보장되면 동기, 보장되지 않으면 비동기블로킹 / 논블로킹 : 함수가 작업 완료 전까지 반환되지 않으면 블로킹, 작업 완료 전에 호출과 거의 동시에 반환하면 논블로킹
Overlapped IO socket의 생성
#include <winsock2.h>
SOCKET WSASocket(
int af,
int type,
int protocol,
LPWSAPROTOCOL_INFO lpProtocolInfo,
GROUP g,
DWORD dwFlags
);
// 성공 시 소켓의 핸들, 실패 시 INVALID_SOCKET 반환
af
: 프로토콜 체계 정보 전달type
: 소켓의 데이터 전송방식에 대한 전달protocol
: 두 소켓 사이에 사용되는 프로토콜 정보 전달lpProtocolInfo
: 생성되는 소켓의 특정 정보를 담고 있는 WSAPROTOCOL_INFO 구조체 변수의 주소값 전달, 필요없는 경우 NULL 전달g
: 함수의 확장을 위해서 예약되어 있는 매개변수, 따라서 0 전달dwFlags
: 소켓의 속성정보 전달
세 번째 매개변수까지는 잘 아는 것이다. 네 번째 매개변수와 다섯 번째 매개변수의 경우, 지금 하려는 일과 관게가 없으니, NULL과 0을 대입해주면 된다. 마지막 매개변수에는 WSA_FLAG_OVERLAPPED를 전달해서 생성되는 소켓에 Overlapped IO가 가능한 속성을 부여하자.
WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
Overlapped IO를 진행하는 WSASend 함수
Overlapped IO 속성이 부여된 소켓의 생성 이후에 진행되는 두 소켓(서버, 클라이언트) 간의 연결과정은 일반 소켓의 연결과정과 차이가 없다. 그러나 데이터의 입출력에 사용되는 함수는 달리해야 한다.
우선 Overlapped IO에 사용되는 출력함수부터 보이겠다.
#include <winsock2.h>
int WSASend(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesSent,
DWORD dwFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환
s
: 소켓의 핸들 전달, Overlapped IO 속성이 적용된 소켓의 핸들 전달 시 Overlapped IO 모델로 출력 진행lpBuffers
: 전송할 데이터 정보를 지니는 WSABUF 구조체 변수들로 이루어진 배열의 주소값 전달.dwBufferCount
: 두 번쨰 인자로 전달된 배열의 길이정보 전달.lpNumberOfBytesSent
: 전송된 바이트 수가 저장될 변수의 주소 값 전달(이는 이후 별도로 설명)dwFlags
: 함수의 데이터 전송특성을 변경하는 경우에 사용, 예로 MSG_OOB를 전달하면 OOB모드 데이터 전송lpOverlapped
: WSAOVERLAPPED 구조체 변수의 주소 값 전달, Event 오브젝트를 사용해서 데이터 전송의 완료를 확인하는 경우에 사용되는 매개변수lpCompletionRoutine
:Completion Routine이라는 함수의 주소 값 전달, 이를 통해서도 데이터 전송의 완료여부를 확인할 수 있다.
위 함수의 두 번째 인자로 전달되는 주소 값의 구조체는 다음과 같다.
typedef struct __WSABUF
{
u_long len;
char FAR* buf;
} WSABUF, *LPWSABUF
위 함수를 이용해서 데이터를 전송할 때에는 다음과 같이 코드를 구성해야 한다.
WSAEvent event;
WSAOverlapped overlapped;
WSABUF dataBuf;
char buf[BUF_SIZE] = {"전송할 데이터"};
int recvBytes = 0;
// . . . . .
event = WSACreateEvent();
memset(&overlapped, 0, sizeof(overlapped));
overlapped.hEvent = event;
dataBuf.len = sizeof(buf);
dataBuf.buf = buf;
WSASend(hSocket, &dataBuf, 1, &recvBytes, 0, &overlapped, NULL);
// . . . . .
- 세 번째 인자가 1인 이유는 두 번째 인자로 전달된, 전송할 데이터를 담고 있는 버퍼의 정보가 하나이기 때문이다.
여섯 번째 인자로 전달된 WSAOVERLAPPED 구조체는 다음과 같이 정의되어 있다.
typedef struct _WSAOVERLAPPED
{
DWORD Internal;
DWORD InternalHigh;
DWORD Offset;
DWORD OffsetHigh;
WSAEvent hEvent;
} WSAOVERLAPPED, *LPWSAOVERLAPPED
위에서 Internal
, InternalHigh
, Offset
, OffsetHigh
는 운영체제 내부적으로 사용이 예약되어 있다.
따라서 hEvent
만 신경 써주면 된다.
WSASend의 6번째 인자인 lpOverlapped는 반드시 NULL이 아닌 유효한 구조체 변수의 주소 값을 전달해야 한다. 이 값을 NULL로 전달하면, WSASend의 첫 번째 인자로 전달된 핸들의 소켓은 블로킹 모드로 동작하는 일반적인 소켓으로 간주된다.
WSASend의 함수호출을 통해서 동시에 둘 이상의 영역으로 데이터를 전송하는 경우에는 여섯 번째 인자로 전달되는 WSAOVERLAPPED 구조체 변수를 각각 별도로 구성해야 한다.
이는 WSAOVERLAPPED 구조체 변수가 Overlapped IO의 진행 과정에서 운영체제에 의해 참조되기 때문이다.
WSASend 함수의 lpNumberOfBytesSent
“WSASend 함수가 호출되자마자 반환하는데, 어떻게 전송된 데이터의 크기가 저장되는가?”
WSASend
함수라고 해서 언제나 함수의 반환과 데이터의 전송 완료 시간이 불일치하는 것은 아니다.
출력 버퍼가 비어있고, 데이터의 크기가 크지 않다면, 함수 호출과 동시에 데이터 전송이 완료될 수도 있다.
이 경우 WSASend
함수는 0을 반환하고, lpNumberOfBytesSent
에는 전송된 데이터 크기가 담긴다.
WSASend
반환 이후에도 데이터 전송이 계속 이루어지는 상황이라면, WSASend
함수는 SOCKET_ERROR를 반환하고, WSAGetLastError
함수호출을 통해서
확인가능한 오류코드로는 WSA_IO_PENDING
이 등록된다. 그리고 이 경우에는 다음 함수 호출을 통해서 실제 전송된 데이터 크기를 확인해야 한다.
#include <winsock2.h>
BOOL WSAGetOverlappedResult(
SOCKET s,
LPWSAOVERLAPPED lpOverlapped,
LPDWORD lpcbTransfer,
BOOL fWait,
LPDWORD lpdwFlags
);
// 성공 시 TRUE, 실패 시 FALSE 반환
s
: Overlapped IO가 진행된 소켓의 핸들lpOberlapped
: Overlapped IO 진행 시 전달한 WSAOVERLAPPED 구조체 변수의 주소 값 전달lpcbTransfer
: 실제 송수신된 바이트 크기를 저장할 변수의 주소 값 전달fWait
: 여전히 IO가 진행중인 상황의 경우, TRUE 전달 시 IO가 완료될 때까지 대기를 하게되고, FALSE 전달 시 FALSE를 반환하면서 함수를 빠져나오게 된다.lpdwFlags
: WSARecv 함수가 호출된 경우, 부수적인 정보(수신된 메세지가 OOB 메세지인지와 같은)를 얻기 위해 사용된다. 불필요하면 NULL을 전달한다.
참고로 이 함수는 데이터의 전송결과 뿐만 아니라 데이터의 수신결과에도 사용되는 함수다.
Overlapped IO를 진행하는 WSARecv함수
기능적으로 WSASend
함수와 데이터를 송신하느냐 수신하느냐의 차이만 있고, 나머지는 동일하다.
#include <winsock2.h>
int WSARecv(
SOCKET s,
LPWSABUF lpBuffers,
DWORD dwBufferCount,
LPDWORD lpNumberOfBytesRecvd,
LPDWORD lpFlags,
LPWSAOVERLAPPED lpOverlapped,
LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
);
// 성공 시 0, 실패 시 SOCKET_ERROR 반환
s
: Overlapped IO 속성이 부여된 소켓의 핸들 전달lpBuffers
: 수신된 데이터 정보가 저장될 버퍼의 정보를 지니는 WSABUF 구조체 배열의 주소값 전달dwBufferCount
: 두 번째 인자로 전달된 배열의 길이정보 전달lpNumberOfBytesRecvd
: 수신된 데이터의 크기정보가 저장될 변수의 주소 값 전달lpFlags
: 전송특성과 관련된 정보를 지정하거나 수신하는 경우에 사용된다lpOverlapped
: WSAOVERLAPPED 구조체 변수의 주소 값 전달lpCompletionRoutine
: Completion Routine이라는 함수의 주소 값 전달
여러 버퍼에 존재하는 데이터들을 모아 한번에 전송하고, 수신된 데이터를 여러 버퍼에 나눠서 저장하는 것을 가리켜 Gather/Scatter IO라 한다. 옛날에 소개한
writev
,readv
함수가 바로 그것이다. 이 함수들은 윈도우에 정의돼있지 않으며, WSASend, WSARecv의 2,3번째 인자를 보면 알 수 있듯 이를 통해 Gather/Scatter IO 구현이 가능하다.
[참고]
Overlapped IO에서의 입출력 완료의 확인
Overlapped IO에서의 입출력의 완료 및 결과를 확인하는 방법에는 두 가지가 있다. 그 두 가지는 다음과 같다.
WSASend
,WSARecv
함수의 여섯 번째 매개변수 활용 방법, Event 오브젝트 기반WSASend
,WSARecv
함수의 일곱 번째 매개변수 활용 방법, Completion Routine 기반
이 둘을 이해해야 윈도우에서 말하는 Overlapped IO를 이해하는 셈이 된다(사실 위에서 설명한 내용보다도 이것이 더 핵심이다).
Event 오브젝트 사용하기
WSASend
, WSARecv
함수의 여섯 번쨰 인자로 전달되는 WSAOVERLAPPED
구조체 변수에 대해서는 앞서 설명하였으니 예제를 보이겠다.
예제를 통해 다음 두 가지 사실을 확인하자.
- IO가 완료되면
WSAOVERLAPPED
구조체 변수가 참조하는 Event 오브젝트가signaled
상태가 된다. - IO의 완료 및 결과를 확인하려면 WSAGetOverlappedResult 함수를 사용한다.
참고로 아래의 예제는 지금까지 설명한 내용을 정리할 수 있는 수준의 예제일 뿐이다.
Overlapped Sender
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(char *msg);
int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN sendAdr;
WSABUF dataBuf;
char msg[] = "Network is Computer!";
int sendBytes = 0;
WSAEVENT evObj;
WSAOVERLAPPED overlapped;
if(argc != 3)
{
printf("Usage: %s <IP> <port> \n", argc[0]);
exit(1);
}
if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hSocket = WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
memset(&sendAdr, 0, sizeof(sendAdr));
sendAdr.sin_family = AF_INET;
sendAdr.sin_addr.s_addr = inet_addr(argv[1]);
sendAdr.sin_port = htons(atoi(argv[2]));
if(connect(hSocket, (SOCKADDR*)&sendAdr, sizeof(sendAdr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");
evObj = WSACreateEvent();
memset(&overlapped, 0, sizeof(overlapped));
overlapped.hEvent = evObj;
dataBuf.len = strlen(msg)+1;
dataBuf.buf = msg;
if(WSASend(hSocket, &dataBuf, 1, &sendBytes, 0, &overlapped, NULL) == SOCKET_ERROR)
{
if(WSAGetLastError() == WSA_IO_PENDING) // WSASend 함수 반환 이후에도 데아터 전송이 이루어지는 경우
{
puts("Background data send");
WSAWaitForMultipleEvents(1, &evObj, TRUE, WSA_INFINITE, FALSE);
WSAGetOverlappedResult(hSocket, &overlapped, &sendBytes, FALSE, NULL); // WSA_IO_PENDING이 등록되었으므로 이 함수를 통해 데이터 크기 확인
}
else // WSA_IO_PENDING이 아니면 그냥 SOCKET_ERROR임.
{
ErrorHandling("WSASend() error");
}
}
printf("Send data size: %d \n", sendBytes);
WSACloseEvent(evObj);
closesocket(hSocket);
WSACleanUp();
return 0;
}
void ErrorHandling(char *msg)
{
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
Overlapped Receiver
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#define BUF_SIZE 1024
void ErrorHandling(char *message);
int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET hLisnSock, hRecvSock;
SOCKADDR_IN lisnAdr, recvAdr;
int recvAdrSz;
WSABUF dataBuf;
WSAEVENT evObj;
WSAOVERLAPPED overlapped;
char buf[BUF_SIZE];
int recvBytes = 0, flags = 0;
if(argc != 2)
{
printf("Usage : %s <port> \n", argv[0]);
exit(1);
}
if(WSAStartup(MAKEWORD(2, 2), &wsaData != 0)
ErrorHandling("WSAStartup() error!");
hLisnSock = WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
memset(&lisnAdr, 0, sizeof(lisnAdr));
lisnAdr.sin_family = AF_INET;
lisnAdr.sin_addr.s_addr = htonl(INADDR_ANY);
lisnAdr.sin_port = htons(atoi(argv[1]));
if(bind(hLisnSock, (SOCKADDR*) &lisnAdr, sizeof(lisnAdr)) == SOCKET_ERROR)
ErrorHandling("bind() error");
if(listen(hLisnSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error");
recvAdrSz = sizeof(recvAdr);
hRecvSock = accept(hLisnSock, (SOCKADDR*)&recvAdr, &recvAdrSz);
evObj = WSACreateEvent();
memset(&overlapped, 0, sizeof(overlapped));
overlapped.hEvent = evObj;
dataBuf.len = BUF_SIZE;
dataBuf.buf = buf;
if(WSARecv(hRecvSock, &dataBuf, 1, &recvBytes, &flags, &overlapped, NULL) == SOCKET_ERROR)
{
if(WSAGetLastError() == WSA_IO_PENDING)
{
puts("Background data receive");
WSAWaitForMultipleEvents(1, &evObj, TRUE, WSA_INFINITE, FALSE);
WSAGetOverlappedResult(hRecvSock, &overlapped, &recvBytes, FALSE, NULL);
}
else
{
ErrorHandling("WSARecv() error");
}
}
printf("Received message: %s \n", buf);
WSACloseEvent(evObj);
closesocket(hRecvSock);
closesocket(hLisnSock);
WSACleanUp();
return 0;
}
void ErrorHandling(char *message)
{
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
한대의 컴퓨터상에서 Sender와 Receiver를 동시에 실행시키면 많은 양의 데이터를 송수신한다 해도 IO의 Pending 상황을(IO가 완료되지 않은 상황을) 쉽게 확인할 수 없다.
Completion Routine 사용하기
앞에서는 IO의 완료를 Event 오브젝트를 이용해서 확인하였는데, 이번에는 WSASend
, WSARecv
함수의 마지막 전달인자를 통해서 등록되는, Completion Routine(이하 줄여서 CR)이라 불리는 함수를 통해서 확인하는 방법을 소개한다.
이러한 CR의 등록은 다음의 의미를 갖는다.
“Pending된 IO가 완료되면, 이 함수를 호출해 달라!”
이렇듯 IO가 완료되었을 때, 자동으로 호출될 함수를 등록하는 형태로 IO 완료 이후의 작업을 처리하는 방식이 Completion Routine을 활용하는 방식이다. 그런데 매우 중요한 작업을 진행중인 상황에서 갑자기 Completion Routine이 호출되면 프로그램의 흐름을 망칠 수 있다. 따라서 운영체제는 다음과 같이 이야기한다.
“IO를 요청한 쓰레드가 alertable wait 상태에 놓여있을 때만 Completion Routine을 호출할게!”
[Alertable 참고]
“alertable wait 상태”라는 것은 운영체제가 전달하는 메시지의 수신을 대기하는 쓰레드의 상태를 뜻하며, 다음 함수가 호출된 상황에서 쓰레드는 alertable wait 상태가 된다.
WaitForSingleObjectEx
WaitForMultipleObjectsEx
WSAWaitForMultipleEvents
SleepEx
이 중에서 1, 2, 4 번째 함수는 WaitForSingleObject
, WaitForNultipleObjects
, Sleep
함수와 동일한 기능을 제공한다.
단, 위 함수들은 이들보다 매개변수가 마지막에 하나 더 추가되어 있는데, 이 매개변수에 TRUE를 전달하면 해당 쓰레드는 alertable wait 상태가 된다.
그리고 WSA로 시작하는 이름의 함수는 앞서 소개한 바 있는데, 이 함수 역시 마지막 매개변수로 TRUE가 전달되면 해당 쓰레드는 alertable wait 상태가 된다.
따라서 IO를 진행시킨 다음에, 급한 다른 볼일들을 철이하고 나서, IO가 완료되었는지 확인하고 싶을 때 위의 함수들 중 하나를 호출하면 된다. 그러면 운영체제는 쓰레드가 alertable wait 상태에 진입한 것을 인식하고, 완료된 IO가 있다면 이에 해당하는 Completion Routine을 호출해 준다.
물론 Completion Routine이 실행되면, 위 함수들은 모두 WAIT_IO_COMPLETION
을 반환하면서 함수를 빠져 나온다. 그리고는 그 다음부터 실행을 이어나간다.
앞서 구현했던 Receiver를 Completion Routine 기반으로 변경해보자.
Completion Routine Receiver
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#define BUF_SIZE 1024
void CALLBACK CompRoutine(DWORD, DWORD, LPWSAOVERLAPPED, DWORD);
void ErrorHandling(char *message);
WSABUF dataBuf;
char buf[BUF_SIZE];
int recvBytes = 0;
int main(int argc, char *argv[])
{
WSADATA wsaData;
SOCKET hLisnSock, hRecvSock;
SOCKADDR_IN lisnAdr, recvAdr;
WSAOVERLAPPED overlapped;
WSAEVENT evObj;
int idx, recvAdrSz, flags = 0;
if(argc != 2)
{
printf("Usage : %s <port> \n", argv[0]);
exit(1);
}
if(WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hLisnSock = WSASocket(PF_INET, SOCK_STREAM, 0, NULL, 0, WSA_FLAG_OVERLAPPED);
memset(&lisnAdr, 0, sizeof(lisnAdr));
lisnAdr.sin_family = AF_INET;
lisnAdr.sin_addr.s_addr = htonl(INADDR_ANY);
lisnAdr.sin_port = htons(atoi(argv[1]));
if(bind(hLisnSock, (SOCKADDR*)&lisnAdr, sizeof(lisnAdr)) == SOCKET_ERROR)
ErrorHandling("bind() error");
if(listen(hLisnSock, 5) == SOCKET_ERROR)
ErrorHandling("listen() error");
recvAdrSz = sizeof(recvAdr);
hRecvSock = accept(hLisnSock, (SOCKADDR*)&recvAdr, &recvAdrSz);
if(hRecvSock == INVALID_SOCKET)
ErrorHandling("accept() error");
memset(&overlapped, 0, sizeof(overlapped))
dataBuf.len = BUF_SIZE;
dataBuf.buf = buf;
evObj = WSACreateEvent(); // Dummy event object
if(WSARecv(hRecvSock, &dataBuf, 1, &recvBytes, &flags, &overlapped, CompRoutine) == SOCKET_ERROR)
{
if(WSAGetLastError() == WSA_IO_PENDING) // 아직 수신작업이 끝나지 않음
{
puts("Background data receive");
}
}
idx = WSAWaitForMultipleEvents(1, &evObj, FALSE, WSA_INFINITE, TRUE); // 여기서 블로킹 alertable wait 상태 진입, IO 작업이 완료되면 CompRoutine 호출
if(idx == WAIT_IO_COMPLETION)
puts("Overlapped I/O Completed");
else // if error occurred
ErrorHandling("WSARecv() error");
WSACloseEvent(evObj);
closesocket(hRecvSock);
closesocket(hLisnSock);
WSACleanup();
return 0;
}
void CALLBACK CompRoutine(
DWORD dwError,
DWORD szRecvBytes,
LPWSAOVERLAPPED lpOverlapped,
DWORD flags){
if(dwError != 0)
{
ErrorHandling("ComRoutine error");
}
else
{
recvBytes = szRecvBytes;
printf("Received message: %s \n", buf);
}
}
void ErrorHandling(char *message)
{
fputs(msg, stderr);
fputc('\n', stderr);
exit(1);
}
Completion Routine 함수 종료 이후 다음 라인 코드가 실행된다. Completion Routine의 원형은 다음과 같다.
void CALLBACK CompletionROUTINE(
DWORD dwError,
DWORD dbTransferred,
LPWSAOVERLAPPED lpOverlapped,
DWORD dwFlags
);
이 중에서 첫 번째 매개변수로는 오류정보가(정상종료 시 0 전달), 두 번째 매개변수로는 완료된 입출력 데이터의 크기정보가 전달된다. 그리고 세 번째 매개변수로는 WSASend, WSARecv 함수의 매개변수 lpOverlapped로 전달된 값이, 마지막으로 dwFlags에는 입출력 함수호출 시 전달된 특성정보 또는 0이 전달된다.
반환형 void 옆에 삽입된 키워드 CALLBACK은 쓰레드의 main 함수에 선언되는 키워드인 WINAPI와 마찬가지로 함수의 호출규약을 선언해 놓은 것이니, Completion Routine을 정의하는 경우에는 반드시 삽입해야 한다.