Làm chủ io_uring trên Linux: Async I/O thế hệ mới vượt trội hơn epoll

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

Câu chuyện I/O điển hình trên Linux thường diễn ra như sau: bạn mở một file descriptor, gọi epoll để theo dõi nó, rồi gọi read() hoặc write() khi kernel báo hiệu đã sẵn sàng. Mô hình này hoạt động được, nhưng mỗi thao tác đều tốn một lần system call — và ở quy mô lớn, chi phí đó tích lũy rất nhanh.

io_uring lật ngược mô hình này hoàn toàn. Được giới thiệu trong Linux 5.1 bởi Jens Axboe (kỹ sư đứng sau tầng block I/O của Linux), nó sử dụng một ring buffer dùng chung giữa userspace và kernel để bạn có thể gửi và thu thập các thao tác I/O mà không cần một syscall cho mỗi thao tác. Kết quả: overhead giảm đáng kể cho các workload throughput cao.

Bài viết này sẽ hướng dẫn bạn cách io_uring so sánh với các mô hình cũ hơn, điểm mạnh của nó (và điểm yếu), cách cài đặt, và cách viết chương trình đầu tiên với nó.

So Sánh Các Mô Hình: Blocking I/O, epoll và io_uring

Để hiểu tại sao io_uring quan trọng, hãy cùng theo dõi sự tiến hóa của các mô hình Linux I/O từ đầu.

Blocking I/O

Mô hình đơn giản nhất: gọi read(), thread của bạn ngủ cho đến khi có dữ liệu. Dễ hiểu, nhưng bạn cần một thread cho mỗi kết nối — và thread rất tốn kém ở quy mô lớn.

int fd = open("data.bin", O_RDONLY);
char buf[4096];
ssize_t n = read(fd, buf, sizeof(buf));  // thread bị block tại đây

epoll

Giải pháp kinh điển cho các kết nối đồng thời. Một thread duy nhất theo dõi hàng nghìn file descriptor và chỉ thức dậy khi có gì đó sẵn sàng. Nhưng đây là vấn đề: bạn vẫn phải gọi read() hoặc write() sau sự kiện — đó là thêm một syscall nữa mỗi thao tác. Và epoll hoàn toàn không hỗ trợ file I/O trên các filesystem thông thường.

int epfd = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);

struct epoll_event events[64];
int n = epoll_wait(epfd, events, 64, -1);

// Vẫn cần thêm một syscall để thực sự đọc:
read(events[0].data.fd, buf, sizeof(buf));

io_uring

Hai ring buffer tồn tại trong bộ nhớ dùng chung giữa tiến trình của bạn và kernel: Submission Queue (SQ) nơi bạn xếp hàng công việc, và Completion Queue (CQ) nơi kernel trả về kết quả. Bạn mô tả những gì muốn làm, gọi io_uring_submit() một lần cho nhiều thao tác, sau đó kiểm tra CQ để lấy kết quả.

#include <liburing.h>

struct io_uring ring;
io_uring_queue_init(32, &ring, 0);

// Xếp hàng một lần đọc — chưa có syscall nào
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, sizeof(buf), 0);

// Gửi tất cả đã xếp hàng — CHỈ MỘT syscall cho N thao tác
io_uring_submit(&ring);

// Thu thập kết quả
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);
printf("Đọc %d bytes\n", cqe->res);
io_uring_cqe_seen(&ring, cqe);

io_uring_queue_exit(&ring);

Lưu ý rằng read() không bao giờ xuất hiện. Kernel xử lý I/O thực tế; bạn chỉ cần mô tả những gì muốn làm trong ring.

Ưu và Nhược Điểm

Tại Sao io_uring Vượt Trội

  • Ít syscall hơn: Gom 50 thao tác, chỉ trả phí 1 lần submit. Với IORING_SETUP_SQPOLL, một kernel thread polling SQ — bạn không tốn syscall nào ở trạng thái ổn định.
  • Mô hình async thống nhất: Hoạt động với cả socket VÀ file thông thường. Linux AIO chỉ xử lý được file O_DIRECT; epoll không thể làm async file reads.
  • Đăng ký buffer cố định: Ghim buffer vào bộ nhớ kernel một lần với io_uring_register_buffers(), sau đó tái sử dụng mà không cần sao chép lại metadata mỗi thao tác.
  • Chuỗi thao tác: Liên kết các thao tác để kết quả đọc trực tiếp đưa vào thao tác ghi mà không cần quay lại userspace.
  • Tận dụng NVMe tốt hơn: Phần cứng lưu trữ có thể xử lý hàng đợi sâu; io_uring thực sự cung cấp cho nó một hàng đợi sâu thay vì tuần tự hóa qua userspace.

Điểm Hạn Chế

  • Yêu cầu phiên bản kernel: Bạn cần tối thiểu Linux 5.1. Hỗ trợ đầy đủ tính năng (bao gồm SQPOLL không đặc quyền) cần 5.19+. Chạy uname -r trước khi lên kế hoạch triển khai.
  • Bề mặt bảo mật: io_uring đã có các CVE trong những năm gần đây — hãy giữ kernel được vá lỗi. Một số môi trường container vô hiệu hóa io_uring theo mặc định vì lý do này.
  • Khó debug hơn: strace sẽ không hiển thị các thao tác I/O riêng lẻ được gửi qua ring. Bạn cần vòng lặp io_uring_peek_cqe và gắn tag user_data cẩn thận để theo dõi những gì đã xảy ra.
  • Hệ sinh thái ngôn ngữ vẫn đang theo kịp: C và Rust (qua Tokio) có hỗ trợ vững chắc. Hỗ trợ Python và Go đang trưởng thành nhưng chưa đạt mức đầu tiên.

Cài Đặt Khuyến Nghị

Ubuntu 22.04 LTS đi kèm kernel 5.15 theo mặc định — điều này bao gồm tất cả các tính năng bạn cần cho các trường hợp sử dụng hàng ngày. Dưới đây là cách chuẩn bị môi trường của bạn.

Kiểm tra phiên bản kernel

uname -r
# Đầu ra phải là 5.15.x trở lên trên Ubuntu 22.04

Cài đặt liburing

sudo apt update
sudo apt install -y liburing-dev liburing2

Nếu bạn cần các tính năng mới nhất (multishot reads, zero-copy sends), hãy build từ source code thay thế:

git clone https://github.com/axboe/liburing.git
cd liburing
./configure --prefix=/usr/local
make -j$(nproc)
sudo make install
sudo ldconfig

Kiểm tra io_uring có được bật trên hệ thống

cat /proc/sys/kernel/io_uring_disabled
# 0 = hoàn toàn được bật
# 1 = vô hiệu hóa cho người dùng không có đặc quyền
# 2 = hoàn toàn bị vô hiệu hóa

Nếu bạn thấy 1 hoặc 2, hãy bật nó lên:

sudo sysctl -w kernel.io_uring_disabled=0

Hướng Dẫn Triển Khai

Cờ biên dịch

Mọi chương trình io_uring đều cần liên kết với liburing:

gcc -o my_program my_program.c -luring

Đọc file với io_uring

Dưới đây là ví dụ hoàn chỉnh hoạt động được để đọc file bất đồng bộ:

#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <liburing.h>

#define BUF_SIZE 4096

int main(void) {
    struct io_uring ring;
    char buf[BUF_SIZE];
    memset(buf, 0, sizeof(buf));

    if (io_uring_queue_init(32, &ring, 0) < 0) {
        perror("io_uring_queue_init");
        return 1;
    }

    int fd = open("/etc/os-release", O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fd, buf, BUF_SIZE, 0);
    sqe->user_data = 42;  // tag để nhận dạng thao tác này

    io_uring_submit(&ring);

    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);

    if (cqe->res < 0) {
        fprintf(stderr, "Lỗi đọc: %s\n", strerror(-cqe->res));
    } else {
        printf("Đọc %d bytes:\n%.*s\n", cqe->res, cqe->res, buf);
    }

    io_uring_cqe_seen(&ring, cqe);
    close(fd);
    io_uring_queue_exit(&ring);
    return 0;
}

Gom nhiều thao tác cùng lúc

Sức mạnh thực sự thể hiện khi bạn gửi nhiều thao tác trong một lần:

// Xếp hàng N lần đọc trước khi gửi
for (int i = 0; i < num_files; i++) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_read(sqe, fds[i], bufs[i], BUF_SIZE, 0);
    sqe->user_data = i;  // theo dõi đây là file nào
}

// Gửi TẤT CẢ — vẫn chỉ một syscall duy nhất
io_uring_submit(&ring);

// Thu thập kết quả khi chúng về (thứ tự có thể khác với lúc gửi)
int completed = 0;
while (completed < num_files) {
    struct io_uring_cqe *cqe;
    io_uring_wait_cqe(&ring, &cqe);
    printf("File %llu: nhận được %d bytes\n", cqe->user_data, cqe->res);
    io_uring_cqe_seen(&ring, cqe);
    completed++;
}

Hiệu năng thực tế

Trên server Ubuntu 22.04 production của tôi với 4GB RAM, tôi nhận thấy cách tiếp cận này giảm đáng kể thời gian xử lý khi nạp file hàng loạt — từ ~1.200ms xuống còn ~380ms khi xử lý 500 file cấu hình nhỏ, so với mô hình epoll + read(). Sự khác biệt nằm ở số lượng syscall: trong khi epoll cần hơn 1.000 syscall (500 wait + 500 read), io_uring xử lý tất cả chỉ trong khoảng 10 lần submit.

Bật SQPOLL để đạt throughput không cần syscall

Đối với các workload mạng hoặc lưu trữ NVMe yêu cầu độ trễ thấp, SQPOLL tạo ra một kernel thread liên tục polling submission queue:

struct io_uring_params params = {0};
params.flags = IORING_SETUP_SQPOLL;
params.sq_thread_idle = 2000;  // kernel thread ngủ sau 2 giây không hoạt động

io_uring_queue_init_params(32, &ring, &params);

Lưu ý quan trọng: kernel thread đó ngốn CPU ngay cả khi không hoạt động, và nó yêu cầu CAP_SYS_NICE trên kernel dưới 5.19. Chỉ nên dùng cho các workload batch nặng về throughput, không phải cho các server dành phần lớn thời gian chờ đợi.

Hướng đi của hệ sinh thái

io_uring không còn là thử nghiệm nữa — đây là hướng đi của Linux I/O. Runtime Tokio của Rust có backend io_uring (tokio-uring). NGINX có module io_uring thử nghiệm. Redis đang đánh giá nó cho các đường dẫn storage I/O. Nếu bạn đang xây dựng một server hiệu năng cao mới bằng C, C++ hoặc Rust, việc thiết kế xung quanh io_uring ngay từ đầu thay vì cải tạo lại từ epoll sau này sẽ tiết kiệm đáng kể công sức.

Hãy bắt đầu với ví dụ đọc đơn giản ở trên, làm quen với vòng lặp SQE/CQE, rồi tiến đến batching. API ở mức thấp hơn epoll, nhưng một khi bạn nắm được mô hình, nó vô cùng linh hoạt để mô tả các workflow I/O phức tạp mà nếu không sẽ phải tốn nhiều lần syscall round-trip.

Share: