Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

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

1999년 Dan Kegel 이 "[C10K problem](http://www.kegel.com/c10k.html)" 이라는 글을 쓸 때, 한 서버에서 **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)

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

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.

현재 단락 (1/286)

1999년 Dan Kegel 이 "[C10K problem](http://www.kegel.com/c10k.html)" 이라는 글을 쓸 때, 한 서버에서 **1만 개** 의 동시 ...

작성 글자: 0원문 글자: 9,371작성 단락: 0/286