io_uring là gì: API I/O bất đồng bộ hiệu năng cao của nhân Linux

io_uring là giao diện lập trình ứng dụng (API) I/O bất đồng bộ dành riêng cho nhân Linux, cho phép tiến trình gửi hàng loạt yêu cầu đọc ghi mà không phải chờ từng lần gọi hệ thống. Thay vì mỗi thao tác I/O là một read() hay write() riêng lẻ, io_uring dùng hai vòng bộ nhớ chia sẻ giữa không gian người dùng và không gian nhân, giúp giảm đáng kể chi phí syscall — một trong những nút thắt cổ chai cũng của các API I/O khác trong hệ điều hành khác.

Đây là cơ chế mà các hệ thống hiệu năng cao như database, message broker, web server hay các ứng dụng lưu trữ như RocksDB, Ceph đang dùng để xử lý hàng triệu thao tác I/O mỗi giây.

Vì sao syscall lại là nút thắt cổ chai

Trên Linux, mỗi lần gọi read() hay write() đều phải chuyển từ không gian người dùng sang không gian nhân. Chi phí của lần chuyển mode này không nhỏ: mỗi syscall trung bình tốn khoảng vài trăm nanosecond chỉ để vào ra, chưa kể sao chép bộ đệm giữa hai không gian.

Khi ứng dụng cần xử lý hàng nghìn thao tác nhỏ mỗi giây — ví dụ một cơ sở dữ liệu đọc hàng trăm trang dữ liệu từ đĩa — tổng thời gian chỉ đi vào syscall có thể chiếm tới một nửa thời gian CPU. Cơ chế aio_read() và aio_write() của POSIX ra đời để giải quyết vấn đề này, nhưng nó dùng cơ chế chia sẻ biến điều kiện và thread, phát sinh chi phí thay vì loại bỏ chi phí.

Sơ đồ kiến trúc nhân Linux với các lớp hệ thống tệp và lớp I/O nơi io_uring đặt giao diện bất đồng bộ

Kiến trúc của io_uring: hai vòng bộ nhớ chia sẻ

Điểm đặc thù của io_uring nằm ở chỗ nó không giao tiếp với nhân chỉ qua syscall. Thay vào đó, người dùng và nhân chia sẻ trực tiếp các vòng bộ nhớ, và syscall chỉ còn chức năng báo hiệu rằng có việc mới.

Cấu trúc gồm hai vòng chính:

  • Submission Queue (SQ) — vòng gửi yêu cầu, nằm ở phía người dùng. Mỗi yêu cầu I/O là một entry gọi là SQE (Submission Queue Entry), mô tả đúng thao tác mà bạn định gọi hệ thống.
  • Completion Queue (CQ) — vòng nhận kết quả, nằm ở phía nhân. Mỗi yêu cầu đã xử lý xong sẽ sinh ra một CQE (Completion Queue Entry) chứa kết quả trả về.

Cả hai vòng đều được ánh xạ vào không gian người dùng bằng mmap(). Người dùng ghi SQE vào đuôi vòng SQ, nhân đọc từ đầu vòng và xử lý. Sau đó nhân ghi CQE vào đuôi vòng CQ, người dùng đọc từ đầu vòng. Đây là mô hình producer-consumer vòng tròn, không cần sao chép bộ đệm qua lại giữa hai không gian.

Khái niệm Vai trò Phía nào ghi
SQE Mô tả một thao tác I/O cần thực hiện Người dùng
CQE Kết quả của một SQE đã xử lý Nhân
opcode Loại thao tác, ví dụ IORING_OP_READV Người dùng
user_data Mã định danh do người dùng tự gán, nhân trả lại nguyên vẹn Người dùng
res Kết quả trả về, giá trị âm là mã lỗi dạng -errno Nhân

Một chi tiết quan trọng: io_uring không dùng biến errno để báo lỗi như API I/O cổ điển. Trường res trong CQE chứa giá trị mà lệnh hệ thống tương ứng sẽ trả về nếu thành công, hoặc mã lỗi âm nếu thất bại — ví dụ res = 1024 nghĩa là đọc được 1024 byte, còn res = -EINVAL nghĩa là tham số không hợp lệ.

Thẻ ổ cứng SSD Intel P3608 NVMe gắn qua khe PCIe, thiết bị lưu trữ mà io_uring hướng tới tối ưu hiệu năng truy cập

Quy trình sử dụng cơ bản

Một chu trình sử dụng io_uring điển hình gồm bốn bước:

  1. Khởi tạo — gọi io_uring_setup() để tạo vòng, rồi dùng mmap() ánh xạ SQ và CQ vào không gian người dùng. Với nhân từ 5.4 trở lại chỉ cần hai lần mmap(), các bản cũ hơn cần ba lần.
  2. Ghi yêu cầu — điền thông tin vào một SQE. Có thể ghi nhiều SQE liên tiếp rồi mới báo cho nhân một lần.
  3. Báo cho nhân — gọi io_uring_enter() để nhân bắt đầu xử lý. Lệnh này còn có thể yêu cầu nhân chờ tới khi đủ số lượng kết quả rồi mới quay lại.
  4. Đọc kết quả — lấy CQE từ CQ, kiểm tra trường res và user_data để biết kết quả thuộc yêu cầu nào.

Ví dụ cấu trúc SQE rút gọn, cho thấy các trường thường dùng nhất:

struct io_uring_sqe {
    __u8  opcode;      /* loại thao tác, ví dụ IORING_OP_READV */
    __s32 fd;          /* file descriptor */
    __u64 off;         /* vị trí trong tệp */
    __u64 addr;        /* con trỏ bộ đệm hoặc mảng iovec */
    __u32 len;         /* kích thước bộ đệm */
    __u64 user_data;   /* mã định danh trả lại khi hoàn tất */
    __u8  flags;       /* các cờ IOSQE */
};

Lấy SQE tiếp theo chỉ cần đọc con trỏ đuôi, che bằng mặt nạ kích thước vòng rồi tăng con trỏ lên một:

struct io_uring_sqe *sqe;
unsigned tail, index;

tail  = *sqring->tail;
index = tail & (*sqring->ring_mask);
sqe   = &sqring->sqes[index];

/* điền thông tin thao tác I/O vào đây */
describe_io(sqe);

sqring->array[index] = index;
tail++;
atomic_store_explicit(sqring->tail, tail, memory_order_release);

Thao tác cập nhật tail dùng memory_order_release là bắt buộc. Vì vòng bộ nhớ nằm ở cả hai không gian, trình biên dịch và CPU có thể tự ý sắp xếp lại thứ tự đọc ghi. Rào bộ nhớ bảo đảm dữ liệu trong SQE luôn nhìn thấy được từ phía nhân trước khi nhân xử lý.

Submission Queue Polling: bỏ hẳn syscall

Tính năng đáng chú ý nhất của io_uring là chế độ SQ Polling. Thay vì gọi io_uring_enter() mỗi lần có yêu cầu mới, io_uring khởi động một luồng nhân chuyên poll vòng SQ. Người dùng chỉ cần ghi SQE vào vòng, luồng nhân sẽ tự nhận ra.

Chi phí syscall bị loại bỏ hoàn toàn khỏi đường chạy nóng (hot path). Đổi lại, CPU của máy chủ sẽ dàng một lõi cho luồng poll liên tục — nên kỹ thuật này chỉ đáng dùng khi tần suất I/O đủ cao để bù lại chi phí đó.

So sánh với AIO và epoll

Tiêu chí io_uring POSIX AIO epoll
Mô hình Vòng bộ nhớ chia sẻ Thread + biến điều kiện Một syscall cho nhiều fd
Sao chép bộ đệm Thường không cần Có Không áp dụng
Băng thông cho tác vụ nhỏ Rất cao Thấp Trung bình
Độ phức tạp lập trình Trung bình Thấp Thấp
Phạm vi I/O Tệp, socket, pipe, timer, poll Chủ yếu tệp Chỉ mô tả sẵn sàng

Điểm mấu chốt: epoll chỉ cho bạn biết “socket này sẵn sàng đọc”, còn io_uring trực tiếp thực hiện thao tác đọc đó. Với ứng dụng nặng I/O, việc gộp cả hai lại vào một giao diện giúp loại bỏ phần lớn logic quản lý trạng thái thủ công.

Lưu ý quan trọng khi lập trình

  • Không có thứ tự hoàn tất. Yêu cầu có thể xong theo bất kỳ thứ tự nào. Với các thao tác cần đúng thứ tự như gửi trên socket TCP, nên dùng cờ IOSQE_IO_LINK để buộc nhân thực thi liên tiếp.
  • Vòng SQ có thể đầy. Luôn kiểm tra số entry còn trống trước khi ghi thêm.
  • Tuổi thọ con trỏ. Khi bật SQ Polling, con trỏ bộ đệm phải sống tới khi thao tác hoàn tất, không được giải phóng sớm. Cờ IORING_FEAT_SUBMIT_STABLE cho biết hành vi này đã thay đổi ở nhân mới hay chưa.

io_uring hiện đã có trong nhân Linux từ phiên bản 5.1 và ngày càng được dùng rộng rãi. Nếu bạn đang xây dựng công cụ xử lý dữ liệu lớn, database nhúng hay proxy hiệu năng cao, đây là công cụ đáng đầu tư tìm hiểu.

Tài liệu tham khảo: trang hướng dẫn chính thức io_uring(7) trong Linux manual pages, tài liệu API của dự án io_uring_queue_init_params(3) và bài tổng quan The Quest for AIO của Jens Axboe.

Tôi là một lập trình viên IOS. Code chính là IOS nhưng thỉnnh thoảng vẫn đá sang Android hoặc web. Mặc dù không quá thông thạo nhưng tôi sẽ chia sẻ những kiến thức mà mình đã tìm hiểu, áp dụng qua.

Bài viết liên quan

Tailwind CSS là gì: Utility-first CSS cho lập trình viên

Tailwind CSS là gì? Đây là một framework CSS mã nguồn mở, khác với Bootstrap hay Foundation ở cách tiếp cận: thay vì đưa sẵn một bộ class dựng sẵn…

Xem thêm

Consistent hashing là gì: Cân bằng tải cho hệ thống phân tán

Consistent hashing là gì? Đây là kỹ thuật băm đặc biệt giúp hệ thống phân tán chỉ phải di chuyển một phần rất nhỏ dữ liệu mỗi khi số lượng…

Xem thêm
Hàng server thật trong data center, nơi Redis chạy trên hạ tầng vật lý

Redis là gì: Kho dữ liệu trong RAM và toàn bộ kiến trúc

Redis là gì và vì sao nó xuất hiện trong gần như mọi kiến trúc backend hiện đại? Câu trả lời nằm ở hai đặc điểm: toàn bộ dữ liệu…

Xem thêm
0 0 đánh giá
Article Rating
Theo dõi
Thông báo của
guest
0 Comments
Cũ nhất
Mới nhất Được bỏ phiếu nhiều nhất