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

[출처] 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