Select 모델

bolee·2022년 5월 3일
0

Select 모델은 select() 함수가 핵심 역할을 한다는 뜻에서 붙인 이름이다. Select 모델을 사용하면 소켓 모드(블로킹, 넌블로킹)에 관계없이 여러 소켓을 한 스레드로 처리할 수 있다.

Select 모델의 동작 원리

Select 모델을 사용하면 소켓 함수 호출이 성공할 수 있는 시점을 미리 알 수 있다. 따라서 소켓 함수 호출 시 조건이 만족되지 않아 생기는 문제를 해결할 수 있다. 소켓 모드에 따른 Select 모델의 사용 효과는 다음과 같다.

  • 블로킹 소켓(blocking socket): 소켓 함수 호출 시 조건이 만족되지 않아 블로킹되는 상황을 막을 수 있다.
  • 넌블로킹 소켓(nonblocking socket): 소켓 함수 호출 시 조건이 만족되지 않아 나중에 다시 호출해야하는 상황을 막을 수 있다.

아래 그림은 Select 모델의 동작 원리를 보여준다. Select 모델을 사용하려면 소켓 셋(socket set) 3개를 준비해야 한다. 소켓 셋은 소켓 디스크립터의 집합을 의미하며, 호출한 함수의 종류에 따라 소켓을 적당한 셋에 넣어두어야 한다. 예를 들면 어떤 소켓에 대해 recv() 함수를 호출하고 싶다면 읽기 셋에 넣고, send() 함수를 호출하고 싶다면 쓰기 셋에 넣으면 된다.

소켓 셋을 3개 준비해 select() 함수를 호출하면, select() 함수는 소켓 셋에 포함된 소켓이 입출력을 위한 준비가 될 때까지 대기한다. 적어도 한 소켓이 준비되면 select() 함수는 리턴한다. 이때 소켓 셋에는 입출력이 가능한 소켓만 남고 나머지는 모두 제거된다.

응용 프로그램은 소켓 셋을 통해 소켓 함수를 성공적으로 호출할 수 있는 시점을 알아낼 수 있고, 드물지만 소켓 함수의 호출 결과를 확인할 수도 있다. 다음은 소켓 셋의 역할을 정리한 표이다.

소켓 셋함수 호출 시점함수 호출 결과
읽기 셋(read set)- 접속한 클라이언트가 있으므로 accept() 함수를 호출할 수 있다.
- 소켓 수신 버퍼에 도착한 데이터가 있으므로 recv(), recvfrom() 등의 함수를 호출해 데이터를 읽을 수 있다.
- TCP 연결이 종료되었으므로 recv(), recvfrom() 등의 함수를 호출해 연결 종료를 감지할 수 있다.
x
쓰기 셋(write set)- 소켓 송신 버퍼의 여유 공간이 충분하므로 send(), sendto() 등의 함수를 호출하여 데이터를 보낼 수 있다.- 넌블로킹 소켓을 활용한 connect() 함수 호출이 성공 했다.
예외 셋(exception set)- OOB(Out-Of-Band) 데이터가 도착했으므로 recv(), recvfrom() 등의 함수를 호출하여 OOB데이터를 받을 수 있다.- 넌블로킹 소켓을 사용한 connect() 함수 호출이 실패했다.

select() 함수 원형은 다음과 같다.

// 성공: 조건을 만족하는 소켓의 개수 또는 0(타임 아웃), 실패: SOCKET_ERROR
int select(
	int	nfds,
    fd_set	*readfds,
    fd_set	*writefds,
    fd_set	*exceptfds,
    const struct timeval	*timeout
);
  • nfds: 유닉스/리눅스와의 호환성을 위해 존재하며 윈도우에서는 무시된다.
  • readfds, writefds, exceptfds: 각각 읽기 셋, 쓰기 셋, 예외 셋을 나타낸다. 사용하지 않는다면 모두 NULL 값이 될 수 있다.
  • timeout: 초(seconds)와 마이크로초(microseconds) 단위로 타임아웃을 지정한다. 이 시간이 지나면 select() 함수는 무조건 리턴한다.

timeval 구조체 형태는 다음과 같다.

typedef struct timeval {
	long tv_sec;	/* seconds */
    long tv_usec;	/* microseconds */
} TIMEVAL;

타임아웃 값에 따른 select() 함수의 동작은 다음과 같다.

  • NULL: 적어도 한 소켓이 조건을 만족할 때가지 무한히 기다린다. 리턴 값은 조건을 만족하는 소켓의 개수가 된다.
  • {0, 0}: 소켓 셋에 포함된 모든 소켓을 검사한 후 곧바로 리턴한다. 리턴 값은 조건을 만족하는 소켓의 개수 또는 0(타임아웃)이 된다.
  • 양수: 적어도 한 소켓이 조건을 만족하거나 타임아웃으로 지정한 시간이 지나면 리턴한다. 리턴 값은 조건을 만족하는 소켓의 개수 또는 0(타임아웃)이 된다.

select() 함수를 이용한 소켓 입출력 절차는 다음과 같다. 단, 타임아웃은 NULL 값을 가정한다.

  1. 소켓 셋을 비운다(초기화)
  2. 소켓 셋에 소켓을 넣는다. 넣을 수 있는 소켓의 최대 개수는 FD_SETSIZE(64)로 정의되어 있다.
  3. select() 함수를 호출한다. 타임아웃이 NULL이면 select() 함수는 조건을 만족하는 소켓이 있을 때까지 리턴하지 않는다.
  4. select() 함수가 리턴하면 소켓 셋에 남아 있는 모든 소켓에 대해 적절한 소켓 함수를 호출해 처리한다.
    1. ~ 4. 을 반복한다.

소켓 셋을 편하게 다룰 수 있도록 다음과 같은 매크로 함수가 제공된다.

매크로 함수기능
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 모델 서버 작성

아래 링크는 넌블로킹 소켓을 사용하고 Select 모텔로 작성된 TCP 서버이다.

https://github.com/LEEBONGHAK/TCP-IP_window_socket/blob/main/Chapter10/Select/SelectTCPServer.cpp

실행 결과는 멀티스레드를 사용한 블로킹 TCP 서버를 사용할 때와 같다. 차이가 있다면 Select 모델 서버는 멀티스레드를 사용하지 않고도 여러 소켓을 처리한다는 점이다. 또한 모든 소켓은 넌블로킹 소켓이지만 Windows 작업 관리자로 보면 CPU 사용률이 매우 낮음을 알 수 있다.

Select 모델 코드 분석

Select 모델은 여러 소켓에 대해 함수 호출 시점(또는 호출 결과)을 알려주는 역할을 할 뿐 소켓 정보를 관리해주지는 않는다. 따라서 각 소켓에 필요한 정보(응용 프로그램 버퍼, 송/수신 바이트 정보 등)을 관리하는 기능은 응용 프로그램이 구현해야 한다.

위 그림은 소켓 정보를 관리하기 위한 구조다. Select 모델에서 처리할 수 있는 소켓의 최대 개수는 FD_SETSIZE(64)로 정해져 있으므로, 이 개수 만큼 사용자 정의 SOCKETINFO 구조체의 주소를 저장할 수 있도록 배열을 선언한다.

새로운 소켓이 생성되면 위 그림처럼 SOCKETINFO* 구조체를 동적으로 할당하고, 배열에 주소값을 저장해둔다.

소켓 정보를 삭제할 때는 위 그림처럼 포인터 배열 중간에 빈 곳이 없도록 배열의 맨 끝에 있는 유효 원소를 삭제한 위치로 옮긴다.

이제 핵심 코드를 행 단위로 분석해 볼 것이다. 소켓 정보 저장을 위한 구조체와 변수는 다음과 같다.

009	// 소켓 정보 저장을 위한 구조체와 변수
010	typedef struct socket_info
011	{
012		SOCKET sock;
013		char buf[BUFSIZE + 1];
014		int recvbytes;
015		int sendbytes;
016	} SOCKETINFO;
017
018	int nTotalSockets = 0;
019	SOCKETINFO *SocketInfoArray[FD_SETSIZE];
  • 10-16: 소켓 정보 저장을 위한 SOCKETINFO구조체이다. 데이터를 받은 후 끝에 널 문자('\0')를 추가하기 위해 BUFSIZE + 1길이인 바이트 배열을 선언했다. recvbytes, sendbytes는 각각 받은 바이트 수와 보낸 바이트 수를 유지하기 위한 변수이다.
  • 18: SOCKETINFO 구조체의 개수다. 소켓을 생성할 때마다 1씩 증가하고 소켓을 닫을 때마다 1씩 감소한다.
  • 19: SOCKETINFO형 포인터를 저장할 배열이다. 원소개수는 Select 모델에서 처리할 수 있는 소켓의 최대 개수(FD_SETSIZE(64))로 정의하고 있다.

윈속 초기화, socket(), bind(), listen() 함수 호출 부분은 일반적인 TCP 서버와 같으므로 설명을 생략하고 그 다음 부분부터 살펴보자.

056		// 넌블러킹 소켓으로 전환
057		u_long on = 1;
058		retval = ioctlsocket(listen_sock, FIONBIO, &on);
059		if (retval == SOCKET_ERROR)
060			err_quit("ioctlsocket()");
  • 56-60: 연결 대기 소켓을 넌블로킹 모드로 전환한다. Select 모델에서는 블로킹과 넌블로킹 소켓을 모두 사용할 수 있지만 넌블로킹 소켓을 사용하는 것이 좀 더 효율적이다. 단, 넌블로킹 소켓을 사용하면 send() 함수 호출 시 지정한 값보다 작은 값이 send() 함수의 리턴 값으로 나올 수 있으므로 주의해야 한다.
068		while (1)
069		{
070			// 소켓 셋 초기화
071			FD_ZERO(&reset);
072			FD_ZERO(&wset);
073			FD_SET(listen_sock, &rset);
074			for (i = 0; i < nTotalSockets; i++)
075			{
076				if (SocketInfoArray[i]->recvbytes > SocketInfoArray[i]->sendbytes)
077					FD_SET(SocketInfoArray[i]->sock, &wset);
078				else
079					FD_SET(SocketInfoArray[i]->sock, &rset);
080			}
  • 71-73: 읽기 셋과 쓰기 셋을 비운 후 연결 대기 소켓을 읽기 셋에 넣는다.
  • 74-80: 소켓 정보 구조체를 참조하여 모든 소켓을 읽기 또는 쓰기 셋에 넣는다. 받은 데이터가 보낸 데이터보다 많으면 쓰기 셋에, 그렇지 않으면 읽기 셋에 넣는다.
082			// select()
083			retval = select(0, &rset, &wset, NULL, NULL);
084			if (retval == SOCKET_ERROR)
085				err_quit("select()");
  • 82-85: select() 함수를 호출한다. 예외 셋은 사용하지 않으므로 NULL 값을 넣는다. 타임아웃 역시 NULL 값을 사용함으로써 조건이 만족될 때까지 무한히 대기하게 된다.
087			// 소켓 셋 검사(1): 클라이언트 접속 수용
088			if (FD_ISSET(listen_sock, &rset))
089			{
090				addrlen = sizeof(clientaddr);
091				client_sock = accept(listen_sock, (SOCKADDR *) &clientaddr, &addrlen);
092				if (client_sock == SOCKET_ERROR)
093					err_display("accept()");
094
095				// 접속한 클라이언트 정보 출력 
096				printf("\n[TCP 서버] 클라이언트 접속: IP 주소=%s, 포트 번호=%d\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port));
097				// 소켓 정보 추가
098				AddSocketInfo(client_sock);
099			}
  • 88: select() 함수가 리턴하면 먼저 읽기 셋을 검사하여 접속한 클라이언트가 있는지 확인한다. 연결 대기 소켓이 읽기 셋에 있다면 접속한 클라이언트가 있다는 뜻이다.
  • 90-83: accept() 함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다. 윈도우 운영체제에서는 listen_sock이 넌블로킹 소켓이면 client_sock도 자동으로 넌블로킹 소켓이 된다. 따라서 client_sock에 대해 56-60행과 비슷한 코드를 작성할 필요는 없다.
  • 96: 접속한 클라이언트 정보를 화면에 출력한다.
  • 98: AddSocketInfo() 함수를 호출해 소켓 정보를 추가한다.
101			// 소켓 셋 검사(2): 데이터 통신
102			for (i = 0; i < nTotalSockets; i++)
103			{
104				SOCKETINFO *ptr = SocketInfoArray[i];
105				if (FD_ISSET(ptr->sock, &rset))
106				{
107					// 데이터 받기
108					retval = recv(ptr->sock, ptr->buf, BUFSIZE, 0);
109					if (retval == SOCKET_ERROR)
110					{
111						err_display("recv()");
112						RemoveSocketInfo(i);
113						continue;
114					}
115					else if (retval == 0)
116					{
117						RemoveSocketInfo(i);
118						continue;
119					}
120					ptr->recvbytes = retval;
121
122					// 받은 데이터 출력
123					addrlen = sizeof(clientaddr);
124					getpeername(ptr->sock, (SOCKADDR *) &clientaddr, &addrlen);
125					ptr->buf[retval] = '\0';
126					printf("[TCP/%s: %d] %s\n", inet_ntoa(clientaddr.sin_addr), ntohs(clientaddr.sin_port), ptr->buf);
127				}
128
129				if (FD_ISSET(ptr->sock, &wset))
130				{
131					// 데이터 보내기
132					retval = send(ptr->sock, ptr->buf + ptr->sendbytes, ptr->recvbytes - ptr->sendbytes, 0);
133					if (retval == SOCKET_ERROR)
134					{
135						err_display("send()");
136						RemoveSocketInfo(i);
137						continue;
138					}
139
140					ptr->sendbytes += retval;
141					if (ptr->recvbytes == ptr->sendbytes)
142						ptr->recvbytes = ptr->sendbytes = 0;
143				}
144			}
145		}
  • 102-104: select() 함수는 조건을 만족하는 소켓의 개수를 리턴하지만 구체적으로 어떤 소켓인지는 알려주지 않는다. 따라서 응용 프로그램이 관리하고 있는 모든 소켓에 대해 소켓 셋에 들어 있는지 여부를 일일이 확인해야 한다.
  • 105-119: 소켓이 읽기 셋에 들어 있다면 recv() 함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다. 오류 발생(SOCKET_ERROR) 또는 정상 종료(0) 모두 RemoveSocketInfo() 함수를 호출해 소켓을 닫고 소켓 정보을 제거한다.
  • 120-126: recv() 함수 호출이 성공했으면, 받은 바이트 수(recvbytes)를 갱신한 후 화면에 출력한다.
  • 129-138: 소켓이 쓰기 셋에 들어 있다면 send() 함수를 호출한 후 리턴 값을 확인하여 오류를 처리한다. 오류가 발생했다면 RemoveSocketInfo() 함수를 호출하여 소켓을 닫고 소켓 정보를 제거한다.
  • 140: send() 함수 호출이 성공했으면, 보낸 바이트 수(sendbytes)를 갱신한다.
  • 141-142: 받은 데이터를 모두 보냈으면, 받은 바이트 수와 보낸 바이트 수를 0으로 초기화한다.

참고 자료
김성우 저, "TCP/IP 윈도우 소켓 프로그래밍", 한빛아카데미, 2018

0개의 댓글