1. I/O 멀티플렉싱 개요
- 커널에서는 하나의 스레드가 여러 개의 소켓(파일)을 핸들링할 수 있는 select, poll, epoll, io_uring과 같은 시스템 콜을 제공합니다.
- 멀티플렉싱 모델에서는 select 함수를 호출해서 여러 개의 소켓 중 read 함수 호출이 가능한 소켓이 생길 때까지 대기합니다.
1.1 동작 방식
- select 시스템 콜 단계
- 애플리케이션이 select 시스템 콜을 호출
- 커널이 "no data ready" 상태 확인
- 커널이 데이터가 준비될 때까지 대기
- 데이터가 준비되면 "return readable" 신호를 애플리케이션에 전달
- read 시스템 콜 단계
- 애플리케이션이 read 시스템 콜 호출
- 커널이 데이터를 복사 (copy data)
- 데이터 복사가 완료되면 "return OK" 신호 전달
- 애플리케이션이 복사된 데이터 처리 시 작
1.2 File Descriptor
- 유닉스/리눅스 시스템에서 파일이나 소켓 같은 I/O 리소스를 식별하는 정수값입니다.
- 모든 I/O 리소스는 파일 디스크립터로 식별되며, 파일 디스크립터를 통해 I/O 작업을 수행합니다.
- File Descriptor 참고
2. select 함수
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
2.1 매개변수 설명
- nfds
- 감시할 파일 디스크립터 중 가장 큰 번호 + 1
- 예: fd 3, 5, 7을 감시한다면 nfds는 8
- readfds
- 읽기가 가능한지 감시할 파일 디스크립터들의 집합
- 읽기 가능 = 블로킹 없이 읽을 수 있는 상태
- select() 반환 후에는 이벤트가 발생한 fd 는 1, 발생하지 않은 fd는 0으로 설정됨
- writefds
- 쓰기가 가능한지 감시할 파일 디스크립터들의 집합
- 쓰기 가능 = 블로킹 없이 쓸 수 있는 상태
- 주의: 대용량 쓰기는 여전히 블로킹될 수 있음
- select() 호출이 끝나면 쓰기 가능한 fd는 1, 불가능한 fd는 0으로 설정됨
- exceptfds
- 예외 상황을 감시할 파일 디스크립터들의 집합
- 긴급 데이터 도착 등의 특별한 상황 감지
- timeout
- NULL: 무한정 대기
- 0: 즉시 반환 (폴링)
- 지정값: 해당 시간만큼 대기
2.2 fd_set 구조체와 매크로
- fd_set은 파일 디스크립터의 집합을 나타내는 구조체입니다.
- POSIX 표준에 따르면 fd_set 구조체가 처리할 수 있는 최대 파일 디스크립터 수는 FD_SETSIZE 매크로로 정의됩니다.
- 대부분의 시스템에서 FD_SETSIZE은 1024로 정의되어 있습니다.
2.2.1 fd_set을 조작하기 위한 주요 매크로:
- FD_ZERO(fd_set *set)
- fd_set을 초기화하여 모든 파일 디스크립터를 제거
- fd_set 사용 전 반드시 먼저 호출해야 함
- FD_SET(int fd, fd_set *set)
- fd_set에 특정 파일 디스크립터를 추가
- 이미 존재하는 fd를 추가해도 오류 발생하지 않음
- FD_CLR(int fd, fd_set *set)
- fd_set에서 특정 파일 디스크립터를 제거
- 존재하지 않는 fd를 제거해도 오류 발생하지 않음
- FD_ISSET(int fd, fd_set *set)
- select() 호출 후 특정 파일 디스크립터가 fd_set에 여전히 존재하는지 확인
- fd가 set에 존재하면 0이 아닌 값 반환, 없으면 0 반환
경고
select() 호출 후 fd_set이 수정되므로, 루프에서 select()를 사용할 때는 매 호출 전에 fd_set을 다시 초기화해야 합니다.
2.3 동작 과정
- select 함수는 호출 시 커널에 파일 디스크립터 집합(readfds, writefds, exceptfds)을 전달합니다.
- 커널은 이 집합들을 복사하여 내부적으로 관리합니다.
- 지정된 파일 디스크립터 중 어느 하나라도 준비될 때까지(또는 타임아웃이 발생할 때까지) 스레드는 블록됩니다.
- 이벤트가 발생하면 커널은 관련 fd_set에서 해당 이벤트가 발생하 지 않은 파일 디스크립터의 비트를 0으로 설정합니다.
- select는 준비된 파일 디스크립터의 총 개수를 반환합니다.
- 사용자는 반환 이후에 FD_ISSET 매크로를 사용하여 어떤 파일 디스크립터가 준비되었는지 확인해야 합니다.
- 이 과정은 O(n) 시간 복잡도를 가지며, 여기서 n은 감시 중인 파일 디스크립터의 총 개수(nfds)입니다.
- 루프에서 select를 반복 호출할 때는 fd_set이 수정되므로 매번 재설정이 필요합니다.
2.4 사용 예시
fd_set read_fds;
struct timeval tv;
// fd_set 초기화
FD_ZERO(&read_fds);
FD_SET(socket_fd, &read_fds); // 소켓 감시
FD_SET(STDIN_FILENO, &read_fds); // 키보드 입력 감시
// 5초 타임아웃 설정
tv.tv_sec = 5;
tv.tv_usec = 0;
// select 호출
int max_fd = (socket_fd > STDIN_FILENO ? socket_fd : STDIN_FILENO);
int ready = select(max_fd + 1, &read_fds, NULL, NULL, &tv);
if (ready > 0) {
if (FD_ISSET(socket_fd, &read_fds)) {
// 소켓에서 데이터 읽기 가능
}
if (FD_ISSET(STDIN_FILENO, &read_fds)) {
// 키보드 입력 있음
}
}
2.5 주요 특징
- 장점
- 단일 스레드로 여러 입출력 동시 처리 가능
- POSIX 표준으로 이식성이 좋음
- 간단한 구현으로 적은 수의 fd 처리에 효과적
- 단점:
- fd 개수 제한 (최대 1024개)
- 매 호출마다 fd_set 초기화 필요
- 준비된 fd 확인을 위해 전체 세트 검사 필요
- 현대 애플리케이션에서는 epoll을 권장
팁
select()는 간단한 동시성 처리에는 유용하지만, 대규모 애플리케이션에서는 epoll 같은 더 효율적인 방식을 사용하는 것이 좋습니다.
3. poll 함수
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
3.1 매개변수 설명
- pollfd 구조체는 감시할 파일 디스크립터와 이벤트를 지정하는데 사용됩니다
- timeout
- -1: 이벤트가 발생할 때까지 무한정 대기
- 0: 즉시 반환 (비블로킹 폴링)
- 양수: 지정된 밀리초만큼 대기
- 반환 값
- poll() 함수는 성공하면 음수가 아닌 값을 반환합니다. 이벤트나 오류가 발생한 파일 디스크립터의 수입니다.
- 반환 값이 0이면, 파일 디스크립터가 준비되기 전에 시스템 호출이 시간 초과되었음을 나타냅니다.
- 오류가 발생하면 -1을 반환하고, errno 변수에 오류를 나타내는 값이 설정됩니다.
pollfd 구조체
struct pollfd {
int fd; // 파일 디스크립터
short events; // 감시할 이벤트
short revents; // 발생한 이벤트
};
- pollfd 구조체는 감시할 파일 디스크립터와 이벤트를 지정하는데 사용됩니다
- events/revents 플래그
- POLLIN: 읽을 데이터가 있음
- POLLOUT: 쓰기가 가능한 상태
- POLLPRI: 긴급 데이터 발생 (TCP 소켓의 OOB 데이터 등)
- POLLERR: 에러 발생 (revents 에서만 사용)
- POLLHUP: 연결이 끊김 (revents에서만 사용)
- POLLNVAL: 잘못된 요청 - fd가 열려있지 않음 (revents에서만 사용)
3.2 동작 과정
- poll도 select와 마찬가지로 멀티플렉싱을 구현하는 시스템 콜입니다.
- 파일 디스크립터의 이벤트를 기다리다가 이벤트가 발생하면, poll에서의 블록이 해제됩니다.
- poll 함수는 이벤트가 발생한 파일 디스크립터의 총 개수를 반환하지만, 어떤 파일 디스크립터에 이벤트가 발생했는지는 알려주지 않습니다.
- 따라서 프로그래머는 반환된 값(이벤트 발생 개수)만큼 pollfd 배열을 순회하면서 revents 필드를 검사해야 합니다.
- 각 파일 디스크립터의 revents 필드가 0이 아니면 해당 파일 디스크립터에 이벤트가 발생한 것입니다.
- 이 과정은 O(n) 시간 복잡도를 갖기 때문에, 감시하는 파일 디스크립터가 많을수록 검사 시간이 선형적으로 증가합니다.
3.3 사용 예시
#define MAX_FDS 2
struct pollfd fds[MAX_FDS];
char buf[1024];
// 소켓과 표준 입력 감시 설정
fds[0].fd = sockfd;
fds[0].events = POLLIN;
fds[1].fd = STDIN_FILENO;
fds[1].events = POLLIN;
while (1) {
int ret = poll(fds, MAX_FDS, 5000); // 5초 타임아웃
if (ret < 0) {
perror("poll");
break;
}
if (ret == 0) {
printf("Poll timed out!\n");
continue;
}
// 소켓 이벤트 체크
if (fds[0].revents & POLLIN) {
recv(sockfd, buf, sizeof(buf), 0);
printf("Received: %s\n", buf);
}
// 표준 입력 이벤트 체크
if (fds[1].revents & POLLIN) {
read(STDIN_FILENO, buf, sizeof(buf));
printf("Read from stdin: %s\n", buf);
}
}
3.4 select와의 차이점
- 감시 방식
- select처럼 표준 입력, 출력, 에러를 따로 감시할 필요가 없습니다.
- select는 fd_set로 읽기/쓰기/예외를 분리했습니다.
- 단일 pollfd 구조체로 모든 이벤트 처리합니다.
- 타임아웃 처리
- select는 timeval 구조체 사용
- poll은 별도 구조체 없이 타임아웃 설정 가능합니다.
3.5 장점과 한계
- select와 같이 단일 프로세스에서 여러 파일 입출력 처리 가능합니다.
- poll은 이론적으로 시스템 리소스가 허용하는 한 무제한의 파일 디스크립터를 감시할 수 있습니다.
- 실제로는 프로세스당 열 수 있는 파일 디스크립터의 수에 의해 제한됩니다.
- 현대 리눅스 시스템에서는 수만 개의 파일 디스크립터를 처리할 수 있습니다.
- 그러나 파일 디스크립터 수가 증가할수록 선형적으로 성능이 저하될 수 있습니다.
- 이벤트 감시 방식이 더 단순하고 직관적입니다.
- 일부 UNIX 시스템은 poll을 지원하지 않아 이식성 제약있습니다.
- select와 poll 모두 이벤트가 발생한 파일 디스크립터를 찾기 위해 전체 디스크립터 집합을 순회해야 하는 O(n) 검사가 필요합니다.
ulimit
- ulimit은 리눅스와 유닉스 계열 운영체제에서 프로세스가 사용할 수 있는 시스템 리소스의 제한을 설정하는 도구입니다.
- 자세한 내용은 ulimit 참고