bdfgdfg

[소켓] 블록킹,논블록킹,동기 I/O,비동기 I/O(Overlapped I/O) 본문

CS/TCP IP

[소켓] 블록킹,논블록킹,동기 I/O,비동기 I/O(Overlapped I/O)

marmelo12 2022. 1. 12. 00:20
반응형

블록킹, 논 블록킹, 동기, 비동기(Blocking, Non-Blocking, Synchronous I/O, Asynchronous I/O)

1
2
3
4
// 기본적인 TCP소켓을 만드는 방법
SOCKET socket = ::socket(AF_INET, SOCK_STREAM, 0);
// 이렇게 만들어진 소켓은 기본적으로 BLOCKING모드.
// WSASocket함수로도 소켓 핸들을 얻을 수 있는데, 기본적으로 기능은 똑같다.
cs

기본적으로 소켓을 생성할 때 만들어지는 소켓 핸들은 블록킹(Blocking)모드이다.

 

블로킹은 디바이스에 처리 요청을 걸어 놓고 응답을 대기하는 함수를 호출할 때 스레드에서 발생하는 대기 현상.

 - 즉 간단하게 동기 I/O 함수를 호출할 때 해당 스레드에서 발생하는 대기 현상.

블로킹 모드의 소켓을 통해 일반 동기 I/O 함수를 호출하면 커널에 존재하는 소켓의 송수신 버퍼 상황에 따라 BLOCK상태에 걸린다.

 - 블록킹이 발생하는 스레드에서는 CPU 연산을 하지 않는다. (스레드 waitable state라고도 함)

 - 쉽게 파일 I/O를 예로 들어 읽기를 하는 함수를 호출했다면 디스크 읽기가 완전히 끝날 때까지 waitable상태를 유지.

 - 해당 작업이 끝나고 반환이 되면 스레드는 다시 running 상태가 된다.

 

여기서는 소켓 프로그래밍의 설명이니 소켓을 대상으로 설명.

1
2
3
4
5
6
// 기본적인 TCP소켓을 만드는 방법
SOCKET socket = ::socket(AF_INET, SOCK_STREAM, 0);
 
//... 어떤 서버 소켓과 1대1 연결된 상황.
char buf[10= "HIHI";
::send(socket, buf, strlen(buf) + 10);
cs

먼저 send를 하는 상황을 보자. 대부분의 상황에서 연결된 소켓의 대상으로 데이터를 전송하는 send함수를 호출하면

대부분 바로 리턴이 될 것이다.

블로킹 모드의 소켓인데 상대방의 수신을 기다리지 않고 바로 함수가 빠져나오는 이유는 커널의 소켓 버퍼를 이해해야 한다.

소켓은 각각 커널에서 송신 버퍼와 수신 버퍼를 가지고 있는데 위의 send함수는 데이터를 송신하고 상대방이 데이터를 수신할 때까지 대기(BLOCK)하는 게 아닌, 소켓의 송신 버퍼에 전송할 데이터를 모두 복사가 되었으면 리턴한다.

 - 소켓의 송신 버퍼의 디폴트 크기는 수천 바이트 ~ 만 바이트.

 - 송신 버퍼에 복사된 데이터는 운영체제에 의해 데이터를 송출하고 송신 버퍼에 있는 데이터를 비운다.

 

그러면 블로킹 모드의 소켓에서 동기 I/O인 send함수를 호출하더라도 대기(BLOCK) 상태에 안 빠지는구나? 하는 건 아니다.

송신 버퍼의 크기도 정해져 있기에 사용자가 send함수를 호출하고 유저 버퍼에 있는 데이터를 커널의 소켓 송신 버퍼에 

복사하려는데 송신 버퍼가 가득 차 있다면 대기(BLOCK) 상태에 빠진다.

 - send 함수가 블로킹된 상황(스레드가 블로킹된 상황)에서도 운영체제는 송신 버퍼에 있는 데이터를 송신하고 버퍼를 차례대로 비우기 때문에(FIFO) 유저 버퍼의 데이터가 복사할 공간이 생긴다면 send함수는 반환이 되고, BLOCK이 해제된다.

 

반대로 recv(수신) 함수도 같다. send와 블록킹 되는 현상(이유)은 반대지만 기본적인 느낌은 동일.

 - 참고로 send, recv함수의 len인자는 송수신받을 길이(크기)

 - 반환 값은 송수신한 길이(크기)

1
2
3
//... 어떤 서버 소켓과 1대1 연결된 상황.
char buf[10= { 0, };
::recv(socket, buf, sizeof(buf), 0);
cs

소켓의 수신 버퍼 안에는 운영체제가 push 한(다른 소켓에서 송신한 데이터) 데이터를 저장하게 된다.

이 데이터를 사용자가 직접 pop 한다는 점이 send와는 정반대의 기능을 하는 것.

수신 버퍼 안에는 데이터가 수신되는 것이 있을 때마다 계속해서 채워진다. 여기서 사용자가 recv함수를 통해 수신 버퍼에 있는 데이터를 읽어올 수 있다.

 - 여기서. 만약 수신 버퍼에 데이터가 존재하지 않는 상황에서 사용자가 recv함수를 호출했을 때 대기(Block) 상태에 빠진다.

TCP 수신 함수인 recv는 단 1바이트라도 수신할 수 있으면 데이터를 읽어오면서 즉시 리턴한다.

(그 외 1바이트 데이터조차도 없다면 블록킹 상태)

 - 이는 반대로 상대방이 데이터를 10byte를 보낸다 할지라도 수신 버퍼에 1000byte가 쌓여있다면 사용자는 이 데이터를 한 번에 다 읽어 들일 수도 있다.(TCP의 데이터 경계가 없다는 특징)

 

다시 돌아와서, 수신 버퍼가 가득 차고 상대방 쪽에서 데이터를 보낼 때 send함수는 블로킹 상태에 빠진다.

즉 받는 입장에서 전혀 recv를 하지 않는다면 받는 쪽과 보내는 쪽 모두가 대기상태에 빠지는 것.

 - TCP의 연결만 살아있는 상태.(연결은 끊어지지 않는다 -> TCP의 신뢰성이 높다는 특징)

 - 반대로 UDP의 경우 송신한 데이터가 상대방의 수신 버퍼에 담을 공간이 없다면 데이터는 손실된다.

 

즉 정리하면 블로킹 모드의 소켓이라 할지라도 동기 I/O 함수를 호출할 때 무조건 블로킹이 일어난다는 소리는 아니다.

 - recv함수의 경우는 언제 상대방이 데이터를 보낼지 모르기에 좀 더 높은 확률로 블로킹에 걸릴 수 있다.

 

하지만 통신의 대상이 많을수록 한 번씩 블록킹이 일어난다면 그 블록킹이 걸리는 시간도 추측할 수 없고 기본적으로는 자의적으로 빠져나올 수 있는 방법이 없기에 메인 스레드가 일을 처리할 때마다 버벅거릴 수밖에 없다.

 - 이것을 해결하겠다고 통신의 대상 수만큼 스레드를 생성하면 운영체제의 특징상 타임 슬라이스를 가지는 스레드가 자기의 실행권을 남에게 넘겨주는 컨텍스트 스위칭 처리만 많아질 뿐이다. + 자원의 낭비

 

이를 해결하기 위해서 논 블록킹(Non-blocking) 모드가 존재한다.

 

논 블록킹 모드의 소켓은 무조건 함수 호출에 대해 즉시 리턴을 한다.

 - 리턴 값은 성공 혹은 'ERROR WOULD BLOCK'(줄여서 WSAEWOULDBLOCK)을 반환한다. (에러는 예외)

would block이란 것은 GetLastError함수에서 반환되는 값인데, 블록킹이 걸렸어야 할 상황에서 바로 빠져나온 것을

의미하며, 에러는 아니다.

 

먼저 이게 왜 필요할까부터 생각해보자. 위에서 통신의 대상이 많을수록 한 번씩 블록킹이 일어나면 그 시간도 추측할 수 없고 기본적으로는 자의적으로 빠져나올 수는 없다고 했다.

 - 메인 스레드의 대기 현상.

블로킹+동기 I/O 조합의 채팅 서버를 만들었다고 생각해보자.

연결되어있는 대상은 여러 명이고 서버는 클라이언트가 송신한 데이터를 접속한 클라이언트들에게 BroadCast 해줘야 하는데 그중 연결된 클라이언트 소켓을 대상으로 recv를 걸었다가 상대방이 데이터를 송신하지 않으면 아무리 다른 클라이언트들이 데이터를 송신하더라도 서버는 대기상태에 빠져 그 데이터를 중계해줄 수가 없다.

 

여기서 논블록킹이 중요한 것. 즉 블록킹 상태에 빠지지 않고 즉시 함수를 리턴하여 나머지 클라이언트들을 대상으로

recv를 체크하고, 데이터를 송신해주면 된다.

1
2
3
4
5
SOCKET socket = ::socket(AF_INET,SOCK_STREAM,0);
u_long arg = 1;
// 리슨 소켓을 대상으로 논블로킹모드로 전환하면 Accept로 반환된 소켓 핸들은
// 자동으로 논블로킹이 된다.
::ioctlsocket(socket, FIONBIO, &arg);
cs

즉 이제 논블로킹 + 동기I/O의 조합에서도 어느정도 돌아가는 채팅서버는 만들 수 있다.

다만 그렇다해서 논블로킹 모드의 소켓이 만능은 절대 아니다.

 

밑의 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
while (true)
{
    // 보통 TCP에서는 데이터의 경계가 존재하지 않기에
    // 그에 대한 처리(패킷 프로토콜등)가 필요하지만 여기서는 단순하게 표현
    // index는 여러 클라이언트를 의미하기 위해.
    int recvBytes = ::recv(socket[index], buf, sizeof(BUF_SIZE), 0);
 
    if (recvBytes == SOCKET_ERROR)
    {
        int errCode = ::WSAGetLastError();
        if (errCode == WSAEWOULDBLOCK) // 오류는 아니고 블로킹상태에서 빠져나온것.
            continue;
        else
            // TODO 에러처리..
    }
 
    //BroadCast...
 
 
 
}
cs

위는 서버에서 클라이언트로 부터 데이터를 읽어들이면 BroadCast하는 단순한 구조를 의사코드 수준으로 작성한 것.

논블로킹 소켓은 블로킹 소켓과 달리 대기 상태에 빠질 수 있다는 걱정사항은 없어지지만 수신할 데이터가 없더라도

계속해서 무한 루프를 돌며 체크를 하기에 CPU를 쉬지 못하는 바쁜 상태(Busy wait상태)로 만든다.

 - CPU를 사용하는게 나쁘다는게 절대 아니다. 다만 의미 없이 루프를 돌면서 CPU사용권을 얻는것은 무의미.

심지어 데이터가 계속해서 전송되지 않아 수신하지 못한다면 위의 로직은 계속해서 무한 루프를 돌며 의미 없이 CPU만

잡아먹을 것이다.

 

이것을 해결하기 위해 입출력 모델이 존재하는데 (select,WSAEventSelect,WSAAsnycSelect등등..)

I/O처리가 가능해지면 반환,신호(이벤트),메시지(윈도우 메세지 큐)를 통해 사용자에게 알려주는 방법이다.

 - Overlapped I/O의 경우에는 요청의 개념.

하지만 위의 모델들은 성능상의 이유로 서버 모델에서 사용하기에는 조금 아쉽다.

 - Select 매번 재등록,동기 함수(타임아웃),모든 배열을 돌면서 I/O가능한 소켓 체크..등

 - EventSelect는 이벤트를 통해 I/O가 발생(가능)한 애들을 체크할 수 있지만 이거또한 배열을 돌며 체크, 64개 제한..(select도 64개 제한) 

 - AsyncSelect은 메세지 큐를 통해 I/O 발생(가능)을 체크, 윈도우 플랫폼에 의존적이며 메세지 큐에 담겨서 오므로 바로 처리가 불가능.

 

이제 비동기 I/O를 보자. 정확히는 Overlapped I/O.

 

Overlapped I/O

Overlapped I/O는 논블록킹 + 비동기(Asynchronous) I/O.

 - 즉 소켓을 논블록킹으로 굳이 바꿀 필요가 없다. (해당 함수가 모두 지원하므로)

 - 장점으로 대기할 필요도 없고, 요청한 결과를 나중에 확인만 하면 된다. (이벤트,콜백방식, + IOCP 큐)

 - 위와 같은 특징을 가지기에 하나의 쓰레드에서 동시에 둘 이상의 I/O가 가능하기에 IO의 중첩이라 한다.

 

논블록 소켓의 장점도 존재했지만, 단점도 존재한다고 했다.

 - 소켓 I/O함수가 리턴한 코드가 WSAEWOULDBLOCK일 경우 재시도 호출 낭비 - CPU 낭비

 - 소켓 I/O함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산이 발생. (추가)

   --> 유저 버퍼 -> 소켓 버퍼로의 복사.

논블록 소켓의 단점이 하나 더 추가가 되었는데, 소켓 송수신 함수에 들어가는 버퍼의 데이터를 다음 그림과 같이

메모리 복사 연산이 일어난다.

메모리를 복사하기 위해 메모리(RAM)에 존재하는 데이터를 가져오는 행위는 속도가 느리다.

캐시 메모리에 해당 버퍼의 내용이 복사되어 있으면 Cash Hit로 데이터 액세스는 빠르지만 그렇지 않으면(Cashe Miss)

상대적으로 느릴 수 밖에 없다. 

 - 고성능의 게임 서버를 개발하기 위해 이러한 복사 연산도 무시할 수 없음.

 

하지만 이러한 문제들을 한번에 해결할 수 있는 것이 Overlapped I/O

 - 재시도 호출 낭비 문제, 소켓 송수신 인자로 넘긴 버퍼의 복사 부하 문제.

 

Overlapped I/O는 요청의 개념이라 했다. 즉 논블록킹 소켓과 같이 매번 I/O가 가능한지 체크, EWOULD BLOCK이면 넘어가기.. 였다면

Overlapped I/O는 해당 Overlapped I/O 함수를 호출.(즉시 반환) 성공했다면 송수신된 바이트 반환. 혹은 진행중이라면 PENDING을 반환.

 - 바로 결과가 처리 되었다면(양수값 - 송수신된 바이트)이어서 로직을 처리하고 그게 아니라면 해당 송수신 I.O에 대한 완료 통지를 이벤트나 콜백 방식으로 얻는다. (+ IOCP)

 - 즉 결과에 대한 확인을 동기 I/O와 같이 계속해서 확인해볼 필요가 없으며 I/O가 완료되었을 때 완료통지를 통해 I/O처리를 확인하고 그에 따른 뒤처리만 하면 된다.  

 

Overlapped I/O는 논블록킹 소켓과 비교했을 때 두가지 이유로 성능상 유리하다.

Overlapped I/O는 I/O에 대한 처리를 Device Driver에 권한을 넘기고 해당 I/O 처리가 별도로 동시간대에 진행이 되는데,

여기서 운영체제는 Overlapped I/O 함수의 인자로 넣은 데이터 블록(유저 버퍼)을 직접 액세스해(Device Driver) 데이터를 채워 넣는다.

 - 복사의 비용이 없다.(한번의 복사 비용이 줄어든다) (Zero-copy) -> 유저 버퍼는 page lock에 걸린다.

 - I/O처리를 순서대로 하지 않고 디스크에 가까운 순서대로 처리.

 - 주의해야 할것은 직접 유저 버퍼를 건드리기에 해당 데이터 블록을 제거하거나 내용을 변경해서는 안된다.

 

참고

- https://chfhrqnfrhc.tistory.com/entry/Overlapped-IO

- https://snowfleur.tistory.com/193

 

이제 Overlapped I/O 전용함수를 보자.

일반버전(동기 I/O) Overlapped I/O
send WSASend
sendto WSASendTo
recv WSARecv
recvfrom WSARecvFrom
connect ConnectEx
accept AcceptEx
1
2
3
4
5
6
7
8
OVERLAPPED overlapped;
WSABUF wsaBuf;
::ZeroMemory(&overlapped, sizeof(OVERLAPPED));
//overlapped 구조체는 보통 확장시켜 사용한다.
overlapped.hEvent = ::WSACreateEvent(); // manual-reset, non-signaled상태로 만듬
wsaBuf.buf = buf;
wsaBuf.len = BUF_SIZE; 
::WSARecv(clientSocket, &wsaBuf, 1&recvBytes, &flags, &overlapped, nullptr); // 이벤트 방식의 Overlapped I/O
cs

위는 이벤트를 통해 해당 I/O의 완료를 확인하는 방식. (이벤트 기반 Overlapped I/O)

 - 비동기 I/O의 작업이 완료되면 이벤트 커널 객체가 Signaled상태가 된다. (따로 Reset처리 필요.)

요약 Overlapped I/O 요청 -> WSAWaitfor~events(대기함수)를 통해 signaled상태 확인.>WSAGetOverlappedResult~ 통해 결과 확인. (이 과정은 PENDING일 때, 요청한 작업이 바로 완료되면 양수값의 송수신된 바이트를 반환)

(WSAWaitforMultipleEvnts함수는 최대 64개의 이벤트 커널 객체의 감지만 가능)

 

콜백 방식의 Overlapped I/O도 존재한다.

Overlapped I/O인 WSAReve함수나 WSASend함수의 마지막 인자로 콜백 함수의 함수 포인터를 넘겨야하는데

넘겨줘야할 함수의 골격은 다음과 같다.

1
2
3
4
5
// CALLBACK은 __stdcall이라는 함수호출규약
void CALLBACK CallBack(DWORD error, DWORD transferredLen, LPWSAOVERLAPPED overlapped, DWORD flags)
{
    // 처리..
}
cs

즉 콜백방식의 Overlapped I/O는 Overlapped I/O함수를 호출하고 난 후,해당 I/O를 호출한 쓰레드를 Alertable Wait상태로 만들어줘야 한다.

 - 인자는 에러 발생시 0이 아닌값, 송수신된 바이트의 수, I/O호출시 넘긴 OVERLAPPED구조체 포인터,flag(0으로)

 - Alertable Wait상태로 만드는 함수는 WSAWaitForMultpleEvents,SleepEx등이 있다.

위 그림과 같이 해당 I/O를 호출한 쓰레드의 APC큐에 비동기 입출력이 완료되었으면 쓰레드의 APC큐에 완료결과가 쌓인다. 

 -> Alertable wait상태가 되면 APC큐에 담긴 모든 일감들을 처리.

 중요한것은 Overlapped I/O를 호출할 때 넘긴 Overlapped구조체의 포인터를 콜백함수에서 그대로 다시 돌려받는다는것인데 이를 통해 우리가 사용할 때 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum class IO_TYPE
{
    IO_RECV,
    IO_SEND
};
 
// Overlapped 확장버전. 해당 Overlapped I/O의 구조체를 넘기는 인자에 주소값을 넘겨주고
// 콜백함수에서 꺼내쓸 때 이게 어떤 IO였는지 데이터가 무엇이었는지,소켓정보등 다양한 처리방식이
// 존재한다
struct OverlappedEx
{
    WSAOVERLAPPED m_wsaOverlapped; // 첫번째 offset으로 맞춘다.
SOCKET m_clientSocket;
    int           m_index;
   WSABUF        m_wsaBuf;
    char          m_dataBuffer[MAX_SOCKBUF];
   IO_TYPE       m_ioType;
};
cs

위와 같이 넘겨준 Overlapped구조체를 확장한 포인터를 캐스팅하여 필요한 정보들을 가져다가 사용할 수 있다.

 

 

이제 논블록킹 방식 대비 Overlapped I/O의 장점을 살펴보면

 - I/O 함수 호출 후 EWOULD BLOCK를 체크하며 재시도 호출 낭비가 없다.

 - I/O 함수를 호출할 때 입력하는 데이터 블록에 대한 복사 연산을 줄일 수 있다.

 - send,recv,connect,accept 함수를 한번 호출하면 이에 대한 완료 신호는 딱 한번만 오기 때문에 깔끔하다.

  -> connect,accept의 비동기 I/O는 조금 복잡하다.

 - IOCP와의 궁합이 좋다.

 

끝으로

논블록 소켓에서는 상태 확인 후 뒤처리를 진행하므로 리액터 패턴(Reactor pattern)이라 하며

Overlapped I/O에서는 먼저 I/O를 요청한 후 결과를 받고나서 뒤처리를 진행하므로 프로액터(Proactor pattern)이라 함.

반응형

'CS > TCP IP' 카테고리의 다른 글

응용계층 패킷 구조 (패딩 비트)  (2) 2022.01.04
Comments