Theory
운영체제
select vs epoll

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가 호출되면 커널은 다음과 같이 동작합니다:

  1. 선형 검색: 0부터 nfds-1까지 모든 파일 디스크립터를 순차적으로 검사
  2. 비트마스크 검사: 각 파일 디스크립터가 설정되어 있는지 확인
  3. 이벤트 확인: 해당 파일 디스크립터에서 원하는 이벤트가 발생했는지 검사

Select의 심각한 한계

1. O(n) 시간 복잡도
  • 가장 큰 파일 디스크립터 번호만큼 반복 검사
  • 파일 디스크립터가 777번이면 0~777번까지 모두 검사
2. FD_SETSIZE 제한 (1024개)
// 위험한 코드 예시
fd_set fds;
FD_SET(2000, &fds); // 스택 오염 위험!
3. 메모리 안전성 문제
  • 1024를 초과하는 파일 디스크립터 사용 시 스택 오염 가능
  • 디버깅이 매우 어려운 랜덤 크래시 발생

Poll: Select의 개선된 버전

1986년에 등장한 pollselect의 일부 문제를 해결했습니다:

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)
1002.93.00.42
1,00035350.53
10,0009909300.66

출처: Linux Network Programming 성능 벤치마크

실제 응용에서의 차이점

작은 규모 (< 1,000 연결)
  • select/poll: 여전히 사용 가능
  • epoll: 오버헤드로 인해 때로는 더 느림
대규모 (> 1,000 연결)
  • select/poll: 성능 급속 저하
  • epoll: 일정한 성능 유지

현실적인 사용 사례와 권장사항

Select를 사용해야 하는 경우

  1. 극도로 정밀한 타이밍 (나노초 단위)

    • 실시간 임베디드 시스템
    • 하드웨어 제어 시스템
  2. 포터빌리티가 중요한 경우

    • 모든 Unix 시스템에서 지원
    • 레거시 시스템 호환성

Epoll을 사용해야 하는 경우

  1. 대규모 동시 연결 (1,000개 이상)
  2. 긴 지속 연결
  3. Linux 환경
  4. 고성능이 중요한 서버

실무 사례

웹 서버
  • 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 요약표

특성SelectEpoll
시간 복잡도O(n)O(1)
최대 FD 수1,024 (FD_SETSIZE)제한 없음
포터빌리티모든 UnixLinux만
메모리 사용매번 복사커널에서 관리
적합한 규모< 1,000 연결> 1,000 연결
트리거 모드레벨만레벨 + 엣지

최종 권장사항

  1. 새로운 프로젝트: epoll 또는 추상화 라이브러리 사용
  2. 크로스 플랫폼: libevent, libuv 등 사용
  3. 고수준 언어: 언어별 async/await 프레임워크 활용
  4. 레거시 시스템: 점진적 migration 고려

결론

epoll의 등장은 단순한 성능 개선을 넘어 현대 인터넷 아키텍처의 근간을 바꾸었습니다. C10K 문제를 해결하며 현재의 대규모 웹 서비스를 가능하게 했죠.

하지만 기술 선택은 항상 상황에 따라 달라집니다. 작은 규모의 애플리케이션이나 포터빌리티가 중요한 경우에는 여전히 selectpoll이 적절할 수 있습니다.

무엇보다 중요한 것은 각 기술의 장단점을 이해하고, 프로젝트의 요구사항에 맞는 최적의 선택을 하는 것입니다. 현대의 고수준 프레임워크들이 이런 복잡성을 추상화해주긴 하지만, 저수준의 동작 원리를 이해하는 것은 성능 최적화와 문제 해결에 큰 도움이 됩니다.


참고 자료:

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가 빠른 진짜 이유

  1. 사전 등록된 콜백: 이벤트 발생 시 커널이 자동으로 Ready List 업데이트
  2. Ready List만 확인: 활성 이벤트가 있는 FD만 저장된 리스트를 순회
  3. 인터럽트 기반: 폴링 대신 하드웨어 인터럽트 활용
  4. 커널 공간 유지: 데이터 구조를 커널에서 관리해 매번 재구성 불필요

핵심: epoll은 이벤트가 발생할 때마다 커널이 자동으로 Ready List를 유지하므로, epoll_wait는 이미 준비된 목록만 반환하면 되기 때문에 O(1) 성능을 달성합니다!

이것이 select/poll과의 근본적인 차이점이자, 대용량 서버에서 epoll이 필수인 이유입니다. 🚀