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은 애플리케이션이 “이 작업을 해줘”라고 커널에 제출하고, 커널이 작업을 끝낸 뒤 “완료됐고 결과는 이렇다”고 알려주는 모델에 가깝다.

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에서 이 버퍼로 읽어줘. 끝나면 알려줘."

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 처리 방식을 바꾼다.

참고한 자료


© 2024. All rights reserved.