Select vs Epoll: I/O 멀티플렉싱의 진화와 성능 비교
들어가며: C10K 문제와 I/O 멀티플렉싱의 중요성
현대 웹 서버는 수천, 수만 개의 동시 연결을 처리해야 합니다. 1999년 Dan Kegel이 제기한 C10K 문제는 "단일 서버에서 10,000개의 동시 연결을 어떻게 처리할 것인가?"라는 질문에서 시작되었습니다. 이 문제의 해결책 중 하나가 바로 효율적인 I/O 멀티플렉싱이며, select
에서 epoll
로의 진화는 현대 인터넷의 토대를 마련했습니다.
Select: 전통적인 I/O 멀티플렉싱의 시작
Select의 기본 동작 원리
select
는 1983년 4.2BSD Unix에서 처음 도입된 시스템 호출로, 여러 파일 디스크립터를 동시에 모니터링할 수 있게 해줍니다.
fd_set fds;
FD_ZERO(&fds);
FD_SET(777, &fds);
select(778, &fds, NULL, NULL, NULL);
Select의 동작 메커니즘
select
가 호출되면 커널은 다음과 같이 동작합니다:
- 선형 검색: 0부터
nfds-1
까지 모든 파일 디스크립터를 순차적으로 검사 - 비트마스크 검사: 각 파일 디스크립터가 설정되어 있는지 확인
- 이벤트 확인: 해당 파일 디스크립터에서 원하는 이벤트가 발생했는지 검사
Select의 심각한 한계
1. O(n) 시간 복잡도
- 가장 큰 파일 디스크립터 번호만큼 반복 검사
- 파일 디스크립터가 777번이면 0~777번까지 모두 검사
2. FD_SETSIZE 제한 (1024개)
// 위험한 코드 예시
fd_set fds;
FD_SET(2000, &fds); // 스택 오염 위험!
3. 메모리 안전성 문제
- 1024를 초과하는 파일 디스크립터 사용 시 스택 오염 가능
- 디버깅이 매우 어려운 랜덤 크래시 발생
Poll: Select의 개선된 버전
1986년에 등장한 poll
은 select
의 일부 문제를 해결했습니다:
struct pollfd pfd;
pfd.fd = 777;
pfd.events = POLLIN;
if (poll(&pfd, 1, -1)) {
if (pfd.revents & POLLIN) { /* 처리 */ }
}
Poll의 장점
- FD_SETSIZE 제한 해결
- 더 명확한 API 설계
- 다양한 이벤트 타입 지원
Poll의 한계
- 여전히 O(n) 시간 복잡도
- 커널이 매번 모든 파일 디스크립터를 검사
Epoll: 현대적 I/O 멀티플렉싱의 혁명
2001년 Davide Libenzi가 개발한 epoll
은 Linux에서 I/O 멀티플렉싱의 판도를 바꾸었습니다.
Epoll의 핵심 개념
1. 관심 목록 (Interest List)
int epfd = epoll_create1(0);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);
2. 준비 목록 (Ready List)
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
// 준비된 이벤트만 처리
}
Epoll의 혁신적 장점
1. O(1) 시간 복잡도
- 이벤트 중심 알림: 활동이 있는 파일 디스크립터만 반환
- 스케일링: 10,000개 연결에서도 일정한 성능
2. 효율적인 메모리 관리
- 커널이 관심 목록을 기억
- 매번 파일 디스크립터 목록을 전달할 필요 없음
3. 고급 트리거 모드
레벨 트리거 (기본값)
// 데이터가 있는 동안 계속 알림
ev.events = EPOLLIN; // 레벨 트리거
엣지 트리거
// 상태 변화 시에만 알림 (고성능)
ev.events = EPOLLIN | EPOLLET; // 엣지 트리거
성능 비교: 실제 벤치마크 결과
연결 수에 따른 성능 차이
연결 수 | Select (ms) | Poll (ms) | Epoll (ms) |
---|---|---|---|
100 | 2.9 | 3.0 | 0.42 |
1,000 | 35 | 35 | 0.53 |
10,000 | 990 | 930 | 0.66 |
출처: Linux Network Programming 성능 벤치마크
실제 응용에서의 차이점
작은 규모 (< 1,000 연결)
select
/poll
: 여전히 사용 가능epoll
: 오버헤드로 인해 때로는 더 느림
대규모 (> 1,000 연결)
select
/poll
: 성능 급속 저하epoll
: 일정한 성능 유지
현실적인 사용 사례와 권장사항
Select를 사용해야 하는 경우
-
극도로 정밀한 타이밍 (나노초 단위)
- 실시간 임베디드 시스템
- 하드웨어 제어 시스템
-
포터빌리티가 중요한 경우
- 모든 Unix 시스템에서 지원
- 레거시 시스템 호환성
Epoll을 사용해야 하는 경우
- 대규모 동시 연결 (1,000개 이상)
- 긴 지속 연결
- Linux 환경
- 고성능이 중요한 서버
실무 사례
웹 서버
- Apache (전통적): 프로세스/스레드 풀 + select/poll
- Nginx: 이벤트 기반 + epoll
- Node.js: 내부적으로 epoll 사용
프로그래밍 언어별 활용
- Go: runtime에서 epoll 자동 사용
- Python: asyncio에서 epoll 활용
- Rust: tokio 등 async 런타임에서 epoll 사용
네트워크 연결의 실제 처리 과정
📡 TCP 소켓 연결의 생명주기
네트워크 서버가 어떻게 연결을 받고 데이터를 처리하는지 단계별로 살펴보겠습니다:
1. 서버 소켓 생성 및 바인딩
// 1단계: 서버 소켓 생성
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 2단계: 주소 바인딩
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
// 3단계: 리스닝 모드로 전환
listen(server_fd, SOMAXCONN); // 최대 대기 큐 크기 설정
2. 클라이언트 연결 수락 과정
// 클라이언트가 connect()를 호출하면 3-way handshake 시작
// SYN -> SYN-ACK -> ACK 완료 후 accept() 반환 가능
int client_fd = accept(server_fd, NULL, NULL);
// 새로운 소켓 디스크립터 생성됨 (서버용과 별개)
🔄 실제 데이터 처리 시나리오
시나리오: 웹 서버에 3개의 클라이언트가 동시 접속
시간순서:
T1: 클라이언트A 연결 요청 → server_fd에 이벤트 발생
T2: 클라이언트B 연결 요청 → server_fd에 이벤트 발생
T3: 클라이언트A가 HTTP 요청 전송 → client_fd_A에 이벤트 발생
T4: 클라이언트C 연결 요청 → server_fd에 이벤트 발생
T5: 클라이언트B가 HTTP 요청 전송 → client_fd_B에 이벤트 발생
Select 방식의 처리 과정
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... 바인딩, 리스닝 ...
int client_fds[MAX_CLIENTS];
int num_clients = 0;
while (1) {
fd_set readfds;
int max_fd = server_fd;
// 1. 매번 관심 있는 모든 FD를 설정
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds); // 새 연결 감지용
for (int i = 0; i < num_clients; i++) {
FD_SET(client_fds[i], &readfds); // 기존 클라이언트 데이터 감지용
if (client_fds[i] > max_fd) max_fd = client_fds[i];
}
// 2. 커널이 0~max_fd까지 순차 검사 (O(max_fd))
int ready = select(max_fd + 1, &readfds, NULL, NULL, NULL);
if (ready > 0) {
// 3. 새 연결 확인
if (FD_ISSET(server_fd, &readfds)) {
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int new_client = accept(server_fd,
(struct sockaddr*)&client_addr, &addr_len);
printf("새 클라이언트 연결: %s:%d (소켓 FD: %d)\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), new_client);
client_fds[num_clients++] = new_client;
}
// 4. 기존 클라이언트 데이터 확인 (모든 클라이언트 재검사)
for (int i = 0; i < num_clients; i++) {
if (FD_ISSET(client_fds[i], &readfds)) {
char buffer[1024];
int bytes_read = read(client_fds[i], buffer, sizeof(buffer));
if (bytes_read > 0) {
printf("클라이언트 %d에서 %d바이트 수신\n",
client_fds[i], bytes_read);
// HTTP 응답 전송
char response[] = "HTTP/1.1 200 OK\r\n\r\nHello World";
write(client_fds[i], response, strlen(response));
} else if (bytes_read == 0) {
printf("클라이언트 %d 연결 종료\n", client_fds[i]);
close(client_fds[i]);
// 배열에서 제거 로직...
}
}
}
}
}
Select의 문제점 - 실제 동작:
- 클라이언트가 1000개면 매번 1000개 FD를 검사
- FD 777만 활성이어도 0~777까지 모두 검사
- 매 루프마다 FD_SET 재구성 필요
Epoll 방식의 처리 과정
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// ... 바인딩, 리스닝 ...
// 1. epoll 인스턴스 생성
int epfd = epoll_create1(0);
// 2. 서버 소켓을 epoll에 등록 (한 번만!)
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
struct epoll_event events[MAX_EVENTS];
while (1) {
// 3. 활성 이벤트만 반환 (O(1))
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == server_fd) {
// 4. 새 연결 처리
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fd = accept(server_fd,
(struct sockaddr*)&client_addr, &addr_len);
printf("새 클라이언트 연결: %s:%d (소켓 FD: %d)\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), client_fd);
// 논블로킹 모드 설정
int flags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
// 새 클라이언트를 epoll에 추가 (한 번만!)
struct epoll_event client_ev;
client_ev.events = EPOLLIN | EPOLLET; // 엣지 트리거
client_ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &client_ev);
} else {
// 5. 기존 클라이언트 데이터 처리
handle_client_data(fd);
}
}
}
void handle_client_data(int client_fd) {
char buffer[1024];
// 엣지 트리거: EAGAIN까지 모든 데이터 읽기
while (1) {
int bytes_read = read(client_fd, buffer, sizeof(buffer));
if (bytes_read > 0) {
printf("클라이언트 %d에서 %d바이트 수신: %.100s\n",
client_fd, bytes_read, buffer);
// HTTP 응답 전송
char response[] = "HTTP/1.1 200 OK\r\n\r\nHello World";
write(client_fd, response, strlen(response));
} else if (bytes_read == 0) {
// 연결 종료
printf("클라이언트 %d 연결 종료\n", client_fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, client_fd, NULL);
close(client_fd);
break;
} else if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 모든 데이터를 다 읽음
break;
} else {
perror("read error");
break;
}
}
}
🔍 레벨 트리거 vs 엣지 트리거 실제 차이
레벨 트리거 (기본값)
// 시나리오: 클라이언트가 1000바이트 전송, 서버가 500바이트만 읽음
// 1차 epoll_wait() 호출
epoll_wait() → client_fd 반환 (데이터 있음)
read(client_fd, buf, 500) → 500바이트 읽음 (500바이트 남아있음)
// 2차 epoll_wait() 호출
epoll_wait() → client_fd 다시 반환! (아직 데이터 남아있음)
read(client_fd, buf, 500) → 남은 500바이트 읽음
엣지 트리거
// 동일한 시나리오: 1000바이트 전송, 500바이트만 읽음
// 1차 epoll_wait() 호출
epoll_wait() → client_fd 반환 (데이터 도착 이벤트)
read(client_fd, buf, 500) → 500바이트 읽음 (500바이트 남아있음)
// 2차 epoll_wait() 호출
epoll_wait() → client_fd 반환 안됨! (새 데이터 도착 이벤트 없음)
// → 남은 500바이트는 영원히 처리되지 않을 수 있음!
// 올바른 엣지 트리거 처리:
while (1) {
int n = read(client_fd, buf, sizeof(buf));
if (n < 0 && errno == EAGAIN) break; // 모든 데이터 처리 완료
// 데이터 처리...
}
📊 실제 성능 차이의 원인
Select/Poll이 느린 이유
// 10,000개 연결이 있을 때 select의 내부 동작:
for (int fd = 0; fd <= max_fd; fd++) { // 최대 10,000번 반복
if (FD_ISSET(fd, &readfds)) { // 비트마스크 확인
if (socket_has_data(fd)) { // 실제 소켓 상태 확인
mark_as_ready(fd); // 준비된 FD로 표시
}
}
}
// 단 1개의 소켓에만 데이터가 있어도 10,000개를 모두 검사!
Epoll이 빠른 이유
// epoll의 내부 동작 (단순화):
// 소켓에 데이터 도착 시 (인터럽트 기반)
void socket_data_ready_interrupt(int fd) {
add_to_ready_list(fd); // 준비 목록에 추가
wake_up_epoll_waiters(); // 대기 중인 프로세스 깨우기
}
// epoll_wait 호출 시
struct epoll_event* epoll_wait(...) {
return ready_list; // 준비된 FD 목록만 반환
}
// 오직 활성 이벤트만 처리! 나머지는 무시
💾 커널 버퍼와 데이터 흐름
데이터가 네트워크에서 애플리케이션까지 도달하는 과정
[클라이언트] → [네트워크] → [NIC] → [커널 버퍼] → [유저 버퍼] → [애플리케이션]
1. 패킷 도착: 네트워크 카드가 패킷 수신
2. 인터럽트 발생: 커널에 데이터 도착 알림
3. 커널 버퍼 저장: TCP 스택이 패킷을 소켓 버퍼에 저장
4. 이벤트 알림: select/epoll에 읽기 가능 이벤트 전달
5. 애플리케이션 처리: read() 호출로 커널→유저 공간 복사
실제 read() 동작
// 블로킹 read
int n = read(fd, buffer, size);
// 커널 버퍼에 데이터가 없으면 도착할 때까지 대기
// 논블로킹 read
int n = read(fd, buffer, size);
if (n < 0 && errno == EAGAIN) {
// 데이터가 없음, 즉시 반환
}
이제 네트워크 연결의 생명주기와 실제 데이터 처리 과정이 더 명확하게 이해되실 것입니다!
정리
Select vs Epoll 요약표
특성 | Select | Epoll |
---|---|---|
시간 복잡도 | O(n) | O(1) |
최대 FD 수 | 1,024 (FD_SETSIZE) | 제한 없음 |
포터빌리티 | 모든 Unix | Linux만 |
메모리 사용 | 매번 복사 | 커널에서 관리 |
적합한 규모 | < 1,000 연결 | > 1,000 연결 |
트리거 모드 | 레벨만 | 레벨 + 엣지 |
최종 권장사항
- 새로운 프로젝트: epoll 또는 추상화 라이브러리 사용
- 크로스 플랫폼: libevent, libuv 등 사용
- 고수준 언어: 언어별 async/await 프레임워크 활용
- 레거시 시스템: 점진적 migration 고려
결론
epoll
의 등장은 단순한 성능 개선을 넘어 현대 인터넷 아키텍처의 근간을 바꾸었습니다. C10K 문제를 해결하며 현재의 대규모 웹 서비스를 가능하게 했죠.
하지만 기술 선택은 항상 상황에 따라 달라집니다. 작은 규모의 애플리케이션이나 포터빌리티가 중요한 경우에는 여전히 select
나 poll
이 적절할 수 있습니다.
무엇보다 중요한 것은 각 기술의 장단점을 이해하고, 프로젝트의 요구사항에 맞는 최적의 선택을 하는 것입니다. 현대의 고수준 프레임워크들이 이런 복잡성을 추상화해주긴 하지만, 저수준의 동작 원리를 이해하는 것은 성능 최적화와 문제 해결에 큰 도움이 됩니다.
참고 자료:
- The C10K Problem - Dan Kegel (opens in a new tab)
- Linux epoll(7) Manual (opens in a new tab)
- Unix Network Programming - W. Richard Stevens (opens in a new tab)
- C10M: The Secret to 10 Million Concurrent Connections (opens in a new tab)
epoll_wait 내부 동작 원리: 어떻게 O(1)로 활성 이벤트만 반환하는가?
🏗️ Epoll의 커널 내부 데이터 구조
epoll의 핵심은 두 개의 분리된 데이터 구조입니다:
1. Interest List (관심 목록) - Red-Black Tree
struct eventpoll {
struct rb_root_cached rbr; // Red-Black Tree 루트
struct list_head rdllist; // Ready List (준비 목록)
wait_queue_head_t wq; // 대기 큐
// ... 기타 필드들
};
struct epitem {
struct rb_node rbn; // RB-Tree에서의 노드
struct list_head rdllink; // Ready List에서의 링크
struct epoll_event event; // 사용자가 등록한 이벤트
struct file *ffd; // 모니터링할 파일
// ... 기타 필드들
};
2. Ready List (준비 목록) - Linked List
// 활성 이벤트가 있는 파일 디스크립터들만 저장
// 이 목록이 epoll_wait의 핵심!
🔄 epoll_wait의 실제 동작 과정
단계 1: 초기 설정 (epoll_ctl 시점)
// epoll_ctl(EPOLL_CTL_ADD) 호출 시 내부 동작
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) {
// 1. Red-Black Tree에 epitem 추가 (O(log n))
ep_rbtree_insert(ep, epi);
// 2. 파일의 poll 함수에 콜백 등록 ⭐⭐⭐ 핵심!
ep_item_poll(epi, &epq.pt, 1);
// 3. 콜백 함수 설정
init_poll_funcptr(&epq.pt, ep_ptable_queue_proc);
}
// 실제 콜백 함수 등록 과정
static void ep_ptable_queue_proc(struct file *file,
wait_queue_head_t *whead,
poll_table *pt) {
struct epitem *epi = container_of(pt, struct ep_pqueue, pt)->epi;
struct eppoll_entry *pwq;
// 콜백 함수를 ep_poll_callback으로 설정 ⭐⭐⭐
init_waitqueue_func_entry(&pwq->wait, ep_poll_callback);
// 파일의 대기 큐에 콜백 등록
add_wait_queue(whead, &pwq->wait);
}
단계 2: 이벤트 발생 시 자동 처리 (인터럽트 기반)
// 네트워크 패킷 도착하면 커널이 자동으로 호출하는 콜백
static int ep_poll_callback(wait_queue_entry_t *wait, unsigned mode,
int sync, void *key) {
struct epitem *epi = ep_item_from_wait(wait);
struct eventpoll *ep = epi->ep;
// ⭐⭐⭐ 핵심: Ready List에 추가!
if (!ep_is_linked(epi)) {
list_add_tail(&epi->rdllink, &ep->rdllist);
}
// epoll_wait에서 대기 중인 프로세스들 깨우기
wake_up(&ep->wq);
return 1;
}
단계 3: epoll_wait 실행
// 사용자가 epoll_wait() 호출
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout) {
return do_epoll_wait(epfd, events, maxevents, timeout);
}
static int do_epoll_wait(int epfd, struct epoll_event __user *events,
int maxevents, struct timespec64 *to) {
struct eventpoll *ep;
struct file *file = fget(epfd);
ep = file->private_data;
// ⭐⭐⭐ 핵심 함수 호출
return ep_poll(ep, events, maxevents, to);
}
// epoll_wait의 실제 구현
static int ep_poll(struct eventpoll *ep, struct epoll_event __user *events,
int maxevents, struct timespec64 *timeout) {
int res = 0;
check_events:
// ⭐⭐⭐ 핵심: Ready List만 확인! (O(1))
if (!list_empty(&ep->rdllist)) {
// Ready List에서 이벤트들 복사
res = ep_send_events(ep, events, maxevents);
if (res)
return res;
}
// Ready List가 비어있으면 대기
if (timeout == 0)
return 0; // 타임아웃 0이면 즉시 반환
// 대기 큐에 현재 프로세스 추가하고 sleep
if (!waiter) {
waiter = true;
init_waitqueue_entry(&wait, current);
add_wait_queue_exclusive(&ep->wq, &wait);
}
// 프로세스 재우기 (이벤트 발생 시 ep_poll_callback이 깨움)
set_current_state(TASK_INTERRUPTIBLE);
if (!list_empty(&ep->rdllist) || timed_out)
break;
schedule_timeout(timespec64_to_jiffies(timeout));
goto check_events;
}
// Ready List에서 사용자 공간으로 이벤트 복사
static int ep_send_events(struct eventpoll *ep,
struct epoll_event __user *events,
int maxevents) {
struct epitem *epi, *tmp;
int res = 0;
// ⭐⭐⭐ Ready List 순회 (활성 이벤트만!)
list_for_each_entry_safe(epi, tmp, &ep->rdllist, rdllink) {
if (res >= maxevents)
break;
// 사용자 공간으로 이벤트 복사
if (copy_to_user(&events[res], &epi->event,
sizeof(struct epoll_event)))
return -EFAULT;
res++;
// 레벨 트리거면 Ready List에 유지
// 엣지 트리거면 Ready List에서 제거
if (!(epi->event.events & EPOLLET))
continue; // 레벨 트리거: 유지
else
list_del_init(&epi->rdllink); // 엣지 트리거: 제거
}
return res;
}
🎯 핵심 포인트: 왜 O(1)인가?
🔄 이벤트 기반 vs 폴링 기반
Select/Poll (폴링 방식)
// Select의 내부 동작 (단순화)
for (int fd = 0; fd < max_fd; fd++) {
if (FD_ISSET(fd, readfds)) {
// 매번 소켓 상태를 직접 확인
if (socket_has_data(fd)) { // ⚠️ 시간 소모!
mark_ready(fd);
}
}
}
// 10,000개 FD가 있으면 10,000번 확인!
Epoll (이벤트 방식)
// Epoll의 내부 동작
// 1. 미리 콜백 등록 (epoll_ctl 시)
register_callback(fd, ep_poll_callback);
// 2. 이벤트 발생 시 자동 호출 (하드웨어 인터럽트)
void ep_poll_callback() {
add_to_ready_list(fd); // Ready List에 추가
wake_up_waiters(); // 대기 프로세스 깨우기
}
// 3. epoll_wait 호출 시
return copy_ready_list_to_user(); // Ready List만 반환!
🏃♂️ 실제 시나리오: 10,000개 연결에서 1개만 활성
Select의 경우
// 😱 10,000개 FD를 모두 검사
for (fd = 0; fd < 10000; fd++) {
if (FD_ISSET(fd, &readfds)) { // 비트 확인
if (poll_socket(fd)) { // 소켓 상태 확인
// 오직 1개만 활성이지만...
}
}
}
// 결과: 9,999번의 불필요한 검사!
Epoll의 경우
// 😊 활성 이벤트만 Ready List에 있음
list_for_each_entry(epi, &ep->rdllist, rdllink) {
copy_to_user(&events[count], &epi->event, sizeof(...));
count++;
}
// 결과: 딱 1번만 처리!
🧠 메모리에서의 실제 구조
[Epoll Instance]
├── RB-Tree (Interest List) - O(log n) 검색
│ ├── FD 5 ─┐
│ ├── FD 100 ─┼─ 콜백 함수 등록
│ ├── FD 500 ─┤
│ └── FD 1000 ─┘
│
└── Ready List (활성 이벤트만) - O(1) 순회
├── FD 100 (데이터 있음)
└── FD 500 (쓰기 가능)
[네트워크 이벤트 발생]
패킷 도착 → NIC 인터럽트 → TCP 스택 → 소켓 버퍼 → ep_poll_callback()
↓
Ready List에 추가
↓
epoll_wait 대기 프로세스 깨우기
💡 결론: epoll_wait가 빠른 진짜 이유
- 사전 등록된 콜백: 이벤트 발생 시 커널이 자동으로 Ready List 업데이트
- Ready List만 확인: 활성 이벤트가 있는 FD만 저장된 리스트를 순회
- 인터럽트 기반: 폴링 대신 하드웨어 인터럽트 활용
- 커널 공간 유지: 데이터 구조를 커널에서 관리해 매번 재구성 불필요
핵심: epoll은 이벤트가 발생할 때마다 커널이 자동으로 Ready List를 유지하므로, epoll_wait는 이미 준비된 목록만 반환하면 되기 때문에 O(1) 성능을 달성합니다!
이것이 select/poll과의 근본적인 차이점이자, 대용량 서버에서 epoll이 필수인 이유입니다. 🚀