原生的 Linux 異步文件操作,io_uring 嚐鮮體驗

Linux異步IO的歷史

異步IO一直是 Linux 系統的痛。Linux 很早就有 POSIX AIO 這套異步IO實現,但它是在用戶空間自己開用戶線程模擬的,效率極其低下。後來在 Linux 2.6 引入了真正的內核級別支持的異步IO實現(Linux aio),但是它只支持 Direct IO,只支持磁盤文件讀寫,而且對文件大小還有限制,總之各種麻煩。到目前爲止(2019年5月),libuv 還是在用pthread+preadv的形式實現異步IO。

隨着 Linux 5.1 的發佈,Linux 終於有了自己好用的異步IO實現,並且支持大多數文件類型(磁盤文件、socket,管道等),這個就是本文的主角:io_uring

IOCP

於IO多路複用模型 epoll 不同,io_uring 的思想更類似於 Windows 上的 IOCP。用快遞來舉例:同步模型就是你從在電商平臺下單前,就在你家樓下一直等,直到快遞公司把貨送到樓下,你再把東西帶上樓。epoll 類似於你下單,快遞公司送到樓下,通知你可以去樓下取貨了,這時你下樓把東西帶上來。雖然還是需要用戶下樓取貨(有一段同步讀寫的時間),但是由於不需要等快遞在路上的時間,效率已經有非常大的提升。但是,epoll不適用於磁盤IO,因爲磁盤文件總是可讀的。

而 IOCP 就是一步到位,直接送貨上門,連下樓取的動作都不需要。整個過程完全是非阻塞的。

io_uring 的簡單使用

io_uring 是一套系統調用接口,雖然總共就3個系統調用,但實際使用卻非常複雜。這裏直接介紹封裝過便於用戶使用的 liburing

在嘗試前請首先確認自己的 Linux 內核版本在 5.1 以上(uname -r)。liburing 需要自己編譯(之後可能會被各大Linux發行版以軟件包的形式收錄),git clone 後直接 ./configure && sudo make install 就好了。

io_uring 結構初始化

liburing 提供了自己的核心結構 io_uring,它內部封裝了 io_uring 自己的文件描述符(fd)以及其他與內核通信所需變量。

struct io_uring {
    struct io_uring_sq sq;
    struct io_uring_cq cq;
    int ring_fd;
};

使用之前需要先初始化,使用 io_uring_queue_init 初始化此結構。

extern int io_uring_queue_init(unsigned entries, struct io_uring *ring,
    unsigned flags);

如函數名稱所示, io_uring 是一個循環隊列(ring_buffer)。第一個參數 entries 表示隊列大小(實際空間可能比用戶指定的大);第二個參數 ring 就是需要初始化的 io_uring 結構指針;第三個參數 flags 是標誌參數,無特殊需要傳 0 即可。例如

#include <liburing.h>
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);

提交讀、寫請求

首先使用 io_uring_get_sqe 獲取 sqe 結構。

extern struct io_uring_sqe *io_uring_get_sqe(struct io_uring *ring);

一個 sqe(submission queue entry)代表一次 IO 請求,佔用循環隊列一個空位。io_uring 隊列滿時 io_uring_get_sqe 會返回 NULL,注意錯誤處理。

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);

然後使用 io_uring_prep_readvio_uring_prep_writev 初始化 sqe 結構。

static inline void io_uring_prep_readv(struct io_uring_sqe *sqe, int fd,
                       const struct iovec *iovecs,
                       unsigned nr_vecs, off_t offset);
static inline void io_uring_prep_writev(struct io_uring_sqe *sqe, int fd,
                    const struct iovec *iovecs,
                    unsigned nr_vecs, off_t offset);

第一個參數 sqe 即前面獲取的 sqe 結構指針;fd 爲需要讀寫的文件描述符,可以是磁盤文件也可以是socket;iovecs 爲 iovec 數組,具體使用請參照 readv 和 writevnr_vecs 爲 iovecs 數組元素個數,offset 爲文件操作的偏移量。

可以看到這兩個函數完全按照 preadvpwritev 設計,語義也相同,所以很好上手。需要注意的是,如果需要順序讀寫文件,偏移量 offset 需要程序自己維護。

struct iovec iov = {
    .iov_base = "Hello world",
    .iov_len = strlen("Hello world"),
};
io_uring_prep_writev(sqe, fd, &iov, 1, 0);

初始化 sqe 後,可以用 io_uring_sqe_set_data,傳入你自己的數據,一般是一個 malloc 得到的指針,C++ 裏面可以直接傳 this。

static inline void io_uring_sqe_set_data(struct io_uring_sqe *sqe, void *data);

注意 prep_ 中會 memset(0),一定要先 prep_ 再 set_data。筆者這裏糾結了兩個小時。

準備好 sqe 後即可使用 io_uring_submit 提交請求。

extern int io_uring_submit(struct io_uring *ring);

你可以一次性初始化多個 sqe 然後一次性 submit

io_uring_submit(&ring);

完成 IO 請求

io_uring_submit 都是異步操作,不會阻塞當前線程。那麼如何得知提交的操作何時完成呢?liburing 提供了函數 io_uring_peek_cqeio_uring_wait_cqe 兩個函數獲取當前已完成的 IO 操作。

extern int io_uring_peek_cqe(struct io_uring *ring,
    struct io_uring_cqe **cqe_ptr);
extern int io_uring_wait_cqe(struct io_uring *ring,
    struct io_uring_cqe **cqe_ptr);

第一個參數是 io_uring 結構指針;第二個參數 cqe_ptr 是輸出參數,是 cqe 指針變量的地址。

cqe(completion queue entry)標記一個已完成的 IO 操作,同時也記錄的之前傳入的用戶數據。每個 cqe 都與前面的 sqe 對應。

這兩個函數,io_uring_peek_cqe 如果沒有已完成的 IO 操作時,也會立即返回,cqe_ptr 被置空;而
io_uring_wait_cqe 會阻塞線程,等待 IO 操作完成。

for (;;) {
    io_uring_peek_cqe(&ring, &cqe);
    if (!cqe) {
        puts("Waiting...");
        // accept 新連接,做其他事
    } else {
        puts("Finished.");
        break;
    }
}

上文簡單起見用忙等待做示例,在實際應用場景中應該是一個事件循環,瀏覽器、nodejs 給我們內部隱藏了事件循環的實現,而寫 C/C++ 語言只能我們自己做。

可通過 io_uring_cqe_get_data 獲取前面給 sqe 設置的用戶數據。

static inline void *io_uring_cqe_get_data(struct io_uring_cqe *cqe);

默認情況下 IO 完成事件不會從隊列中清除,導致 io_uring_peek_cqe 會獲取到相同事件,使用 io_uring_cqe_seen 標記該事件已被處理

static inline void io_uring_cqe_seen(struct io_uring *ring,
                     struct io_uring_cqe *cqe);
io_uring_cqe_seen(&ring, cqe);

清除 io_uring,釋放資源

清除 io_uring 結構使用 io_uring_queue_exit

extern void io_uring_queue_exit(struct io_uring *ring);
io_uring_queue_exit(&ring);

完整代碼列舉如下:這段代碼作用就是創建文件 /home/carter/test.txt 並寫入字符串 Hello world

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

int main()
{
    struct io_uring ring;
    io_uring_queue_init(32, &ring, 0);

    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    int fd = open("/home/carter/test.txt", O_WRONLY | O_CREAT);
    struct iovec iov = {
        .iov_base = "Hello world",
        .iov_len = strlen("Hello world"),
    };
    io_uring_prep_writev(sqe, fd, &iov, 1, 0);
    io_uring_submit(&ring);

    struct io_uring_cqe *cqe;

    for (;;) {
        io_uring_peek_cqe(&ring, &cqe);
        if (!cqe) {
            puts("Waiting...");
            // accept 新連接,做其他事
        } else {
            puts("Finished.");
            break;
        }
    }
    io_uring_cqe_seen(&ring, cqe);
    io_uring_queue_exit(&ring);
}

可以看到,C語言的異步操作還是比同步操作複雜不少,libuv(nodejs 的底層 IO 庫)已經 表示會引入 io_uring。如果要自己用,一定要使用一個協程庫簡化異步操作。

這裏 是我使用自己編寫的協程庫 Cxx-yield 實現的一個簡單的文件服務器 demo。可以看到,經過簡單封裝後,異步文件讀寫可以簡化到一行:https://github.com/CarterLi/C...。就是那種在 JavaScript 裏寫 async、await 的快感

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章