반응형

이전 포스팅에서 소켓의 블로킹(Blocking)/논블로킹(Non-blocking) 여부를 설정하기 위해 ioctlsocket 함수를 사용하였다.

이번 포스팅에선 간략하게 해당 함수에 대해서 다루고자 한다.


1) ioctlsocket 함수란?
ioctlsocket 함수는 소켓을 제어하기 위해 호출하는 함수이다.
ioctlsocket 함수의 인자는 다음과 같다.

int ioctlsocket(
  SOCKET s,
  long   cmd,
  u_long *argp
);

- 1번째 인자(SOCKET s): 제어할 소켓
- 2번째 인자(long cmd): 명령어
- 3번째 인자(u_long *argp): 명령어(2번째 인자)에 대한 매개 변수에 대한 포인터

 

2) ioctlsocket 명령어
ioctlsocket함수에서 2번째 인자로 오는 명령어에는 여러 종류가 있다.
해당 기능들은 BSD 소켓 기반에서 지원하는 명령 중 몇몇 옵션을 지원한다.

cmd명 기능
FIONBIO 논블로킹 여부를 활성화/비활성화 한다.

- argp가 0이 아님: 소켓이 논블로킹 모드가 된다.
- argp가 0임: 소켓이 블로킹 모드가 된다.
FIONREAD 현재 수신 대기중이며 바로 읽을 수 있는 byte수를 가져온다.
보통 recv()함수로 데이터를 읽기 전, 얼마만큼 받아올 수 있는지 체크하는데 쓴다.

- argp에 결과값을 저장한다.
SIOCATMARK 긴급 데이터(Out-of-band data,OOB)가 포함된 위치를 찾는다.
※ Out-of-band data(OOB): TCP프로토콜에서만 사용. 인터럽트 신호, 중요 공지 등  긴급 신호를 보내기 위해 사용된다.

- argp에 결과값을 저장한다.

3) ioctlsocket을 통해 논블로킹 모드로 변경하기
소켓은 생성하면 기본적으로 블로킹 모드로 설정되어있다. 따라서 ioctlsocket함수를 사용하여 논블로킹 모드를 사용해야한다.
아래는 논블로킹 모드로 변경하는 예시이다.

SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
 // 논블로킹 모드 설정
u_long nb_mode = 1;
ioctlsocket(sock, FIONBIO, &nb_mode);

여기에서, 논블로킹 모드로 변경하였다면 한가지 주의할 점이 있다.
논블로킹 소켓은 각종 소켓 처리함수(accept, recv등)에서 현재 처리할 작업이 없거나, 즉시 완료할 수 없는 상태면WSAEWOULDBLOCK 오류를 반환한다.
해당 오류는 소켓의 문제되는 오류가 아니므로 예외처리를 진행하며, 실제로 소켓에서 처리할 작업이 올때까지 대기하도록 해야한다.

아래는 간단하게 논블로킹 모드로 설정된 소켓통신을 하는 예시이다.

#include <winsock2.h>
#include <iostream>

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

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

    SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
	char buf[1024];
    // 논블로킹 모드 설정
    u_long mode = 1;
    ioctlsocket(sock, FIONBIO, &mode);
    std::cout << "논블로킹 모드 설정됨" << std::endl;
    while(1) {
		int recvBytes = recv(sock, buf, sizeof(buf), 0);
		if (recvBytes > 0) {
			// 소켓을 통해 데이터가 정상적으로 수신되었을때
			std::cout << "[수신 데이터]: " << buf << std::endl;
        }
        else if (recvBytes == 0) {
            std::cout << "[소켓 연결 종료됨]" << std::endl;
            closesocket(sock);
            break;
        }
        else if (WSAGetLastError() != WSAEWOULDBLOCK) { // 논블로킹의 경우 소켓에 읽은 데이터가 큐에 없으면 반환된다.
             if (WSAGetLastError() != WSAECONNRESET) { // 상대방이 먼저 연결을 종료한 경우는 오류 제외
                 std::cerr << "recv() 오류: " << WSAGetLastError() << std::endl;
                 break;
             }
        }
    }
    closesocket(sock);
    WSACleanup();
    return 0;
}

논블로킹 모드로 설정된 소켓을 다시 블로킹 모드로 돌려놓고 싶다면 아래와 같이 설정하면 된다.

SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);
 // 논블로킹 모드 설정
u_long nb_mode = 1;
ioctlsocket(sock, FIONBIO, &nb_mode);

SOCKET sock = socket(AF_INET, SOCK_STREAM, 0);

 // ★추가 - 블로킹 모드로 돌아오기
u_long b_mode = 0;
ioctlsocket(sock, FIONBIO, &b_mode);

※ 비교

  블로킹 모드(arg: 0) 논블로킹 모드(arg: 1)
accept() 클라이언트 연결이 있을 때까지 대기 연결이 없으면 즉시 반환
recv() 수신데이터가 있을때까지 대기 데이터가 없으면 즉시 반환
(WSAEWOULDBLOCK 발생)
send() 버퍼가 가득 차면 대기 버퍼가 가득 차면 즉시 반환
(WSAEWOULDBLOCK 발생)

블로킹 상태일 경우 무한대기를 하지만, 논블로킹의 경우 현재 작업을 처리할 수 없다면
곧바로 반환을 하여 복수의 클라이언트에게 네트워크 관련 처리를 하거나, 비동기 처리를 할 때 유용하게 사용할 수 있다. 


4) ioctlsocket을 통해 수신 대기중인 데이터 양 확인
FIONREAD 명령을 통하여 수신 대기중인 데이터의 크기를 확인할 수 있다.
아래는 FIONREAD 커맨드를 사용하는 예시이다.
만일 수신 대기중인 데이터에 따라 버퍼의 동적할당이 필요하다면 유용하다.

u_long r_size = 0;
ioctlsocket(clntSocket, FIONREAD, &r_size);

printf("수신대기중인 byte : %ld\n", r_size);

해당 함수를 통해 동적으로 데이터 버퍼를 할당할 수 있다.
아래는 예시 코드이다

// (소켓 정의 및 연결 부분은 생략)
//  1. FIONREAD로 수신 대기 중인 데이터 크기 확인
    u_long dataSize = 0;
    if (ioctlsocket(clientSocket, FIONREAD, &dataSize) != 0) {
        std::cerr << "ioctlsocket(FIONREAD) 실패: " << WSAGetLastError() << std::endl;
        // 실패에 따른 작업 추가
        return 1;
    }

    if (dataSize > 0) {
        // 2. 수신 대기 중인 데이터만큼 버퍼 동적 할당
        char* buffer = new char[dataSize + 1];
        memset(buffer, 0, dataSize + 1);  // 버퍼 초기화

        // 3. 수신
        int bytesReceived = recv(clientSocket, buffer, dataSize, 0);
        if (bytesReceived > 0) {
            buffer[bytesReceived] = '\0';  // 널 문자 추가
            std::cout << "수신된 데이터: " << buffer << std::endl;
        } else if (bytesReceived == 0) {
            std::cout << "클라이언트 연결 종료!" << std::endl;
        } else {
            std::cerr << "recv() 실패: " << WSAGetLastError() << std::endl;
        }

        // 4. 동적 할당 해제
        delete[] buffer;
    } else {
        std::cout << "수신 대기 중인 데이터가 없습니다." << std::endl;
    }

 


5) 기타
ioctlSocket 함수는 BSD소켓의 일부 기능만 제공한다.
예를들면 FIONREAD는 제공하지만, 송신가능한 데이터를 확인하는 FIONSEND는 제공하지 않는다.
더 많은 기능들이 들어간 소켓제어 함수를 Windows에서 사용한다면 WSAIoctl함수를 사용해보자.



6) Non-blocking EchoServer 만들기
이전 포스팅에서 기록하였던 블로킹 모드의 echoServer 코드를 개선하여, 논블로킹 모드로 echoServer를 작성하였다.

EchoServer.cpp

#pragma comment(lib, "ws2_32")
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <iostream>
#include <stdio.h>
#include <winsock2.h>
#include <memory>
#include <vector>
#define BUF_SIZE 1024

using namespace std;

int main()
{
	printf("Server Start!\n");

	// 1) WinSock 라이브러리를 초기화한다   
	WSAData wsaData; //WinSock 초기화 구조체
	int initWinSockRes = WSAStartup(MAKEWORD(2, 0), &wsaData);

	if (initWinSockRes != 0) {
		printf("윈도우소켓 초기화 실패");
		return 0;
	}

	// 2) 서버와 클라이언트 소켓을 생성하고 서버소켓을 bind, listen한다.
	SOCKET servSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	sockaddr_in servAddr, clntAddr;

	servAddr.sin_family = AF_INET;
	servAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
	servAddr.sin_port = htons(11000);

	if (bind(servSocket, (sockaddr*)&servAddr, sizeof(servAddr)) != 0) {
		printf("bind() 오류 [오류코드: %d]\n", WSAGetLastError());
		return 0;
	}

	// 크기 3의 대기열을 만든다.
	// 클라이언트의 연결요청이 왔을때 서버에서 준비된 소켓이 없다면 대기열에서 연결을 기다리게된다. 
	if (listen(servSocket, 3) != 0) {
		printf("listen() 오류 [오류코드: %d]\n", WSAGetLastError());
		return 0;
	}

	char buf[BUF_SIZE] = {};
	int  clntAddrLen = sizeof(clntAddr);
	int socketCnt = 0;
	std::vector<SOCKET> clntSockets = vector<SOCKET>(0);

	// ★ 추가: 서버 소켓을 논블로킹으로 한다.
	u_long mode = 1;
	ioctlsocket(servSocket, FIONBIO, &mode);

	while (1) {
		// 3) 클라이언트가 연결을 요청하면 수락한다.
		SOCKET clntSocket = accept(servSocket, (sockaddr*)&clntAddr, &clntAddrLen);
		if (clntSocket == INVALID_SOCKET) {
			if (WSAGetLastError() != WSAEWOULDBLOCK) {
				printf("accept() 오류 [오류 코드: %d]\n", WSAGetLastError());
				return 0;
			}
		}
		else {
			// ★ 추가: 클라이언트 소켓을 논블로킹으로 한다.
			ioctlsocket(clntSocket, FIONBIO, &mode);
			clntSockets.push_back(clntSocket);
			printf("accept client\n");
		}

		// 연결된 소켓들을 순차적으로 처리한다.
		for (auto it = clntSockets.begin(); it != clntSockets.end();) {
			SOCKET clntSocket = *it;

			memset(buf, 0, sizeof(buf));

			// ★ 추가: 수신 대기중인 데이터를 확인해본다.
			u_long r_size = 0;
			ioctlsocket(clntSocket, FIONREAD, &r_size);
			if (r_size > 0) { // 읽어들일 데이터가 없을땐 0으로 반환한다. (출력이 많아지는것을 방지하기위해 0 이상일때만 출력하도록 함)
				printf("readablie size : %ld\n", r_size);
			}

			int recvBytes = recv(clntSocket, buf, r_size, 0);
			if (recvBytes > 0) {
				// 4) 클라이언트로부터 온 메세지를 받는다.
				std::cout << "[ 클라이언트 " << clntSocket << "] 수신: " << buf << std::endl;
				// 5) 클라이언트로부터 온 메세지를 그대로 클라이언트에게 전송한다.
				send(clntSocket, buf, recvBytes, 0);
			}
			else if (recvBytes == 0) {
				std::cout << "[ 클라이언트 (소켓: " << clntSocket << ") 연결 종료 ]" << std::endl;
				closesocket(clntSocket);
				it = clntSockets.erase(it);
				continue;
			}
			else if (WSAGetLastError() != WSAEWOULDBLOCK) { // 논블로킹의 경우 소켓에 읽은 데이터가 큐에 없으면 반환된다.
				if (WSAGetLastError() != WSAECONNRESET) { // 클라이언트가 먼저 연결을 종료한 경우는 오류 제외
					std::cerr << "recv() 오류: " << WSAGetLastError() << std::endl;
				}
			}
			++it;
		}
		// TODO: 서버에서 수행할 다른 작업들을 실행할 수 있다.
	}

	WSACleanup();

	return 0;
}

 

EchoClient.cpp

#pragma comment(lib, "ws2_32")
#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <string>
#include <iostream>
#include <winsock2.h>
#include <memory>
#define BUF_SIZE 1024

using namespace std;

int main() {
	WSAData wsaData;
	SOCKET clnt_socket;
	
	std::cout << "Client Start!\n";

	// WINSOCK 초기화
	int initWinSockRes = WSAStartup(MAKEWORD(2, 0), &wsaData);

	if (initWinSockRes == SOCKET_ERROR) {
		return 0;
	}

	clnt_socket = socket(AF_INET, SOCK_STREAM, 0);
	if (clnt_socket == INVALID_SOCKET) {
		printf("윈도우소켓 초기화 실패");
		return 0;
	}

	sockaddr_in servAddr;

	// 로컬 주소로 연결한다. 포트는 서버에서 bind한 포트와 동일해야, 서버로 연결이 가능하다
	servAddr.sin_family = AF_INET;
	servAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
	servAddr.sin_port = htons(11000);

	if (connect(clnt_socket, (sockaddr*)& servAddr, sizeof(servAddr)) != 0) {
		printf("connect() 오류 [오류 코드: %d\]n", WSAGetLastError());
		return 0;
	}
	
	char received[BUF_SIZE];

	srand((int)time(nullptr));
	int randVal = rand()%1000;
	int seq = 1;
	while (1) {
		memset(received, 0, BUF_SIZE);
		string input;

		cin >> input;

		if (send(clnt_socket, input.c_str(), input.length(), 0) == SOCKET_ERROR) {
			printf("send() 오류 [오류 코드: %d]\n", WSAGetLastError());
			break;
		}
		printf("send작업 성공\n");

		if (recv(clnt_socket, received, BUF_SIZE, 0) == SOCKET_ERROR) {
			if (WSAGetLastError() != WSAEWOULDBLOCK) {
				printf("recv() 오류 [오류 코드: %d]\n", WSAGetLastError());
				return 0;
			}
		}
	}

	return 0;
}

 

[실행결과]

서버 실행결과 클라이언트 결과


만일 서버 소켓의 논블로킹 모드를 설정하는 코드를 지운다면, accept에서 연결이 올때까지 대기하는 것을 확인할 수 있을 것이다.
또한 클라이언트에서 보내주는 데이터만큼 FIONREAD명령어를 통해 byte수를 불러오는것을 확인할 수 있다.

반응형

이전 포스팅에서는 블로킹과 논블로킹, 동기와 비동기형 모델의 각각 특징에 대해서 살펴보았다.
이번 포스팅은 이 개념들이 조합된 모델에 대해서 다뤄본다.

[출처] https://developer.ibm.com/articles/l-async/

위의 이미지는 ibm 문서에서 정의된 유명한 2x2매트릭스이다. 여기서 각 개념들이 조합된 I/O모델의 예시를 알아보자

1) 동기 블로킹 I/O (Sync Blocking I/O)

 동기 블로킹 방식은 I/O 호출을 하면, 응용프로그램은 멈추며, I/O를 요청한 함수가 완료될 때까지 스레드는 아무런 작업을 하지않고 기다린다. 작업이 완료되면 이후 정의된 작업을 순차적으로 진행하게 된다.
winsock에서 기본적으로 제공하는 send(), recv() 함수가 이러한 모델들이다.
아래는 동기 블로킹 소켓모델에 대한 예시이다. 이전에 구현했던 클라이언트에서 단 한번만 send()/recv()작업을 하는 코드로 변경하였다.

int main() {
	WSAData wsaData;
	SOCKET clientSocket;

    // ... 중략 ... //
    // 중략된 내용은 이전 포스팅(https://dodo000.tistory.com/29)의 클라이언트 코드와 동일합니다. 
	clientSocket = socket(AF_INET, SOCK_STREAM, 0);
	if (clientSocket == INVALID_SOCKET) {
		printf("윈도우소켓 초기화 실패");
		return 0;
	}
	if (connect(clientSocket, (sockaddr*)&servAddr, sizeof(servAddr)) != 0) {
		printf("connect() 오류 :%d\n", WSAGetLastError());
		return 0;
	}

    // ... 중략 ... //

	if (send(clientSocket, input, sizeof(input), 0) == SOCKET_ERROR) {
		printf("send() 오류 : %d\n", WSAGetLastError());
		return 0;
	}
	printf("send 함수가 완료된 후, 다음 작업이 실행됩니다.\n");

	if (recv(clientSocket, received, BUF_SIZE, 0) == SOCKET_ERROR) {
		printf("recv() 오류 : %d\n", WSAGetLastError());
		return 0;
	}

	printf("recv 함수가 완료된 후, 다음 작업이 실행됩니다.\n");
	printf("[message from server] %s \n", received);

	return 0;
}

send()함수 이후 printf작업이 진행되며, recv()함수가 완전히 실행된 이후 다음 작업들이 실행된다.
동기-블로킹 모델은 구현에 있어서 간편하다는 장점이 있지만, 한 소켓의 send()/recv() 작업동안은 아무것도 할 수 없기에
다수의 클라이언트의 동시요청을 서비스하기 어렵다.

 

2) 동기 논블로킹I/O (Synchronous Non blocking I/O)


 동기-논블로킹 방식은 I/O 함수가 호출되자마자 전송/수신이 완료여부와 상관없이 반환한다. 이때 send나 recv 작업이 완료되지 않았다면 반환되는 응답값은 WSAEWOULDBLOCK라는 에러코드이다.
 하지만 동기방식을 택하게 되면, 응용프로그램은 입출력 작업에 뒤이어서 오는 작업을 수행하기전에 send()/recv() 작업이 온전하게 데이터를 받을때까지 체크를 해야한다.
 그래서 여러번 소켓의 함수를 호출하여 체크를 하고 완전히 입출력이 완료되었을 때, 다음 작업을 수행하는 방식이다.

int main() {
	WSAData wsaData;
	SOCKET clientSocket;
    // ... 중략 ... //
	clientSocket = socket(AF_INET, SOCK_STREAM, 0);
	if (connect(clientSocket, (sockaddr*)&servAddr, sizeof(servAddr)) != 0) {
		printf("connect() 오류 :%d\n", WSAGetLastError());
		return 0;
	}
    // ... 중략 ... //
	u_long mode = 1;
	//소켓을 논블록모드로 지정한다.
	ioctlsocket(clientSocket, FIONBIO, &mode);

	cin.getline(input, BUF_SIZE);
	if (send(clientSocket, input, sizeof(input), 0) == SOCKET_ERROR) {
		printf("send() 오류 : %d]\n", WSAGetLastError());
		return 0;
	}
	printf("send 함수가 완료된 후, 다음 작업이 실행됩니다.\n");

	while (1) {
		// recv가 완료되지 않으면 호출 즉시 WSAEWOULDBLOCK에러를 반환한다.
		// 이후 recv를 완료하면 SOCKET_ERROR가 발생하지 않으므로, 성공할때까지 루프를 돌며 체크한다.
		if (recv(clientSocket, received, BUF_SIZE, 0) != SOCKET_ERROR) {
			break;
		}
		printf("socket error: %d\n", WSAGetLastError());
		if (WSAGetLastError() != WSAEWOULDBLOCK) {
			printf("recv() 오류 : %d]\n", WSAGetLastError());
			return 0;
		}
	}
	printf("[message from server] %s \n", received);

	return 0;
}

 여기서 ioctlsocket 함수는 소켓의 모드를 설정할 수 있는 함수이다.
FIONBIO는 소켓의 블로킹/논블로킹 여부를 지정하겠다는 옵션이며, mode 변수로 온 값이 0이면 블로킹, 0이 아니면 논블로킹 모드로 세팅된다. 

해당 소스코드를 실행한 결과이다.

 사실 이 방식은 꽤 비효율적일 수 있다. 논블로킹으로 소켓이 바로 반환되지만 순차적으로 작업들을 처리하기 위해 계속해서 recv()함수의 결과를 확인해서 다음 작업을 진행하지 못하고 있다.
 동기-블로킹 방식의 경우 입출력 작업을 위해 한 스레드가 blocking될 경우, CPU는 다른 스레드에게 제어권을 넘기면서 병렬처리를 할 수 있다. 하지만 동기-논블로킹은 계속해서 CPU가 해당 스레드를 실행하면서 입출력 작업을 확인하므로 busy-wait라는 자원낭비가 된다.

 

3) 비동기 블로킹 I/O(Asynchronous Blocking I/O)

 비동기 블로킹 방식은 각각의 소켓들의 입출력이 완료되지 않아도 다음 작업을 실행할 수 있지만, 이후 블로킹 모드로 작동하는 select 함수를 통해 하나 이상의 소켓에서 입출력 처리가 완료되기 전까지 응용프로그램의 작동이 잠깐 멈추는 방식이다.
  ※ select 입출력 모델에 대해서는 이전포스팅에서 다루었다

 select 함수를 통해 다수의 소켓들을 묶어서 처리하기 때문에, 단일 스레드에서 다수의 클라이언트를 실시간 서비스할 수 있는 장점이 있다.
 하지만 select함수가 완료될 동안 해당 스레드는 아무런 작업을 할 수 없으며, select 함수 내부적으로 각각 소켓(리눅스에서는 파일디렉토리)들을 하나하나 순회하며 체크하기 때문에 C10K문제(10,000명의 동시접속자의 요청을 처리하는 문제)에서는 효율이 떨어지는 한계를 보인다. 

 

4) 비동기 논블로킹 I/O(Asynchronous Non blocking I/O)

 비동기 논블로킹 모델은 각 소켓들이 입출력 처리가 완료되지 않아도 바로 반환되며, 스레드는 다른 작업들을 진행한다. 이후 특정 시점에서 입출력 작업이 완료되었을 때, 이를 통지받고 이에 따른 작업을 진행한다.
 효율적으로 다른 프로세스들을 처리할 수 있고 여러 클라이언트에서 동시다발적으로 오는 요청을 처리하기에 용이하다. 하지만 구현이 보다 복잡해진다.
 이 모델의 예시로는 Overlapped I/O와 IOCP 모델이 존재한다. 이 모델을 채택한 서버에 대해서는 다음 포스팅들에서 다룰 예정이다. 

+ Recent posts