한 줄 정의

Linux 커널이 제공하는 I/O 이벤트 통지 메커니즘. 감시 대상 파일 디스크립터(fd)를 커널에 등록해두면, 이벤트가 발생한 fd만 O(1)으로 반환한다.

쉽게 말하면

select()/poll()은 매번 교실의 학생 10,000명을 한 명씩 돌아다니며 “손 든 사람?”하고 묻는 방식입니다. epoll은 학생이 손을 들면 알림벨이 울리는 방식이라서, 손 든 학생만 바로 확인하면 됩니다. 학생이 10,000명이든 100,000명이든 손을 든 학생 수만큼만 처리하면 되므로 전체 수에 영향을 받지 않습니다.

왜 이걸 알아야 하는가?

  • 고성능 서버의 기반 기술: Nginx, Netty, Node.js(libuv), Redis — 리눅스에서 돌아가는 거의 모든 고성능 네트워크 서버가 내부적으로 epoll을 사용합니다. epoll을 모르면 이들의 동작 원리를 설명할 수 없습니다.
  • Virtual Thread/Coroutine의 I/O 계층: Java Virtual Thread가 블로킹 API 호출 시 unmount하고 I/O 완료를 기다리는 메커니즘도 결국 epoll 기반입니다. JDK의 sun.nio.ch.EPoll이 내부적으로 epoll_wait()를 호출하며, 이것이 Virtual Thread의 “블로킹 코드인데 논블로킹으로 동작”하는 비밀입니다.
  • C10K Problem의 핵심 해결책: select/poll의 O(n) 한계를 O(1)로 돌파한 것이 epoll이며, 이것 없이는 C10K 이후의 서버 아키텍처가 존재하지 않습니다.

왜 이렇게 설계했는가?

  • 해결하는 문제: select()/poll()이 매 호출마다 감시 대상 fd 전체를 커널에 복사하고 전체를 순회하는 O(n) 비용을 제거합니다.
  • 이게 없다면: 소켓 10,000개를 감시할 때 매번 10,000개 fd 배열을 유저 → 커널로 복사하고, 커널이 10,000개를 순회해서 이벤트를 확인하고, 다시 유저 공간으로 복사해야 합니다. 이벤트가 1개만 발생해도 10,000개를 전부 검사하는 셈입니다.
  • 핵심 설계 원칙: 상태 유지(stateful) — fd 집합을 커널에 한 번 등록해두고 재사용합니다. 매 호출마다 복사하지 않습니다. 이벤트 기반 통지 — 커널이 이벤트가 발생한 fd만 ready list에 추가하고, 유저는 이 리스트만 읽습니다.

어떻게 동작하는가?

세 가지 시스템 콜

epoll은 단 세 개의 시스템 콜로 동작합니다.

1. epoll_create() — epoll 인스턴스 생성

int epfd = epoll_create1(0);

커널 내부에 **레드-블랙 트리(관심 fd 저장)**와 **ready list(이벤트 발생 fd)**를 가진 epoll 인스턴스를 생성합니다.

2. epoll_ctl() — fd 등록/수정/삭제

struct epoll_event ev;
ev.events = EPOLLIN;          // 읽기 가능 이벤트 감시
ev.data.fd = sockfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);  // 등록

감시할 fd를 레드-블랙 트리에 추가합니다. 이 시점에 커널이 해당 fd의 wait queue에 콜백을 등록하므로, 이벤트 발생 시 자동으로 ready list에 추가됩니다.

3. epoll_wait() — 이벤트 대기

struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epfd, events, MAX_EVENTS, timeout);
for (int i = 0; i < n; i++) {
    handle(events[i].data.fd);  // 이벤트 발생한 fd만 처리
}

ready list에 있는 fd만 반환합니다. 전체 fd를 순회하지 않으므로 O(1)입니다 (정확히는 이벤트 수에 비례하는 O(ready count)).

커널 내부 구조

epoll 인스턴스
├── 레드-블랙 트리 (관심 fd 저장)
│   ├── fd 3: EPOLLIN
│   ├── fd 7: EPOLLIN | EPOLLOUT
│   ├── fd 42: EPOLLIN
│   └── ... (10,000개)
│
└── ready list (이벤트 발생한 fd)
    ├── fd 7: 데이터 도착
    └── fd 42: 데이터 도착
  • 레드-블랙 트리: epoll_ctl()로 등록한 모든 fd를 O(log n)으로 관리합니다.
  • ready list: 이벤트가 발생하면 커널이 콜백을 통해 해당 fd를 이 리스트에 추가합니다. epoll_wait()는 이 리스트만 읽으면 됩니다.
  • 콜백 등록: epoll_ctl(ADD) 시점에 해당 fd의 디바이스 드라이버 wait queue에 콜백을 걸어둡니다. 데이터가 도착하면 드라이버가 이 콜백을 호출하고, 콜백이 ready list에 fd를 추가합니다.

select/poll과의 핵심 차이

구분select/pollepoll
fd 집합 전달매 호출마다 유저 → 커널 복사최초 등록 후 커널에서 유지
이벤트 감지전체 fd 순회 O(n)콜백 기반 ready list O(1)
결과 반환전체 fd 배열 반환 (유저가 순회)이벤트 발생 fd만 반환
fd 수 제한select: 1024개 (FD_SETSIZE)사실상 무제한 (시스템 메모리)
상태stateless (매번 재구성)stateful (커널에 유지)

Level-Triggered vs Edge-Triggered

epoll은 두 가지 동작 모드를 지원합니다.

Level-Triggered (LT, 기본값): fd가 읽기 가능한 상태인 동안 epoll_wait()가 계속 해당 fd를 반환합니다. 버퍼에 데이터가 남아있으면 다음 epoll_wait() 호출에서도 다시 알려줍니다. 프로그래밍이 쉽지만, 매번 불필요하게 반환될 수 있습니다.

Edge-Triggered (ET, EPOLLET 플래그): fd의 상태가 변경될 때만 한 번 알려줍니다. 데이터가 도착한 시점에 한 번만 반환하고, 그 데이터를 다 읽지 않아도 다시 알려주지 않습니다. 반드시 논블로킹 I/O와 함께 사용해야 하며, 데이터를 EAGAIN이 나올 때까지 반복해서 읽어야 합니다. 안 그러면 데이터가 유실됩니다.

구분Level-TriggeredEdge-Triggered
알림 조건상태가 유지되는 동안 반복상태가 변경될 때 한 번
사용 난이도쉬움 (기본값)어려움 (EAGAIN 루프 필수)
성능불필요한 반환 가능최소한의 시스템 콜
대표 사용처일반 서버Nginx, Netty

Nginx와 Netty가 ET 모드를 사용하는 이유는 epoll_wait() 호출 횟수를 최소화하여 시스템 콜 오버헤드를 줄이기 위해서입니다.

시각화

epoll 동작 흐름

sequenceDiagram
    participant App as 애플리케이션
    participant K as 커널 (epoll)
    participant D as 디바이스 드라이버

    App->>K: epoll_create() → epoll 인스턴스 생성
    App->>K: epoll_ctl(ADD, fd=3) → 레드-블랙 트리에 등록
    App->>K: epoll_ctl(ADD, fd=7) → 레드-블랙 트리에 등록
    App->>K: epoll_wait() → 블록 (이벤트 대기)

    D-->>K: fd=7에 데이터 도착 → 콜백 호출 → ready list에 추가
    K-->>App: epoll_wait() 리턴: [{fd=7, EPOLLIN}]
    App->>App: fd=7의 데이터 처리

정리

기준selectpollepollkqueue (BSD)
OSPOSIXPOSIXLinux 전용BSD/macOS
fd 수 제한1024 (FD_SETSIZE)없음없음없음
시간 복잡도O(n)O(n)O(1)O(1)
fd 전달 방식매번 복사매번 복사커널에 유지커널에 유지
ET 모드없음없음지원지원

실무에서 만난 사례

  • Netty의 EpollEventLoopGroup: Netty는 Linux에서 기본 NioEventLoopGroup 대신 EpollEventLoopGroup을 사용하면 JNI를 통해 epoll을 직접 호출합니다. Java NIO의 Selector도 Linux에서는 내부적으로 epoll을 사용하지만, Netty의 네이티브 구현이 JNI 오버헤드를 더 최적화합니다.
  • Redis의 이벤트 루프: Redis는 싱글 스레드인데도 수만 클라이언트를 처리할 수 있는 이유가 epoll 기반 이벤트 루프입니다. ae_epoll.c에서 직접 epoll을 사용하며, 모든 클라이언트 소켓을 하나의 epoll 인스턴스로 관리합니다.
  • Java NIO Selector: Selector.open()은 Linux에서 내부적으로 epoll_create()를 호출합니다. selector.select()epoll_wait()이고, channel.register(selector, ...)epoll_ctl(ADD, ...)입니다. Virtual Thread의 I/O 처리도 이 경로를 탑니다.

관련 개념

출처

  • Linux man pages: epoll(7), epoll_create(2), epoll_ctl(2), epoll_wait(2)
  • Robert Love, “Linux System Programming” Chapter 2
  • Dan Kegel, “The C10K Problem” (1999)
  • Jonathan Corbet, “The new epoll API” (LWN.net, 2002)