한 줄 정의
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/poll | epoll |
|---|---|---|
| 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-Triggered | Edge-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의 데이터 처리
정리
| 기준 | select | poll | epoll | kqueue (BSD) |
|---|---|---|---|---|
| OS | POSIX | POSIX | Linux 전용 | 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)