IO 멀티플렉싱: 블로킹 소켓에서 epoll까지의 진화

블로킹 I/O의 근본적 한계

서버가 클라이언트 연결을 받고 데이터를 읽는 가장 단순한 코드는 다음과 같다.

int sock = socket(AF_INET, SOCK_STREAM, 0);
bind(sock, ...);
listen(sock, 5);

while (1) {
    int client = accept(sock, ...);   // 연결 대기 (블로킹)
    char buf[1024];
    int n = read(client, buf, sizeof(buf));  // 데이터 대기 (블로킹)
    process(buf);
    close(client);
}

이 코드는 accept에서 클라이언트 접속을 기다리고, 연결이 되면 read에서 해당 클라이언트가 보낸 데이터가 도착할 때까지 스레드 전체가 멈춘다. read는 내부적으로 두 단계로 나뉘는데, 커널 버퍼에 데이터가 준비될 때까지 대기하는 단계와 준비된 데이터를 유저 공간으로 복사하는 단계로 구성된다. 이 전형적인 블로킹 I/O 모델은 한 클라이언트가 데이터를 보내지 않으면 다른 클라이언트 연결조차 처리할 수 없는 문제를 낳는다.

논블로킹 I/O의 등장

가장 쉽게 떠올릴 수 있는 해결책은 연결마다 별도 스레드를 할당하는 방식이다.

while (1) {
    int client = accept(sock, ...);
    pthread_create(&tid, NULL, worker, (void*)client);
}

void* worker(void* arg) {
    int fd = (int)arg;
    char buf[1024];
    int n = read(fd, buf, sizeof(buf));
    process(buf);
    close(fd);
    return NULL;
}

스레드 생성으로 메인 루프가 read에 묶이지 않지만, 실제 read 자체는 여전히 블로킹 호출이다. 진정한 논블로킹 I/O를 위해서는 운영체제에 read가 데이터가 없을 때 실패를 반환하도록 요청해야 한다.

int flags = fcntl(client, F_GETFL, 0);
fcntl(client, F_SETFL, flags | O_NONBLOCK);
int n = read(client, buf, sizeof(buf));
if (n == -1 && errno == EAGAIN) {
    // 아직 데이터 없음
}

이제 read는 데이터가 아직 NIC에 도착하지 않았거나 커널 버퍼로 복사되지 않았을 때 바로 -1을 반환한다. 그러나 데이터가 커널 버퍼에 도착한 상태라면 read는 여전히 커널 버퍼에서 유저 버퍼로 복사하는 동안 블로킹된다. 따라서 완전한 비동기라고 보기는 어렵다.

파일 디스크립터 배열을 직접 감시하는 방법의 한계

연결될 때마다 파일 디스크립터를 배열에 저장한 뒤, 별도의 스레드에서 반복문으로 각 디스크립터에 대해 논블로킹 read를 호출하는 방법을 생각할 수 있다.

std::vector<int> fds;
// ... accept에서 fds.push_back(client) ...

while (1) {
    for (int fd : fds) {
        char buf[1024];
        int n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            process(buf);
        }
    }
}

이 방법은 유저 공간에서 다수의 연결을 하나의 스레드로 감시한다는 점에서 멀티플렉싱과 유사해 보이지만, 실제로는 매번 read 시스템 콜을 호출하면서 커널 모드로 진입하는 오버헤드가 누적된다. 효율적인 문제 해결을 위해서는 운영체제가 제공하는 전용 감시 인터페이스가 필요하다.

select: 커널에게 감시를 위임하다

select는 관심 있는 파일 디스크립터 집합을 커널에 전달하고, 커널이 그중에서 읽기/쓰기 가능한 것들을 표시해주도록 하는 시스템 콜이다.

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);

사용법은 다음과 같다. 연결 수락 스레드가 계속 accept하여 논블로킹으로 설정한 디스크립터를 목록에 추가하고, 감시 전용 루틴은 select를 호출한다.

fd_set read_fds;
FD_ZERO(&read_fds);
int maxfd = -1;
for (int fd : client_fds) {
    FD_SET(fd, &read_fds);
    if (fd > maxfd) maxfd = fd;
}
int ready = select(maxfd + 1, &read_fds, NULL, NULL, NULL);

select는 준비된 디스크립터의 총 개수만 알려줄 뿐, 어떤 디스크립터가 준비되었는지는 직접 확인해야 한다. 그래도 이전처럼 모든 디스크립터에 대해 무의미한 시스템 콜을 반복하는 대신, 커널이 플래그를 설정해둔 것만 읽으면 되므로 오버헤드가 크게 줄어든다.

그러나 select에는 다음과 같은 단점이 있다.

  • 매 호출마다 감시할 fd 집합을 커널로 복사해야 한다.
  • 커널 내부에서도 여전히 모든 fd를 순차적으로 검사한다.
  • 사용자 코드는 준비된 fd를 찾기 위해 전체 집합을 다시 순회해야 한다.

poll: 제한을 풀다

pollselect와 유사하지만, 1024개라는 fd 개수 제한이 사라진다.

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd;
    short events;
    short revents;
};

각 fd마다 감시할 이벤트와 실제 발생한 이벤트를 분리하여 구조체 배열로 전달한다. 하지만 내부적으로 모든 fd를 순회하는 근본 구조는 동일하다.

epoll: 이벤트 기반 멀티플렉싱

epollselect/poll의 문제들을 해결한 리눅스의 최종 진화형이다.

  • 커널에 감시할 fd 집합을 유지하고, 변경 사항만 통보한다.
  • 커널이 폴링하지 않고, 해당 fd에서 I/O 이벤트가 발생하면 비동기적으로 깨어난다.
  • 실제 이벤트가 발생한 fd 목록만 사용자에게 반환하여 반복 순회가 필요 없다.

전형적인 사용 패턴은 다음과 같다.

int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;  // 읽기 이벤트 감시
ev.data.fd = listen_sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev);

struct epoll_event events[MAX_EVENTS];
while (1) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_sock) {
            int client = accept(listen_sock, ...);
            set_nonblocking(client);
            ev.events = EPOLLIN;
            ev.data.fd = client;
            epoll_ctl(epfd, EPOLL_CTL_ADD, client, &ev);
        } else {
            char buf[1024];
            int len = read(events[i].data.fd, buf, sizeof(buf));
            if (len > 0) process(buf);
            else { close(events[i].data.fd); }
        }
    }
}

이 구조에서는 감시할 fd를 한 번 등록해두면 커널이 변경 사항을 이벤트로 통지하므로, epoll_wait 반환 시 실제 활성화된 fd만 골라서 처리할 수 있다. 시스템 콜 오버헤드와 불필요한 순회가 모두 제거되어 높은 동시 접속을 감당할 수 있게 된다.

태그: IO 멀티플렉싱 select poll epoll 논블로킹 I/O

9월 22일 03:03에 게시됨