I/O 멀티플렉싱 비교: select·poll·epoll·kqueue·IOCP와 C10K 문제

이 글의 핵심

하나의 스레드로 많은 소켓을 다루는 I/O 멀티플렉싱의 원리와 select·poll·epoll·kqueue·IOCP 각각의 동작 방식, 운영체제별 선택 기준을 코드 예제로 정리합니다.

연결마다 스레드를 하나씩 붙이는 블로킹 서버는 동시 연결이 수천 개로 늘면 스레드 메모리와 컨텍스트 스위칭 비용에 먼저 막힙니다. 이 글은 그 한계(C10K 문제)에서 출발해, 한 스레드가 여러 소켓의 준비 상태를 감시하는 select·poll부터 epoll·kqueue·IOCP까지 각 API의 동작 방식과 코드, 시간 복잡도 차이, 운영체제별 선택 기준을 정리합니다. 각 API마다 실제로 자주 부딪히는 함정(select의 1024 한계를 넘었을 때의 메모리 오염, epoll Edge-Triggered의 이벤트 유실, IOCP의 버퍼 수명)도 함께 다룹니다.


기초: 소켓과 블로킹 I/O

소켓(Socket)이란?

소켓은 네트워크 통신의 양 끝점입니다. 유닉스 계열에서는 소켓도 파일처럼 정수 번호인 파일 디스크립터(fd)로 다루며, 연결·송신·수신·종료 같은 동작은 전부 이 fd를 통해 이뤄집니다. 서버의 기본 흐름:

// 서버 소켓 생성
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 주소 바인딩
bind(server_fd, ...);
// 연결 대기
listen(server_fd, 10);
// 클라이언트 연결 수락
int client_fd = accept(server_fd, ...);
// 데이터 받기 (받은 바이트 수를 반환)
char buffer[1024];
ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
// 데이터 보내기
send(client_fd, "Hello", 5, 0);
// 연결 종료
close(client_fd);

Blocking vs Non-blocking I/O

블로킹 소켓에서는 recv()가 데이터가 올 때까지 반환하지 않습니다.

// recv()는 데이터가 올 때까지 이 스레드를 멈춰 둠
char buffer[1024];
ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
printf("Received %zd bytes\n", n);

상대가 3초 뒤에 데이터를 보내면 이 스레드는 3초 동안 다른 일을 전혀 하지 못합니다.

논블로킹 소켓에서는 읽을 데이터가 없으면 recv()가 즉시 -1을 반환하고 errno를 EAGAIN으로 설정합니다.

// 소켓을 Non-blocking으로 설정 (기존 플래그를 보존)
int flags = fcntl(client_fd, F_GETFL, 0);
fcntl(client_fd, F_SETFL, flags | O_NONBLOCK);
// recv()가 즉시 반환
ssize_t n = recv(client_fd, buffer, sizeof(buffer), 0);
if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
    // 데이터가 없음, 다른 일 할 수 있음
    printf("No data yet, doing other work...\n");
}

동시 접속 처리 방법

첫 번째 방법은 연결마다 스레드(또는 프로세스)를 하나씩 붙이는 것입니다.

// 클라이언트마다 스레드 생성
while (1) {
    int client_fd = accept(server_fd, ...);
    pthread_create(&thread, NULL, handle_client, (void*)(intptr_t)client_fd);
}
// 연결 10,000개 = 스레드 10,000개

두 번째 방법이 이 글의 주제인 I/O 멀티플렉싱입니다.

// 하나의 스레드로 여러 소켓 동시 처리
while (1) {
    // 여러 소켓 중 준비된 것만 처리 (첫 인자는 가장 큰 fd + 1)
    int ready = select(max_fd + 1, &read_fds, ...);

    for (int fd = 0; fd <= max_fd; fd++) {
        if (FD_ISSET(fd, &read_fds)) {
            // 이 소켓에 데이터 있음
            handle_client(fd);
        }
    }
}
// 스레드 하나로 여러 연결을 처리 (select는 fd 번호 1024 미만까지만, 아래 참고)

I/O 멀티플렉싱이란?

epoll 계열 준비 통지 모델과 IOCP·io_uring 완료 통지 모델의 단계 비교

그림: epoll은 “이제 읽어도 된다”를 알려 주고, IOCP·io_uring은 “읽어 놨다”를 알려 준다. 이 차이가 이벤트 루프 설계를 가른다.

정의

I/O 멀티플렉싱은 하나의 스레드로 여러 소켓의 준비 상태를 한 번에 기다렸다가, 준비된 것만 처리하는 기법입니다. 블로킹 호출 하나에 스레드 전체가 묶이지 않게 하는 것이 목적입니다.

중요한 점은 멀티플렉싱이 I/O 자체를 빠르게 만들지는 않는다는 것입니다. recv()가 데이터를 복사하는 비용은 그대로이고, 달라지는 것은 “어느 소켓을 지금 읽으면 막히지 않는가”를 알아내는 방법입니다. 그래서 준비된 소켓만 골라 호출하는 대신 한 소켓에서 오래 걸리는 작업(무거운 계산, 동기 DB 호출)을 이벤트 루프 안에서 하면, 그동안 나머지 모든 연결이 멈춥니다. 이벤트 루프 기반 서버가 느려졌을 때 가장 먼저 의심해야 하는 부분이 이것입니다.

동작 원리

┌──────────────────────────────────────┐
│         애플리케이션 (1개 스레드)      │
└──────────────┬───────────────────────┘
               │
               │ select/epoll/kqueue
               │ "어떤 소켓이 준비됐나요?"
               │
┌──────────────▼───────────────────────┐
│            커널 (OS)                  │
│  Socket 1: 데이터 있음                │
│  Socket 2: 대기 중                    │
│  Socket 3: 데이터 있음                │
│  Socket 4: 대기 중                    │
└──────────────┬───────────────────────┘
               │
               │ "Socket 1, 3이 준비됐어요"
               │
┌──────────────▼───────────────────────┐
│         애플리케이션                   │
│  Socket 1 처리 → recv()               │
│  Socket 3 처리 → recv()               │
└──────────────────────────────────────┘

Blocking I/O와 스레드당 연결 방식의 한계

단일 클라이언트 처리

// 간단한 에코 서버
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(server_fd, ...);
listen(server_fd, 10);
while (1) {
    int client_fd = accept(server_fd, ...);  // 연결 대기 (Blocking)

    char buffer[1024];
    int n = recv(client_fd, buffer, sizeof(buffer), 0);  // 데이터 대기 (Blocking)

    send(client_fd, buffer, n, 0);  // 에코
    close(client_fd);
}
// 문제: 한 번에 1명만 처리 가능!
// 클라이언트 A가 recv()에서 대기 중이면
// 클라이언트 B는 accept()조차 못함

멀티 스레드 방식

void* handle_client(void* arg) {
    int client_fd = (int)(intptr_t)arg;  // 값으로 전달받음

    char buffer[1024];
    int n = recv(client_fd, buffer, sizeof(buffer), 0);
    send(client_fd, buffer, n, 0);

    close(client_fd);
    return NULL;
}
int main() {
    while (1) {
        int client_fd = accept(server_fd, ...);

        pthread_t thread;
        pthread_create(&thread, NULL, handle_client, (void*)(intptr_t)client_fd);
        pthread_detach(thread);
    }
}

&client_fd처럼 지역 변수의 주소를 넘기면 새 스레드가 값을 읽기 전에 다음 accept()가 그 변수를 덮어써서 두 스레드가 같은 소켓을 처리하는 경쟁이 생기므로, 위처럼 값으로 넘기거나 힙에 복사해서 넘겨야 합니다.

이 방식의 한계는 연결 수에 비례하는 스레드 비용입니다. 리눅스에서 스레드 하나의 기본 스택은 8MB인데, 이는 가상 주소 공간을 예약하는 크기이고 실제 물리 메모리는 사용한 페이지만큼만 듭니다. 그래도 스레드마다 커널 자료구조와 실제로 쓴 스택 페이지가 필요하고, 수천 개의 스레드가 번갈아 깨어나며 생기는 컨텍스트 스위칭과 스케줄링 비용이 연결 수와 함께 늘어납니다. 대부분의 연결이 유휴 상태인 서버라면 이 비용은 거의 낭비입니다.


select - 기본 멀티플렉싱

select란?

select는 가장 오래된 I/O 멀티플렉싱 API로, 1983년 4.2BSD에 처음 들어갔습니다.

int select(
    int nfds,                  // 최대 fd + 1
    fd_set *readfds,           // 읽기 준비 확인할 fd 집합
    fd_set *writefds,          // 쓰기 준비 확인할 fd 집합
    fd_set *exceptfds,         // 예외 확인할 fd 집합
    struct timeval *timeout    // 타임아웃
);

select 예제

#include <sys/select.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#define MAX_CLIENTS 1024
#define PORT 8080
int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 10);

    fd_set master_fds;  // 모든 소켓 저장
    FD_ZERO(&master_fds);
    FD_SET(server_fd, &master_fds);

    int max_fd = server_fd;

    printf("Server listening on port %d\n", PORT);

    while (1) {
        fd_set read_fds = master_fds;  // 복사 (select가 수정함)

        // 준비된 소켓 대기
        int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);

        if (ready < 0) {
            perror("select");
            break;
        }

        // 모든 소켓 확인
        for (int fd = 0; fd <= max_fd; fd++) {
            if (!FD_ISSET(fd, &read_fds)) {
                continue;  // 준비 안 됨
            }

            if (fd == server_fd) {
                // 새 연결
                int client_fd = accept(server_fd, NULL, NULL);
                if (client_fd < 0) continue;
                if (client_fd >= FD_SETSIZE) {  // fd_set에 넣을 수 없는 번호
                    close(client_fd);
                    continue;
                }
                FD_SET(client_fd, &master_fds);

                if (client_fd > max_fd) {
                    max_fd = client_fd;
                }

                printf("New client: %d\n", client_fd);
            } else {
                // 기존 클라이언트 데이터
                char buffer[1024];
                int n = recv(fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    // 연결 종료
                    printf("Client %d disconnected\n", fd);
                    close(fd);
                    FD_CLR(fd, &master_fds);
                } else {
                    // 에코
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

select의 장단점

장점: POSIX 계열 어디에나 있고(Windows Winsock에도 있음) API가 단순하며 타임아웃을 걸기 쉽습니다.

단점: select()는 호출이 끝나면 fd_set을 “준비된 fd만 남은 상태”로 덮어쓰므로, 매 호출 전에 집합을 다시 만들어야 합니다. 그리고 가장 위험한 한계가 FD_SETSIZE(리눅스 glibc에서 1024)입니다. fd_set은 크기가 고정된 비트 배열이라서, fd 번호가 1024 이상인 소켓을 FD_SET에 넣으면 에러가 나는 것이 아니라 배열 밖 메모리를 덮어씁니다. 연결 수가 1024개가 안 되더라도 프로세스가 파일·로그·DB 연결로 fd를 많이 열어 두었다면 새 소켓의 번호가 1024를 넘을 수 있고, 이때 스택이 조용히 오염되어 전혀 상관없는 곳에서 크래시가 납니다. glibc에서 -D_FORTIFY_SOURCE=2로 빌드하면 *** bit out of range 0 - FD_SETSIZE on fd_set ***: terminated로 즉시 중단시켜 주므로 원인을 찾기 쉬워집니다. 리눅스에서는 FD_SETSIZE를 재정의해도 커널·glibc가 이를 따르지 않으므로, fd가 많아질 가능성이 있다면 처음부터 poll이나 epoll을 쓰는 것이 맞습니다.

Ubuntu 24.04 컨테이너(gcc)에서 fd 번호를 1102까지 올린 뒤 FD_SET을 호출해 봤습니다. 일반 빌드에서는 아무 오류 없이 지나갔고, -O2 -D_FORTIFY_SOURCE=2로 빌드하자 *** bit out of range 0 - FD_SETSIZE on fd_set ***: terminated와 함께 종료 코드 134로 중단되었습니다. 일반 빌드에서 이 호출이 실제로 어느 메모리를 건드리는지는 이 시험으로 확인하지 못했습니다.

select의 비용

연결이 1,000개라면 select를 한 번 호출할 때마다 fd_set을 커널로 복사하고, 커널이 1,000개 fd의 상태를 모두 확인하고, 반환된 뒤에는 애플리케이션이 다시 1,000개를 순회하며 FD_ISSET으로 준비된 것을 찾습니다. 실제로 준비된 연결이 몇 개뿐이어도 비용은 전체 fd 수 n에 비례합니다.


poll - select 개선

poll이란?

poll은 select의 FD_SETSIZE 제한을 없앤 API로, System V Release 3(1987년)에서 도입되었습니다.

int poll(
    struct pollfd *fds,  // fd 배열
    nfds_t nfds,         // 배열 크기
    int timeout          // 타임아웃 (ms)
);
struct pollfd {
    int fd;         // 파일 디스크립터
    short events;   // 확인할 이벤트 (POLLIN, POLLOUT)
    short revents;  // 발생한 이벤트
};

poll 예제

#include <poll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
#include <unistd.h>
#define MAX_CLIENTS 10000
#define PORT 8080
int main() {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, 10);

    struct pollfd fds[MAX_CLIENTS];
    int nfds = 1;

    // 서버 소켓 추가
    fds[0].fd = server_fd;
    fds[0].events = POLLIN;

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 준비된 소켓 대기
        int ready = poll(fds, nfds, -1);

        if (ready < 0) {
            perror("poll");
            break;
        }

        // 모든 소켓 확인
        for (int i = 0; i < nfds; i++) {
            if (fds[i].revents == 0) {
                continue;  // 이벤트 없음
            }

            if (fds[i].fd == server_fd) {
                // 새 연결
                int client_fd = accept(server_fd, NULL, NULL);
                if (client_fd < 0) continue;
                if (nfds == MAX_CLIENTS) {  // 배열이 가득 참
                    close(client_fd);
                    continue;
                }

                fds[nfds].fd = client_fd;
                fds[nfds].events = POLLIN;
                nfds++;

                printf("New client: %d (total: %d)\n", client_fd, nfds - 1);
            } else {
                // 기존 클라이언트 데이터
                char buffer[1024];
                int n = recv(fds[i].fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    // 연결 종료
                    printf("Client %d disconnected\n", fds[i].fd);
                    close(fds[i].fd);

                    // 배열에서 제거 (마지막 요소와 교체)
                    fds[i] = fds[nfds - 1];
                    nfds--;
                    i--;  // 다시 확인
                } else {
                    // 에코
                    send(fds[i].fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    return 0;
}

poll vs select

항목selectpoll
fd 번호 제한FD_SETSIZE 미만 (glibc 1024)없음 (프로세스 fd 상한까지)
호출마다 집합 재구성필요불필요
호출당 비용O(n)O(n)
이식성높음높음 (Windows는 WSAPoll)

poll은 FD_SETSIZE 제한이 없고, 입력(events)과 출력(revents)이 분리되어 있어 호출할 때마다 집합을 다시 만들 필요가 없습니다.

하지만 매 호출마다 pollfd 배열 전체를 커널로 복사하고, 커널과 애플리케이션 모두 모든 fd를 순회해야 하므로 비용은 여전히 O(n)입니다. 연결 수가 수천 개인데 그중 몇 개만 활발한 전형적인 서버 워크로드에서는 대부분의 순회가 헛수고가 됩니다. 위 예제처럼 끊긴 연결을 마지막 원소와 바꿔 지우는 방식은 배열을 빽빽하게 유지하는 흔한 기법이지만, i--를 빠뜨리면 바꿔 들어온 원소의 이벤트를 이번 루프에서 건너뛰게 됩니다.


epoll - Linux 고성능

레벨 트리거와 엣지 트리거 epoll이 50바이트만 읽은 뒤 보이는 차이, dup된 fd가 남아 있을 때 close 후 이벤트

그림: 실제 실행 결과(g++ 13.3, Ubuntu 24.04) — LT는 일부만 읽어도 다음 epoll_wait가 다시 반환하고, ET는 새 데이터가 올 때까지 0을 반환한다. dup으로 복제된 fd가 남아 있으면 원본을 close해도 epoll 등록은 유지된다. (환경: Ubuntu 24.04 x86_64 · g++ 13.3)

epoll이란?

epoll은 Linux 2.5.44(2002년)에 들어간 I/O 멀티플렉싱 API입니다. select·poll과 달리 관심 fd 목록을 커널 안에 유지하고, 준비된 fd만 따로 모아 둡니다. 커널 내부 동작을 단순화해 보면 다음과 같습니다.

epoll의 핵심: 이벤트 기반 알림 (Event Notification)

커널 자료구조:

1. epoll 인스턴스 (struct eventpoll):

   epoll_create1(0)
   ↓
   커널이 eventpoll 구조체 할당:

   struct eventpoll {
       wait_queue_head_t wq;       // 대기 큐
       struct list_head rdllist;   // Ready List (준비된 fd)
       struct rb_root rbr;         // Red-Black Tree (모든 fd)
       ...
   };

   ┌─────────────────────────────────┐
   │       epoll 인스턴스            │
   │                                 │
   │ Red-Black Tree (모든 fd):      │
   │        [fd=5]                   │
   │       /      \                  │
   │   [fd=3]    [fd=10]             │
   │                                 │
   │ Ready List (준비된 fd):         │
   │ → [fd=3] → [fd=10] → NULL       │
   └─────────────────────────────────┘

2. fd 등록 (epoll_ctl):

   epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)
   ↓
   커널 동작:

   a. Red-Black Tree에 fd 추가 (O(log n)):
      struct epitem 할당:
      - fd
      - event (EPOLLIN, EPOLLOUT)
      - callback 함수 등록

   b. 해당 fd의 wait queue에 콜백 등록:
      socket->wq.add(epoll_callback)

      socket의 recv 버퍼에 데이터 도착 시:
      → epoll_callback() 자동 호출
      → fd를 Ready List에 추가

   시간 복잡도: O(log n)

3. 이벤트 대기 (epoll_wait):

   epoll_wait(epfd, events, MAX_EVENTS, timeout)
   ↓
   커널 동작:

   a. Ready List 확인:
      비어있음? → sleep (timeout까지 또는 이벤트 발생까지)
      있음? → 즉시 반환

   b. Ready List에서 이벤트 복사:
      Ready List → [fd=3, fd=10]
      ↓
      events[0].data.fd = 3
      events[0].events = EPOLLIN
      events[1].data.fd = 10
      events[1].events = EPOLLIN
      ↓
      return 2  // 준비된 fd 개수

   시간 복잡도: O(k) (k = 준비된 fd 개수)

4. 데이터 도착 시 (커널 내부):

   네트워크 카드 → 인터럽트
   ↓
   커널: TCP 스택 처리
   ↓
   socket recv 버퍼에 데이터 복사
   ↓
   wait queue 순회:
   for callback in socket->wq:
       callback()  ← epoll_callback 호출!
   ↓
   epoll_callback:
       if fd not in Ready List:
           Ready List에 fd 추가
           epoll_wait()에 sleep 중이면 깨우기
   ↓
   epoll_wait() 반환
   ↓
   애플리케이션이 recv() 호출

Level-Triggered vs Edge-Triggered:

Level-Triggered (LT, 기본):
- 데이터가 있는 동안 계속 알림
- epoll_wait() 호출마다 Ready List에 유지
- 데이터 일부만 읽어도 다음에도 알림

예시:
1. 데이터 100바이트 도착
2. epoll_wait() → fd 반환
3. recv() → 50바이트만 읽음
4. epoll_wait() → fd 또 반환! (50바이트 남음)

Edge-Triggered (ET, EPOLLET):
- 상태 변화 시에만 알림
- 한 번 알림 후 Ready List에서 제거
- 데이터 전부 읽어야 함 (EAGAIN까지)

예시:
1. 데이터 100바이트 도착
2. epoll_wait() → fd 반환
3. recv() → 50바이트만 읽음
4. epoll_wait() → fd 반환 안 됨!
5. while (recv() != EAGAIN) 필수

장단점:
LT:
  - 사용 쉬움
  - 데이터 일부 읽기 가능
  - 성능 약간 낮음

ET:
  - 성능 높음 (알림 최소화)
  - 반드시 Non-blocking + while 루프
  - 실수하기 쉬움

select·poll과 나란히 놓으면:

select/poll:
- 매번 모든 fd를 커널에 전달
- 커널이 모든 fd 확인 (O(n))
- 애플리케이션이 모든 fd 순회 (O(n))

epoll:
- fd를 커널에 한 번만 등록
- 커널이 준비된 fd만 반환 (O(1))
- 애플리케이션이 준비된 fd만 처리 (O(k), k = 준비된 개수)

예: 연결 10,000개 중 10개만 활성일 때
select/poll: 호출마다 10,000개 확인
epoll: 준비된 10개만 반환

epoll을 처음 쓸 때 흔히 걸리는 함정이 몇 가지 있습니다. 첫째, epoll이 추적하는 대상은 fd 번호가 아니라 그 fd가 가리키는 커널의 열린 파일 객체입니다. fork()나 dup()으로 같은 소켓을 가리키는 fd가 하나 더 있으면, close(fd)만 해서는 epoll에서 빠지지 않아 이미 닫았다고 생각한 소켓의 이벤트가 계속 올라옵니다. 닫기 전에 EPOLL_CTL_DEL을 먼저 호출하는 습관이 안전합니다. 둘째, 여러 스레드가 같은 epoll 인스턴스에서 epoll_wait를 하면 한 소켓의 이벤트가 두 스레드에 동시에 전달될 수 있어, 같은 연결을 두 스레드가 동시에 읽는 경쟁이 생깁니다. EPOLLONESHOT을 쓰면 이벤트를 한 번 전달한 뒤 해당 fd를 비활성화하므로, 처리가 끝난 스레드가 EPOLL_CTL_MOD로 다시 켜 주는 방식으로 한 연결을 한 스레드만 다루게 할 수 있습니다. 셋째, 여러 프로세스가 같은 리스닝 소켓을 각자의 epoll에 등록하면 연결 하나에 모든 프로세스가 깨어나는 thundering herd 현상이 생기는데, 리눅스 4.5부터는 EPOLLEXCLUSIVE로, 3.9부터는 SO_REUSEPORT로 소켓 자체를 나눠 이를 줄일 수 있습니다.

둘째 함정은 Ubuntu 24.04 컨테이너(gcc, socketpair 한 쌍)에서 확인했습니다. 레벨 트리거로 등록한 fd 하나를 스레드 둘이 같은 epoll에서 epoll_wait로 기다리게 하고 1바이트를 쓰면 두 스레드가 모두 깨어났습니다. EPOLLONESHOT을 붙이면 한 스레드만 깨어났고, 같은 fd는 EPOLL_CTL_MOD로 다시 켜기 전까지 두 번째 epoll_wait에서 0을 반환했으며 다시 켜자 1을 반환했습니다. 셋째의 EPOLLEXCLUSIVE는 리스닝 소켓을 각자 epoll에 등록한 프로세스 4개로 확인했습니다. 연결 하나가 들어오면 옵션이 없을 때는 4개 모두, EPOLLEXCLUSIVE를 붙이면 1개만 깨어났습니다. SO_REUSEPORT는 같은 컨테이너에서 확인했습니다. 옵션 없이 같은 포트에 두 번째 bind를 하면 Address already in use가 났고, 옵션을 켜면 리스닝 소켓 두 개가 같은 포트를 열었습니다. 연결 200개를 보내자 두 소켓이 108개와 92개로 나눠 받았습니다. 분배 비율은 커널이 연결의 해시로 정하므로 실행마다 다를 수 있습니다.

epoll API

// 1. epoll 인스턴스 생성
int epoll_fd = epoll_create1(0);
// 2. fd 등록
struct epoll_event event;
event.events = EPOLLIN;  // 읽기 이벤트
event.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);
// 3. 이벤트 대기
struct epoll_event events[MAX_EVENTS];
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
// 4. 준비된 fd만 처리
for (int i = 0; i < n; i++) {
    int fd = events[i].data.fd;
    // 처리
}

epoll 예제

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#define MAX_EVENTS 1024
#define PORT 8080
// Non-blocking 설정
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 서버 소켓 생성
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);

    // epoll 생성
    int epoll_fd = epoll_create1(0);

    // 서버 소켓 등록
    struct epoll_event event;
    event.events = EPOLLIN | EPOLLET;  // Edge-Triggered
    event.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);

    struct epoll_event events[MAX_EVENTS];

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 이벤트 대기
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;

            if (fd == server_fd) {
                // 새 연결
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break;  // 더 이상 연결 없음
                        }
                        perror("accept");
                        break;
                    }

                    set_nonblocking(client_fd);

                    // 클라이언트 소켓 등록
                    event.events = EPOLLIN | EPOLLET;
                    event.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);

                    printf("New client: %d\n", client_fd);
                }
            } else {
                // 클라이언트 데이터
                char buffer[1024];

                while (1) {
                    ssize_t r = recv(fd, buffer, sizeof(buffer), 0);

                    if (r < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
                        break;  // 더 이상 데이터 없음
                    }
                    if (r <= 0) {
                        // r == 0: 상대가 연결 종료, r < 0: 오류
                        printf("Client %d disconnected\n", fd);
                        epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                        close(fd);
                        break;
                    }

                    // 에코 (부분 전송 처리는 아래 설명 참고)
                    send(fd, buffer, r, 0);
                }
            }
        }
    }

    close(server_fd);
    close(epoll_fd);
    return 0;
}

Edge-Triggered vs Level-Triggered

Level-Triggered (LT, 기본값):

데이터가 있는 동안 계속 알림
시간 →
0초: 데이터 100바이트 도착
0초: epoll_wait() 반환 → 이벤트 알림
1초: 50바이트만 읽음
1초: epoll_wait() 반환 → 이벤트 알림 (아직 50바이트 남음)
2초: 50바이트 읽음
2초: epoll_wait() 대기 (데이터 없음)

Edge-Triggered (ET):

데이터 상태가 변할 때만 알림
시간 →
0초: 데이터 100바이트 도착
0초: epoll_wait() 반환 → 이벤트 알림
1초: 50바이트만 읽음
1초: epoll_wait() 대기 (알림 없음! 주의!)
→ 반드시 EAGAIN까지 읽어야 함!

ET 모드에서는 한 번 알림을 받으면 EAGAIN이 나올 때까지 읽어야 합니다.

// 잘못된 ET 사용: 한 번만 읽으면 남은 데이터에 대한 알림이 다시 오지 않음
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
send(fd, buffer, n, 0);

// 올바른 ET 사용
while (1) {
    ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
    if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;  // 다 읽음
    if (n < 0 && errno == EINTR) continue;                          // 시그널로 중단됨
    if (n <= 0) { close_connection(fd); break; }                    // 종료 또는 오류
    send(fd, buffer, n, 0);
}

ET 모드에는 반대 방향의 함정도 있습니다. “EAGAIN까지 읽기” 규칙을 지키려고 한 연결에서 끝까지 읽다 보면, 데이터를 쉬지 않고 보내는 클라이언트 하나가 이벤트 루프를 독점해 다른 연결이 굶는(starvation) 문제가 생깁니다. 실무에서는 한 번에 읽을 양에 상한을 두고, 상한에 걸리면 “아직 남았음” 목록에 넣어 다음 루프에서 이어 읽는 방식을 씁니다. 또 위 코드는 send()가 요청한 만큼 다 보냈다고 가정하는데, 논블로킹 소켓의 send()는 송신 버퍼가 차면 일부만 보내거나 EAGAIN을 반환합니다. 남은 데이터는 버퍼에 보관하고 EPOLLOUT을 등록해 쓰기 가능해졌을 때 이어 보내야 하며, 이를 생략하면 부하가 걸렸을 때만 응답이 잘리는 재현하기 어려운 버그가 됩니다. LT와 ET 중 무엇이 “빠른가”보다 이런 처리를 빠짐없이 할 수 있는가가 선택 기준이고, 확신이 없다면 기본값인 LT로 시작하는 것이 안전합니다.


kqueue - BSD/macOS

kqueue란?

kqueue는 FreeBSD 4.1(2000년)에 들어간 이벤트 알림 API로, macOS를 비롯한 BSD 계열에서 씁니다. epoll과 비교하면 다음이 다릅니다.

  • epoll은 fd로 표현되는 것만 감시합니다. 타이머·시그널·파일 변경은 timerfd, signalfd, inotify 같은 별도 fd를 만들어 등록해야 합니다.
  • kqueue는 I/O, 파일 변경(EVFILT_VNODE), 시그널, 타이머, 프로세스 종료(EVFILT_PROC)를 필터 종류로 직접 지원합니다.
  • kqueue는 등록 변경과 이벤트 대기를 kevent() 한 번의 호출로 묶을 수 있어, 많은 fd의 설정을 바꿀 때 시스템 콜 횟수가 줄어듭니다.

kqueue도 기본은 level-triggered처럼 동작하고, EV_CLEAR를 주면 epoll의 ET와 비슷하게 상태가 바뀔 때만 알립니다. 또 EVFILT_READ 이벤트의 data 필드에는 읽을 수 있는 바이트 수가, flags에 EV_EOF가 담겨 오므로 상대가 연결을 닫았는지를 recv() 호출 전에 알 수 있습니다.

kqueue API

// 1. kqueue 생성
int kq = kqueue();
// 2. 이벤트 등록
struct kevent change;
EV_SET(&change, fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
// 3. 이벤트 대기
struct kevent events[MAX_EVENTS];
int n = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);
// 4. 이벤트 처리
for (int i = 0; i < n; i++) {
    int fd = events[i].ident;
    // 처리
}

kqueue 예제

#include <sys/event.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#define MAX_EVENTS 1024
#define PORT 8080
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 서버 소켓 생성
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);

    // kqueue 생성
    int kq = kqueue();

    // 서버 소켓 등록
    struct kevent change;
    EV_SET(&change, server_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
    kevent(kq, &change, 1, NULL, 0, NULL);

    struct kevent events[MAX_EVENTS];

    printf("Server listening on port %d\n", PORT);

    while (1) {
        // 이벤트 대기
        int n = kevent(kq, NULL, 0, events, MAX_EVENTS, NULL);

        for (int i = 0; i < n; i++) {
            int fd = events[i].ident;

            if (fd == server_fd) {
                // 새 연결
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd < 0) break;

                    set_nonblocking(client_fd);

                    // 클라이언트 소켓 등록
                    EV_SET(&change, client_fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
                    kevent(kq, &change, 1, NULL, 0, NULL);

                    printf("New client: %d\n", client_fd);
                }
            } else if (events[i].filter == EVFILT_READ) {
                // 클라이언트 데이터
                char buffer[1024];
                int n = recv(fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    // 연결 종료
                    printf("Client %d disconnected\n", fd);
                    close(fd);
                } else {
                    // 에코
                    send(fd, buffer, n, 0);
                }
            }
        }
    }

    close(server_fd);
    close(kq);
    return 0;
}

kqueue의 다른 필터

파일 변경 감지:

// 파일 변경 감지
int fd = open("config.txt", O_RDONLY);
struct kevent change;
EV_SET(&change, fd, EVFILT_VNODE, EV_ADD | EV_CLEAR,
       NOTE_WRITE | NOTE_DELETE, 0, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
// 파일이 수정되거나 삭제되면 이벤트 발생

타이머 (data의 기본 단위는 밀리초):

// 1초 타이머
struct kevent change;
EV_SET(&change, 1, EVFILT_TIMER, EV_ADD, 0, 1000, NULL);
kevent(kq, &change, 1, NULL, 0, NULL);
// 1초마다 이벤트 발생 (한 번만 원하면 EV_ONESHOT)

IOCP - Windows 고성능

IOCP란?

IOCP(I/O Completion Port)는 Windows의 비동기 I/O 모델입니다. 앞의 API들과 알림의 의미가 다릅니다.

select/epoll/kqueue (Readiness Notification):
- "이 소켓에 데이터가 있어요" 알림
- 알림을 받은 뒤 애플리케이션이 recv()를 호출해 직접 복사
IOCP (Completion Notification):
- 애플리케이션이 비동기 recv() 요청
- 커널이 데이터를 버퍼에 복사
- "recv() 완료됐어요" 알림 (데이터는 이미 버퍼에 있음)

IOCP 동작 원리

1. 애플리케이션: "데이터 받아줘" (WSARecv 호출)
   ↓
2. 커널: "알았어, 나중에 알려줄게" (즉시 반환)
   ↓
3. 애플리케이션: 다른 일 함
   ↓
4. 커널: 데이터 도착! 버퍼에 복사
   ↓
5. 커널: Completion Port에 알림
   ↓
6. 애플리케이션: GetQueuedCompletionStatus() → "완료됐어요!"

완료 기반 모델에서 가장 조심해야 할 것은 버퍼와 OVERLAPPED 구조체의 수명입니다. WSARecv에 넘긴 버퍼는 호출이 반환된 뒤에도 커널이 나중에 데이터를 써 넣을 자리이므로, 완료 통지를 받기 전에 해제하거나 스택 변수로 두면 이미 다른 용도로 쓰이는 메모리에 커널이 데이터를 쓰는 사고가 납니다. 그래서 IOCP 코드에서는 연결마다 OVERLAPPED를 첫 멤버로 가진 구조체를 힙에 할당하고, 완료 통지에서 받은 OVERLAPPED*를 원래 구조체로 되돌려 쓰는 패턴이 표준입니다. 소켓을 닫을 때도 진행 중인 I/O가 ERROR_OPERATION_ABORTED로 완료 통지될 때까지 그 구조체를 살려 둬야 합니다. readiness 모델에서는 “읽을 수 있을 때 버퍼를 준비”하면 되지만, 완료 모델에서는 “미리 버퍼를 맡겨 두는” 구조라 연결 수만큼 버퍼 메모리가 묶인다는 점도 설계 시 고려해야 합니다.

IOCP 예제

아래는 accept는 메인 스레드가 블로킹으로 처리하고, 수신·송신 완료는 워커 스레드들이 처리하는 에코 서버입니다. 한 연결에서는 “수신 완료 → 받은 만큼 송신 → 송신 완료 → 다시 수신”이 순서대로 일어나므로, 같은 OVERLAPPED를 동시에 두 I/O에 쓰지 않습니다. 하나의 OVERLAPPED를 수신과 송신에 동시에 넘기면 완료 통지가 섞여 버퍼가 깨지므로, 둘을 동시에 걸어야 한다면 연산마다 별도의 구조체가 필요합니다.

#include <winsock2.h>
#include <windows.h>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib")

#define PORT 8080
#define BUFFER_SIZE 1024

enum OpType { OP_RECV, OP_SEND };

// 연결마다 힙에 하나. OVERLAPPED를 첫 멤버로 둔다.
struct Connection {
    WSAOVERLAPPED overlapped;
    SOCKET socket;
    OpType op;
    WSABUF wsaBuf;
    char buffer[BUFFER_SIZE];
};

static void close_connection(Connection* c) {
    closesocket(c->socket);
    delete c;
}

static void post_recv(Connection* c) {
    DWORD flags = 0;
    ZeroMemory(&c->overlapped, sizeof(c->overlapped));
    c->op = OP_RECV;
    c->wsaBuf.buf = c->buffer;
    c->wsaBuf.len = BUFFER_SIZE;
    if (WSARecv(c->socket, &c->wsaBuf, 1, NULL, &flags, &c->overlapped, NULL) == SOCKET_ERROR
        && WSAGetLastError() != WSA_IO_PENDING) {
        close_connection(c);  // 요청 자체가 실패하면 완료 통지도 오지 않음
    }
}

static void post_send(Connection* c, DWORD len) {
    ZeroMemory(&c->overlapped, sizeof(c->overlapped));
    c->op = OP_SEND;
    c->wsaBuf.buf = c->buffer;
    c->wsaBuf.len = len;
    if (WSASend(c->socket, &c->wsaBuf, 1, NULL, 0, &c->overlapped, NULL) == SOCKET_ERROR
        && WSAGetLastError() != WSA_IO_PENDING) {
        close_connection(c);
    }
}

static DWORD WINAPI worker(LPVOID param) {
    HANDLE iocp = (HANDLE)param;
    for (;;) {
        DWORD bytes = 0;
        ULONG_PTR key = 0;
        LPOVERLAPPED ov = NULL;
        BOOL ok = GetQueuedCompletionStatus(iocp, &bytes, &key, &ov, INFINITE);
        if (ov == NULL) break;  // 포트 자체의 오류 (I/O 완료가 아님)

        Connection* c = CONTAINING_RECORD(ov, Connection, overlapped);
        if (!ok || (c->op == OP_RECV && bytes == 0)) {
            close_connection(c);  // I/O 실패, 또는 상대가 연결을 닫음
            continue;
        }
        if (c->op == OP_RECV) {
            post_send(c, bytes);  // 받은 만큼 되돌려 보냄
        } else {
            post_recv(c);         // 송신 완료 후 다음 수신
        }
    }
    return 0;
}

int main() {
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);

    SOCKET listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
    sockaddr_in addr = {};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);
    bind(listener, (sockaddr*)&addr, sizeof(addr));
    listen(listener, SOMAXCONN);

    // 마지막 인자 0: 동시에 실행할 워커 수를 CPU 수로 제한
    HANDLE iocp = CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0);

    SYSTEM_INFO sysInfo;
    GetSystemInfo(&sysInfo);
    for (DWORD i = 0; i < sysInfo.dwNumberOfProcessors; ++i) {
        CreateThread(NULL, 0, worker, iocp, 0, NULL);
    }

    printf("Server listening on port %d\n", PORT);
    for (;;) {
        SOCKET s = accept(listener, NULL, NULL);
        if (s == INVALID_SOCKET) continue;

        Connection* c = new Connection();
        c->socket = s;
        CreateIoCompletionPort((HANDLE)s, iocp, 0, 0);  // 소켓을 포트에 연결
        post_recv(c);                                    // 첫 수신 요청을 걸어 둠
    }
}

GetQueuedCompletionStatus가 FALSE를 반환해도 ov가 NULL이 아니면 그것은 “실패한 I/O의 완료 통지”이므로 해당 연결을 정리해야 하고, ov가 NULL일 때만 포트 자체의 문제입니다. 이 둘을 구분하지 않고 FALSE면 무조건 continue하면 끊긴 연결의 구조체가 영원히 해제되지 않습니다. CreateIoCompletionPort의 마지막 인자는 동시에 실행될 워커 스레드 수의 상한이며, 0이면 CPU 수가 됩니다. 워커가 블로킹 호출(DB 등)에 들어가면 커널이 대기 중인 다른 워커를 깨워 그 자리를 채우므로, 블로킹이 섞인 서버는 상한보다 워커 스레드를 조금 더 만들어 두기도 합니다. 실제 서버에서는 accept 대신 AcceptEx로 수락까지 완료 포트로 처리하는 경우가 많습니다.


연결 수에 따른 성능 비교

성능은 무엇에 좌우되는가

인터넷에서 “IOCP가 epoll보다 몇 퍼센트 빠르다”는 식의 숫자를 자주 보지만, 운영체제·하드웨어·커널 버전이 모두 다른 조건에서 측정한 값이라 서로 비교할 수 있는 근거가 되지 못합니다. 실제로 차이를 만드는 건 API 이름보다 아래 요인들입니다.

  • 활성 비율: 연결 1만 개 중 대부분이 유휴 상태라면 select·poll은 매번 1만 개를 훑는 비용이 지배적이고, epoll·kqueue는 활성 연결 수에만 비례합니다. 반대로 모든 연결이 계속 바쁘다면 어느 API든 준비된 fd가 n개에 가까워져 차이가 줄어듭니다.
  • 시스템 콜 횟수: readiness 모델은 “대기 → 읽기 → 쓰기”마다 시스템 콜이 필요하고, IOCP·io_uring 같은 완료 모델은 요청과 완료를 묶어 처리할 수 있습니다. 작은 메시지를 많이 주고받는 워크로드에서 이 차이가 커집니다.
  • 애플리케이션 코드: 대부분의 실서비스에서 병목은 멀티플렉싱 API가 아니라 파싱·직렬화·DB 호출입니다.

자기 워크로드에 대한 숫자가 필요하다면 같은 머신에서 wrk나 h2load 같은 부하 도구로 직접 측정하는 것이 유일하게 믿을 수 있는 방법입니다.

복잡도 비교

메커니즘등록·변경대기 호출 1회결과 처리
select호출마다 집합 전체 전달O(n)O(n)
poll호출마다 배열 전체 전달O(n)O(n)
epollepoll_ctl 1회당 O(log n)O(k)O(k)
kqueuekevent 변경 1건당 O(1)~O(log n)O(k)O(k)
IOCP소켓을 포트에 한 번 연결O(k)O(k)

n은 감시 중인 fd 수, k는 준비되었거나 완료된 이벤트 수입니다.

연결 수에 따라 달라지는 것

연결이 수십~수백 개 수준이면 select·poll이 매번 전체를 훑는 비용은 작아서 epoll과 체감 차이가 거의 없습니다. 연결이 수천 개를 넘고 그중 일부만 활발해지면 O(n) 순회가 눈에 띄기 시작합니다. 연결이 1만 개를 넘으면 select는 성능 이전에 fd 번호가 FD_SETSIZE(1024)를 넘어서 쓸 수 없고, poll은 동작은 하지만 호출마다 1만 개를 복사·순회합니다. epoll·kqueue·IOCP는 비용이 활성 이벤트 수에 비례하므로 10만 개 이상의 연결도 다룰 수 있으며, 이때의 한계는 API가 아니라 연결당 메모리와 fd 상한 같은 시스템 설정입니다.


플랫폼별 선택 기준과 라이브러리

선택 기준

운영체제별 네이티브 API는 사실상 정해져 있습니다. Linux는 epoll, macOS·FreeBSD는 kqueue, Windows는 IOCP입니다. Linux에서 완료 모델이 필요하다면 io_uring도 선택지입니다(아래 참고). 여러 플랫폼을 지원해야 한다면 libuv, libevent, Boost.Asio처럼 플랫폼별 백엔드를 감싼 라이브러리를 쓰는 편이 현실적입니다. Node.js는 libuv를 쓰고, Nginx는 자체 이벤트 모듈로 epoll·kqueue 등을 직접 사용합니다.

libuv 예제

libuv는 Node.js가 쓰는 C 라이브러리입니다. 읽기 버퍼를 할당하는 콜백을 직접 제공해야 하고, uv_write에 넘긴 버퍼는 쓰기 완료 콜백이 불릴 때까지 살아 있어야 한다는 점이 핵심입니다.

#include <stdio.h>
#include <stdlib.h>
#include <uv.h>

typedef struct {
    uv_write_t req;   // 첫 멤버라서 uv_write_t*를 write_req_t*로 되돌릴 수 있음
    uv_buf_t buf;
} write_req_t;

static void alloc_buffer(uv_handle_t* handle, size_t suggested, uv_buf_t* buf) {
    buf->base = malloc(suggested);
    buf->len = suggested;
}

static void on_close(uv_handle_t* handle) {
    free(handle);
}

static void on_write(uv_write_t* req, int status) {
    write_req_t* wr = (write_req_t*)req;
    free(wr->buf.base);   // 쓰기가 끝난 뒤에야 버퍼를 해제
    free(wr);
}

static void on_read(uv_stream_t* client, ssize_t nread, const uv_buf_t* buf) {
    if (nread > 0) {
        write_req_t* wr = malloc(sizeof(*wr));
        wr->buf = uv_buf_init(buf->base, (unsigned int)nread);
        uv_write(&wr->req, client, &wr->buf, 1, on_write);  // 버퍼 소유권을 넘김
        return;
    }
    if (nread < 0) {
        uv_close((uv_handle_t*)client, on_close);  // UV_EOF 또는 오류
    }
    free(buf->base);
}

static void on_connection(uv_stream_t* server, int status) {
    if (status < 0) return;
    uv_tcp_t* client = malloc(sizeof(uv_tcp_t));
    uv_tcp_init(uv_default_loop(), client);
    if (uv_accept(server, (uv_stream_t*)client) == 0) {
        uv_read_start((uv_stream_t*)client, alloc_buffer, on_read);
    } else {
        uv_close((uv_handle_t*)client, on_close);
    }
}
int main() {
    uv_tcp_t server;
    uv_tcp_init(uv_default_loop(), &server);

    struct sockaddr_in addr;
    uv_ip4_addr("0.0.0.0", 8080, &addr);

    uv_tcp_bind(&server, (const struct sockaddr*)&addr, 0);
    uv_listen((uv_stream_t*)&server, 128, on_connection);

    printf("Server listening on port 8080\n");
    uv_run(uv_default_loop(), UV_RUN_DEFAULT);

    return 0;
}

Boost.Asio 예제

Boost.Asio는 같은 일을 C++ 객체와 콜백으로 표현합니다. 세션 객체가 shared_from_this()로 자신을 콜백에 붙잡아 두기 때문에, 진행 중인 비동기 작업이 있는 동안에는 세션이 해제되지 않습니다.

#include <boost/asio.hpp>
#include <iostream>
using boost::asio::ip::tcp;
class Session : public std::enable_shared_from_this<Session> {
public:
    Session(tcp::socket socket) : socket_(std::move(socket)) {}

    void start() {
        do_read();
    }

private:
    void do_read() {
        auto self(shared_from_this());
        socket_.async_read_some(
            boost::asio::buffer(buffer_),
            [this, self](boost::system::error_code ec, std::size_t length) {
                if (!ec) {
                    do_write(length);
                }
            }
        );
    }

    void do_write(std::size_t length) {
        auto self(shared_from_this());
        boost::asio::async_write(
            socket_,
            boost::asio::buffer(buffer_, length),
            [this, self](boost::system::error_code ec, std::size_t) {
                if (!ec) {
                    do_read();
                }
            }
        );
    }

    tcp::socket socket_;
    char buffer_[1024];
};
class Server {
public:
    Server(boost::asio::io_context& io_context, short port)
        : acceptor_(io_context, tcp::endpoint(tcp::v4(), port)) {
        do_accept();
    }

private:
    void do_accept() {
        acceptor_.async_accept(
            [this](boost::system::error_code ec, tcp::socket socket) {
                if (!ec) {
                    std::make_shared<Session>(std::move(socket))->start();
                }
                do_accept();
            }
        );
    }

    tcp::acceptor acceptor_;
};
int main() {
    boost::asio::io_context io_context;
    Server server(io_context, 8080);

    std::cout << "Server listening on port 8080\n";
    io_context.run();  // 내부적으로 epoll/kqueue/IOCP 사용

    return 0;
}

Reactor 패턴과 Proactor 패턴

Reactor 패턴 (epoll, kqueue)

Reactor 패턴: 이벤트 루프가 "준비됨" 이벤트를 받아 핸들러를 호출하고,
핸들러가 직접 recv()/send()를 수행
┌─────────────────────────────────┐
│        Event Loop               │
│  while (1) {                    │
│    events = epoll_wait()        │
│    for event in events:         │
│      handler(event)             │
│  }                              │
└─────────────────────────────────┘

최소한의 구조만 보면 다음과 같습니다.

class Reactor {
public:
    void register_handler(int fd, std::function<void()> handler) {
        handlers_[fd] = handler;

        struct epoll_event event;
        event.events = EPOLLIN | EPOLLET;
        event.data.fd = fd;
        epoll_ctl(epoll_fd_, EPOLL_CTL_ADD, fd, &event);
    }

    void run() {
        struct epoll_event events[MAX_EVENTS];

        while (1) {
            int n = epoll_wait(epoll_fd_, events, MAX_EVENTS, -1);

            for (int i = 0; i < n; i++) {
                int fd = events[i].data.fd;
                handlers_[fd]();  // 핸들러 호출
            }
        }
    }

private:
    int epoll_fd_ = epoll_create1(0);
    std::unordered_map<int, std::function<void()>> handlers_;
};
// 사용
Reactor reactor;
reactor.register_handler(server_fd, [&]() {
    int client_fd = accept(server_fd, NULL, NULL);

    reactor.register_handler(client_fd, [client_fd]() {
        char buffer[1024];
        int n = recv(client_fd, buffer, sizeof(buffer), 0);
        if (n > 0) {
            send(client_fd, buffer, n, 0);
        }
    });
});
reactor.run();

이 Reactor 예제는 구조를 보여 주기 위한 최소 코드라 그대로 쓰면 문제가 있습니다. EPOLLET로 등록했는데 핸들러가 accept()와 recv()를 한 번씩만 호출하므로, 연결 요청이 동시에 여러 개 들어오거나 데이터가 버퍼 크기보다 많이 오면 나머지는 다시 알림이 오지 않아 처리되지 않습니다. 소켓을 논블로킹으로 설정하지도 않았고, 연결이 끊겼을 때(n <= 0) 핸들러와 epoll 등록을 지우고 소켓을 닫는 코드도 없습니다. 실제 구현에서는 앞의 ET 규칙(논블로킹 + EAGAIN까지 반복)과 연결 정리를 모두 넣어야 합니다.

Proactor 패턴 (IOCP)

Proactor 패턴: 애플리케이션이 버퍼와 함께 비동기 I/O를 요청하고,
OS가 I/O를 끝낸 뒤 완료 핸들러를 호출
┌─────────────────────────────────┐
│        Proactor                 │
│  1. async_read(handler)         │
│  2. 커널이 읽기 수행             │
│  3. 완료 시 handler 호출         │
└─────────────────────────────────┘

Boost.Asio는 인터페이스가 Proactor 형태라서, Windows에서는 IOCP를 그대로 쓰고 Linux에서는 epoll 위에서 “준비되면 대신 읽어 준 뒤 완료 핸들러를 부르는” 방식으로 Proactor를 흉내 냅니다.


C10K 문제와 Nginx의 해법

C10K 문제란?

C10K는 서버 한 대로 동시 연결 1만 개를 처리하는 문제로, 1999년 Dan Kegel이 글로 정리하면서 널리 알려졌습니다. 당시에는 select·poll의 O(n) 비용과 연결당 스레드 방식의 자원 비용 때문에 1만 연결이 어려웠고, 해법으로 등장한 것이 kqueue(FreeBSD, 2000), epoll(Linux, 2002) 같은 이벤트 통지 API였습니다. Windows의 IOCP는 그보다 앞선 Windows NT 3.5(1994)부터 있었습니다. 지금은 커널 튜닝과 충분한 메모리가 있으면 서버 한 대로 수십만 개 이상의 연결을 유지할 수 있고, 이때 병목은 API가 아니라 연결당 메모리(소켓 버퍼, 애플리케이션 상태)입니다.

C10K 해결 사례: Nginx

Nginx는 보통 CPU 코어 수만큼 워커 프로세스를 띄우고, 각 워커가 하나의 이벤트 루프(Linux에서는 epoll)로 많은 연결을 처리합니다. 워커끼리는 메모리를 거의 공유하지 않으므로 요청 처리 경로에 락이 거의 없습니다. 연결당 스레드를 쓰지 않으니 유휴 연결이 많아도 비용은 연결 상태를 담는 메모리 정도입니다.


Zero-Copy·SO_REUSEPORT·TCP_NODELAY

Zero-Copy

파일을 read()로 읽어 send()로 보내면 데이터가 커널(페이지 캐시) → 애플리케이션 버퍼 → 커널(소켓 버퍼)로 사용자 공간을 한 번 왕복합니다. sendfile·splice는 이 왕복을 없앱니다.

// Linux sendfile
#include <sys/sendfile.h>
// 파일을 소켓으로 직접 전송 (사용자 공간 복사 없음)
off_t offset = 0;
sendfile(client_fd, file_fd, &offset, file_size);

커널 안에서의 복사까지 사라지는지는 NIC가 scatter-gather를 지원하는지 등에 따라 다르지만, 사용자 공간 왕복과 그에 따른 시스템 콜이 줄어드는 것만으로도 정적 파일 서빙에서는 효과가 큽니다. TLS를 쓰면 암호화를 사용자 공간에서 해야 하므로, 커널 TLS(kTLS)를 쓰지 않는 한 sendfile의 이점이 사라집니다.

SO_REUSEPORT (Linux 3.9+)

// 여러 프로세스(스레드)가 같은 포트에 각자 소켓을 bind (bind 전에 설정)
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
// 커널이 들어오는 연결을 소켓들에 해시로 분배

Nginx의 listen ... reuseport 옵션이 이 기능을 씁니다.

TCP_NODELAY

// Nagle 알고리즘 비활성화: 작은 쓰기도 모으지 않고 바로 전송
int flag = 1;
setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &flag, sizeof(flag));

Nagle 알고리즘은 확인 응답(ACK)을 기다리는 동안 작은 쓰기를 모아 보내는데, 상대의 지연 ACK와 겹치면 요청-응답마다 수십 ms의 지연이 생길 수 있습니다. 작은 메시지를 주고받는 게임·채팅·RPC에서는 보통 켜 두고, 대신 애플리케이션이 쓰기를 직접 모아 한 번에 보내는 편이 좋습니다.


이벤트 루프·멀티 프로세스·스레드 풀 서버 구조

단일 스레드 이벤트 루프 (Node.js, Redis)

┌─────────────────────────────────┐
│      Main Thread                │
│  ┌───────────────────────────┐  │
│  │     Event Loop            │  │
│  │  - epoll_wait()           │  │
│  │  - 이벤트 처리            │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

구조가 단순하고 공유 상태에 락이 필요 없지만, CPU 코어를 하나만 쓰고 핸들러 하나가 오래 걸리면 모든 연결이 멈춥니다.

멀티 프로세스 (Nginx)

┌─────────────────────────────────┐
│      Master Process             │
│  - 설정 관리                     │
│  - Worker 관리                   │
└──────────┬──────────────────────┘
           │
    ┌──────┼──────┐
    │      │      │
┌───▼──┐ ┌─▼───┐ ┌─▼───┐
│Worker│ │Worker│ │Worker│
│epoll │ │epoll │ │epoll │
└──────┘ └──────┘ └──────┘

여러 코어를 쓰고 워커 하나가 죽어도 나머지는 영향을 받지 않지만, 워커 간 상태 공유에는 공유 메모리나 IPC가 필요합니다.

스레드 풀 (IOCP, Boost.Asio)

┌─────────────────────────────────┐
│      Main Thread                │
│  - accept() 처리                 │
└──────────┬──────────────────────┘
           │
    ┌──────┼──────┐
    │      │      │
┌───▼──┐ ┌─▼───┐ ┌─▼───┐
│Worker│ │Worker│ │Worker│
│Thread│ │Thread│ │Thread│
│ IOCP │ │ IOCP │ │ IOCP │
└──────┘ └──────┘ └──────┘

여러 코어를 쓰면서 메모리를 그대로 공유할 수 있지만, 공유 상태에는 동기화가 필요하고 경쟁 상태 버그를 찾기 어려워집니다.


epoll 기반 HTTP 서버 구현

요청을 한 번 읽고 고정 응답을 보낸 뒤 연결을 닫는, 동작 확인용 최소 HTTP 서버입니다. 요청이 여러 패킷으로 나뉘어 오는 경우, Keep-Alive, 부분 전송은 처리하지 않습니다.

#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define MAX_EVENTS 1024
#define PORT 8080
const char* HTTP_RESPONSE =
    "HTTP/1.1 200 OK\r\n"
    "Content-Type: text/html\r\n"
    "Content-Length: 13\r\n"
    "\r\n"
    "Hello, World!";
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    // 서버 소켓 생성
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    set_nonblocking(server_fd);

    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(PORT);

    bind(server_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(server_fd, SOMAXCONN);

    // epoll 생성
    int epoll_fd = epoll_create1(0);

    struct epoll_event event;
    event.events = EPOLLIN | EPOLLET;
    event.data.fd = server_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);

    struct epoll_event events[MAX_EVENTS];

    printf("HTTP Server listening on http://localhost:%d\n", PORT);

    while (1) {
        int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;

            if (fd == server_fd) {
                // 새 연결
                while (1) {
                    int client_fd = accept(server_fd, NULL, NULL);
                    if (client_fd < 0) break;

                    set_nonblocking(client_fd);

                    event.events = EPOLLIN | EPOLLET;
                    event.data.fd = client_fd;
                    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event);
                }
            } else {
                // HTTP 요청 처리
                char buffer[4096];
                int n = recv(fd, buffer, sizeof(buffer), 0);

                if (n <= 0) {
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                } else {
                    // HTTP 응답 전송
                    send(fd, HTTP_RESPONSE, strlen(HTTP_RESPONSE), 0);

                    // Keep-Alive 미지원, 즉시 종료
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);
                    close(fd);
                }
            }
        }
    }

    close(server_fd);
    close(epoll_fd);
    return 0;
}
gcc -o http_server http_server.c
./http_server
# 테스트
curl http://localhost:8080
# Hello, World!
# 부하 테스트 (동시 연결 1000이면 ulimit -n도 그 이상이어야 함)
ab -n 100000 -c 1000 http://localhost:8080/

커널 파라미터와 애플리케이션 튜닝

커널 파라미터 최적화

# /etc/sysctl.conf
# 최대 파일 디스크립터
fs.file-max = 1000000
# TCP 버퍼 크기
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# SYN backlog 크기
net.ipv4.tcp_max_syn_backlog = 8192
net.core.somaxconn = 8192
# TIME_WAIT 소켓 재사용
net.ipv4.tcp_tw_reuse = 1
# 적용
sudo sysctl -p

프로세스별 fd 상한:

# /etc/security/limits.conf
* soft nofile 1000000
* hard nofile 1000000
# 확인
ulimit -n

애플리케이션 최적화

소켓 버퍼 크기:

// 소켓 버퍼 크기 증가
int buffer_size = 1024 * 1024;  // 1MB
setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &buffer_size, sizeof(buffer_size));
setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &buffer_size, sizeof(buffer_size));

수신 타임아웃:

// 읽기 타임아웃
struct timeval timeout;
timeout.tv_sec = 30;
timeout.tv_usec = 0;
setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));

SO_RCVTIMEO는 블로킹 소켓의 recv()에만 의미가 있습니다. 이 글의 이벤트 루프처럼 논블로킹 소켓을 쓰면 recv()는 기다리지 않고 바로 EAGAIN을 반환하므로 이 옵션은 아무 효과가 없고, 유휴 연결 정리는 연결마다 마지막 활동 시각을 기록해 두고 epoll_wait의 타임아웃이나 타이머 휠로 주기적으로 검사하는 방식으로 구현해야 합니다. SO_RCVBUF를 직접 지정하는 것도 주의가 필요한데, 리눅스에서는 이 값을 설정하는 순간 커널의 자동 버퍼 튜닝이 꺼지고 값도 net.core.rmem_max로 잘립니다. 측정 없이 크게 잡는 것보다 기본 자동 튜닝을 믿는 편이 대부분의 경우 낫습니다. TCP Keep-Alive:

// TCP Keep-Alive 활성화
int keepalive = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keepalive, sizeof(keepalive));
// Keep-Alive 간격
int keepidle = 60;   // 60초 후 첫 probe
int keepintvl = 10;  // 10초 간격
int keepcnt = 3;     // 3번 실패 시 종료
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keepidle, sizeof(keepidle));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keepintvl, sizeof(keepintvl));
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keepcnt, sizeof(keepcnt));

TCP Keep-Alive는 상대 호스트가 사라진(전원이 꺼지거나 네트워크가 끊긴) 연결을 감지하기 위한 것이고, HTTP Keep-Alive(연결 재사용)와는 별개입니다. TCP_KEEPIDLE 등 세부 옵션은 리눅스 기준이며 macOS에서는 TCP_KEEPALIVE처럼 이름이 다릅니다.


Nginx·Node.js·Redis는 어떻게 쓰는가

Nginx (epoll)

worker_processes auto;          # CPU 코어 수만큼 워커

events {
    worker_connections 10000;   # 워커당 최대 연결 (events 블록 안에 둠)
    use epoll;                  # Linux에서는 생략해도 epoll이 자동 선택됨
    multi_accept on;
}

worker_connections × worker_processes가 동시 연결 수의 상한입니다. 리버스 프록시라면 요청 하나가 클라이언트 쪽과 업스트림 쪽 연결을 하나씩 쓰므로 실제로는 절반 정도로 계산합니다. 워커의 fd 상한(worker_rlimit_nofile)도 이 값보다 커야 합니다.

Node.js (libuv)

// Node.js는 내부적으로 libuv 사용
// libuv가 플랫폼별 최적 메커니즘 선택
// - Linux: epoll
// - macOS: kqueue
// - Windows: IOCP
const http = require('http');
const server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end('Hello World');
});
server.listen(8080);
// 단일 스레드로 수만 개 연결 처리

Redis (epoll/kqueue)

// Redis 이벤트 루프 (ae.c)
void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    while (!eventLoop->stop) {
        aeProcessEvents(eventLoop, AE_ALL_EVENTS);
    }
}
// 내부적으로 epoll/kqueue 사용
// 명령 실행은 단일 스레드 (6.0부터 네트워크 I/O만 선택적으로 멀티스레드)

Redis가 단일 스레드로도 빠른 이유는 멀티플렉싱 자체보다 명령 하나하나가 메모리 안에서 짧게 끝난다는 데 있습니다. 같은 구조에서 KEYS *처럼 오래 걸리는 명령 하나가 실행되면 그동안 모든 클라이언트가 멈추는데, 이것이 이벤트 루프 모델의 본질적인 한계이고 운영 중인 Redis에서 KEYS 사용을 금지하는 이유이기도 합니다.


select·poll·epoll·kqueue·IOCP 종합 비교

종합 비교

항목selectpollepollkqueueIOCP
플랫폼Unix 계열, WindowsUnix 계열 (Windows는 WSAPoll)LinuxBSD, macOSWindows
fd 제한FD_SETSIZE (glibc 1024)프로세스 fd 상한프로세스 fd 상한프로세스 fd 상한핸들 상한
대기 비용O(n)O(n)O(k)O(k)O(k)
감시 대상소켓 등 fdfdfd (timerfd·signalfd 포함)I/O, 파일, 시그널, 타이머, 프로세스파일·소켓 등 핸들의 I/O 완료
알림 방식ReadinessReadinessReadinessReadinessCompletion

연결이 수백 개 이하이고 이식성이 중요하다면 poll로도 충분합니다. 그보다 많아질 수 있다면 운영체제의 네이티브 API(epoll, kqueue, IOCP)를 쓰거나, 여러 플랫폼을 지원해야 할 때는 libuv·libevent·Boost.Asio로 감싸는 것이 일반적인 선택입니다.


Too many open files·epoll_wait 멈춤 같은 문제

”Too many open files”

# 원인: 파일 디스크립터 제한 초과 (accept()가 EMFILE 반환)
# 해결:
ulimit -n 100000
# 영구 설정 (로그인 세션용)
echo "* soft nofile 100000" >> /etc/security/limits.conf
echo "* hard nofile 100000" >> /etc/security/limits.conf
# systemd 서비스는 limits.conf를 읽지 않음 → 유닛 파일에 지정
# [Service]
# LimitNOFILE=100000

limits.conf를 고쳤는데도 서비스에서 Too many open files가 계속 난다면 대부분 systemd로 띄운 서비스이기 때문입니다. limits.conf는 PAM을 통해 로그인 세션에만 적용되므로, 서비스는 유닛 파일의 LimitNOFILE로 설정하고 cat /proc/<PID>/limits로 실제 적용값을 확인해야 합니다. 또 이벤트 루프에서 accept()가 EMFILE을 반환했을 때 그냥 넘어가면, 리스닝 소켓은 여전히 “읽을 수 있음” 상태라 LT 모드에서는 epoll_wait가 즉시 계속 반환되며 CPU를 100% 쓰는 바쁜 루프가 됩니다. 여분의 fd 하나를 미리 열어 두었다가 EMFILE 때 닫고 accept 후 바로 닫아 연결을 거절하는 기법이 libuv 등에서 쓰입니다.

데이터가 남아 있는데 epoll_wait()가 깨어나지 않음

Edge-Triggered 모드에서 소켓을 끝까지 읽지 않은 경우입니다. 남은 데이터에 대해서는 새 알림이 오지 않으므로, 앞의 “올바른 ET 사용” 코드처럼 EAGAIN이 나올 때까지 읽고, 0이나 다른 오류가 나오면 연결을 정리해야 합니다. 오류 분기를 빠뜨리고 루프를 돌면 끊긴 소켓에서 같은 오류를 끝없이 받는 바쁜 루프가 됩니다.

연결이 끊길 때마다 메모리가 늘어남

연결 종료 경로에서 epoll 등록, 소켓, 연결별 상태 중 하나를 빠뜨린 경우입니다.

if (n <= 0) {
    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL);  // epoll에서 제거
    close(fd);                                      // 소켓 닫기
    free(client_data);                              // 메모리 해제
}

TIME_WAIT와 tcp_tw_reuse

net.ipv4.tcp_tw_reuse는 이 서버가 밖으로 여는(outgoing) 연결에서만 TIME_WAIT 소켓 재사용을 허용합니다. 클라이언트 연결을 받는 쪽의 TIME_WAIT는 줄여 주지 않으므로, 업스트림으로 연결을 많이 여는 프록시에서 로컬 포트가 고갈될 때(connect: Cannot assign requested address) 효과가 있는 옵션입니다. 비슷해 보이는 tcp_tw_recycle은 NAT 뒤 클라이언트의 연결을 끊는 문제로 리눅스 4.12에서 제거되었으니 오래된 튜닝 글을 그대로 따라 하지 않도록 주의하세요.


io_uring과 eBPF·XDP

io_uring (Linux 5.1+, 2019)

io_uring은 Linux 5.1에 들어간 비동기 I/O 인터페이스입니다. epoll이 “준비됐다”를 알려 주는 readiness 모델이라면, io_uring은 IOCP처럼 “완료됐다”를 알려 주는 completion 모델에 가깝습니다. 다만 보안 문제가 여러 차례 보고되어 일부 컨테이너 런타임과 배포판은 기본 seccomp 정책에서 io_uring을 막아 두므로, 도입 전에 실행 환경에서 쓸 수 있는지 먼저 확인해야 합니다. epoll 방식은 epoll_wait(), recv(), send()마다 시스템 콜이 필요합니다. io_uring은 커널과 공유하는 두 개의 링 버퍼(제출 큐와 완료 큐)에 요청을 쌓고 결과를 꺼내므로, 여러 요청을 한 번의 io_uring_enter 호출로 제출할 수 있고 설정에 따라 시스템 콜 없이도 동작합니다. 시스템 콜 횟수가 줄어드는 만큼 고부하에서 이점이 있지만, 실제 이득은 워크로드마다 달라 측정이 필요합니다. 일반 파일 I/O도 진짜 비동기로 처리할 수 있다는 점은 epoll이 갖지 못한 장점입니다.

liburing을 쓴 최소 예제입니다.

#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(256, &ring, 0);
// 비동기 read 요청
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buffer, sizeof(buffer), 0);
io_uring_submit(&ring);
// 완료 대기
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
// 결과 처리
int bytes_read = cqe->res;
io_uring_cqe_seen(&ring, cqe);

eBPF와 XDP

멀티플렉싱과는 다른 층의 이야기지만, 패킷을 애플리케이션까지 올리지 않고 처리하는 방법도 있습니다. eBPF는 검증된 작은 프로그램을 커널 안에서 실행하는 기능이고, XDP는 그 프로그램을 네트워크 드라이버가 패킷을 받는 가장 이른 시점에 붙이는 훅입니다. 소켓 버퍼를 할당하기도 전에 패킷을 버리거나 다른 곳으로 보낼 수 있어서, DDoS 방어와 L4 로드 밸런서(예: Facebook의 Katran)에 쓰입니다.


동작 확인과 모니터링

# 상태별 소켓 수 요약
ss -s
# ESTABLISHED 연결 수
ss -Htn state established | wc -l
# 이벤트 루프가 어떤 시스템 콜을 하는지 확인
strace -f -e trace=epoll_wait,epoll_ctl,accept4,recvfrom,sendto ./server
# 성능 프로파일링
perf record -g ./server
perf report


같이 보면 좋은 글