[譯] Linux 異步 I-O 框架 io_uring:基本原理、程序示例與性能壓測

譯者序

本文組合翻譯了以下兩篇文章的乾貨部分,作爲 io_uring 相關的入門參考:

io_uring 是 2019 年 Linux 5.1 內核首次引入的高性能 異步 I/O 框架,能顯著加速 I/O 密集型應用的性能。 但如果你的應用已經在使用 傳統 Linux AIO 了,並且使用方式恰當, 那 io_uring 並不會帶來太大的性能提升 —— 根據原文測試(以及我們 自己的復現),即便打開高級特性,也只有 5%。除非你真的需要這 5% 的額外性能,否則 切換io_uring 代價可能也挺大,因爲要 重寫應用來適配 io_uring(或者讓依賴的平臺或框架去適配,總之需要改代碼)。

既然性能跟傳統 AIO 差不多,那爲什麼還稱 io_uring 爲革命性技術呢?

  1. 它首先和最大的貢獻在於:統一了 Linux 異步 I/O 框架

    • Linux AIO 只支持 direct I/O 模式的存儲文件 (storage file),而且主要用在數據庫這一細分領域
    • io_uring 支持存儲文件和網絡文件(network sockets),也支持更多的異步系統調用 (accept/openat/stat/...),而非僅限於 read/write 系統調用。
  2. 設計上是真正的異步 I/O,作爲對比,Linux AIO 雖然也 是異步的,但仍然可能會阻塞,某些情況下的行爲也無法預測;

    似乎之前 Windows 在這塊反而是領先的,更多參考:

  3. 靈活性和可擴展性非常好,甚至能基於 io_uring 重寫所有系統調用,而 Linux AIO 設計時就沒考慮擴展性。

eBPF 也算是異步框架(事件驅動),但與 io_uring 沒有本質聯繫,二者屬於不同子系統, 並且在模型上有一個本質區別:

  1. eBPF 對用戶是透明的,只需升級內核(到合適的版本),應用程序無需任何改造
  2. io_uring 提供了新的系統調用和用戶空間 API,因此需要應用程序做改造

eBPF 作爲動態跟蹤工具,能夠更方便地排查和觀測 io_uring 等模塊在執行層面的具體問題。

本文介紹 Linux 異步 I/O 的發展歷史,io_uring 的原理和功能, 並給出了一些程序示例性能壓測結果(我們在 5.10 內核做了類似測試,結論與原文差不多)。

另外,Ceph 已經支持了 io_uring。我們對 kernel 5.10 + ceph 15.x 的壓測顯示, bluestore 打開 io_uring 優化之後,

Ceph 關於 io_uring 的資料非常少,這裏提供一點參考配置:

$ cat /etc/ceph/ceph.conf
[osd]
bluestore_ioring = true
...

確認配置生效(這是隻是隨便挑一個 OSD):

$ ceph config show osd.16 | grep ioring
bluestore_ioring                       true                                            file

以下是譯文。

很多人可能還沒意識到,Linux 內核在過去幾年已經發生了一場革命。這場革命源於 兩個激動人心的新接口的引入:eBPF 和 io_uring。 我們認爲,二者將會完全改變應用與內核交互的方式,以及 應用開發者思考和看待內核的方式

本文介紹 io_uring(我們在 ScyllaDB 中有 io_uring 的深入使用經驗),並略微提及一下 eBPF。

1 Linux I/O 系統調用演進

1.1 基於 fd 的阻塞式 I/O:read()/write()

作爲大家最熟悉的讀寫方式,Linux 內核提供了基於文件描述符的系統調用, 這些描述符指向的可能是存儲文件(storage file),也可能是 network sockets

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

二者稱爲阻塞式系統調用(blocking system calls),因爲程序調用 這些函數時會進入 sleep 狀態,然後被調度出去(讓出處理器),直到 I/O 操作完成:

但很容易想到,隨着存儲設備越來越快,程序越來越複雜, 阻塞式(blocking)已經這種最簡單的方式已經不適用了。

1.2 非阻塞式 I/O:select()/poll()/epoll()

阻塞式之後,出現了一些新的、非阻塞的系統調用,例如 select()poll() 以及更新的 epoll()。 應用程序在調用這些函數讀寫時不會阻塞,而是立即返回,返回的是一個 已經 ready 的文件描述符列表

但這種方式存在一個致命缺點:只支持 network sockets 和 pipes —— epoll() 甚至連 storage files 都不支持。

1.3 線程池方式

對於 storage I/O,經典的解決思路是 thread pool: 主線程將 I/O 分發給 worker 線程,後者代替主線程進行阻塞式讀寫,主線程不會阻塞。

這種方式的問題是線程上下文切換開銷可能非常大,後面性能壓測會看到。

1.4 Direct I/O(數據庫軟件):繞過 page cache

隨後出現了更加靈活和強大的方式:數據庫軟件(database software) 有時 並不想使用操作系統的 page cache, 而是希望打開一個文件後,直接從設備讀寫這個文件(direct access to the device)。 這種方式稱爲直接訪問(direct access)或直接 I/O(direct I/O),

1.5 異步 IO(AIO)

前面提到,隨着存儲設備越來越快,主線程和 worker 線性之間的上下文切換開銷佔比越來越高。 現在市場上的一些設備,例如 Intel Optane延遲已經低到和上下文切換一個量級(微秒 us)。換個方式描述, 更能讓我們感受到這種開銷: 上下文每切換一次,我們就少一次 dispatch I/O 的機會

因此,Linux 2.6 內核引入了異步 I/O(asynchronous I/O)接口, 方便起見,本文簡寫爲 linux-aio。AIO 原理是很簡單的:

近期,Linux AIO 甚至支持了 epoll():也就是說 不僅能提交 storage I/O 請求,還能提交網絡 I/O 請求。照這樣發展下去,linux-aio 似乎能成爲一個王者。但由於它糟糕的演進之路,這個願望幾乎不可能實現了。 我們從 Linus 標誌性的激烈言辭中就能略窺一斑

Reply to: to support opening files asynchronously

So I think this is ridiculously ugly.

AIO is a horrible ad-hoc design, with the main excuse being “other, less gifted people, made that design, and we are implementing it for compatibility because database people — who seldom have any shred of taste — actually use it”.

— Linus Torvalds (on lwn.net)

首先,作爲數據庫從業人員,我們想借此機會爲我們的沒品(lack of taste)向 Linus 道歉。 但更重要的是,我們要進一步解釋一下爲什麼 Linus 是對的:Linux AIO 確實問題纏身,

  1. 只支持 O_DIRECT 文件,因此對常規的非數據庫應用 (normal, non-database applications)幾乎是無用的
  2. 接口在設計時並未考慮擴展性。雖然可以擴展 —— 我們也確實這麼做了 —— 但每加一個東西都相當複雜;
  3. 雖然從技術上說接口是非阻塞的,但實際上有 很多可能的原因都會導致它阻塞,而且引發的方式難以預料。

1.6 小結

以上可以清晰地看出 Linux I/O 的演進:

另外也看到,在非阻塞式讀寫的問題上並沒有形成統一方案

  1. Network socket 領域:添加一個異步接口,然後去輪詢(poll)請求是否完成(readiness);
  2. Storage I/O 領域:只針對某一細分領域(數據庫)在某一特定時期的需求,添加了一個定製版的異步接口。

這就是 Linux I/O 的演進歷史 —— 只着眼當前,出現一個問題就引入一種設計,而並沒有多少前瞻性 —— 直到 io_uring 的出現。

2 io_uring

io_uring 來自資深內核開發者 Jens Axboe 的想法,他在 Linux I/O stack 領域頗有研究。 從最早的 patch aio: support for IO polling 可以看出,這項工作始於一個很簡單的觀察:隨着設備越來越快, 中斷驅動(interrupt-driven)模式效率已經低於輪詢模式 (polling for completions) —— 這也是高性能領域最常見的主題之一。

2.1 與 Linux AIO 的不同

io_uringlinux-aio 有着本質的不同:

  1. 在設計上是真正異步的(truly asynchronous)。只要 設置了合適的 flag,它在系統調用上下文中就只是將請求放入隊列, 不會做其他任何額外的事情,保證了應用永遠不會阻塞

  2. 支持任何類型的 I/O:cached files、direct-access files 甚至 blocking sockets。

    由於設計上就是異步的(async-by-design nature),因此無需 poll+read/write 來處理 sockets。 只需提交一個阻塞式讀(blocking read),請求完成之後,就會出現在 completion ring。

  3. 靈活、可擴展:基於 io_uring 甚至能重寫(re-implement)Linux 的每個系統調用。

2.2 原理及核心數據結構:SQ/CQ/SQE/CQE

每個 io_uring 實例都有兩個環形隊列(ring),在內核和應用程序之間共享:

這兩個隊列:

使用方式

2.3 帶來的好處

io_uring 這種請求方式還有一個好處是:原來需要多次系統調用(讀或寫),現在變成批處理一次提交。

還記得 Meltdown 漏洞嗎?當時我還寫了一篇文章 解釋爲什麼我們的 Scylla NoSQL 數據庫受影響很小:aio 已經將我們的 I/O 系統調用批處理化了。

io_uring 將這種批處理能力帶給了 storage I/O 系統調用之外的 其他一些系統調用,包括:

此外,io_uring 使異步 I/O 的使用場景也不再僅限於數據庫應用,普通的 非數據庫應用也能用。這一點值得重複一遍:

雖然 io_uringaio 有一些相似之處,但它的擴展性和架構是革命性的: 它將異步操作的強大能力帶給了所有應用(及其開發者),而 不再僅限於是數據庫應用這一細分領域

我們的 CTO Avi Kivity 在 the Core C++ 2019 event 上 有一次關於 async 的分享。 核心點包括:從延遲上來說

  1. 現代多核、多 CPU 設備,其內部本身就是一個基礎網絡;
  2. CPU 之間是另一個網絡;
  3. CPU 和磁盤 I/O 之間又是一個網絡。

因此網絡編程採用異步是明智的,而現在開發自己的應用也應該考慮異步。 這從根本上改變了 Linux 應用的設計方式

2.4 三種工作模式

io_uring 實例可工作在三種模式:

  1. 中斷驅動模式(interrupt driven)

    默認模式。可通過 io_uring_enter() 提交 I/O 請求,然後直接檢查 CQ 狀態判斷是否完成。

  2. 輪詢模式(polled)

    Busy-waiting for an I/O completion,而不是通過異步 IRQ(Interrupt Request)接收通知。

    這種模式需要文件系統(如果有)和塊設備(block device)支持輪詢功能。 相比中斷驅動方式,這種方式延遲更低(連繫統調用都省了), 但可能會消耗更多 CPU 資源。

    目前,只有指定了 O_DIRECT flag 打開的文件描述符,才能使用這種模式。當一個讀 或寫請求提交給輪詢上下文(polled context)之後,應用(application)必須調用 io_uring_enter() 來輪詢 CQ 隊列,判斷請求是否已經完成。

    對一個 io_uring 實例來說,不支持混合使用輪詢和非輪詢模式

  3. 內核輪詢模式(kernel polled)

    這種模式中,會 創建一個內核線程(kernel thread)來執行 SQ 的輪詢工作。

    使用這種模式的 io_uring 實例, 應用無需切到到內核態 就能觸發(issue)I/O 操作。 通過 SQ 來提交 SQE,以及監控 CQ 的完成狀態,應用無需任何系統調用,就能提交和收割 I/O(submit and reap I/Os)。

    如果內核線程的空閒時間超過了用戶的配置值,它會通知應用,然後進入 idle 狀態。 這種情況下,應用必須調用 io_uring_enter() 來喚醒內核線程。如果 I/O 一直很繁忙,內核線性是不會 sleep 的。

2.5 io_uring 系統調用 API

有三個:

下面展開介紹。完整文檔見 manpage

2.5.1 io_uring_setup()

執行異步 I/O 需要先設置上下文

int io_uring_setup(u32 entries, struct io_uring_params *p);

這個系統調用

SQ 和 CQ 在應用和內核之間共享,避免了在初始化和完成 I/O 時(initiating and completing I/O)拷貝數據。

參數 p:

io_uring_setup() 成功時返回一個文件描述符(fd)。應用隨後可以將這個 fd 傳給 mmap(2) 系統調用,來 map the submission and completion queues 或者傳給 to the io_uring_register() or io_uring_enter() system calls.

2.5.2 io_uring_register()

註冊用於異步 I/O 的文件或用戶緩衝區(files or user buffers):

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

註冊文件或用戶緩衝區,使內核能長時間持有對該文件在內核內部的數據結構引用(internal kernel data structures associated with the files), 或創建應用內存的長期映射(long term mappings of application memory associated with the buffers), 這個操作只會在註冊時執行一次,而不是每個 I/O 請求都會處理,因此減少了 per-I/O overhead。

註冊的緩衝區(buffer)性質

通過 eventfd() 訂閱 completion 事件

可以用 eventfd(2) 訂閱 io_uring 實例的 completion events。 將 eventfd 描述符通過這個系統調用註冊就行了。

The credentials of the running application can be registered with io_uring which returns an id associated with those credentials. Applications wishing to share a ring between separate users/processes can pass in this credential id in the SQE personality field. If set, that particular SQE will be issued with these credentials.

2.5.3 io_uring_enter()

int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t *sig);

這個系統調用用於初始化和完成(initiate and complete)I/O,使用共享的 SQ 和 CQ。 單次調用同時執行:

  1. 提交新的 I/O 請求
  2. 等待 I/O 完成

參數:

  1. fdio_uring_setup() 返回的文件描述符;

  2. to_submit 指定了 SQ 中提交的 I/O 數量;

  3. 依據不同模式:

    • 默認模式,如果指定了 min_complete,會等待這個數量的 I/O 事件完成再返回;

    • 如果 io_uring 是 polling 模式,這個參數表示:

      1. 0:要求內核返回當前以及完成的所有 events,無阻塞;
      2. 非零:如果有事件完成,內核仍然立即返回;如果沒有完成事件,內核會 poll,等待指定的次數完成,或者這個進程的時間片用完。

注意:對於 interrupt driven I/O,應用無需進入內核就能檢查 CQ 的 event completions

io_uring_enter() 支持很多操作,包括:

當這個系統調用返回時,表示一定數量的 SEQ 已經被消費和提交了,此時可以安全的重用隊列中的 SEQ。 此時 IO 提交有可能還停留在異步上下文中,即實際上 SQE 可能還沒有被提交 —— 不過 用戶不用關心這些細節 —— 當隨後內核需要使用某個特定的 SQE 時,它已經複製了一份。

2.6 高級特性

io_uring 提供了一些用於特殊場景的高級特性:

  1. File registration(文件註冊):每次發起一個指定文件描述的操 作,內核都需要花費一些時鐘週期(cycles)將文件描述符映射到內部表示。 對於那些針對同一文件進行重複操作的場景,io_uring 支持提前註冊這些文件,後面直接查找就行了。
  2. Buffer registration(緩衝區註冊):與 file registration 類 似,direct I/O 場景中,內核需要 map/unmap memory areas。io_uring 支持提前 註冊這些緩衝區(buffers)。
  3. Poll ring(輪詢環形緩衝區):對於非常快是設備,處理中斷的開 銷是比較大的。io_uring 允許用戶關閉中斷,使用輪詢模式。前面 “三種工作模式” 小節 也介紹到了這一點。
  4. Linked operations(鏈接操作):允許用戶發送串聯的請求。這兩 個請求同時提交,但後面的會等前面的處理完纔開始執行。

2.7 用戶空間庫 liburing

liburing 提供了一個簡單的高層 API, 可用於一些基本場景,應用程序避免了直接使用更底層的系統調用。 此外,這個 API 還避免了一些重複操作的代碼,如設置 io_uring 實例。

舉個例子,在 io_uring_setup() 的 manpage 描述中,調用這個系統調用獲得一個 ring 文 件描述符之後,應用必須調用 mmap() 來這樣的邏輯需要一段略長的代碼,而用 liburing 的話,下面的函數已經將上述流程封裝好了:

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

下一節來看兩個例子基於 liburing 的例子。

3 基於 liburing 的示例應用

編譯:

$ git clone https://github.com/axboe/liburing.git
$ git co -b liburing-2.0 tags/liburing-2.0

$ cd liburing
$ ls examples/
io_uring-cp  io_uring-cp.c  io_uring-test  io_uring-test.c  link-cp  link-cp.c  Makefile  ucontext-cp  ucontext-cp.c

$ make -j4

$ ./examples/io_uring-test <file>
Submitted=4, completed=4, bytes=16384

$ ./examples/link-cp <in-file> <out-file>

3.1 io_uring-test

這個程序使用 4 個 SQE,從輸入文件中讀取最多 16KB 數據

源碼及註釋

爲方便看清主要邏輯,忽略了一些錯誤處理代碼,完整代碼見 io_uring-test.c

/* SPDX-License-Identifier: MIT */
/*
 * Simple app that demonstrates how to setup an io_uring interface,
 * submit and complete IO against it, and then tear it down.
 *
 * gcc -Wall -O2 -D_GNU_SOURCE -o io_uring-test io_uring-test.c -luring
 */
#include "liburing.h"

#define QD    4 // io_uring 隊列長度

int main(int argc, char *argv[]) {
    int i, fd, pending, done;
    void *buf;

    // 1. 初始化一個 io_uring 實例
    struct io_uring ring;
    ret = io_uring_queue_init(QD,    // 隊列長度
                              &ring, // io_uring 實例
                              0);    // flags,0 表示默認配置,例如使用中斷驅動模式

    // 2. 打開輸入文件,注意這裏指定了 O_DIRECT flag,內核輪詢模式需要這個 flag,見前面介紹
    fd = open(argv[1], O_RDONLY | O_DIRECT);
    struct stat sb;
    fstat(fd, &sb); // 獲取文件信息,例如文件長度,後面會用到

    // 3. 初始化 4 個讀緩衝區
    ssize_t fsize = 0;             // 程序的最大讀取長度
    struct iovec *iovecs = calloc(QD, sizeof(struct iovec));
    for (i = 0; i < QD; i++) {
        if (posix_memalign(&buf, 4096, 4096))
            return 1;
        iovecs[i].iov_base = buf;  // 起始地址
        iovecs[i].iov_len = 4096;  // 緩衝區大小
        fsize += 4096;
    }

    // 4. 依次準備 4 個 SQE 讀請求,指定將隨後讀入的數據寫入 iovecs 
    struct io_uring_sqe *sqe;
    offset = 0;
    i = 0;
    do {
        sqe = io_uring_get_sqe(&ring);  // 獲取可用 SQE
        io_uring_prep_readv(sqe,        // 用這個 SQE 準備一個待提交的 read 操作
                            fd,         // 從 fd 打開的文件中讀取數據
                            &iovecs[i], // iovec 地址,讀到的數據寫入 iovec 緩衝區
                            1,          // iovec 數量
                            offset);    // 讀取操作的起始地址偏移量
        offset += iovecs[i].iov_len;    // 更新偏移量,下次使用
        i++;

        if (offset > sb.st_size)        // 如果超出了文件大小,停止準備後面的 SQE
            break;
    } while (1);

    // 5. 提交 SQE 讀請求
    ret = io_uring_submit(&ring);       // 4 個 SQE 一次提交,返回提交成功的 SQE 數量
    if (ret < 0) {
        fprintf(stderr, "io_uring_submit: %s\n", strerror(-ret));
        return 1;
    } else if (ret != i) {
        fprintf(stderr, "io_uring_submit submitted less %d\n", ret);
        return 1;
    }

    // 6. 等待讀請求完成(CQE)
    struct io_uring_cqe *cqe;
    done = 0;
    pending = ret;
    fsize = 0;
    for (i = 0; i < pending; i++) {
        io_uring_wait_cqe(&ring, &cqe);  // 等待系統返回一個讀完成事件
        done++;

        if (cqe->res != 4096 && cqe->res + fsize != sb.st_size) {
            fprintf(stderr, "ret=%d, wanted 4096\n", cqe->res);
        }

        fsize += cqe->res;
        io_uring_cqe_seen(&ring, cqe);   // 更新 io_uring 實例的完成隊列
    }

    // 7. 打印統計信息
    printf("Submitted=%d, completed=%d, bytes=%lu\n", pending, done, (unsigned long) fsize);

    // 8. 清理工作
    close(fd);
    io_uring_queue_exit(&ring);
    return 0;
}

其他說明

代碼中已經添加了註釋,這裏再解釋幾點:

link-cp 使用 io_uring 高級特性 SQE chaining 特性來複制文件。

I/O chain

io_uring 支持創建 I/O chain。一個 chain 內的 I/O 是順序執行的,多個 I/O chain 可以並行執行。

io_uring_enter() manpage 中對 IOSQE_IO_LINK詳細解釋

When this flag is specified, it forms a link with the next SQE in the submission ring. That next SQE will not be started before this one completes. This, in effect, forms a chain of SQEs, which can be arbitrarily long. The tail of the chain is denoted by the first SQE that does not have this flag set. This flag has no effect on previous SQE submissions, nor does it impact SQEs that are outside of the chain tail. This means that multiple chains can be executing in parallel, or chains and individual SQEs. Only members inside the chain are serialized. A chain of SQEs will be broken, if any request in that chain ends in error. io_uring considers any unexpected result an error. This means that, eg, a short read will also terminate the remainder of the chain. If a chain of SQE links is broken, the remaining unstarted part of the chain will be terminated and completed with -ECANCELED as the error code. Available since 5.3.

爲實現複製文件功能,link-cp 創建一個長度爲 2 的 SQE chain。

源碼及註釋

/* SPDX-License-Identifier: MIT */
/*
 * Very basic proof-of-concept for doing a copy with linked SQEs. Needs a
 * bit of error handling and short read love.
 */
#include "liburing.h"

#define QD    64         // io_uring 隊列長度
#define BS    (32*1024)

struct io_data {
    size_t offset;
    int index;
    struct iovec iov;
};

static int infd, outfd;
static unsigned inflight;

// 創建一個 read->write SQE chain
static void queue_rw_pair(struct io_uring *ring, off_t size, off_t offset) {
    struct io_uring_sqe *sqe;
    struct io_data *data;
    void *ptr;

    ptr = malloc(size + sizeof(*data));
    data = ptr + size;
    data->index = 0;
    data->offset = offset;
    data->iov.iov_base = ptr;
    data->iov.iov_len = size;

    sqe = io_uring_get_sqe(ring);                            // 獲取可用 SQE
    io_uring_prep_readv(sqe, infd, &data->iov, 1, offset);   // 準備 read 請求
    sqe->flags |= IOSQE_IO_LINK;                             // 設置爲 LINK 模式
    io_uring_sqe_set_data(sqe, data);                        // 設置 data

    sqe = io_uring_get_sqe(ring);                            // 獲取另一個可用 SQE
    io_uring_prep_writev(sqe, outfd, &data->iov, 1, offset); // 準備 write 請求
    io_uring_sqe_set_data(sqe, data);                        // 設置 data
}

// 處理完成(completion)事件:釋放 SQE 的內存緩衝區,通知內核已經消費了 CQE。
static int handle_cqe(struct io_uring *ring, struct io_uring_cqe *cqe) {
    struct io_data *data = io_uring_cqe_get_data(cqe);       // 獲取 CQE
    data->index++;

    if (cqe->res < 0) {
        if (cqe->res == -ECANCELED) {
            queue_rw_pair(ring, BS, data->offset);
            inflight += 2;
        } else {
            printf("cqe error: %s\n", strerror(cqe->res));
            ret = 1;
        }
    }

    if (data->index == 2) {        // read->write chain 完成,釋放緩衝區內存
        void *ptr = (void *) data - data->iov.iov_len;
        free(ptr);
    }

    io_uring_cqe_seen(ring, cqe);  // 通知內核已經消費了 CQE 事件
    return ret;
}

static int copy_file(struct io_uring *ring, off_t insize) {
    struct io_uring_cqe *cqe;
    size_t this_size;
    off_t offset;

    offset = 0;
    while (insize) {                      // 數據還沒處理完
        int has_inflight = inflight;      // 當前正在進行中的 SQE 數量
        int depth;  // SQE 閾值,當前進行中的 SQE 數量(inflight)超過這個值之後,需要阻塞等待 CQE 完成

        while (insize && inflight < QD) { // 數據還沒處理完,io_uring 隊列也還沒用完
            this_size = BS;
            if (this_size > insize)       // 最後一段數據不足 BS 大小
                this_size = insize;

            queue_rw_pair(ring, this_size, offset); // 創建一個 read->write chain,佔用兩個 SQE
            offset += this_size;
            insize -= this_size;
            inflight += 2;                // 正在進行中的 SQE 數量 +2
        }

        if (has_inflight != inflight)     // 如果有新創建的 SQE,
            io_uring_submit(ring);        // 就提交給內核

        if (insize)                       // 如果還有 data 等待處理,
            depth = QD;                   // 閾值設置 SQ 的隊列長度,即 SQ 隊列用完纔開始阻塞等待 CQE;
        else                              // data 處理已經全部提交,
            depth = 1;                    // 閾值設置爲 1,即只要還有 SQE 未完成,就阻塞等待 CQE

        // 下面這個 while 只有 SQ 隊列用完或 data 全部提交之後纔會執行到
        while (inflight >= depth) {       // 如果所有 SQE 都已經用完,或者所有 data read->write 請求都已經提交
            io_uring_wait_cqe(ring, &cqe);// 等待內核 completion 事件
            handle_cqe(ring, cqe);        // 處理 completion 事件:釋放 SQE 內存緩衝區,通知內核 CQE 已消費
            inflight--;                   // 正在進行中的 SQE 數量 -1
        }
    }

    return 0;
}

static int setup_context(unsigned entries, struct io_uring *ring) {
    io_uring_queue_init(entries, ring, 0);
    return 0;
}

static int get_file_size(int fd, off_t *size) {
    struct stat st;

    if (fstat(fd, &st) < 0)
        return -1;
    if (S_ISREG(st.st_mode)) {
        *size = st.st_size;
        return 0;
    } else if (S_ISBLK(st.st_mode)) {
        unsigned long long bytes;

        if (ioctl(fd, BLKGETSIZE64, &bytes) != 0)
            return -1;

        *size = bytes;
        return 0;
    }

    return -1;
}

int main(int argc, char *argv[]) {
    struct io_uring ring;
    off_t insize;
    int ret;

    infd = open(argv[1], O_RDONLY);
    outfd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);

    if (setup_context(QD, &ring))
        return 1;
    if (get_file_size(infd, &insize))
        return 1;

    ret = copy_file(&ring, insize);

    close(infd);
    close(outfd);
    io_uring_queue_exit(&ring);
    return ret;
}

其他說明

代碼中實現了三個函數:

  1. copy_file():高層複製循環邏輯;它會調用 queue_rw_pair(ring, this_size, offset) 來構造 SQE pair; 並通過一次 io_uring_submit() 調用將所有構建的 SQE pair 提交。

    這個函數維護了一個最大 DQ 數量的 inflight SQE,只要數據 copy 還在進行中;否則,即數據已經全部讀取完成,就開始等待和收割所有的 CQE。

  2. queue_rw_pair() 構造一個 read-write SQE pair.

    read SQE 的 IOSQE_IO_LINK flag 表示開始一個 chain,write SQE 不用設置這個 flag,標誌着這個 chain 的結束。 用戶 data 字段設置爲同一個 data 描述符,並且在隨後的 completion 處理中會用到。

  3. handle_cqe() 從 CQE 中提取之前由 queue_rw_pair() 保存的 data 描述符,並在描述符中記錄處理進展(index)。

    如果之前請求被取消,它還會重新提交 read-write pair。

    一個 CQE pair 的兩個 member 都處理完成之後(index==2),釋放共享的 data descriptor。 最後通知內核這個 CQE 已經被消費。

4 io_uring 性能壓測(基於 fio

對於已經在使用 linux-aio 的應用,例如 ScyllaDB, 不要期望換成 io_uring 之後能獲得大幅的性能提升,這是因爲: io_uring 性能相關的底層機制與 linux-aio 並無本質不同(都是異步提交,輪詢結果)。

在此,本文也希望使讀者明白:io_uring 首先和最重要的貢獻在於: 將 linux-aio 的所有優良特性帶給了普羅大衆(而非侷限於數據庫這樣的細分領域)。

4.1 測試環境

本節使用 fio 測試 4 種模式:

  1. synchronous reads
  2. posix-aio (implemented as a thread pool)
  3. linux-aio
  4. io_uring

硬件:

4.2 場景一:direct I/O 1KB 隨機讀(繞過 page cache)

第一組測試中,我們希望所有的讀請求都能命中存儲設備(all reads to hit the storage),完全繞開操作系統的頁緩存(page cache)。

測試配置:

這種配置保證了 CPU 處於飽和狀態,便於觀察 I/O 性能。 如果 CPU 數量足夠多,那每組測試都可能會打滿設備帶寬,結果對 I/O 壓測就沒意義了。

表 1. Direct I/O(繞過系統頁緩存):1KB 隨機讀,CPU 100% 下的 I/O 性能

32F7rT

幾點分析:

  1. io_uring 相比 linux-aio 確實有一定提升,但並非革命性的。

  2. 開啓高級特性,例如 buffer & file registration 之後性能有進一步提升 —— 但也還 沒有到爲了這些性能而重寫整個應用的地步,除非你是搞數據庫研發,想榨取硬件的最後一分性能。

  3. io_uring and linux-aio 都比同步 read 接口快 2 倍,而後者又比 posix-aio 快 2 倍 —— 初看有點差異。但看看上下文切換次數,就不難理解爲什麼 posix-aio 這麼慢了。

    • 同步 read 性能差是因爲:在這種沒有 page cache 的情況下, 每次 read 系統調用都會阻塞,因此就會涉及一次上下文切換
    • posix-aio 性能更差是因爲:不僅內核和應用程序之間要頻繁上下文切換,線程池的多個線程之間也在頻繁切換

4.2 場景二:buffered I/O 1KB 隨機讀(數據提前加載到內存,100% hot cache)

第二組測試 buffered I/O:

  1. 將文件數據提前加載到內存,然後再測隨機讀。

    • 由於數據全部在 page cache,因此同步 read 永遠不會阻塞
    • 這種場景下,我們預期同步讀和 io_uring 的性能差距不大(都是最好的)
  2. 其他測試條件不變。

表 2. Buffered I/O(數據全部來自 page cache,100% hot cache):1KB 隨機讀,100% CPU 下的 I/O 性能

79hH8d

結果分析:

  1. 同步讀和 io_uring 性能差距確實很小,二者都是最好的。

    但注意,實際的應用不可能一直 100% 時間執行 IO 操作,因此 基於同步讀的真實應用性能還是要比基於 io_uring 要差的,因爲 io_uring 會將多個系統調用批處理化。

  2. posix-aio 性能最差,直接原因是上下文切換次數太多,這也和場景相關: 在這種 CPU 飽和的情況下,它的線程池反而是累贅,會完全拖慢性能。

  3. linux-aio不是針對 buffered I/O 設計的,在這種 page cache 直接返回的場景, 它的異步接口反而會造成性能損失 —— 將操作分 爲 dispatch 和 consume 兩步不但沒有性能收益,反而有額外開銷。

4.3 性能測試小結

最後再次提醒,本節是極端應用 / 場景(100% CPU + 100% cache miss/hit)測試, 真實應用的行爲通常處於同步讀和異步讀之間:時而一些阻塞操作,時而一些非阻塞操作。 但不管怎麼說,用了 io_uring 之後,用戶就無需擔心同步和異步各佔多少比例了,因爲它在任何場景下都表現良好

  1. 如果操作是非阻塞的,io_uring 不會有額外開銷;
  2. 如果操作是阻塞式的,也沒關係,io_uring 是完全異步的,並且不依賴線程池或昂貴的上下文切換來實現這種異步能力;

本文測試的都是隨機讀,但對其他類型的操作io_uring 表現也是非常良好的。例如:

  1. 打開 / 關閉文件
  2. 設置定時器
  3. 通過 network sockets 傳輸數據

而且使用的是同一套 io_uring 接口

4.4 ScyllaDB 與 io_uring

Scylla 重度依賴 direct I/O,從一開始就使用 linux-aio。 在我們轉向 io_uring 的過程中,最開始測試顯示對某些 workloads,能取得 50% 以上的性能提升。 但深入研究之後發現,這是因爲我們之前的 linux-aio 用的不夠好。 這也揭示了一個經常被忽視的事實:獲得高性能沒有那麼難(前提是你得弄對了)。 在對比 io_uringlinux-aio 應用之後,我們很快改進了一版,二者的性能差距就消失了。 但坦率地說,解決這個問題需要一些工作量,因爲要改動一個已經使用 了很多年的基於 linux-aio 的接口。而對 io_uring 應用來說,做類似的改動是輕而 易舉的。

以上只是一個場景,io_uring 相比 linux-aio優勢是能應用於 file I/O 之外的場景。 此外,它還自帶了特殊的 高性能 接口,例如 buffer registration、file registration、輪詢模式等等。

啓用 io_uring 高級特性之後,我們看到性能確實有提升:Intel Optane 設備,單個 CPU 讀取 512 字節,觀察到 5% 的性能提升。與 表 1 & 2 對得上。雖然 5% 的提升 看上去不是太大,但對於希望壓榨出硬件所有性能的數據庫來說,還是非常寶貴的。

1vAIdo

5 eBPF

eBPF 也是一個事件驅動框架(因此也是異步的),允許用戶空間程序動態向內核注入字節碼,主要有兩個使用場景:

  1. Networking:本站 已經有相當多的文章
  2. Tracing & Observability:例如 bcc 等工具

eBPF 在內核 4.9 首次引入,4.19 以後功能已經很強大。更多關於 eBPF 的演進信息,可參考: (譯)大規模微服務利器:eBPF + Kubernetes(KubeCon, 2020)

談到與 io_uring 的結合,就是用 bcc 之類的工具跟蹤一些 I/O 相關的內核函數,例如:

  1. Trace how much time an application spends sleeping, and what led to those sleeps. (wakeuptime)
  2. Find all programs in the system that reached a particular place in the code (trace)
  3. Analyze network TCP throughput aggregated by subnet (tcpsubnet)
  4. Measure how much time the kernel spent processing softirqs (softirqs)
  5. Capture information about all short-lived files, where they come from, and for how long they were opened (filelife)

6 結束語

io_uring 和 eBPF 這兩大特性將給 Linux 編程帶來革命性的變化。 有了這兩個特性的加持,開發者就能更充分地利用 Amazon i3en meganode systems 之類的多核 / 多處理器系統,以及 Intel Optane 持久存儲 之類的 us 級延遲存儲設備。

參考資料

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://arthurchiao.art/blog/intro-to-io-uring-zh/