Linux의 epoll과 io_uring 비교

1 hour ago 1
  • TinyGate 리버스 프록시는 워커 기반 구조에서 epoll로 바꾸며 성능을 끌어올렸지만, 이후 한계를 만나 io_uring으로 다시 작성됨
  • epoll은 I/O가 가능한 시점을 알려주는 준비 상태 모델이라 epoll_wait 뒤에 read()/write()를 별도로 호출해야 함
  • io_uring은 I/O 완료를 기준으로 움직이는 완료 모델이며, 애플리케이션과 커널이 공유 링 버퍼로 제출 큐와 완료 큐를 주고받음
  • io_uring_enter()는 기본적으로 필요하지만 여러 작업을 한 번에 제출·회수할 수 있고, IORING_SETUP_SQPOLL은 syscall을 줄이는 대신 CPU 사용량을 비용으로 가짐
  • kernel v5.1+를 쓰는 최신 Linux 서버에서 새 프로젝트를 시작한다면, epoll보다 io_uring이 더 적합한 선택지로 평가됨

TinyGate가 드러낸 epoll의 한계

  • TinyGate는 학생들과 만든 리버스 프록시 서버였고, 첫 버전은 단순한 워커 기반 구조였음
  • 교육용 프로젝트로는 동작했지만 nginx나 haproxy 같은 도구와 비교하면 아키텍처 한계가 컸음
  • 두 번째 버전은 epoll 기반으로 바뀌며 첫 버전보다 성능이 크게 좋아짐
    • 다만 벤치마크에서는 여전히 nginx/haproxy를 넘지 못함
  • 이후 epoll의 한계 때문에 io_uring으로 전환했고, 프로젝트를 처음부터 다시 작성하게 됨

epoll: 준비 상태 통지와 반복 syscall

  • epoll은 Linux에서 오래 쓰인 비동기 I/O 관리 방식이며, 2002년에 Linux kernel에 들어감
  • 핵심은 I/O가 가능한 시점을 알려주는 준비 상태 통지
    • epoll은 “읽거나 쓸 수 있음”을 알려줌
    • 실제 데이터 읽기와 쓰기는 이후 read() 또는 write() syscall로 애플리케이션이 수행함
  • 일반적인 흐름에서는 이벤트마다 syscall 비용이 반복됨
    • epoll_ctl은 파일 디스크립터를 등록하는 1회성 syscall임
    • 실제 I/O 이벤트마다 epoll_wait와 read()/write()가 필요함
    • 결과적으로 이벤트 처리에 추가 syscall이 계속 붙음
  • syscall은 사용자 모드와 커널 모드 사이 컨텍스트 전환을 만들며, 연결 수가 많아질수록 오버헤드가 커짐

io_uring: 완료 모델과 공유 링 버퍼

  • io_uring은 epoll이 Linux kernel에 들어간 지 약 17년 뒤인 2019년에 등장했고, kernel v5.1+에서 지원됨
  • epoll과 달리 I/O가 가능한지가 아니라 I/O가 완료됐는지를 기준으로 동작함
  • 애플리케이션과 커널은 공유 메모리의 링 버퍼를 함께 사용함
    • 제출 큐에는 애플리케이션이 커널에 요청할 작업을 넣음
    • 완료 큐에는 커널이 완료 결과를 다시 올림
  • 기본 설정에서는 커널이 제출 큐를 확인하도록 io_uring_enter()를 호출해야 함
    • 한 번의 호출로 여러 작업을 제출하고 여러 완료를 회수할 수 있음
    • epoll과 read() 조합처럼 작업마다 syscall 쌍을 반복하는 구조가 아님
  • IORING_SETUP_SQPOLL을 쓰면 커널 스레드가 제출 큐를 폴링함
    • 정상 운용 상태에서는 syscall을 거의 없앨 수 있음
    • 큐가 비어 있어도 커널 스레드가 돌기 때문에 CPU를 사용함
    • sq_thread_idle 이후에는 sleep으로 물러나지만 비용이 사라지는 것은 아님

코드 예제로 보는 차이

  • epoll 예제

    • stdin 파일 디스크립터를 등록하고, 이벤트가 오면 별도 read()를 호출함
    • epoll_create1로 epoll 인스턴스를 만듦
    • epoll_ctl로 STDIN_FILENO를 등록함
    • epoll_wait로 읽을 수 있을 때까지 블록함
    • 이벤트가 오면 read() syscall로 데이터를 읽음
    • 이 흐름에서는 실제 I/O 이벤트마다 epoll_wait와 read가 필요해짐
  • io_uring 예제

    • liburing을 사용함
    • io_uring_queue_init으로 링을 초기화함
    • io_uring_get_sqe로 제출 큐 엔트리를 얻음
    • io_uring_prep_read로 stdin 읽기 작업을 준비함
    • io_uring_submit으로 제출하고 io_uring_wait_cqe로 완료를 기다림
    • io_uring 예제에는 별도 준비 상태 확인이 없고, 완료 시점에 따로 read()를 호출하지 않음
    • 단순화를 위해 두 예제에는 중요한 예외 처리가 빠져 있음
    • stdin에 데이터가 없으면 영원히 블록될 수 있음
    • io_uring 예제는 제출 큐가 가득 찼을 때 io_uring_get_sqe()가 NULL을 반환하는 경우를 검사하지 않음

io_uring을 쓸 때의 추가 조건

  • zero-copy I/O를 쓰려면 io_uring_register_buffers()로 버퍼를 미리 등록해야 함
    • 작업마다 커널이 메모리를 다시 매핑하는 일을 피할 수 있음
    • 네트워크 전송에서는 kernel 6.0+의 IORING_OP_SEND_ZC가 버퍼를 커널로 복사하지 않는 전송을 제공함
  • IORING_SETUP_SQPOLL은 syscall을 줄일 수 있지만 CPU 사용량이 비용임
    • 큐가 비어 있어도 커널 스레드가 계속 폴링함
    • idle timeout 이후 sleep으로 전환될 수 있지만 비용이 없어지는 것은 아님
  • io_uring의 오류는 동기 syscall의 직접 반환값이 아니라 완료 큐 엔트리의 res 필드로 비동기적으로 돌아옴
    • 오류 처리는 cqe->res를 통해 해야 함

최신 Linux 서버에서의 선택

  • epoll은 I/O 가능 시점 통지와 별도 syscall 호출에 기반한 오래된 Linux 비동기 I/O 방식임
  • io_uring은 최신 Linux에서 완료 기반 모델과 배치 제출·완료 처리를 제공함
  • 현대 Linux 서버에서 처음부터 새 프로젝트를 만든다면 io_uring을 선택하는 쪽이 자연스러움
  • 오래된 시스템 지원을 합리적인 시점에 중단할 수 있다면, kernel v5.1+ 환경에서는 epoll을 고를 이유가 많지 않음
Read Entire Article