Skip to content
Published on

Linux I/O 진화사 완전 정복 — blocking, select, poll, epoll, io_uring까지 (2025)

Authors

0. 서버는 왜 느려지는가 — C10K 문제의 재방문

1999년 Dan Kegel 이 "C10K problem" 이라는 글을 쓸 때, 한 서버에서 1만 개 의 동시 연결을 처리하는 게 불가능해 보였다. 그때의 상식:

  • 연결 하나 = 스레드 하나.
  • 스레드 1만 개 = 스택만 10GB (2MB × 10000).
  • 문맥 전환 비용 폭증.
  • 커널 자료구조 고갈.

그런데 2020년대 지금 nginx 는 100만 연결을 단일 서버에서 처리한다. 무슨 일이 일어난 걸까? 이 글은 30년에 걸친 Linux I/O 인터페이스의 진화를 따라간다. 한 줄 read() 호출의 뒤에 어떤 혁명들이 있었는지.

1. 블로킹 I/O — 1970년대의 유산

1.1 가장 단순한 모델

char buf[1024];
int n = read(fd, buf, sizeof(buf));  // 데이터 올 때까지 블록
  • 커널은 이 스레드를 대기 큐 에 넣고 다른 스레드를 실행.
  • 데이터가 도착하면 스레드를 깨움.
  • 프로그래머 관점에서는 동기식이라 코드가 깔끔.

1.2 문제: 한 연결만 처리하는 서버

while (1) {
    int client = accept(server_fd, ...);
    while (1) {
        int n = read(client, ...);  // 이 연결만 처리
        write(client, ...);
    }
}

여러 클라이언트를 동시에 못 받음. 해결:

1.3 스레드 풀 — Apache prefork 모델

// 마스터
while (1) {
    int client = accept(server_fd, ...);
    spawn_worker(client);  // 각 연결마다 스레드/프로세스
}

// 워커
void* worker(int client) {
    while (1) read/write loop
}
  • 장점: 로직이 직관적.
  • 단점:
    • 스레드 생성 비용.
    • 스택 메모리 (스레드당 2MB).
    • 문맥 전환 오버헤드.
    • 1만 연결에서 시스템이 무너짐.

Apache 의 prefork MPM 이 이 모델. C10K 의 벽에 정면으로 부딪힘.

2. Select — 최초의 I/O 멀티플렉싱 (1983)

2.1 "하나의 스레드로 여러 연결 감시"

fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);
FD_SET(fd3, &readfds);

int n = select(maxfd+1, &readfds, NULL, NULL, NULL);
// n: 준비된 fd 개수, readfds: 어느 fd가 준비됐는지

1983년 4.2BSD 가 도입. 아이디어: "여러 fd 를 한 번에 감시하고, 하나라도 준비되면 알려달라."

2.2 select 의 3가지 치명적 한계

한계 1: FD_SETSIZE 상한 (1024)

typedef struct {
    long __fds_bits[1024/64];  // 1024 비트
} fd_set;

fd_set 이 1024 비트 고정. 1025번 fd 를 SET 하면 스택 오버플로우. 리눅스에서 이 상수를 늘리려면 glibc 재컴파일 해야 함.

한계 2: O(n) 스캔

select 는 호출마다:

  1. 모든 fd 비트맵을 커널로 복사.
  2. 커널이 모든 fd 를 순회하며 상태 체크.
  3. 결과 비트맵을 유저로 복사.
  4. 유저가 다시 모든 fd 를 순회하며 어느 것이 준비됐는지 찾음.

1만 연결 × 매번 1만 번 스캔 = 선형 저하. 실제 활성 연결이 1% 여도 99% 를 매번 체크.

한계 3: 호출마다 비트맵 재설정

select 가 반환하면 fd_set 이 덮어써진다 → 매 호출 전에 재설정 필요.

3. Poll — 같은 문제, 다른 포장 (1986)

3.1 struct pollfd 배열

struct pollfd fds[10000];
fds[0].fd = sock1; fds[0].events = POLLIN;
fds[1].fd = sock2; fds[1].events = POLLIN;
...

int n = poll(fds, 10000, timeout);
for (int i = 0; i < 10000; i++) {
    if (fds[i].revents & POLLIN) {
        read(fds[i].fd, ...);
    }
}

System V 에서 도입. 개선점:

  • FD 개수 제한 없음 (배열 크기만큼).
  • 비트맵 대신 구조체 배열 → 의미 있는 에러 코드 (POLLHUP, POLLERR 등).
  • 반환 후 events 가 유지됨 → 매번 재설정 불필요.

하지만 O(n) 스캔 문제는 여전. 1만 연결에서 각 poll() 호출마다 1만 번 스캔.

4. Nonblocking I/O — 블로킹 방지의 기본기

4.1 O_NONBLOCK 플래그

fcntl(fd, F_SETFL, O_NONBLOCK);
int n = read(fd, buf, size);
if (n == -1 && errno == EAGAIN) {
    // 지금은 데이터 없음, 나중에 다시 시도
}

블로킹 없이 즉시 반환. 없으면 EAGAIN (또는 EWOULDBLOCK) 에러.

4.2 select/poll + nonblocking 조합

실전 패턴:

while (1) {
    int n = select(..., &readfds, ...);
    for (각 fd) {
        if (FD_ISSET(fd, &readfds)) {
            while (read(fd, buf, size) > 0) {
                // 데이터 처리
            }
            // EAGAIN 나오면 빠져나옴
        }
    }
}

이게 reactor 패턴의 원형. 한 스레드가 여러 fd 를 감시 + nonblocking read 로 실제 I/O.

5. Epoll — Linux 의 혁명 (2002)

5.1 등장 배경

2000년대 초 C10K 문제가 실제로 중요해짐 (ICQ, IRC, 게임 서버). Linux 2.5.44 에서 Davide Libenzi 가 epoll 도입.

5.2 API 3개로 구성

int epfd = epoll_create1(0);                    // 1. 인스턴스 생성

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev);      // 2. fd 등록 (한 번)

struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, timeout);  // 3. 이벤트 대기
for (int i = 0; i < n; i++) {
    read(events[i].data.fd, ...);
}

핵심 혁신:

5.3 왜 O(1) 인가

  • fd 집합을 커널이 영구 보관 (epoll_ctl 로 한 번 등록).
  • 이벤트 발생 시에만 커널이 내부 red-black tree + ready list 에 등록.
  • epoll_wait 는 ready list 만 반환 → "준비된 것의 개수" 만큼만 처리.

1만 연결 중 100개가 활성이면 100번만 순회. select/poll 이 매번 1만 번 순회하던 것과 비교.

5.4 Level-triggered vs Edge-triggered

Level-triggered (LT, 기본):

  • 조건이 참인 한 계속 알림. 예: 버퍼에 데이터가 있는 한.
  • "한 번에 다 안 읽어도 다음 epoll_wait 에서 또 알려줌".
  • select/poll 과 호환되는 직관적 모델.

Edge-triggered (ET):

  • 상태 변화가 있을 때만 알림.
  • "읽을 수 있게 되는 순간" 딱 한 번.
  • EAGAIN 나올 때까지 모두 읽어야 함 — 안 그러면 다음 데이터 알림 누락.
  • 높은 성능, 하지만 코딩 어려움.
// ET 모드에서는 이렇게 써야 함
while (1) {
    int n = read(fd, buf, size);
    if (n == -1 && errno == EAGAIN) break;  // 다 읽었음
    if (n <= 0) break;                       // 에러/종료
    process(buf, n);
}

nginx 는 ET 로 구현되어 극한 성능을 뽑는다.

5.5 EPOLLEXCLUSIVE — Thundering Herd 해결

여러 스레드가 같은 listen socket 을 epoll 에 등록하면, accept 가능해질 때 모두 깨어남 → 하나만 accept 성공, 나머지는 EAGAIN → 자원 낭비.

Linux 4.5+ 의 EPOLLEXCLUSIVE: "이 fd 는 한 명한테만 알림". nginx, HAProxy 가 이걸 활용.

5.6 epoll 의 한계

  • 여전히 시스템 콜이 많음: 매 이벤트마다 read, write 호출. 고성능에서는 시스템 콜 오버헤드가 주요 비용.
  • LT 모드의 추가 웨이크업: 처리해도 다시 알림 → 쓸모 없는 context 전환.
  • 파일 I/O 는 항상 준비됨: epoll 이 regular file 에는 쓸모 없음. 디스크 I/O 는 결국 블로킹.

이 한계들이 io_uring 을 낳았다.

6. kqueue — BSD 의 대안

같은 시기 FreeBSD (2000) 가 kqueue 를 도입. epoll 과 비슷하지만:

  • 파일 시스템 이벤트, 시그널, 타이머 등 더 많은 이벤트 소스.
  • 통합된 API 로 모든 것.

macOS 도 kqueue. libevent, libuv 같은 크로스 플랫폼 라이브러리는 "epoll on Linux, kqueue on BSD/macOS, IOCP on Windows" 를 추상화.

7. 비동기 디스크 I/O — 네트워크와는 다른 전쟁

7.1 epoll 이 파일엔 안 먹히는 이유

Regular file 에 epoll_ctl 하면 항상 "준비됨" 을 반환. 이유: 파일은 페이지 캐시에 이미 있거나 없거나이지 "준비 중" 이 없음. 없으면 블로킹 I/O 로 디스크에서 읽어옴.

7.2 POSIX AIO — 실패한 첫 시도

struct aiocb cb = { .aio_fildes = fd, .aio_buf = buf, .aio_nbytes = size };
aio_read(&cb);
while (aio_error(&cb) == EINPROGRESS) {
    // 뭔가 다른 일
}
int n = aio_return(&cb);

Linux 의 glibc POSIX AIO 는 사실 사용자 공간에서 스레드 풀 로 흉내낸 것. 진짜 커널 비동기가 아님.

7.3 Linux AIO (libaio) — 제한적 성공

io_submit, io_getevents 시스템 콜. 진짜 커널 AIO 지만:

  • O_DIRECT 만 지원 (OS 페이지 캐시 우회).
  • 많은 상황에서 여전히 블로킹.
  • 쓰기 흔하지 않음.

MySQL InnoDB, 일부 DB 에서만 사용. 일반 서버에는 도입 안 됨.

8. io_uring — 2019년 리눅스 I/O 혁명

8.1 Jens Axboe 의 비전

2019년 리눅스 5.1, 커널 블록 I/O 담당자 Jens Axboe 가 io_uring 도입. 핵심:

"시스템 콜을 아예 호출하지 않고도 I/O 를 제출하고 결과를 받을 수 있다."

8.2 두 개의 링 버퍼

  • Submission Queue (SQ): 유저가 I/O 요청을 넣음.
  • Completion Queue (CQ): 커널이 완료 결과를 넣음.

둘 다 mmap 으로 유저-커널 공유 메모리:

유저:
  SQ 에 요청 작성
  io_uring_enter() 호출 (선택적)

커널:
  SQ 에서 요청 읽음
  I/O 수행
  CQ 에 결과 씀

유저:
  CQ 에서 결과 읽음

8.3 왜 혁명적인가

1. 시스템 콜 감소

  • 배치 제출: 한 번의 io_uring_enter() 로 여러 요청.
  • SQ_POLL 모드: 커널 스레드가 SQ 를 poll → 시스템 콜 0번.
  • 높은 QPS 에서 10배 이상 성능 향상.

2. 통합 인터페이스

네트워크, 파일, 타이머, 시그널 — 모두 같은 API. epoll/AIO 섞어 쓸 필요 없음.

3. 링크된 요청 (linked SQE)

"이 요청이 성공하면 다음 요청을 자동 실행". 예: openatreadclose 를 한 번에 제출.

4. Buffer Selection

수천 연결에 각각 버퍼를 미리 할당하지 않고, 풀에서 필요할 때만 선택.

8.4 io_uring 의 성장 속도

  • 5.1 (2019): 기본 도입.
  • 5.5: Accept 지원.
  • 5.7: 시그널, 파일 열기/닫기.
  • 5.19: 네트워크에 대한 zero-copy 전송.
  • 6.x: 더 많은 op code, multishot accept.

2025년 현재 거의 모든 Linux 시스템 콜이 io_uring 으로 가능.

8.5 io_uring 의 어두운 면

보안 문제가 많이 발견됨. Google ChromeOS, Android 가 io_uring 을 비활성화 했다 (2023). 이유:

  • 공격 표면이 넓음 (수많은 op code).
  • 커널 취약점의 새로운 경로.
  • 기존 seccomp 필터로 제어 어려움.

해결 방향: io_uring 용 seccomp 확장, ACL, 그리고 "제어된 op code 서브셋만 허용" 정책.

9. 실전 아키텍처 — Event Loop 패턴

9.1 Reactor 패턴 (Node.js, nginx, Redis)

┌─────────────────────────┐
Event Loop (1개 스레드)while (true) {│    events = epoll_wait()for (e in events)handle(e)}└─────────────────────────┘
  • 한 스레드가 모든 I/O 감시.
  • 이벤트 올 때 짧은 핸들러 실행 → 즉시 다음 이벤트.
  • 핸들러는 블로킹 금지 (그러면 전체 루프 멈춤).
  • CPU 집약적 작업은 worker thread 로 오프로드.

9.2 Node.js 의 구조

┌──────────────────────────┐
V8 JavaScript Runtime├──────────────────────────┤
libuv (크로스플랫폼)│   ├── epoll/kqueue/IOCP│   └── Thread Pool├──────────────────────────┤
OS Kernel└──────────────────────────┘
  • I/O 는 libuv 가 epoll 로 비동기 처리 → JavaScript 콜백 실행.
  • DNS resolution, 파일 읽기, 암호화 같은 "비동기 에뮬레이션" 은 thread pool (기본 4개).
  • CPU 집약적 작업은 Worker threads (v10+) 로.

9.3 nginx 의 master-worker 모델

Master (루트 권한, 설정 관리)
  ├── Worker 0 (epoll 루프, 수만 연결)
  ├── Worker 1 (epoll 루프, 수만 연결)
  └── Worker N (보통 CPU 코어 수)
  • 각 worker 는 독립 event loop.
  • listen socket 을 공유 (SO_REUSEPORT) → 커널이 연결 분배.
  • Master-worker 분리로 무중단 설정 리로드.

9.4 Redis — 싱글 스레드의 미학

Redis 는 하나의 메인 스레드 가 모든 명령 처리:

  • epoll 로 수천 연결 감시.
  • 메모리만 쓰므로 명령 처리가 µs 단위.
  • 락 없음 → 경쟁 조건 없음 → 버그 적음.

6.0+ 부터 I/O threading: 네트워크 읽기/쓰기만 여러 스레드, 명령 실행은 여전히 싱글 스레드. CPU 병목이 I/O 인 환경에서 개선.

9.5 io_uring 기반 현대 아키텍처

  • ScyllaDB: 처음부터 io_uring 중심 설계. Cassandra 호환이지만 10배 빠름.
  • QEMU/KVM: 가상 디스크 I/O 를 io_uring 으로 → 40% 성능 향상.
  • Ceph: 백엔드 스토리지에 io_uring 도입.
  • nginx experimental: io_uring 플러그인.

10. Reactor vs Proactor — 두 가지 비동기 철학

10.1 Reactor (알림 기반)

  • "준비됐다고 알려주면 내가 읽을게".
  • epoll, kqueue 스타일.
  • 유저가 버퍼 관리.

10.2 Proactor (완료 기반)

  • "여기에 읽어서 다 되면 알려줘".
  • Windows IOCP, io_uring 스타일.
  • 커널이 직접 버퍼에 씀.

10.3 왜 Proactor 가 더 빠른가

Reactor: "읽을 수 있음" → read() 시스템 콜 → 데이터 복사 → 처리. Proactor: 커널이 백그라운드에서 복사 완료 → 유저는 바로 처리.

시스템 콜 한 번 덜 들어감. 대량 트래픽에서 이 차이가 결정적.

Windows 는 1994년 NT 3.5 부터 IOCP 로 proactor. 오랫동안 Linux 가 epoll 로 reactor 였다가 2019년 io_uring 으로 proactor 진영 합류.

11. 네트워크 스택의 Zero-Copy

11.1 일반 sendfile vs 일반 read+write

파일 → 소켓 전송 시:

일반:
  read(file)  : 디스크 → 커널 → 유저 버퍼 (복사 1)
  write(sock) : 유저 버퍼 → 커널 → NIC (복사 2)

sendfile:
  디스크 → 커널 → NIC (복사 0, DMA 로 직접)

nginx, Apache 가 정적 파일 전송에 sendfile 을 쓰는 이유. 2배 빠름 + CPU 10배 절감.

11.2 splice, tee, vmsplice

splice: 파이프를 통해 fd 간 데이터 이동, 유저 공간 복사 없음.

splice(fd_in, NULL, pipefd[1], NULL, size, SPLICE_F_MORE);
splice(pipefd[0], NULL, fd_out, NULL, size, SPLICE_F_MORE);

11.3 MSG_ZEROCOPY

Linux 4.14+ send(fd, buf, size, MSG_ZEROCOPY):

  • 유저 버퍼를 직접 NIC 로 DMA.
  • 전송 완료까지 버퍼를 건드리면 안 됨 → errqueue 로 완료 알림.
  • 큰 전송에서 30% 성능 향상.

11.4 io_uring + zero-copy

io_uring_prep_send_zc(sqe, fd, buf, size, 0, 0);

io_uring 안에서 MSG_ZEROCOPY 와 동등 효과. 큰 응답을 내보내는 CDN, 비디오 서버에서 결정적.

12. 관찰과 튜닝 — 실무

12.1 연결 수 한계

ulimit -n              # fd 상한 확인 (보통 1024 또는 1M)
ulimit -n 1000000      # 임시로 올림
/etc/security/limits.conf  # 영구 설정

추가로:

sysctl fs.file-max                   # 시스템 전체 fd 한도
sysctl net.core.somaxconn             # listen 백로그 상한
sysctl net.ipv4.ip_local_port_range   # 사용 가능 ephemeral 포트 범위
sysctl net.ipv4.tcp_tw_reuse          # TIME_WAIT 재사용

12.2 TCP 버퍼 크기

sysctl net.core.rmem_max        # 수신 버퍼 최대
sysctl net.core.wmem_max        # 송신 버퍼 최대
sysctl net.ipv4.tcp_rmem        # 자동 조정 범위

BDP (Bandwidth-Delay Product) 가 기본 버퍼 크기보다 크면 조정 필요. 10Gbps × 100ms = 125MB → 기본 208KB 로는 부족.

12.3 관찰 도구

  • ss -antp: 현재 연결 상태 (netstat 대체).
  • iftop, nload: 실시간 네트워크.
  • tcpdump / wireshark: 패킷 덤프.
  • bpftrace: 커널 내부 이벤트 트레이싱.
  • perf trace: 시스템 콜 프로파일링.

12.4 io_uring 도입 전략

  • 점진적 마이그레이션: 성능 핫스팟만 io_uring 으로, 나머지는 epoll.
  • 커널 5.15+ 권장: 초기 버전은 버그 많음.
  • 보안 고려: seccomp 로 허용 op code 제한.
  • 라이브러리: liburing (Jens Axboe 공식), tokio-uring (Rust), io_uring-rs.

13. 마치며 — 30년 I/O 진화의 교훈

1970년대 read() 한 줄에서 시작된 여정:

  • 1983 select: 한 스레드가 여러 fd.
  • 1986 poll: 제한 해제, 하지만 여전히 O(n).
  • 1994 Windows IOCP: 최초 proactor.
  • 2000 FreeBSD kqueue: 통합 이벤트.
  • 2002 Linux epoll: O(1) 이벤트.
  • 2019 Linux io_uring: 시스템 콜 없는 I/O.

각 세대는 이전 세대의 한계에서 태어났다. 다음 혁명은 무엇일까? 후보들:

  • DPDK / XDP: 커널 우회, 10Gbps+ 라인 레이트 처리.
  • Userspace TCP: Kernel bypass 로 레이턴시 µs 단위.
  • RDMA: CPU 우회 메모리 접근.
  • Smart NIC: I/O 처리를 NIC 에 오프로드.

"정해진 답" 이 없는 변화의 연속이다. 하지만 핵심 원리 — "시스템 콜은 비싸다", "복사는 비싸다", "감시 대상이 커질수록 O(1) 이어야 한다" — 는 50년 전이나 지금이나 같다.

다음 글에서는 네트워크 스택 자체 — TCP 상태 머신, 혼잡 제어 (Cubic vs BBR), nagle, delayed ack, Fast Open, 그리고 QUIC 가 왜 UDP 위에 새 스택을 만들었는지 — 를 파볼 예정이다.

참고 자료

  • Dan Kegel — "The C10K problem" (1999-2014).
  • Davide Libenzi — "Improving (network) I/O performance..." epoll 제안 (2002).
  • Jens Axboe — "Efficient IO with io_uring" (2019).
  • Jens Axboe — "Ringing in a new asynchronous I/O API" (LWN.net, 2019).
  • Linux kernel source: fs/eventpoll.c, fs/io_uring.c, io_uring/.
  • liburing: https://github.com/axboe/liburing
  • "The Secret To 10 Million Concurrent Connections" — Robert Graham.
  • Felix Uherek — "The method to epoll's madness".
  • ScyllaDB Engineering Blog — Seastar / io_uring 시리즈.
  • "What Every Systems Programmer Should Know About Concurrency" (PDF) — Matt Kline.