Operating System/UNIX

[UNIX] I/O 멀티플렉싱 (Multiplexing) 및 select

  • -

I/O가 성능에 미치는 영향

I/O란 데이터 입출력을 일컫는 말이다. 네트워크 전송, 콘솔 출력, 파일 입출력 등을 모두 포함한다.

 

함수 실행과 같이 CPU를 사용하는 작업으로 인한 blocking은 개발자가 할 수 있는게 별로 없지만,

I/O로 인한 blocking은 CPU를 긴 시간 idle하게 두기 때문에 다른 작업을 할 수 있음에도 오랫동안 할 수 없어 매우 비효율적이다.

 

I/O에서 발생하는 시간은 CPU를 사용하는 시간과 대기 시간 중에 대기 시간에 속하기 때문에

I/O가 많아진다는 것은 CPU가 아무것도 하지 못하고 대기하는 시간이 길어진다는 의미고, 프로세스의 처리 속도가 같이 느려진다.

 

Blocking I/O

I/O 작업이 진행되는 동안 유저 프로세스가 자신의 작업을 중단한 채, I/O가 끝날 때까지 대기하는 방식

유저 프로세스가 입출력 함수를 호출(시스템콜)하면 커널에 I/O를 요청하게 되고,

커널 단의 I/O버퍼가 다 쌓이고 나서 유저 프로세스의 I/O버퍼에게 옮겨 담을 때까지 프로그램은 block 되어 다른 작업을 수행하지 못한다.

 

Non Blocking I/O

I/O 작업이 진행되는 동안 유저 프로세스의 작업을 중단하지 않고 다른 일을 수행할 수 있게 하는 방식유저 프로세스는 중간중간 시스템 콜을 보내서 I/O가 완료 됐는지 확인한다.

유저 프로세스가 입출력 함수를 호출하면 커널의 I/O 작업 완료 여부와는 무관하게 즉시 응답한다.

이는 커널이 시스템 콜을 받자마자 CPU 제어권을 다시 넘겨주어 다른 작업을 수행할 수 있게 한다. 

 

 

다중접속 서버의 구현 방법

멀티프로세스 (Multi-process)

클라이언트가 접속 시, 자식 프로세스를 만들어 해당 클라이언트에 대한 일을 자식 프로세스에게 넘기며 다수의 프로세스를 생성하는 방식

프로세스 생성에 많은 시간과 자원이 필요하고 프로세스간의 통신(Inter-Process Communication, IPC)이 복잡하다.

 

멀티스레드 (Multi-thread)

클라이언트가 접속 시, 자식 프로세스 대신에 스레드를 만들어 넘기는 방식

스레드의 생성 및 컨텍스트 스위칭(Context switching)은 프로세스의 생성 및 컨텍스트 스위칭보다 빠르다.

스레드 사이에서의 데이터 교환에는 특별한 기법이 필요하지 않다.

스레드 생성 시, 메모리를 복사하지 않아 자원을 비교적 덜 사용한다.

 

I/O 멀티플렉싱 (Multi-plexing)

하나의 스레드가 여러 입출력 관련 파일을 관리하는 방식

커널은 감시해야 할 파일 디스크립터(이하 fd)들을 감시하고, 해당 fd에 읽기/쓰기 등의 이벤트가 발생했을 경우 유저 프로세스에게 이벤트를 보내준다. 유저 프로세스는 이벤트가 발생된 fd의 소켓(socket)이랑만 통신할 수 있게 된다.

따라서 싱글 프로세스, 싱글 스레드로 여러 클라이언트와 통신이 가능하다.

적은 물리적 자원으로 좋은 효율을 낼 수 있다.

프로세스와 스레드 사이에 컨텍스트 스위칭에 대한 비용이 없다.

 

fd와 소켓에 대한 더 자세한 설명은 아래의 링크 참고

 

[UNIX] 파일 디스크립터 (File Descriptor)

개념 유닉스 및 유닉스 계열 운영 체제에서 파일 또는 파이프나 네트워크 소켓과 같은 기타 입출력 리소스에 대한 프로세스 고유 식별자이자 핸들이다. 일반적으로 음수가 아닌 정수 값이며, 음

sikpang.tistory.com

 

[UNIX] TCP/IP 소켓 (Socket) 프로그래밍

개념 물리적으로 연결된 네트워크상에서의 데이터 송수신에 사용할 수 있는 소프트웨어적 장치다. (네트워크 망의 연결에 사용되는 도구, 더 나아가서 네트워크를 통한 두 컴퓨터의 연결) 커널

sikpang.tistory.com

 

 

클라이언트가 send/write로 data를 보내면 서버 kernel의 socket 버퍼에 data가 쌓이고, 이후 서버 프로세스에게 read 이벤트를 보내 소켓 버퍼의 데이터를 읽을 수 있음을 알린다.

이후 서버 프로세스는 recv/read를 통해 해당 data를 kernel 버퍼에서 유저 프로세스 버퍼로 복사해온다.

 

커널이 fd를 감시할 때 필요한 함수들(select, poll, epoll, kqueue, iocp)이 존재하고 해당 함수를 통해 I/O 멀티플렉싱 모델 구현이 진행된다.

해당 포스팅에선 select()에 대해 소개한다.

 

입출력 함수(read/write/recv/send)를 호출했을 경우 데이터를 커널 버퍼에서 유저 프로세스 버퍼로 옮겨오는 과정에 대한 비동기인 AIO 는 본 포스팅에서 다루지 않는다.

 

 

select()

fd_set 이라는 fd 비트 배열에 등록하고 다음과 같은 이벤트가 발생했을 경우 확인하는 방식

수신, 전송, 예외에 따라서 구분해서 모아야 한다.

 

#include <sys/select.h>
#include <sys/time.h>

int select(
	int maxfd,         // 검사 대상이 되는 fd 수
	fd_set* readset,   // 수신 데이터 감지 fd_set
	fd_set* writeset,  // 블로킹 없고 데이터 전송 가능 감지 fd_set
	fd_set* exceptset, // 예외 상황 발생 여부 감지 fd_set
	const struct timeval* timeout);  // 무한정 blocking 되지 않도록 timeout 설정

 

fd에 변화가 생기지 않으면 무한정 blocking 되는데, 이를 막기 위해 마지막 인자로 넘긴 시간마다 select()가 반환하게 한다. (반환 값이 0이면 타임아웃)

 

이벤트 종류

- 수신 이벤트 : 소켓이 수신한 데이터를 지니고 있음

- 전송 이벤트 : 소켓이 blocking 되지 않으면서 데이터의 전송이 가능한 상황이 됨

- 예외 이벤트 : 소켓이 예외 상황이 발생함

 

fd 관리 및 fd_set 구조체 

fd 하나당 0과 1의 상태로 표현하는 fd_set 구조체가 존재한다.

프로그래머가 직접 fd_set에 비트 값을 넣을 필요는 없고, 매크로 함수 활용하면 된다.

FD_ZERO(fd_set* fdset)		// 0으로 초기화
FD_SET(int fd, fdset* fdset)	// fd 등록
FD_CLR(int fd, fdset* fdset)	// fd 삭제
FD_ISSET(int fd, fdset* fdset)	// 해당 fd가 등록되어 있다면 참 반환

 

FD_ISSET은 select()의 호출 결과를 확인하는 용도로 사용된다.

 

select()가 이벤트 감지 시, 서버 socket이면 새로운 client 등록, 아니면 해당 socket에 대한 이벤트 처리를 해주면 된다.

 

select() 에코 서버 예제

#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <sys/select.h>
#include <fcntl.h>

#define BUF_SIZE 10000

void handle_event(int server_socket)
{
	fd_set reads, writes, cpy_reads;
	struct timeval timeout;
	char buf[BUF_SIZE];
	int fd_max = server_socket;
	int fd_num;
    
	FD_ZERO(&reads);
	FD_SET(server_socket, &reads);

	while(1)
	{
		cpy_reads = reads;
		timeout.tv_sec = 5;
		timeout.tv_usec = 5000;

		if((fd_num = select(fd_max+1, &cpy_reads, 0, 0, &timeout)) == -1)
			break;
		if(fd_num == 0)
		{	
			printf("timeout\n");
			continue;
		}

		for(i=0; i<fd_max+1; i++)
		{
			if(FD_ISSET(i, &cpy_reads))
			{
				if(i == server_socket)     // connection request!
				{
					int client_socket = accept(server_socket, NULL , NULL);
                    
					int flag = fcntl(client_socket, F_GETFL, 0);
					fcntl(client_socket, F_SETFL, flag | O_NONBLOCK);
                    
					FD_SET(client_socket, &reads);
					if(fd_max < client_socket)
						fd_max = client_socket;
                
					printf("connected client: %d \n", client_socket);
				}
				else    // read message!
				{
					ssize_t res = read(i, buf, BUF_SIZE);
                    
					if(res == 0)    // close request!
					{
						FD_CLR(i, &reads);
						close(i);
						printf("closed client: %d \n", i);
					}
					else
					{
						write(i, buf, res);    // echo!
					}
				}
			}
		}
	}
	close(server_socket);
}

 

select() 문제점

1. select()를 호출할 때마다 인자로 매번 fd 정보들을 전달해야 하는 것

    (응용 프로세스에서 운영체제로 정보를 전달하는 것은 큰 부담이다.)

 

2. 모든 fd를 대상으로 하는 반복문
    (하나의 fd만 이벤트가 발생하더라도 모든 fd를 순회하며 FD_ISSET으로 확인해야 한다.)

 

3. 감지할 수 있는 이벤트의 종류가 한정적 (시그널, 파일 시스템 변경, AIO 완료 등 감지 불가)

 

4. fd 수가 증가함에 따라 잘 확장되지 않으며, 병목현상이 발생하여 성능이 저하될 수 있음

 

- 이를 해결한 여러 운영체제별 개선 함수가 존재한다. Linux의 epoll, MacOS의 kqueue, Windows의 iocp

하지만 개선된 모델들은 운영체제별로 호환되지 않는 단점이 있다.

 

- 서버의 접속자 수가 많지 않고 다양한 운영체제에서 운영이 가능해야 하면 select()를 사용하는 것이 좋다.

 

 

『윤성우의 열혈 TCP/IP 소켓 프로그래밍』 책을 읽고 정리한 글입니다.
검색을 허용하지 않고, 수익을 창출하지 않습니다.
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.