왜 기존 비동기 I/O가 병목이 되는가
리눅스에서 오랫동안 쓰인 epoll은 소켓 같은 준비 상태(readiness) 기반 이벤트에는 잘 맞지만, 파일 I/O에는 사실상 비동기가 아니다. 일반 파일은 항상 "읽을 준비됨"으로 보고되어 실제 read/write에서 블로킹되기 때문이다. 그래서 디스크 I/O는 스레드 풀로 우회하는 경우가 많은데, 이 방식은 컨텍스트 스위칭과 스레드 관리 비용을 유발한다.
또 하나의 문제는 시스템 콜 횟수다. 요청 하나마다 syscall이 발생하면 커널/유저 모드 전환 비용이 누적된다. 고부하 서버에서는 이 전환 자체가 CPU 시간을 상당히 잡아먹는다.
io_uring의 핵심 아이디어
io_uring은 유저 공간과 커널이 공유하는 두 개의 링 버퍼로 동작한다. 제출 큐(SQ)에 작업을 넣고 완료 큐(CQ)에서 결과를 회수한다. 두 링이 mmap으로 공유되므로, 작업 제출과 완료 확인 과정에서 데이터 복사가 없고 syscall도 배치로 줄일 수 있다.
특히 파일과 소켓을 동일한 인터페이스로 진짜 비동기 처리한다는 점이 epoll과 결정적으로 다르다.
| 항목 | epoll + 스레드풀 | io_uring |
|---|---|---|
| 파일 I/O 비동기 | 실질적 불가(우회 필요) | 네이티브 지원 |
| syscall 횟수 | 이벤트당 다수 | 배치 제출로 최소화 |
| 데이터 복사 | 있음 | 공유 링으로 제거 |
| 구현 복잡도 | 중간 | 높음(추상화 라이브러리 권장) |
liburing으로 시작하기
raw io_uring 시스템 콜을 직접 다루기보다 liburing 래퍼를 쓰는 편이 안전하다. 링 초기화와 SQE 준비, 제출, 완료 대기가 명확한 API로 정리되어 있다.
# 설치 및 커널 확인
uname -r # 5.6+ 권장, 5.19+ 에서 안정성 개선
sudo apt install liburing-dev
# 컴파일
gcc -O2 read_file.c -o read_file -luring
파일 읽기 예제
아래는 단일 파일을 비동기로 읽는 최소 예제다. SQE에 readv 작업을 준비하고 제출한 뒤, 완료 큐에서 결과 코드(res)를 확인한다. res는 읽은 바이트 수이며, 음수면 -errno 값이다.
#include <liburing.h>
#include <fcntl.h>
#include <stdio.h>
int main() {
struct io_uring ring;
io_uring_queue_init(8, &ring, 0); // 큐 깊이 8
int fd = open("data.bin", O_RDONLY);
char buf[4096];
struct iovec iov = { .iov_base = buf, .iov_len = sizeof(buf) };
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_readv(sqe, fd, &iov, 1, 0); // offset 0
io_uring_submit(&ring); // 배치 제출
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); // 완료 대기
if (cqe->res < 0)
fprintf(stderr, "read err: %d\n", cqe->res);
else
printf("read %d bytes\n", cqe->res);
io_uring_cqe_seen(&ring, cqe); // CQE 반환 필수
io_uring_queue_exit(&ring);
return 0;
}
핵심은 io_uring_cqe_seen을 반드시 호출해 커널에 슬롯을 돌려주는 것이다. 이를 빠뜨리면 CQ가 가득 차 이후 완료 이벤트가 유실될 수 있다.
성능을 끌어올리는 옵션
SQPOLL 모드를 켜면 커널 스레드가 제출 큐를 폴링하므로 io_uring_submit의 syscall조차 생략할 수 있다. 지연에 민감한 워크로드에서 유효하지만, 유휴 시에도 CPU를 점유하므로 저부하 환경에서는 오히려 낭비다.
struct io_uring_params p = {0};
p.flags = IORING_SETUP_SQPOLL;
p.sq_thread_idle = 2000; // 2초 유휴 후 슬립
io_uring_queue_init_params(64, &ring, &p);
이 밖에 미리 등록한 버퍼(registered buffers)와 파일(registered files)을 쓰면 매 요청마다 발생하는 참조/검증 비용을 줄일 수 있다. 처리량이 중요한 스토리지 엔진에서 특히 효과가 크다.
실무 도입 시 주의점
첫째, 커널 버전 의존성이 크다. 기능마다 최소 커널 요구사항이 다르므로, 배포 대상 커널에서 해당 opcode 지원 여부를 런타임에 확인하는 방어 코드가 필요하다. 둘째, 보안 정책상 seccomp나 컨테이너 런타임이 io_uring 관련 syscall을 차단하는 경우가 있어 사전 점검이 필요하다.
셋째, 버퍼 수명 관리가 까다롭다. 제출한 iovec 버퍼는 완료가 회수될 때까지 유효해야 하며, 스택 변수를 넘기면 미묘한 메모리 오류로 이어진다. 넷째, 완료 순서는 제출 순서와 무관하므로 user_data 필드로 요청을 식별해 매핑해야 한다.
정리하면 io_uring은 파일과 네트워크 I/O를 통합된 비동기 모델로 다루면서 syscall과 복사 비용을 줄여준다. 다만 복잡도와 커널 의존성이 있으므로, 병목이 실제 I/O 경로에 있음을 측정으로 확인한 뒤 liburing 같은 검증된 추상화 위에서 점진적으로 도입하는 편이 안전하다.