입출력 함수를 통한 프로그램을 작성해본 경험이 있다면 아래와 같은 형태의 코드를 본 적이 있을 것입니다. 파일/콘솔 등에서 사용자의 입력을 받고 그에 대한 적절한 처리를 통해 출력을 수행하는 프로그램의 일부입니다. (예제에서는 입력받은 값을 그대로 출력합니다.)
while((str_len = read(fd_in, buf, BUF_SIZE) > 0) {
write(fd_out, buf, str_len);
}
위와 같은 구조의 I/O를 blocking I/O라고 합니다. 입력이 수행되는 동안은 출력을 할 수 없고, 출력이 수행되는 동안에는 입력을 수행할 수 없습니다. 따라서 많은 수의 파일/소켓 등에 I/O를 동시에 수행해야 하는 경우에는 적절한 구조가 아닙니다.
몇 가지 해결방안
입력과 출력이 동시에 수행될 수 없다는 게 blocking I/O의 문제점이라면 입력과 출력 프로세스를 분리하면 어떨까요? 위의 문제점을 해결함과 동시에 코드 또한 보기에 좋아 보이지 않나요?
pid = fork();
if(pid == 0) { // child proc
write_something(fd, buf);
} else { // parent proc
read_something(fd, buf);
}
하지만 어느 한쪽 process가 종료되는 경우를 생각해봅시다. child process가 종료되는 경우에는(signal handling이 되어있다면) SIGCHLD signal이 발생하여 parent process가 그에 따라 프로세스를 종료하는 등의 적절한 조치를 취할 수 있을 것입니다. 하지만 parent process가 종료되는 경우에는 kill() system call 등을 이용해서 child process에 signal(SIGUSR1 등...)을 전달해야 합니다. 이런 방법은 프로세스 생성 및 signal의 전달에 필요한 오버헤드가 너무 크고 복잡하기 때문에 적절한 방법이 아닙니다.
이외에도 polling loop를 사용하거나 asynchronous I/O를 사용하여 어떤 file descriptor가 I/O를 수행 가능한 상태인지 확인하는 방법이 있지만 많은 양의 I/O를 수행하기엔 오버헤드가 너무 크거나 자체적인 한계를 가지고 있기 때문에 적절하지 않습니다.
I/O Multiplexing
많은 파일/소켓/스트림에 동시에 I/O를 수행하는데 특화된 방법이 바로 I/O Multiplexing입니다. 다수의 file descriptor 중 I/O를 수행할 수 있는 file descriptor를 찾아내서 해당 file descriptor에만 I/O를 수행하는 방식입니다. I/O를 수행 가능한지 여부는 운영체제가 판단하여 우리에게 알려줍니다.
I/O Multiplexing에는 select, pselect, poll 등의 system call이 사용되는데, 이 함수들은 운영체제로 하여금 어떤 file descriptor가 I/O를 수행할 수 있는 상태가 된다면 우리에게 알려달라고 요청합니다. 그런 상태가 된다면 함수가 return 되어 준비된 file descriptor에 I/O를 수행하면 되는 것이죠.
select() system call function
#include <sys/select.h>
/* Check the first NFDS descriptors each in READFDS (if not NULL) for read
readiness, in WRITEFDS (if not NULL) for write readiness, and in EXCEPTFDS
(if not NULL) for exceptional conditions. If TIMEOUT is not NULL, time out
after waiting the interval specified therein. Returns the number of ready
descriptors, or -1 for errors.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int select (int __nfds, fd_set *__restrict __readfds,
fd_set *__restrict __writefds,
fd_set *__restrict __exceptfds,
struct timeval *__restrict __timeout);
nfds
첫 번째 argument인 nfds는 관심이 있는 file descriptor의 수를 의미합니다. UNIX계열의 운영체제에서 file descriptor는 0번부터 시작해서 생성될 때마다 1씩 증가하므로, 가장 큰 file descriptor number에 1을 더해서 해당하는 값을 구할 수 있습니다. 또는 <sys/select.h>에 정의되어있는 FD_SETSIZE상수(보통 1024입니다.)를 사용할 수도 있습니다. 하지만 이는 커널로 하여금 쓸모없는 작업을 수행하게 하므로 첫 번째로 언급한 방법으로 값을 전달하는 것이 좋습니다.
fd_set
중간에 3개의 argument(readfds, writefds, exceptfds)는 각각 읽을 수 있는 상태인지, 쓸 수 있는 상태인지 그리고 예외가 발생했는지 여부에 관심이 있는 file descriptor의 set입니다. 각각의 file descriptor set(이하 fds)은 fd_set restrict pointer로 전달됩니다. 따라서, 같은 목록을 parameter로 넘기고 싶다면 같은 file descriptor를 포함하는 fd_set pointer를 별도로 선언하여 전달해야 합니다.
fd_set은 아래의 4개의 매크로 함수를 통해 접근할 수 있습니다.
/* Access macros for `fd_set'. */
#define FD_SET(fd, fdsetp) __FD_SET (fd, fdsetp)
#define FD_CLR(fd, fdsetp) __FD_CLR (fd, fdsetp)
#define FD_ISSET(fd, fdsetp) __FD_ISSET (fd, fdsetp)
#define FD_ZERO(fdsetp) __FD_ZERO (fdsetp)
각각의 함수들은 이름에서 간단히 유추할 수 있듯이 순서대로, fd_set에 fd를 추가하거나 삭제하거나, fd_set에 fd가 추가되어 있는지 여부를 확인하거나, fd_set를 0으로 clear 하는 등의 기능을 제공합니다.
timeout
마지막 parameter인 timeout은 timeval이라는 구조체의 포인터를 통해서 전달됩니다. timval 구조체의 원형은 아래와 같습니다.
struct timeval {
__kernel_old_time_t tv_sec; /* seconds */
__kernel_suseconds_t tv_usec; /* microseconds */
};
변수이름에서 유추할 수 있듯이 각각 sec, milisec단위로 시간 정보를 담을 수 있는 구조체입니다. select함수는 전해진 timeout parameter가 가리키고 있는 timeval구조체의 값에 따라 아래와 같은 동작들을 수행합니다.
1. timeout == NULL인 경우
이 경우에는 timeout값이 없는 경우를 의미합니다. 즉, 앞서 전달한 file descriptor에 관심 있는 변화가 생기거나 System Interrupt(signal)가 발생할 때까지 함수를 반환하지 않고 영원히 기다리게 됩니다.
2. timeout->tv_sec == 0 && timeout->tv_usec == 0인 경우
이 경우는 앞선 경우와 같이 timeout값이 없는것이 아니라 timeout값이 0인 경우입니다. 따라서 select함수는 관심이 있는 descriptor를 한 번씩 확인한 다음 바로 함수를 반환합니다. select함수를 통해 blocking 없이 다수의 file descriptor를 검사하기 위해서 해당 값을 전달합니다.
3. timeout->tv_sec != 0 || timeout->tv_usec != 0인 경우
0이 아닌 timeout값을 전달한 경우입니다. file descriptor에 관심 있는 변화가 생기거나 timeout값만큼 시간이 지난 경우 함수를 반환합니다.
return value
1. -1을 반환하는 경우
함수가 반환되기 전에 System Interrupt가 발생하는등의 함수 실행상의 에러가 발생하는 경우입니다. 이때 parameter로 전달한 fd_set값은 변하지 않습니다.
2. 0을 반환하는 경우
어떠한 file descriptor도 관심있는 변화를 일으키지 않고 timeout 되었음을 의미합니다. 이때 parameter로 전달한 fd_set값은 모두 clear 됩니다.
3. 양의 정수 값을 반환하는 경우
해당 정수 값만큼의 file descriptor가 관심 있는 변화를 일으켰다는 뜻입니다. 이 값은 parameter로 전달한 각각의 fd_set에서 변화한 file descriptor의 수를 모두 더한 값입니다. 이때 fd_set에서 변화한 file descriptor를 제외한 descriptor들은 모두 clear 됩니다.