← System Programming DUJINLABS.COM

Observe / Harden · Linux userspace / kernel ABI

io_uring ring과 request 수명

SQ/CQ shared ring, submission entry, kernel request, completion entry, user buffer·fd 수명이 비동기 I/O에서 어떻게 겹치는지 봅니다.

Series
35 / 37
Build
cc -std=c17 -Wall -Wextra -O2 uring_read.c -luring -o uring_read
Run
./uring_read /etc/hostname
Kernel
Linux 6.18.37 LTS

io_uring_enter가 반환되면 제출한 buffer를 바로 재사용해도 되는가?

io_uring setup은 kernel과 userspace가 공유하는 submission/completion ring memory를 만든다. userspace가 SQE를 채워 tail을 publish하고 enter로 kernel에 알리면 kernel request가 실행된 뒤 CQE를 쓴다.

submit syscall 반환은 작업 완료가 아니다. CQE를 소비할 때까지 operation이 user buffer와 file을 참조할 수 있으므로 request별 ownership과 cancellation 결과를 추적해야 한다.

구조 그림

그림 1. 공유 SQ/CQ ring과 in-flight request

Submission Queue

head 2tail 6
0free1free2READ#413WRITE#424TIMEOUT#435NOP#446free7free

Kernel in-flight

head 0tail 3
0req#41 · buffer A1req#42 · buffer B2req#43 · timer3worker/driver

Completion Queue

head 1tail 4
0seen1CQE#422CQE#413CQE#434free5free6free7free

SQE slot은 kernel이 가져간 뒤 재사용할 수 있지만 user buffer는 CQE 완료 전까지 유지해야 한다. 두 수명이 같지 않다.

호출 흐름

그림 2. 사용자 코드에서 관찰 가능한 결과까지
io_uring_setup ring fd + mmap offsets
fill SQE opcode/fd/buffer
publish SQ tail memory ordering
kernel request async/sync 실행
CQE res/user_data 소비

SQE slot 수명, request 수명, user buffer 수명, CQE 수명을 한 줄로 겹쳐 그린다. slot 재사용과 buffer 재사용은 서로 다른 완료 조건을 가진다.

그림 3. 커널 내부에서 지나가는 주요 지점
io_submit_sqes SQE copy/validate
io_init_req io_kiocb
issue file operation
io_req_complete result 확정
io_cqring_fill_event CQ publish

함수 이름을 외우기 위한 그림이 아니다. 반환값, 파일 디스크립터, 메모리 매핑, 대기 큐 가운데 무엇이 다음 단계로 전달되는지 확인한다.

Linux 6.18.37 LTS 소스 위치

glibc 함수에서 멈추지 않고 syscall 구현과 커널 객체가 만나는 파일까지 내려간다. 링크는 동일한 태그의 원본 파일을 가리킨다.

파일함수·구조체여기서 볼 것
io_uring/io_uring.c io_uring_setup(), io_uring_enter() ring context 생성과 submission/completion 진입
io_uring/io_uring.c io_submit_sqes(), io_init_req() SQE를 kernel request로 변환
io_uring/io_uring.c io_req_complete_post(), io_cqring_event_overflow() CQE publish와 overflow 처리

실행 예제 원본

아래 코드는 설명을 위해 중간 줄을 생략한 의사 코드가 아니다. 파일로 빌드해 실행할 수 있는 최소 예제다.

빌드cc -std=c17 -Wall -Wextra -O2 uring_read.c -luring -o uring_read
01#include <fcntl.h>
02#include <liburing.h>
03#include <stdio.h>
04#include <string.h>
05#include <unistd.h>
06
07int main(int argc, char **argv)
08{
09    if (argc != 2)
10        return 2;
11    int fd = open(argv[1], O_RDONLY | O_CLOEXEC);
12    struct io_uring ring;
13    if (fd < 0 || io_uring_queue_init(8, &ring, 0) < 0)
14        return 1;
15
16    char buffer[256];
17    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
18    io_uring_prep_read(sqe, fd, buffer, sizeof(buffer), 0);
19    io_uring_sqe_set_data64(sqe, 0x1001);
20    if (io_uring_submit(&ring) < 0)
21        return 1;
22
23    struct io_uring_cqe *cqe;
24    if (io_uring_wait_cqe(&ring, &cqe) < 0)
25        return 1;
26    int result = cqe->res;
27    if (result > 0)
28        write(STDOUT_FILENO, buffer, (size_t)result);
29    else if (result < 0)
30        fprintf(stderr, "read: %s\n", strerror(-result));
31    io_uring_cqe_seen(&ring, cqe);
32    io_uring_queue_exit(&ring);
33    close(fd);
34    return result < 0;
35}

코드 조각별 설명

실제 코드 13행io_uring_queue_init(8

8-entry SQ/CQ ring과 ring fd, shared mappings를 준비한다. 실제 CQ 크기와 feature는 setup 결과를 확인한다.

실제 코드 16행char buffer[256]

read completion 전까지 stack frame과 buffer가 유효해야 한다. function을 먼저 반환하거나 다른 request에 재사용하면 data race가 생긴다.

실제 코드 19행io_uring_sqe_set_data64

completion을 application request object와 연결할 opaque key를 넣는다. fd 번호만 넣으면 재사용 문제를 해결하지 못한다.

실제 코드 26행int result = cqe->res

CQE 결과는 libc처럼 -1/errno가 아니라 성공 byte 또는 음수 errno다. -result를 strerror에 넘긴다.

실제 코드 31행io_uring_cqe_seen

CQ head를 진행시켜 해당 completion slot을 ring에 반환한다. 결과와 user_data를 먼저 복사한 뒤 호출한다.

세부 동작

01

비동기 여부는 opcode와 file type에 따라 달라진다

모든 operation이 항상 별도 worker나 hardware async로 실행되는 것은 아니다. inline completion, task work, io-wq offload가 섞일 수 있다.

syscall 감소만 보지 말고 completion latency, worker saturation, context switch를 측정한다.

02

registered resource는 pinning 비용을 바꾼다

fixed file과 registered buffer는 매 request fd lookup/pinning을 줄일 수 있지만 registration table과 page pin 수명을 길게 만든다. update/unregister 동안 in-flight request를 고려한다.

장기 pinned page는 reclaim/migration에 영향을 줄 수 있다.

03

cancel도 completion으로 확인한다

cancel SQE 성공은 target operation을 찾고 취소 요청을 적용했다는 결과이며 target CQE와 cancel CQE의 순서를 모두 처리해야 한다. 이미 완료된 request에는 ENOENT 같은 결과가 올 수 있다.

timeout link와 multishot operation은 한 user_data가 여러 CQE를 만들 수 있는 flag를 확인한다.

객체와 수명

대상언제 생기고 없어지는가확인할 값
io_ring_ctxqueue_init/setup에서 생기고 queue_exit/ring fd close에서 해제된다SQ/CQ, task refs, worker
SQE/io_kiocbSQ slot 작성 후 kernel request로 바뀌고 completion까지 in-flight다opcode, user_data, buffer/fd refs
CQEcompletion publish에서 생기고 cqe_seen으로 slot이 반환된다res, flags, user_data

실패 조건과 오해하기 쉬운 부분

겉으로 보이는 현상실제 원인 후보확인 방법
buffer 내용 corruptioncompletion 전 재사용/stack 반환request owner와 CQE timeline
CQ overflow/stallcompletion을 충분히 소비하지 않음CQ depth, overflow flag, seen 호출
오류 해석 이상cqe->res를 errno 방식으로 처리음수 errno 규칙

직접 확인

  1. 여러 offset read를 제출하고 user_data로 완료 순서가 제출 순서와 다를 수 있음을 기록한다.
  2. registered file/buffer 전후 syscall, throughput, tail latency, pinned memory를 비교한다.
  3. linked timeout과 async cancel을 추가해 target CQE와 cancel/timeout CQE 조합을 표로 만든다.
실행./uring_read /etc/hostname
추적strace -e trace=io_uring_setup,io_uring_enter,io_uring_register,mmap,munmap ./uring_read /etc/hostname

원문