Ringing을 이용한 새로운 비동기 I/O API (io_uring)

5 minute read

lwn 기사의 번역 글입니다.

2.5 개발 사이클 이후로 리눅스 커널은 비동기 IO를 제공했지만 계속되는 사용자들의 불만이 있습니다. 현재 인터페이스는 사용하기 어렵고 비효율적입니다. 몇몇 IO 타입이 다른 것들보다 더 좋게 지원되었습니다. 이 때 Jens Axboe가 새로운 인터페이스 “io_uring”을 소개하였습니다. 이름에서 알 수 있듯이, io_uring은 커널에서 필요한 또 다른 ring buffer를 소개합니다.

Setting up

    int io_uring_setup(int entries, struct io_uring_params *params);

entires 인자는 submission과 completion 모두에 사용됩니다. 구조체는 아래와 같이 생겼습니다.

    struct io_uring_params {
	__u32 sq_entries;
	__u32 cq_entries;
	__u32 flags;
	__u16 resv[10];
	struct io_sqring_offsets sq_off;
	struct io_cqring_offsets cq_off;
    };

초기에 이 구조체는 0으로 초기화되어야 합니다. 호출에 성공하면, sq_entries와 cq_entries 필드가 실제 submission과 completion queue의 사이즈로 세팅됩니다. 이 코드는 submission 엔트리를 할당 하고 그 두배를 completion 엔트리로 할당합니다.

io_uring_setup()의 리턴 값은 file descriptor(fd)이며 버퍼를 프로세스의 address space로 매핑하기 위해 mmap()에 넣을 수 있습니다. 좀 더 자세하게 이야기하자면, 두개의 ring buffer와 submission queue 엔트리 배열을 매핑하기 위해서는 세번의 함수 호출이 필요합니다. 이 매핑을 하기 위한 값은 io_uring_params 구조체의 sq_off와 cq_off 필드에서 구할 수 있습니다. 특히 정수 배열의 형태를 가진 queue submission은 다음과 같은 호출을 통해서 매핑됩니다.

    subqueue = mmap(0, params.sq_off.array + params.sq_entries*sizeof(__u32),
    		    PROT_READ|PROT_WRITE|MAP_SHARED|MAP_POPULATE,
		    ring_fd, IORING_OFF_SQ_RING);

params의 io_uring_params 구조체에서, ring_fd는 io_uring_setup()에서 리턴된 fd입니다. params.sq_off.array에 영역의 길이를 더하는 것(params.sq_off.array + params.sq_entries*sizeof(__u32))은 ring이 정확히 처음에 위치하지 않는 사실을 나타냅니다. 대신에 실제 submission queue 엔트리의 길이는 아래와 같은 방법으로 매핑합니다.

    sqentries = mmap(0, params.sq_entries*sizeof(struct io_uring_sqe),
    		    PROT_READ|PROT_WRITE|MAP_SHARED|MAP_POPULATE,
		    ring_fd, IORING_OFF_SQES);

submission의 순서와는 다르게 IO 동작이 완료되기 때문에 ring buffer로부터 queue entries를 분리되어 있습니다. completion queue의 경우는 더 쉽습니다. queue와 엔트리가 분리되어 있지 않기 때문입니다. 사용하는 방법은 비슷합니다.

    cqentries = mmap(0, params.cq_off.cqes + params.cq_entries*sizeof(struct io_uring_cqe),
    		    PROT_READ|PROT_WRITE|MAP_SHARED|MAP_POPULATE,
		    ring_fd, IORING_OFF_CQ_RING);

Axboe가 userspace 라이브러리에서 작업을 하며 대부분의 사용자에게 인터페이스의 복잡도를 숨길 수 있다는 점에서 주목할 필요가 있습니다.

I/O submission 처음에 io_uring 구조체가 세팅되면, 비동기 IO를 수행하는 데 사용됩니다. io_uring_sqe 구조체를 채우고 IO 동작을 요청하며, io_uring_sqe는 약간 간소화되어 아래와 같습니다.

    struct io_uring_sqe {
	__u8	opcode;		/* type of operation for this sqe */
	__u8	flags;		/* IOSQE_ flags */
	__u16	ioprio;		/* ioprio for the request */
	__s32	fd;		/* file descriptor to do IO on */
	__u64	off;		/* offset into file */
	void	*addr;		/* buffer or iovecs */
	__u32	len;		/* buffer size or number of iovecs */
	union {
	    __kernel_rwf_t	rw_flags;
	    __u32		fsync_flags;
	};
	__u64	user_data;	/* data to be passed back at completion time */
	__u16	buf_index;	/* index into fixed buffers, if used */
    };

opcode는 수행될 동작을 나타냅니다. 옵션에는 IORING_OP_READV, IORING_OP_WRITEV, IORING_OP_FSYNC같은 것들이 있습니다. IO가 어떻게 동작할 지에 영향을 끼치는 여러 인자가 있습니다. 하지만 대부분은 상대적으로 간단합니다. 예를 들면 fd는 IO가 일어날 파일을 이야기하며, addr과 len은 IO가 일어나는 메모리를 가리키는 iovec 구조체를 나타냅니다.

위에서 이야기 했듯이, io_uring_sqe 구조체는 user와 kernel space에 매핑되는 배열에 저장됩니다. 사실은 이 구조체들 중 하나를 제출하려면 submission queue에 인덱스를 할당해야 하며, 아래와 같다.

    struct io_uring {
	u32 head;
	u32 tail;
    };

    struct io_sq_ring {
	struct io_uring		r;
	u32			ring_mask;
	u32			ring_entries;
	u32			dropped;
	u32			flags;
	u32			array[];
    };

head와 tail 값은 ring의 엔트리를 관리합니다. 두 값이 같다면 ring은 비어있는 상태입니다. userspace 코드는 인덱스를 배열에 넣고 tail을 증가시킴으로서 엔트리를 추가합니다. 오직 커널만이 r.head를 바꿀 수 있습니다. 한개 이상의 엔트리가 ring에 들어가면, 한번의 호출로 제출할 수 있습니다.

int io_uring_enter(unsigned int fd, u32 to_submit, u32 min_complete, u32 flags);

여기 fd는 ring과 관련되어 있으며 to_submit은 커널이 제출해야하는 링 안의 엔트리의 숫자입니다. 리턴값은 성공하면 0입니다.

completion 이벤트는 동작이 실행되면 completion queue로 갑니다. flags가 IORING_ENTER_GETEVENTS를 포함하고 min_complete가 0이 아니면, io_uring_enter()는 동작이 완료 될때까지 block됩니다. 실제 결과는 completion 구조체를 통해서 얻을 수 있습니다.

    struct io_uring_cqe {
	__u64	user_data;	/* sqe->user_data submission passed back */
	__s32	res;		/* result code for this event */
	__u32	flags;
    };

user_data는 userspace에서 제출되었을 때 값이 넘겨지고 res는 동작의 리턴 값입니다. 만약 리퀘스트가 IO를 수행할 필요없이 만족된다면 flags 필드는 IOCQE_FLAG_CACHEHIT을 가진다. 이것은 사이드 채널로서 페이지 캐시를 사용하는 것에 대한 우려를 생각해보면 다시 생각해봐야하는 옵션입니다. 이 구조체들은 completion queue 에 있고 submission queue 와 비슷합니다:

    struct io_cq_ring {
	struct io_uring		r;
	u32			ring_mask;
	u32			ring_entries;
	u32			overflow;
	struct io_uring_cqe	cqes[];
    };

이 ring에서는 r.head 인덱스가 첫 completion event를 가리키며, r.tail은 마지막을 가리킵니다; userspace에서는 r.head만을 수정할 수 있습니다.

위에서 설명한 인터페이스들은 userspace 프로그램에서 다중 IO 동작을 넣고 그에 대한 결과를 받기에는 충분합니다. 기능적인 측면에서 현재 AIO 인터페이스가 제공하는 것과 비슷하지만 인터페이스는 많이 다릅니다. Axboe는 훨씬 다르다고 주장하나 이것을 뒷바침할 벤치마크 결과가 없습니다. 또 다른 것은 이 인터페이스는 페이지 캐시의 몇몇 경우에는 context switch없이 비동기 buffered IO를 할 수 있다는 것입니다. buffered IO는 Linux AIO의 아픈 손가락입니다.

고급 기능: 이 인터페이스는 주목할 만한 기능이 몇 가지 더 있습니다. 프로그램의 IO 버퍼를 커널에 매핑하는 것입니다. 보통 각 IO 연산 데이터가 버퍼 안밖으로 복사 될 때 일어납니다. 버퍼들은 연산이 끝나면 매핑이 해제됩니다. 버퍼들이 프로그램 실행 중에 여러번 사용된다면 한번 매핑해두는 것보다 훨씬 효율적일 것입니다.

    struct io_uring_register_buffers {
	struct iovec *iovecs;
	__u32 nr_iovecs;
    };

이 구조체는 새로운 시스템 콜로 전달된다.

    int io_uring_register(unsigned int fd, unsigned int opcode, void *arg);

이 경우, opcode는 IORING_REGISTER_BUFFERS가 됩니다. 프로그램에서 IORING_UNREGISTER_BUFFERS를 통해서 명시적으로 unmap을 하지 않는 이상, 초기 fd가 open된 상태와 함께 버퍼의 매핑이 유지됩니다. 버퍼를 mapping하는 것은 메모리를 RAM에 locking 하는 것입니다. mlock()에 사용되는 리소스 한도가 여기에도 적용된다. 매핑된 버퍼에 IO동작을 수행하는 것은 IORING_OP_READ_FIXED와 IORING__OP_WRITE_FIXED가 사용됩니다.

IORING_REGISTER_FILES라는 옵션도 있는 데 이것은 같은 파일에 여러 연산이 동작할 때 최적화를 위해서 사용합니다.

많은 high-bandwidth 경우에서, completion event를 polling하는 것이 커널이 이벤트를 통해서 어플리케이션을 깨워주는 방식보다 효율적입니다. 예를 들면, 이것은 기존에 존재하는 block-layer polling interface의 동기가 됩니다. 적어도 하나의 사용될 completion 이벤트가 있고 이것을 어플리케이션이 polling 을 하고 있을 때 polling은 가장 효율적입니다. 이 polling mode는 io_uring의 io_uring_setup()을 호출할 때 IORING_SETUP_IOPOLL플래그를 세팅하면 동작합니다. completion 이벤트를 completion 큐에 작성되도록 하려면 io_uring_enter()를 호출해야 합니다.

마지막으로, 시스템 콜이 거의 필요없는 완전한 polled mode도 있습니다. 이 모드는 IORING_SETUP_SQPOLL 플래그를 세팅하면 가능합니다. io_uring_enter()를 호출하면 커널스레드가 실행되고 submission queue를 polling하며 거기에 있는 요청들을 제출합니다; 요청받으면 receive-queue polling 또한 수행합니다. 어플리케이션이 IO를 요청하고 결과를 받을 동안 시스템 콜 없이 IO가 발생합니다.

결론적으로, 새로운 request가 없고 polling 이 멈추면 커널이 지루할 것입니다. 그 때에는 submission queue에 IORING_SQ_NEED_WAKEUP가 세팅될 것입니다. 어플리케이션에서는 비트를 체크해서 세팅되어 있다면 io_uring_enter()를 호출하여 다시 메커니즘을 실행해야 합니다.

이 패치 셋은 이번에 작성한 것이 세번째 버전입니다. 이전에 최소 10개의 개정된 polled AIO 패치가 있었기 때문에 약간 기만적입니다. 반면에 이제 안정화를 시작할 수 있어서 아직 특별히 큰 변경사항이 없다는 것이 그다지 놀랍지 않습니다. 이름을 “io_urine과 덜 비슷한 것”을 바꿔야 한다는 Matthew Wilcox의 요청은 아직 검토하지 않았습니다. 이것이 남은 가장 큰 이슈가 될 수 있습니다. 모두들 알듯이, 이름짓는 것은 마지막의 가장 어려운 부분입니다. 그러나 이러한 세세한 부분이 해결되면 리눅스 커널은 불만 없는 비동기 IO 구현체를 가질 것입니다.

궁금증을 위해, Axobe는 io_uring 인터페이스의 완전한 예제 프로그램을 게시했습니다.

원문 : LWN: Ringing in a new asynchronous I/O API