본문 바로가기
프로그래밍/C

윈도우 소켓 ] select 입출력 모델

by eteo 2023. 11. 26.

 

 

 

 

select 모델

 

select() 함수는 동기식 다중 입출력함수(Synchronous Multiplexing IO)로 recv(), send() 등 소켓 함수 호출이 성공할 수 있는 시점을 미리 알 수 있다. 따라서 소켓 함수 호출 시 조건을 만족하지 않아 생기는 여러 문제를 해결할 수 있다. 또한 멀티스레드 방식 등 다른 모델과 비교하여 select 모델의 사용상의 장점은 여러 소켓을 한 스레드로 처리할 수 있다는 점이다.

 

 

 

 

 

select 모델 사용 준비

 

select() 모델을 사용하려면 세 종류의 소켓 셋을 준비해야 한다. 읽기셋, 쓰기셋, 예외셋이 있는데 세 종류중에 필요한 소켓셋만 준비해도 된다.

 

소켓 셋은 소켓의 집합으로 종류에 따라 소켓들을 담아두는 역할을 한다. 예를 들어 어떤 소켓에 대해 recv()함수를 호출하고 싶다면 읽기셋에 넣고, send()함수를 호출하고 싶다면 쓰기셋에 넣으면 된다.

 

이렇게 소켓셋을 준비하고 select()함수를 호출하면 select()함수는 소켓셋에 포함된 소켓이 입출력을 위한 준비가 될 때까지(timeout 인자가 NULL인경우)  대기한다. 소켓 셋 중 적어도 한 소켓이 준비가 되면 리턴하는데 이 때 소켓셋에는 입출력이 가능한 소켓만 남고 나머지는 제거되기 때문에 남아있는 소켓에 대해 소켓 함수를 호출하면 성공적으로 원하는 작업을 할 수 있다.

 

 

 

select 모델의 특징

 

select 함수를 사용한다고 해서 recv(), send() 함수 등 입출력 함수를 사용하는 방법이 달라지지는 않는다. 대신 recv(), send() 함수 호출이 성공할 시점까지 블로킹 상태로 대기하기 때문에 동기식 통지(Synchronous Notification IO) 방식을 사용한다고 할 수 있다.

 

물론 select 함수도 타임아웃을 지정하면 IO 상태변화가 발생하지 않은 상태에서 리턴할 수 도 있다. 그러나 이후 상태변화를 확인하기 위해 다시 소켓셋을 모아 select함수를 호출한다는 점에서 여전히 동기식이다.

 

만약 비동기 통지 방식을 사용하기 원한다면 select 대신 WSAEventSelect 함수를 사용하는 방법이 있다.

 

 

 

select() 함수 리턴 후 소켓 셋을 통해 알아낼 수 있는 것

 

  • 읽기 셋 (Readable) 
    • 리스닝 소켓에 접속한 클라이언트가 있으므로 accept() 함수를 호출할 수 있다.
    • 소켓 수신 버퍼에 도착한 데이터가 있으므로 recv() 함수를 호출하여 데이터를 수신할 수 있다.
    • TCP 연결이 종료되었으므로 recv() 함수를 호출하면 리턴값 0으로 연결 종료를 알 수 있다.
  • 쓰기 셋 (Writable)
    • 소켓 송신 버퍼의 공간이 충분하므로 send() 함수를 호출해 데이터를 전송할 수 있다.
    • 클라이언트의 connect() 함수 호출이 성공했다. (Write-ready 상태가 됐으므로)
  • 예외셋 (Error occured)
    • 오류와 예외 상황

 

 

 

 

 

소켓 셋을 조작하는 매크로 함수

 

  • FD_ZERO(fd_set *set) : 셋을 비운다.
  • FD_SET(SOCKET s, fd_set *set) : 소켓 s를 셋에 넣는다.
  • FD_CLR(SOCKET s, fd_set *set) : 소켓 s를 셋에서 제거한다.
  • FD_ISSET(SOCKET s, fd_set *set) : 소켓 s가 셋에 들어있으면 0이 아닌 값을 리턴, 셋에 없으면 0을 리턴한다.

 

 

 

 

 

 

select() 함수의 원형

 

#include <winsock2.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timeval *timeout);
  • nfds : 리눅스에선 감시 대상 디스크립터 중 가장 큰 파일 디스크립터 값보다 하나 큰 값을 넘긴다. 예를들어 파일 디스크립터 0, 1, 2를 감시한다면 3으로 설정한다. 윈도우에선 리눅스와의 호환성을 위해 해당 파라미터가 존재하긴하지만 무시된다.
  • readfds : 읽기 가능한 이벤트를 감시하려는 파일 디스크립터 셋, 사용하지 않는다면 NULL로 설정한다.
  • writefds : 쓰기 가능한 이벤트를 감시하려는 파일 디스크립터 셋, 사용하지 않는다면 NULL로 설정한다.
  • exceptfds : 예외상황을 감시하려는 파일 디스크립터 셋, 사용하지 않는다면 NULL로 설정한다.
  • timeout : 최대 대기시간을 지정하는 구조체로 struct timeval 타입이며, 멤버변수는 long tv_sec과 long tv_usec이 있다. select() 함수가 얼마동안 블록되어 대기할지가 이 파라미터에 의해 정해진다.
    • NULL : 적어도 한개의 소켓이 조건을 만족할 때까지 무한히 기다린다. 리턴값은 조건을 만족하는 소켓 개수
    • {0, 0} 담은 timeval 구조체 주소 : 소켓 셋에 포함된 모든 소켓을 검사한 후 바로 리턴한다. 리턴값을 조건을 만족하는 소켓 개수, 없으면 0
    • 구조체에 0보다 큰 시간 설정해 넘기는 경우 : 적어도 한 개의 소켓이 조건을 만족하거나 타임아웃으로 지정한 시간이 되면 리턴한다. 리턴값은 조건을 만족하는 소켓 개수 또는 타임아웃 시 0
  • 반환 값
    • 입출력 이벤트가 발생한 경우 : 이벤트가 발생한 파일 디스크립터의 수
    • 지정한 타임아웃 시간 동안 아무 이벤트가 발생하지 않은 경우 : 0 반환
    • 오류가 발생한 경우 : SOCKET_ERROR(-1) 반환, WSAGetLastError() 함수를 사용해 오류코드를 알 수 있다.

 

 

 

 

 

select() 함수 사용 절차

 

1. 소켓 셋을 비운다.

2. 소켓 셋에 소켓을 넣는다. 넣을 수 있는 최대 소켓의 개수는 FD_SETSIZE(64)로 정의되어 있으며 변경 가능하다.

3. timeout value를 셋팅한다. (옵션)

4. select() 함수를 호출해 소켓 이벤트를 기다린다.

5. select() 리턴시 소켓 셋에 남아 있는 소켓을 FD_ISSET으로 알아내고 해당 소켓에 대해 적절한 소켓함수를 호출하여 처리한다.

6. 1~5를 반복

 

 

❗참고로 timeout value를 사용하는 경우 struct timeval의 값 셋팅을 반복문 내에서 select() 함수호출 전에 해야 한다. select() 함수 내부에서 timeval을 감소시키며 카운트다운하기 때문이다.

 

 

 

Select 모델을 사용한 에코 서버 예제

 

#pragma comment(lib, "ws2_32")
#include <stdio.h>
#include <winsock2.h>
#include <ws2tcpip.h>

#define SERVER_PORT			2000
#define BUF_SIZE			1024
// TCP 서버 소켓의 대기열 크기
#define BACKLOG				10
#define MAX_CLIENT			FD_SETSIZE - 1

int main(void)
{
	WSADATA wsaData;
	if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
		return 1;

	SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, 0);
	if (listenSocket == INVALID_SOCKET)
		return 1;


	struct sockaddr_in serverAddr;
	memset(&serverAddr, 0, sizeof(serverAddr));
	serverAddr.sin_family = AF_INET;
	serverAddr.sin_addr.s_addr = htons(INADDR_ANY);
	serverAddr.sin_port = htons(SERVER_PORT);

	if (bind(listenSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr)) == SOCKET_ERROR)
		return 1;

	if (listen(listenSocket, BACKLOG) == SOCKET_ERROR)
		return 1;

	int clntNum = 0;
	SOCKET clntArray[MAX_CLIENT];
	SOCKET clientSocket;
	struct sockaddr_in clntAddr;
	int addrLen = sizeof(clntAddr);
	fd_set readSet;
	int setSockNum;
	char recvBuf[BUF_SIZE];

	while (1)
	{
		FD_ZERO(&readSet);
		FD_SET(listenSocket, &readSet);

		// 클라이언트 소켓을 읽기셋에 넣는다. 클라이언트가 없는 경우 패스
		for (int i = 0; i < clntNum; i++)
		{
			FD_SET(clntArray[i], &readSet);
		}

		// 윈도우에선 select 함수 첫번째 파라미터로 0을 넣으면 된다.
		// 현재 마지막 파라미터인 타임아웃을 NULL로 설정했으므로 이벤트가 발생할 때까지 블로킹되어 대기한다.
		setSockNum = select(0, &readSet, NULL, NULL, NULL);
		if (setSockNum == SOCKET_ERROR)
			continue;

		// 클라이언트의 연결 요청이 들어온 경우
		if (FD_ISSET(listenSocket, &readSet))
		{
			clientSocket = accept(listenSocket, (struct sockaddr*)&clntAddr, &addrLen);
			if (clientSocket == INVALID_SOCKET) 
				continue;

			// 클라이언트 정보 출력
			char strAddr[16];
			inet_ntop(AF_INET, &clntAddr.sin_addr, strAddr, sizeof(strAddr));
			printf("[TCP 서버] 클라이언트 접속: IP = %s, Port = %d\n", strAddr, ntohs(clntAddr.sin_port));

			if (clntNum < MAX_CLIENT)
			{
				// 클라이언트 소켓 관리를 위해 어레이에 추가, clntNum 증가
				clntArray[clntNum++] = clientSocket;
			}
			else
			{
				// MAX_CLIENT 초과시 소켓 닫기
				closesocket(clientSocket);
			}

			// select 함수 호출 이후 셋된 소켓의 수를 확인해 다 처리했으면 다시 select 호출로 돌아감
			if (--setSockNum <= 0)
				continue;
		}

		for (int i = 0; i < clntNum; i++)
		{
			if (FD_ISSET(clntArray[i], &readSet))
			{
				int recvBytes = recv(clntArray[i], recvBuf, BUF_SIZE, 0);

				// 원격지에서 연결을 끊은 경우
				if (recvBytes <= 0)
				{
					// 클라이언트 정보 출력
					getpeername(clntArray[i], (struct sockaddr*)&clntAddr, &addrLen);
					char strAddr[16];
					inet_ntop(AF_INET, &clntAddr.sin_addr, strAddr, sizeof(strAddr));
					printf("[TCP 서버] 클라이언트 연결 종료 : IP = %s, Port = %d\n", strAddr, ntohs(clntAddr.sin_port));

					// 소켓 닫고 마지막 요소를 빈곳에 넣음
					closesocket(clntArray[i]);
					clntArray[i] = clntArray[clntNum - 1];
					--clntNum;
				}
				else
				{
					getpeername(clntArray[i], (struct sockaddr*)&clntAddr, &addrLen);
					char strAddr[16];
					inet_ntop(AF_INET, &clntAddr.sin_addr, strAddr, sizeof(strAddr));
					if (recvBytes > BUF_SIZE - 1) recvBytes = BUF_SIZE - 1;
					recvBuf[recvBytes] = '\0';
					printf("[TCP/%s:%d] %s\n", strAddr, ntohs(clntAddr.sin_port), recvBuf);
				}

				// select 함수 호출 이후 셋된 소켓의 수를 확인해 다 처리했으면 다시 select 호출로 돌아감
				if (--setSockNum <= 0) break;
			}
		}
	}

	closesocket(listenSocket);
	WSACleanup();

	return 0;
}

 

 

 

 

 

 

 

참고 : TCP/IP 소켓 프로그래밍, 김선우 저