본문으로 건너뛰기

1. I/O 멀티플렉싱 개요​

  • 커널에서는 하나의 스레드가 여러 개의 소켓(파일)을 핸들링할 수 있는 select, poll, epoll, io_uring과 같은 시스템 콜을 제공합니다.
  • 멀티플렉싱 모델에서는 select 함수를 호출해서 여러 개의 소켓 중 read 함수 호출이 가능한 소켓이 생길 때까지 대기합니다.

1.1 동작 방식​

multiplexing-io

  • 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 참고

4. epoll 함수​

4.1 개요​

  • epoll은 select와 poll의 단점을 보완하기 위해 리눅스 커널 2.5.44 버전에서 처음 소개된 시스템 콜입니다.
  • 이벤트 기반의 I/O 모델을 사용하며, 이벤트가 발생한 파일 디스크립터만 반환하기 때문에 select와 poll의 단점을 보완했습니다.
  • 레퍼런스

4.2 주요 함수​


int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

4.2.1 epoll_create1​

int epoll_create1(int flags);
  • 레퍼런스
  • epoll 인스턴스를 생성하고 이를 참조하는 파일 디스크립터를 반환합니다.
    • 에러 발생 시 -1을 반환합니다.
  • flags 파라미터 : epoll 인스턴스 생성 옵션
    • 0: 만약 flags가 0이면, 더 이상 사용되지 않는 size 인자가 제거된 것 외에는, epoll_create1()은 epoll_create()와 동일합니다.
    • EPOLL_CLOEXEC: 새로운 파일 디스크립터에 close-on-exec (FD_CLOEXEC) 플래그를 설정합니다.
      • FD_CLOEXEC 플래그가 설정된 파일 디스크립터는 프로세스가 exec() 계열 함수(새로운 프로그램을 실행하는 함수)를 호출할 때 자동으로 닫힙니다.
  • 생성된 파일 디스크립터는 더 이상 필요하지 않을 때 close()로 명시적으로 닫아야 합니다.

4.2.2 epoll_ctl​

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *_Nullable event);
  • 레퍼런스
  • epoll_ctl() 함수는 epoll 인스턴스의 "관심 목록"을 관리하는 함수입니다
  • op를 통해 관심 목록에 파일 디스크립터를 추가, 수정, 삭제할 수 있습니다.
    • EPOLL_CTL_ADD: 새로운 파일 디스크립터를 감시 목록에 추가합니다.
    • EPOLL_CTL_MOD: 이미 감시 중인 파일 디스크립터의 설정을 변경합니다.
    • EPOLL_CTL_DEL: 파일 디스크립터를 감시 목록에서 제거합니다.
  • fd
    • epoll 인스턴스에 추가할 파일 디스크립입니다.
  • event
    • 파일 디스크립터의 이벤트 설정을 담은 epoll_event 구조체입니다.
  • 반환값
    • 성공 시 0을 반환합니다.
    • 실패 시 -1을 반환하고 errno에 오류 코드를 설정합니다.
예시​
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT; // 읽기 및 쓰기 이벤트 모두 감시
ev.data.fd = some_fd; // 이벤트 발생 시 식별을 위한 fd 저장

// epoll 인스턴스에 파일 디스크립터 추가
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, some_fd, &ev);

4.2.3 epoll_wait​

int epoll_wait(int epfd, struct epoll_event events[.maxevents], int maxevents, int timeout);
  • 레퍼런스
  • epoll_wait() 시스템 콜은 파일 디스크립터 epfd로 참조되는 epoll인스턴스에서 이벤트를 기다립니다.
  • 이벤트가 발생한 파일 디스크립터의 정보를 events 배열에 저장합니다.
  • epoll_wait()는 최대 maxevents 개수만큼 반환합니다. maxevents 인자는 0보다 커야 합니다.
  • timeout 인자는 epoll_wait()가 블록할 밀리초 수를 지정합니다. 시간은 CLOCK_MONOTONIC 시계를 기준으로 측정됩니다.
    • timeout이 -1이면 무한정 대기합니다.
    • timeout이 0이면 즉시 반환합니다.
  • epoll_wait() 호출은 다음 중 하나가 발생할 때까지 블록됩니다
    • 관심 목록에 있는 파일 디스크립터 중 하나에 이벤트가 발생
    • 호출이 시그널 핸들러에 의해 중단됨
    • timeout이 지남

4.3 동작 과정​

  • epoll은 select/poll과 달리 커널에서 이벤트 상태를 추적하며, 두 개의 주요 데이터 구조를 사용합니다:
    • "관심 목록"(interest list): 감시 대상 파일 디스크립터와 관심 이벤트 정보 저장
    • "준비 목록"(ready list): 이벤트가 발생한 파일 디스크립터 정보 저장
  • 동작 단계:
    1. epoll_create1()로 epoll 인스턴스를 생성하면 커널 내부에 전용 데이터 구조가 할당됩니다.
    2. epoll_ctl()을 사용해 각 파일 디스크립터와 관심 이벤트(EPOLLIN, EPOLLOUT 등)를 관심 목록에 등록합니다.
    3. epoll_wait()를 호출하면 이벤트 발생을 기다리며 스레드가 블록됩니다.
    4. 이벤트가 발생하면 커널은 해당 파일 디스크립터 정보를 준비 목록에 추가합니다.
    5. epoll_wait()는 준비 목록의 이벤트 정보만 사용자 공간의 events 배열로 복사하여 반환합니다.
    6. 반환된 events 배열은 실제 이벤트가 발생한 파일 디스크립터만 포함하므로 전체 목록을 검사할 필요가 없습니다.
  • epoll은 두 가지 트리거 모드를 지원합니다:
    • 레벨 트리거(Level Triggered, 기본 모드): 읽기/쓰기 가능 상태가 지속되는 한 계속 이벤트 통지 (select/poll과 유사)
    • 에지 트리거(Edge Triggered, EPOLLET 플래그): 상태 변화가 발생한 시점에만 한 번 통지 (더 효율적이지만 구현이 복잡)
  • 성능 특징:
    • 이벤트가 발생한 파일 디스크립터만 반환하므로 O(1) 시간 복잡도로 이벤트 처리 가능
    • 매번 커널에 파일 디스크립터 집합을 전달하지 않아 시스템 콜 오버헤드 감소
    • 대규모 연결(수천, 수만 개)에서도 효율적인 성능 유지

4.4 트리거 모드​

  • epoll은 이벤트 감지 방식에 따라 두 가지 트리거 모드를 제공합니다:

4.4.1 레벨 트리거(Level Triggered, LT)​

  • 기본 동작 방식으로, select와 poll과 유사하게 작동합니다.
  • 읽기/쓰기 가능 상태가 지속되는 한 계속해서 이벤트를 통지합니다.
  • 버퍼에 데이터가 남아있는 한 반복적으로 이벤트가 발생합니다.
  • 이벤트를 놓칠 위험이 적어 구현이 더 간단합니다.

4.4.2 에지 트리거(Edge Triggered, ET)​

  • EPOLLET 플래그를 지정하여 사용할 수 있습니다.
  • 상태 변화가 발생한 시점에만 한 번 이벤트를 통지합니다.
  • 예를 들어, 데이터가 도착할 때 한 번만 알림을 받고, 버퍼에 데이터가 남아있더라도 추가 알림은 없습니다.
  • 더 효율적이지만 구현이 복잡하며, 이벤트를 놓치지 않도록 주의해야 합니다.

4.4.3 동작 차이 예시​

다음 시나리오를 통해 두 모드의 차이를 이해할 수 있습니다:

  1. 파이프의 읽기 측(rfd)이 epoll 인스턴스에 등록됩니다.
  2. 파이프 쓰기 측에서 2KB의 데이터를 씁니다.
  3. epoll_wait()를 호출하면 rfd가 준비된 파일 디스크립터로 반환됩니다.
  4. 파이프 읽기 측에서 1KB의 데이터만 읽습니다.
  5. 다시 epoll_wait()를 호출합니다.
  • 에지 트리거(ET) 모드에서는 5단계의 epoll_wait() 호출이 대기 상태에 빠질 수 있습니다.
    • 이벤트는 상태 변화 시에만 생성되기 때문에, 2단계에서 데이터가 도착할 때 한 번만 이벤트가 발생하고 3단계에서 소비됩니다.
    • 4단계에서 버퍼의 일부 데이터만 읽었지만, 새로운 데이터가 도착하지 않았으므로 5단계에서는 이벤트가 발생하지 않습니다.
  • 레벨 트리거(LT) 모드에서는 5단계의 epoll_wait() 호출이 즉시 반환됩니다.
    • 버퍼에 여전히 데이터가 남아있는 상태이기 때문입니다.

4.4.4 에지 트리거 사용 시 권장사항​

에지 트리거(EPOLLET) 모드를 사용할 때는:

  1. 반드시 넌블로킹(non-blocking) 파일 디스크립터를 사용해야 합니다.
  2. read() 또는 write()가 EAGAIN을 반환한 후에만 이벤트를 기다려야 합니다.
  3. 한 번의 이벤트 알림 후 더 이상 해당 파일 디스크립터를 처리하지 않으려면 EPOLLONESHOT 플래그를 함께 사용할 수 있습니다.
    • 이 경우 이벤트 처리 후 epoll_ctl()의 EPOLL_CTL_MOD 연산으로 파일 디스크립터를 다시 활성화해야 합니다.

4.4.5 성능 최적화​

  • 에지 트리거 모드에서는 여러 스레드나 프로세스가 동일한 epoll 파일 디스크립터를 기다리고 있을 때, 이벤트가 발생하면 그 중 하나만 깨어납니다.
  • 이는 "thundering herd"(다수의 프로세스가 동시에 깨어나는 현상) 문제를 방지하는 유용한 최적화입니다.
팁

대부분의 고성능 서버 애플리케이션은 에지 트리거 모드를 사용하지만, 구현 복잡성을 고려하여 특정 상황에 맞는 모드를 선택하는 것이 중요합니다.

4.5 사용 예시​

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int epollfd;

// epoll 인스턴스 생성
epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}

// 소켓 추가
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl");
exit(EXIT_FAILURE);
}

// 이벤트 대기
for (;;) {
int nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}

for (int n = 0; n < nfds; ++n) {
// 이벤트 처리
if (events[n].data.fd == listen_sock) {
// 새로운 연결 처리
} else {
// 데이터 처리
}
}
}

4.6 주요 특징​

  • 효율적인 이벤트 처리
    • 상태 변화를 확인하기 위한 전체 파일 디스크립터 대상 반복문 불필요합니다.
    • 커널에서 상태 정보를 유지하여 매번 정보 전달 불필요합니다.
    • 이벤트 발생한 파일 디스크립터만 처리합니다.
  • 확장성
    • poll과 마찬가지로 별도의 정적 제한은 없으며, 시스템에서 허용하는 최대 파일 디스크립터 수에 따라 감시할 수 있습니다.
    • 보통 수천 개, 심지어 수만 개 이상의 파일 디스크립터를 효율적으로 감시할 수 있습니다.
    • 대규모 동시 연결 처리에 적합합니다.

4.7 한계​

  • Linux 전용 시스템 콜로 이식성 제한
  • Windows의 IOCP 기반 시스템과의 호환성 문제
  • select 대비 구현 복잡도 증가

5. POSIX와 이식성​

5.1 POSIX 표준​

  • POSIX(Portable Operating System Interface)는 이식 가능 운영 체제 인터페이스의 약자로:
    • 서로 다른 UNIX OS의 공통 API를 정리
    • 이식성이 높은 유닉스 응용 프로그램 개발 목적
    • IEEE가 책정한 애플리케이션 인터페이스 규격

5.2 멀티플렉싱 API 선택 기준​

  1. 이식성 중시
    • select: POSIX 표준으로 높은 이식성 필요시
    • poll: 유닉스 계열에서 향상된 기능 필요시
  2. 성능 중시
    • epoll: 리눅스 환경에서 고성능 필요시
    • IOCP: Windows 환경에서 고성능 필요시

참고