io_uring은 epoll과 무엇이 다를까
epoll의 readiness notification과 io_uring의 completion notification을 비교하고, SQ/CQ ring, syscall batching, 파일 I/O와 네트워크 I/O 흐름을 정리합니다.
Linux 서버 I/O를 공부하다 보면 epoll은 비교적 빨리 감이 온다.
많은 fd 중 지금 읽거나 쓸 수 있는 fd를 알려준다.
그런데 io_uring은 조금 다르다. 단순히 “더 빠른 epoll”이라고 이해하면 중요한 부분을 놓치기 쉽다.
가장 큰 차이는 알림의 성격이다.
epoll = 준비 상태 알림, readiness notification
io_uring = 완료 결과 알림, completion notification
epoll은 커널이 “이 fd는 지금 read/write 가능해 보인다”고 알려주는 모델이다. 그 뒤 실제 read()와 write()는 애플리케이션이 다시 호출해야 한다.
io_uring은 애플리케이션이 “이 작업을 해줘”라고 커널에 제출하고, 커널이 작업을 끝낸 뒤 “완료됐고 결과는 이렇다”고 알려주는 모델에 가깝다.
- 1fd 등록
epoll_ctl()로 관심 fd를 등록한다. - 2준비 대기
epoll_wait()가 readable/writable fd를 돌려준다. - 3앱이 I/O 호출애플리케이션이
read()/write()를 직접 호출한다.
- 1작업 작성SQE에
read,recv,send,accept같은 작업을 적는다. - 2커널에 제출
io_uring_enter()로 제출하거나 polling 모드가 가져가게 한다. - 3완료 수거CQE의
res로 성공 바이트 수나 에러를 확인한다.
epoll은 I/O를 대신 해주지 않는다
epoll은 fd를 감시하는 메커니즘이다. 커널 안의 epoll 인스턴스에는 관심 목록, interest list가 있고, 그중 이벤트가 발생한 fd는 ready list로 올라온다.
흐름은 대략 이렇다.
socket fd 생성
-> epoll_ctl()로 interest list에 등록
-> epoll_wait()로 ready event 대기
-> readable 이벤트 수신
-> 애플리케이션이 read(fd, buf) 호출
-> 커널 버퍼에서 유저 버퍼로 데이터 복사
중요한 점은 epoll_wait()가 데이터를 읽어주는 것이 아니라는 점이다. epoll_wait()는 “이 fd에 읽을 수 있는 이벤트가 있다”는 정보를 반환한다. 실제 I/O는 그 다음 read()나 write() syscall에서 일어난다.
그래서 epoll 기반 이벤트 루프는 보통 이런 모습이 된다.
while (running) {
events = epoll_wait(epfd, events, maxevents, timeout);
for (event in events) {
if (event.readable) {
n = read(event.fd, buf, sizeof(buf));
handle(buf, n);
}
}
}
이 모델은 네트워크 소켓과 잘 맞는다. Tomcat NIO, Netty epoll transport, Redis, Nginx 같은 구조가 이런 readiness 모델 위에서 오래 검증되어 왔다.
다만 readiness는 “가능해 보인다”에 가깝다. edge-triggered 모드에서는 특히 읽을 수 있는 만큼 drain해야 하고, 실제 read() 결과가 짧게 오거나 EAGAIN으로 끝날 수 있다.
io_uring은 작업을 제출하고 완료를 받는다
io_uring은 Linux 전용 비동기 I/O 인터페이스다. 이름처럼 userspace와 kernel space가 공유하는 ring buffer를 중심으로 동작한다.
두 개의 큐가 핵심이다.
Submission Queue, SQ: 애플리케이션 -> 커널
Completion Queue, CQ: 커널 -> 애플리케이션
애플리케이션은 SQE, submission queue entry에 작업을 쓴다.
opcode = READ / WRITE / ACCEPT / RECV / SEND / TIMEOUT ...
fd = 대상 파일 또는 소켓
addr = 유저 버퍼 주소
len = 길이
user_data = 완료를 다시 매칭하기 위한 식별자
커널은 작업을 처리하고 CQE, completion queue entry에 결과를 남긴다.
user_data = 원래 요청 식별자
res = 결과값, 예: 읽은 바이트 수 또는 음수 에러 코드
flags = 부가 정보
즉 질문이 바뀐다.
epoll:
"이 fd 지금 읽을 수 있어?"
io_uring:
"이 fd에서 이 버퍼로 읽어줘. 끝나면 알려줘."
recv, read, send 작업을 준비한다.
user_data를 공유 큐에 기록한다.
res와 요청 식별자 user_data를 기록한다.
syscall을 줄인다는 말의 의미
io_uring이 빠르다고 말할 때 자주 나오는 이유가 syscall 감소다. 이것도 조금 정확히 봐야 한다.
epoll 기반 루프는 대략 이렇게 syscall이 나뉜다.
epoll_wait()
read()
write()
epoll_wait()
read()
write()
반면 io_uring은 여러 작업을 SQ에 적어 두고, io_uring_enter() 한 번으로 여러 요청을 제출하거나 완료를 기다릴 수 있다.
SQ에 여러 recv/send/read/write SQE 적재
-> io_uring_enter()
-> CQ에서 여러 완료 확인
모든 syscall이 사라지는 것은 아니다. 커널에 새 제출이 있다는 사실을 알려야 하고, 완료를 기다려야 할 수도 있다. 다만 요청과 완료의 메타데이터가 공유 ring을 통해 오가기 때문에, 여러 I/O를 batch로 묶을 여지가 생긴다. SQPOLL, fixed buffer, registered file 같은 고급 옵션을 쓰면 특정 경로의 비용을 더 줄일 수도 있다.
네트워크 I/O에서는 무엇이 달라질까
epoll 기반 네트워크 서버의 read 흐름은 보통 이렇게 보인다.
NIC
-> kernel TCP receive buffer
-> epoll_wait(): socket readable
-> application read(fd, user_buffer)
-> kernel buffer에서 user buffer로 복사
-> HTTP 파싱 / handler 호출
io_uring 기반이라면 읽기 요청을 미리 제출해 둘 수 있다.
recv SQE 제출
-> 커널이 socket recv 수행
-> CQE 도착
-> res = 읽은 바이트 수
-> handler 호출
-> 다음 recv SQE 제출
여기서도 일반적인 socket receive는 여전히 kernel TCP buffer에서 user buffer로 복사가 일어난다. io_uring이라는 이름만으로 자동 zero-copy가 되는 것은 아니다.
최신 커널에는 io_uring 기반 zero-copy receive 같은 기능도 있다. Linux kernel 문서 기준으로 ZC Rx는 네트워크 수신 경로에서 kernel-to-user copy를 제거하여 packet data를 userspace memory로 직접 받을 수 있게 하는 기능이다. 하지만 이건 일반적인 io_uring 사용 전체에 자동 적용되는 기본 동작이 아니다. NIC 조건, API 조건, 애플리케이션 구조가 맞아야 하는 별도 고급 기능으로 보는 편이 안전하다.
파일 I/O에서 의미가 더 커지는 이유
네트워크 소켓은 non-blocking readiness 모델과 잘 맞는다. 그래서 epoll만으로도 매우 빠른 서버를 만들 수 있다.
문제는 일반 파일 I/O다.
일반 파일은 네트워크 소켓처럼 “나중에 readable이 된다”는 readiness 모델과 잘 맞지 않는다. Linux epoll_ctl(2) 문서도 대상 fd가 epoll을 지원하지 않으면 EPERM을 반환할 수 있고, 그 예로 regular file이나 directory를 든다. 설령 poll/select 계열에서 일반 파일이 ready처럼 취급되는 상황을 생각하더라도, 실제 read는 page cache miss나 디스크 I/O 때문에 애플리케이션 스레드를 오래 붙잡을 수 있다.
그래서 전통적인 서버 구조에서는 이런 식의 분리가 흔했다.
network socket = epoll event loop
file I/O = worker thread pool
예를 들어 파일을 읽어서 소켓으로 보내야 하는 서버라면, 이벤트 루프 스레드가 직접 파일 read를 하면 블로킹 위험이 있다. 그래서 파일 read를 worker pool에 넘기고, 완료 후 다시 socket write를 진행하는 구조가 나온다.
io_uring은 파일 read/write 자체를 비동기로 제출할 수 있어서 이 지점에서 의미가 커진다.
완료 순서는 제출 순서와 다를 수 있다
io_uring을 이해할 때 중요한 감각이 하나 더 있다. 여러 요청을 한 번에 제출하면, 완료 순서가 제출 순서와 같다고 가정하면 안 된다.
submit A: file read
submit B: socket recv
submit C: timeout
completion:
B 완료
C 완료
A 완료
그래서 SQE의 user_data가 중요하다. 요청을 제출할 때 애플리케이션이 식별자를 넣어 두고, CQE가 돌아왔을 때 그 user_data로 어떤 요청의 완료인지 매칭해야 한다.
이 점은 epoll의 fd 이벤트 처리와 코드 구조를 다르게 만든다. io_uring 기반 코드는 보통 “완료 이벤트를 받아 다음 작업을 제출하는 상태머신”처럼 흐른다.
accept도 completion 스타일로 바뀐다
epoll 기반 accept는 이런 식이다.
listen socket을 epoll에 등록
-> EPOLLIN 발생
-> accept() 호출
-> client fd 획득
-> client fd를 다시 epoll에 등록
io_uring 기반 accept는 이렇게 볼 수 있다.
accept SQE 제출
-> 새 연결이 들어오면 accept 완료 CQE 발생
-> CQE 결과로 client fd 획득
-> client fd에 recv SQE 제출
-> 다음 accept SQE 다시 제출
이 차이는 “이벤트가 왔으니 내가 syscall을 호출한다”와 “작업을 걸어두고 완료를 받는다”의 차이다.
epoll과 io_uring 비교
| 항목 | epoll | io_uring |
|---|---|---|
| 모델 | readiness notification | completion notification |
| 핵심 질문 | “이 fd 지금 준비됐나?” | “내가 제출한 I/O가 끝났나?” |
| I/O 실행 | 애플리케이션이 read()/write() 호출 |
커널에 SQE 제출 후 CQE로 완료 수신 |
| 네트워크 소켓 | 매우 성숙하고 강함 | completion 기반으로 처리 가능 |
| 파일 I/O | event loop와 직접 결합하기 까다로움 | 비동기 제출/완료 모델과 잘 맞음 |
| 성능 포인트 | non-blocking readiness 처리 | shared ring, batching, fixed resource 최적화 |
| 복잡도 | 상대적으로 단순하고 생태계가 성숙함 | 버퍼 생명주기와 완료 상태머신 설계가 중요함 |
Java 백엔드에서는 바로 체감하기 어렵다
Spring Boot, Tomcat, JDBC 기반 서버를 생각하면 io_uring이 곧바로 성능 향상으로 이어진다고 말하기 어렵다.
이런 서버의 병목은 자주 다른 곳에 있다.
JDBC blocking call
DB connection pool 대기
외부 API latency
serialization/deserialization
GC
worker thread 점유
Tomcat NIO나 Netty가 epoll 기반 readiness 모델을 잘 사용하더라도, 컨트롤러 안에서 JDBC를 호출하면 worker thread는 DB 응답을 기다리며 블로킹된다.
socket readable
-> request parsing
-> worker thread 배정
-> JDBC query
-> worker thread blocking
이 상황에서 네트워크 이벤트 감시 모델만 io_uring으로 바꾼다고 전체 병목이 사라지지는 않는다. io_uring을 제대로 활용하려면 서버 런타임이나 네트워크 라이브러리 레벨에서 completion 기반 transport를 제공하고, 애플리케이션 구조도 그 모델에 맞아야 한다.
반대로 다음 같은 시스템에서는 가치가 커질 수 있다.
- 고성능 프록시
- 파일 서버
- object storage gateway
- 로그 수집 서버
- 네트워크와 디스크를 함께 많이 쓰는 서버
- C/Rust 기반 커스텀 런타임
- syscall overhead가 병목인 초고성능 서버
io_uring이 항상 상위호환은 아니다
io_uring은 강력하지만 공짜는 아니다.
- 커널 버전에 따라 지원 opcode와 안정성이 달라질 수 있다.
- completion 기반 상태머신으로 코드를 설계해야 한다.
- 유저 버퍼의 생명주기를 완료 시점까지 안전하게 유지해야 한다.
- 특정 작업은 커널 내부 worker thread를 사용할 수 있다.
- 보안 정책이나 운영 환경에 따라 제한될 수 있다.
- 기존 epoll 기반 생태계는 훨씬 오래 검증되어 있다.
특히 버퍼 생명주기는 자주 놓치는 부분이다. SQE에 유저 버퍼 주소를 넘긴다는 것은, 커널이 그 작업을 끝낼 때까지 그 버퍼가 살아 있어야 한다는 뜻이다. completion 기반 I/O는 “요청을 제출한 함수가 끝났다”와 “버퍼를 재사용해도 된다”가 같은 시점이 아니다.
한 문장으로 정리하면
epoll은 준비된 fd 목록을 받아 애플리케이션이 직접 I/O를 호출하는 readiness 모델이고, io_uring은 I/O 작업을 커널에 제출한 뒤 완료 결과를 CQE로 받는 completion 모델이다.
그래서 io_uring은 epoll의 단순한 상위호환이라기보다, 파일 I/O와 네트워크 I/O를 같은 완료 기반 흐름으로 묶고 syscall 경계를 줄이기 위한 더 넓은 Linux I/O 인터페이스에 가깝다.
차분히 보면 핵심은 하나다.
epoll:
"준비됐어?"
io_uring:
"끝났어?"
그 작은 질문의 차이가 서버의 상태머신, syscall 경계, 버퍼 생명주기, 파일 I/O 처리 방식을 바꾼다.