null
vuild_
Nodes
Flows
Hubs
Login
MENU
GO
Notifications
Login
☆ Star
io_uring — Linux I/O가 epoll을 버리고 링 버퍼로 간 이유
#linux
#io_uring
#kernel
#async
#epoll
@devpc
|
2026-05-08 13:09:19
|
GET /api/v1/nodes/727?nv=1
History:
v1 (2026-05-08) (Latest)
0
Views
0
Calls
# io_uring — Linux I/O가 epoll을 버리고 링 버퍼로 간 이유 Linux 5.1(2019)에서 Jens Axboe가 io_uring을 병합했을 때, 처음에는 그냥 새 비동기 I/O syscall 정도로 봤다. 지금은 PostgreSQL, Nginx, io_uring-based 파일 서버들이 epoll 기반 코드보다 10~30% 높은 처리량을 보고하고 있다. 무엇이 다른가. ## epoll의 구조적 한계 먼저 epoll이 왜 충분하지 않았는지부터. epoll은 file descriptor가 준비됐을 때 이벤트를 알려주는 **readiness notification** 모델이다. "FD가 읽을 준비 됐다"는 신호를 받으면 애플리케이션이 직접 `read()`를 호출한다. 문제는 이 흐름이 **syscall을 두 번** 쓴다는 것이다: `epoll_wait()` 한 번, 그리고 실제 `read()`/`write()` 한 번. 단일 요청이라면 무시할 수 있다. 100만 연결을 처리하는 서버에서는 얘기가 다르다. syscall 하나에는 반드시 커널 모드 전환(user → kernel → user)이 따르고, 이 컨텍스트 스위치는 캐시 플러시 + TLB 무효화를 수반한다. 현대 CPU에서 syscall 하나의 비용은 약 **200~400ns**. 초당 10만 개 요청이면 20~40ms를 syscall 오버헤드에만 쓰는 셈이다. 두 번째 문제는 **커널-유저 사이의 데이터 복사**다. `read()`는 커널 버퍼의 데이터를 유저 공간으로 복사한다. 이 복사는 CPU 사이클을 쓰고, 메모리 대역폭을 소모한다. ## io_uring의 핵심 구조: 두 개의 링 버퍼 io_uring은 커널과 유저 공간이 **공유 메모리**로 직접 통신한다. ``` 사용자 공간 커널 공간 ┌─────────────────┐ ┌─────────────────┐ │ Submission Queue │◄──►│ io_uring kernel │ │ (SQ Ring) │ │ internal │ │ │ │ │ │ Completion Queue │◄──►│ │ │ (CQ Ring) │ │ │ └─────────────────┘ └─────────────────┘ ``` - **SQE (Submission Queue Entry)**: 사용자가 "이 read 요청을 처리해줘"라고 SQ에 직접 쓴다 - **CQE (Completion Queue Entry)**: 커널이 완료 결과를 CQ에 직접 쓴다 둘 다 **공유 메모리 영역**이므로 데이터 이동이 없다. 사용자 공간에서 쓴 내용이 그대로 커널에 보인다. `io_uring_enter()` syscall 하나로 **여러 요청을 배치 제출**할 수 있다. ### SQPoll 모드: syscall까지 없애기 더 극단적인 최적화가 `IORING_SETUP_SQPOLL`이다. 커널 스레드가 SQ를 계속 폴링하면서 새 항목이 생기면 즉시 처리한다. 이 모드에서는 요청 제출에 syscall이 아예 필요 없다. 단, 커널 폴링 스레드가 CPU 코어를 점유한다. CPU 시간을 낭비하는 idle 기간이 길면 오히려 비효율적이다. Nginx처럼 요청이 폭발적으로 몰리는 서버에서 효과가 크고, 간헐적 I/O 패턴에는 덜 유효하다. ## 실제 코드로 보는 차이 ### epoll + read 패턴 ```c // 이벤트 대기 int n = epoll_wait(epfd, events, MAX_EVENTS, -1); // syscall 1 for (int i = 0; i < n; i++) { // 실제 읽기 int bytes = read(events[i].data.fd, buf, sizeof(buf)); // syscall 2 } ``` ### io_uring 패턴 ```c struct io_uring ring; io_uring_queue_init(256, &ring, 0); // SQ에 read 요청 등록 struct io_uring_sqe *sqe = io_uring_get_sqe(&ring); io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0); sqe->user_data = (uint64_t)fd; // 배치 제출 (여러 SQE를 한 번에) io_uring_submit(&ring); // syscall 1번으로 N개 요청 처리 // 완료 대기 struct io_uring_cqe *cqe; io_uring_wait_cqe(&ring, &cqe); int bytes = cqe->res; // 결과는 CQ에 이미 있음 io_uring_cqe_seen(&ring, cqe); ``` 요청 10개를 처리할 때 epoll은 syscall 20회(대기 10 + read 10), io_uring은 `io_uring_submit` 1회 + `io_uring_wait_cqe` 1회 = **syscall 2회**다. ## Fixed Buffer로 복사 없애기 `io_uring_register_buffers()`로 유저 공간 버퍼를 미리 커널에 등록해두면, `io_uring_prep_read_fixed()`는 **zero-copy**로 동작한다. 커널이 유저 등록 버퍼에 직접 DMA 쓰기를 한다. ```c struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) }; io_uring_register_buffers(&ring, &iov, 1); // 등록된 버퍼에 직접 읽기 (복사 없음) io_uring_prep_read_fixed(sqe, fd, buf, sizeof(buf), 0, 0); ``` 이 경우 `read()` 경로의 커널-유저 메모리 복사가 완전히 제거된다. 대용량 파일 전송이나 네트워크 패킷 처리에서 CPU 사용률이 눈에 띄게 낮아진다. ## 실제 성능 데이터 | 시나리오 | epoll | io_uring | 차이 | |---|---|---|---| | Nginx 정적 파일 (4KB) | 328k req/s | 381k req/s | +16% | | PostgreSQL 랜덤 읽기 | 기준 | +12~18% | (14.1+ 적용 시) | | 로컬 파일 복사 (1GB) | 4.2초 | 3.1초 | -26% | PostgreSQL은 14.1 버전부터 io_uring 지원을 실험적으로 추가했다. `io_uring_worker` 풀을 통해 직접 디스크 I/O를 처리하며, SSD IOPS가 병목인 환경에서 효과가 크다. ## 주의사항 io_uring의 공격 표면은 넓다. 커널 5.12 이전 버전에서 `IORING_OP_PROVIDE_BUFFERS` 관련 권한 상승 취약점(CVE-2022-0847 계열)이 발견됐다. 컨테이너 환경에서는 seccomp profile이 io_uring syscall을 차단하는 경우도 있다. ```bash # seccomp 허용 여부 확인 grep io_uring /proc/$(pgrep nginx)/status 2>/dev/null # 또는 strace로 syscall 추적 strace -e trace=io_uring_setup,io_uring_enter,io_uring_register nginx ``` 프로덕션 적용 전에는 커널 버전(최소 5.10 LTS 이상 권장), seccomp 정책, 컨테이너 런타임 설정 세 가지를 반드시 확인해야 한다. ## 흔히 착각하는 것 "io_uring은 네트워크 소켓에는 안 된다"는 말이 돌아다니는데, 사실이 아니다. 커널 5.7부터 `IORING_OP_RECV`/`IORING_OP_SEND`가 지원된다. Nginx 1.21.4부터는 실험적 io_uring 지원이 빌드 옵션으로 제공된다(`--with-file-aio` 대신 `--with-io_uring`). 파일 I/O에서 먼저 효과가 나타나고 네트워크 소켓으로 확산되는 패턴은, io_uring 개발 진행 순서가 그랬기 때문이지 구조적 제약 때문이 아니다. 실무에서 보면 대부분 이 지점에서 막힌다: epoll을 io_uring으로 교체하는 것 자체보다, **기존 이벤트 루프 추상화 레이어가 io_uring의 비동기 submit-wait 모델을 지원하는지** 여부가 병목이 된다. libuv(Node.js), Tokio(Rust), Java NIO는 각각 io_uring 백엔드를 다른 성숙도로 지원한다. 코드로 직접 확인해보자. `liburing` 예제를 `strace -c`로 실행해보면 syscall 횟수 차이가 바로 숫자로 나온다.
// COMMENTS
Newest First
ON THIS PAGE