[譯] Linux 異步 I-O 框架 io_uring:基本原理、程序示例與性能壓測
譯者序
本文組合翻譯了以下兩篇文章的乾貨部分,作爲 io_uring
相關的入門參考:
- How io_uring and eBPF Will Revolutionize Programming in Linux, ScyllaDB, 2020
- An Introduction to the io_uring Asynchronous I/O Framework, Oracle, 2020
io_uring
是 2019 年 Linux 5.1 內核首次引入的高性能 異步 I/O 框架,能顯著加速 I/O 密集型應用的性能。 但如果你的應用已經在使用 傳統 Linux AIO 了,並且使用方式恰當, 那 io_uring
並不會帶來太大的性能提升 —— 根據原文測試(以及我們 自己的復現),即便打開高級特性,也只有 5%。除非你真的需要這 5% 的額外性能,否則 切換成 io_uring
代價可能也挺大,因爲要 重寫應用來適配 io_uring
(或者讓依賴的平臺或框架去適配,總之需要改代碼)。
既然性能跟傳統 AIO 差不多,那爲什麼還稱 io_uring
爲革命性技術呢?
-
它首先和最大的貢獻在於:統一了 Linux 異步 I/O 框架,
- Linux AIO 只支持 direct I/O 模式的存儲文件 (storage file),而且主要用在數據庫這一細分領域;
io_uring
支持存儲文件和網絡文件(network sockets),也支持更多的異步系統調用 (accept/openat/stat/...
),而非僅限於read/write
系統調用。
-
在設計上是真正的異步 I/O,作爲對比,Linux AIO 雖然也 是異步的,但仍然可能會阻塞,某些情況下的行爲也無法預測;
似乎之前 Windows 在這塊反而是領先的,更多參考:
- 淺析開源項目之 io_uring,“分步試存儲” 專欄,知乎
- Is there really no asynchronous block I/O on Linux?,stackoverflow
-
靈活性和可擴展性非常好,甚至能基於
io_uring
重寫所有系統調用,而 Linux AIO 設計時就沒考慮擴展性。
eBPF 也算是異步框架(事件驅動),但與 io_uring
沒有本質聯繫,二者屬於不同子系統, 並且在模型上有一個本質區別:
- eBPF 對用戶是透明的,只需升級內核(到合適的版本),應用程序無需任何改造;
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
優化之後,
- 吞吐(iops) 提升
20%~30%
,同時 - 延遲降低
20~30%
。
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 操作完成:
- 如果數據在文件中,並且文件內容已經緩存在 page cache 中,調用會立即返回;
- 如果數據在另一臺機器上,就需要通過網絡(例如 TCP)獲取,會阻塞一段時間;
- 如果數據在硬盤上,也會阻塞一段時間。
但很容易想到,隨着存儲設備越來越快,程序越來越複雜, 阻塞式(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),
- 需要指定
O_DIRECT
flag; - 需要應用自己管理自己的緩存 —— 這正是數據庫軟件所希望的;
- 是 zero-copy I/O,因爲應用的緩衝數據直接發送到設備,或者直接從設備讀取。
1.5 異步 IO(AIO)
前面提到,隨着存儲設備越來越快,主線程和 worker 線性之間的上下文切換開銷佔比越來越高。 現在市場上的一些設備,例如 Intel Optane ,延遲已經低到和上下文切換一個量級(微秒 us
)。換個方式描述, 更能讓我們感受到這種開銷: 上下文每切換一次,我們就少一次 dispatch I/O 的機會。
因此,Linux 2.6 內核引入了異步 I/O(asynchronous I/O)接口, 方便起見,本文簡寫爲 linux-aio
。AIO 原理是很簡單的:
- 用戶通過
io_submit()
提交 I/O 請求, - 過一會再調用
io_getevents()
來檢查哪些 events 已經 ready 了。 - 使程序員能編寫完全異步的代碼。
近期,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 確實問題纏身,
- 只支持
O_DIRECT
文件,因此對常規的非數據庫應用 (normal, non-database applications)幾乎是無用的; - 接口在設計時並未考慮擴展性。雖然可以擴展 —— 我們也確實這麼做了 —— 但每加一個東西都相當複雜;
- 雖然從技術上說接口是非阻塞的,但實際上有 很多可能的原因都會導致它阻塞,而且引發的方式難以預料。
1.6 小結
以上可以清晰地看出 Linux I/O 的演進:
- 最開始是同步(阻塞式)系統調用;
- 然後隨着實際需求和具體場景,不斷加入新的異步接口,還要保持與老接口的兼容和協同工作。
另外也看到,在非阻塞式讀寫的問題上並沒有形成統一方案:
- Network socket 領域:添加一個異步接口,然後去輪詢(poll)請求是否完成(readiness);
- 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) —— 這也是高性能領域最常見的主題之一。
io_uring
的基本邏輯與 linux-aio 是類似的:提供兩個接口,一個將 I/O 請求提交到內核,一個從內核接收完成事件。- 但隨着開發深入,它逐漸變成了一個完全不同的接口:設計者開始從源頭思考 如何支持完全異步的操作。
2.1 與 Linux AIO 的不同
io_uring
與 linux-aio
有着本質的不同:
-
在設計上是真正異步的(truly asynchronous)。只要 設置了合適的 flag,它在系統調用上下文中就只是將請求放入隊列, 不會做其他任何額外的事情,保證了應用永遠不會阻塞。
-
支持任何類型的 I/O:cached files、direct-access files 甚至 blocking sockets。
由於設計上就是異步的(async-by-design nature),因此無需 poll+read/write 來處理 sockets。 只需提交一個阻塞式讀(blocking read),請求完成之後,就會出現在 completion ring。
-
靈活、可擴展:基於
io_uring
甚至能重寫(re-implement)Linux 的每個系統調用。
2.2 原理及核心數據結構:SQ/CQ/SQE/CQE
每個 io_uring 實例都有兩個環形隊列(ring),在內核和應用程序之間共享:
- 提交隊列:submission queue (SQ)
- 完成隊列:completion queue (CQ)
這兩個隊列:
- 都是單生產者、單消費者,size 是 2 的冪次;
- 提供無鎖接口(lock-less access interface),內部使用 內存屏障做同步(coordinated with memory barriers)。
使用方式:
-
請求
- 應用創建 SQ entries (SQE),更新 SQ tail;
- 內核消費 SQE,更新 SQ head。
-
完成
- 內核爲完成的一個或多個請求創建 CQ entries (CQE),更新 CQ tail;
- 應用消費 CQE,更新 CQ head。
- 完成事件(completion events)可能以任意順序到達,到總是與特定的 SQE 相關聯的。
- 消費 CQE 過程無需切換到內核態。
2.3 帶來的好處
io_uring
這種請求方式還有一個好處是:原來需要多次系統調用(讀或寫),現在變成批處理一次提交。
還記得 Meltdown 漏洞嗎?當時我還寫了一篇文章 解釋爲什麼我們的 Scylla NoSQL 數據庫受影響很小:aio
已經將我們的 I/O 系統調用批處理化了。
io_uring
將這種批處理能力帶給了 storage I/O 系統調用之外的 其他一些系統調用,包括:
read
write
send
recv
accept
openat
stat
- 專用的一些系統調用,例如
fallocate
此外,io_uring
使異步 I/O 的使用場景也不再僅限於數據庫應用,普通的 非數據庫應用也能用。這一點值得重複一遍:
雖然
io_uring
與aio
有一些相似之處,但它的擴展性和架構是革命性的: 它將異步操作的強大能力帶給了所有應用(及其開發者),而 不再僅限於是數據庫應用這一細分領域。
我們的 CTO Avi Kivity 在 the Core C++ 2019 event 上 有一次關於 async 的分享。 核心點包括:從延遲上來說,
- 現代多核、多 CPU 設備,其內部本身就是一個基礎網絡;
- CPU 之間是另一個網絡;
- CPU 和磁盤 I/O 之間又是一個網絡。
因此網絡編程採用異步是明智的,而現在開發自己的應用也應該考慮異步。 這從根本上改變了 Linux 應用的設計方式:
- 之前都是一段順序代碼流,需要系統調用時才執行系統調用,
- 現在需要思考一個文件是否 ready,因而自然地引入 event-loop,不斷通過共享 buffer 提交請求和接收結果。
2.4 三種工作模式
io_uring 實例可工作在三種模式:
-
中斷驅動模式(interrupt driven)
默認模式。可通過 io_uring_enter() 提交 I/O 請求,然後直接檢查 CQ 狀態判斷是否完成。
-
輪詢模式(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 實例來說,不支持混合使用輪詢和非輪詢模式。
-
內核輪詢模式(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
有三個:
io_uring_setup(2)
io_uring_register(2)
io_uring_enter(2)
下面展開介紹。完整文檔見 manpage。
2.5.1 io_uring_setup()
執行異步 I/O 需要先設置上下文:
int io_uring_setup(u32 entries, struct io_uring_params *p);
這個系統調用
- 創建一個 SQ 和一個 CQ,
- queue size 至少
entries
個元素, - 返回一個文件描述符,隨後用於在這個 io_uring 實例上執行操作。
SQ 和 CQ 在應用和內核之間共享,避免了在初始化和完成 I/O 時(initiating and completing I/O)拷貝數據。
參數 p:
- 應用用來配置 io_uring,
- 內核返回的 SQ/CQ 配置信息也通過它帶回來。
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)性質
- Registered buffers 將會被鎖定在內存中(be locked in memory),並計入用戶的 RLIMIT_MEMLOCK 資源限制。
- 此外,每個 buffer 有 1GB 的大小限制。
- 當前,buffers 必須是匿名、非文件後端的內存(anonymous, non-file-backed memory),例如 malloc(3) or mmap(2) with the MAP_ANONYMOUS flag set 返回的內存。
- Huge pages 也是支持的。整個 huge page 都會被 pin 到內核,即使只用到了其中一部分。
- 已經註冊的 buffer 無法調整大小,想調整隻能先 unregister,再重新 register 一個新的。
通過 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。 單次調用同時執行:
- 提交新的 I/O 請求
- 等待 I/O 完成
參數:
-
fd
是io_uring_setup()
返回的文件描述符; -
to_submit
指定了 SQ 中提交的 I/O 數量; -
依據不同模式:
-
默認模式,如果指定了
min_complete
,會等待這個數量的 I/O 事件完成再返回; -
如果 io_uring 是 polling 模式,這個參數表示:
- 0:要求內核返回當前以及完成的所有 events,無阻塞;
- 非零:如果有事件完成,內核仍然立即返回;如果沒有完成事件,內核會 poll,等待指定的次數完成,或者這個進程的時間片用完。
-
注意:對於 interrupt driven I/O,應用無需進入內核就能檢查 CQ 的 event completions。
io_uring_enter()
支持很多操作,包括:
- Open, close, and stat files
- Read and write into multiple buffers or pre-mapped buffers
- Socket I/O operations
- Synchronize file state
- Asynchronously monitor a set of file descriptors
- Create a timeout linked to a specific operation in the ring
- Attempt to cancel an operation that is currently in flight
- Create I/O chains
- Ordered execution within a chain
- Parallel execution of multiple chains
當這個系統調用返回時,表示一定數量的 SEQ 已經被消費和提交了,此時可以安全的重用隊列中的 SEQ。 此時 IO 提交有可能還停留在異步上下文中,即實際上 SQE 可能還沒有被提交 —— 不過 用戶不用關心這些細節 —— 當隨後內核需要使用某個特定的 SQE 時,它已經複製了一份。
2.6 高級特性
io_uring
提供了一些用於特殊場景的高級特性:
- File registration(文件註冊):每次發起一個指定文件描述的操 作,內核都需要花費一些時鐘週期(cycles)將文件描述符映射到內部表示。 對於那些針對同一文件進行重複操作的場景,
io_uring
支持提前註冊這些文件,後面直接查找就行了。 - Buffer registration(緩衝區註冊):與 file registration 類 似,direct I/O 場景中,內核需要 map/unmap memory areas。
io_uring
支持提前 註冊這些緩衝區(buffers)。 - Poll ring(輪詢環形緩衝區):對於非常快是設備,處理中斷的開 銷是比較大的。
io_uring
允許用戶關閉中斷,使用輪詢模式。前面 “三種工作模式” 小節 也介紹到了這一點。 - 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;
}
其他說明
代碼中已經添加了註釋,這裏再解釋幾點:
- 每個 SQE 都執行一個 allocated buffer,後者是用
iovec
結構描述的; - 第 3 & 4 步:初始化所有 SQE,用於接下來的
IORING_OP_READV
操作,後者 提供了readv(2)
系統調用的異步接口。 - 操作完成之後,這個 SQE iovec buffer 中存放的是相關
readv
操作的結果; - 接下來調用
io_uring_wait_cqe()
來 reap CQE,並通過cqe->res
字段驗證讀取的字節數; io_uring_cqe_seen()
通知內核這個 CQE 已經被消費了。
3.2 link-cp
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。
- 第一個 SQE 是一個讀請求,將數據從輸入文件讀到 buffer;
- 第二個請求,與第一個請求是 linked,是一個寫請求,將數據從 buffer 寫入輸出文件。
源碼及註釋
/* 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;
}
其他說明
代碼中實現了三個函數:
-
copy_file()
:高層複製循環邏輯;它會調用queue_rw_pair(ring, this_size, offset)
來構造 SQE pair; 並通過一次io_uring_submit()
調用將所有構建的 SQE pair 提交。這個函數維護了一個最大 DQ 數量的 inflight SQE,只要數據 copy 還在進行中;否則,即數據已經全部讀取完成,就開始等待和收割所有的 CQE。
-
queue_rw_pair()
構造一個 read-write SQE pair.read SQE 的
IOSQE_IO_LINK
flag 表示開始一個 chain,write SQE 不用設置這個 flag,標誌着這個 chain 的結束。 用戶 data 字段設置爲同一個 data 描述符,並且在隨後的 completion 處理中會用到。 -
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 種模式:
- synchronous reads
posix-aio
(implemented as a thread pool)linux-aio
io_uring
硬件:
- NVMe 存儲設備,物理極限能打到 3.5M IOPS。
- 8 核處理器
4.2 場景一:direct I/O 1KB
隨機讀(繞過 page cache)
第一組測試中,我們希望所有的讀請求都能命中存儲設備(all reads to hit the storage),完全繞開操作系統的頁緩存(page cache)。
測試配置:
- 8 個 CPU 執行 72
fio
job, - 每個 job 隨機讀取 4 個文件,
iodepth=8
(number of I/O units to keep in flight against the file.)。
這種配置保證了 CPU 處於飽和狀態,便於觀察 I/O 性能。 如果 CPU 數量足夠多,那每組測試都可能會打滿設備帶寬,結果對 I/O 壓測就沒意義了。
表 1. Direct I/O(繞過系統頁緩存):1KB 隨機讀,CPU 100% 下的 I/O 性能
幾點分析:
-
io_uring
相比linux-aio
確實有一定提升,但並非革命性的。 -
開啓高級特性,例如 buffer & file registration 之後性能有進一步提升 —— 但也還 沒有到爲了這些性能而重寫整個應用的地步,除非你是搞數據庫研發,想榨取硬件的最後一分性能。
-
io_uring
andlinux-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:
-
將文件數據提前加載到內存,然後再測隨機讀。
- 由於數據全部在 page cache,因此同步 read 永遠不會阻塞。
- 這種場景下,我們預期同步讀和 io_uring 的性能差距不大(都是最好的)。
-
其他測試條件不變。
表 2. Buffered I/O(數據全部來自 page cache,100% hot cache):1KB 隨機讀,100% CPU 下的 I/O 性能
結果分析:
-
同步讀和
io_uring
性能差距確實很小,二者都是最好的。但注意,實際的應用不可能一直 100% 時間執行 IO 操作,因此 基於同步讀的真實應用性能還是要比基於 io_uring 要差的,因爲 io_uring 會將多個系統調用批處理化。
-
posix-aio
性能最差,直接原因是上下文切換次數太多,這也和場景相關: 在這種 CPU 飽和的情況下,它的線程池反而是累贅,會完全拖慢性能。 -
linux-aio
並不是針對 buffered I/O 設計的,在這種 page cache 直接返回的場景, 它的異步接口反而會造成性能損失 —— 將操作分 爲 dispatch 和 consume 兩步不但沒有性能收益,反而有額外開銷。
4.3 性能測試小結
最後再次提醒,本節是極端應用 / 場景(100% CPU + 100% cache miss/hit)測試, 真實應用的行爲通常處於同步讀和異步讀之間:時而一些阻塞操作,時而一些非阻塞操作。 但不管怎麼說,用了 io_uring 之後,用戶就無需擔心同步和異步各佔多少比例了,因爲它在任何場景下都表現良好。
- 如果操作是非阻塞的,
io_uring
不會有額外開銷; - 如果操作是阻塞式的,也沒關係,
io_uring
是完全異步的,並且不依賴線程池或昂貴的上下文切換來實現這種異步能力;
本文測試的都是隨機讀,但對其他類型的操作,io_uring
表現也是非常良好的。例如:
- 打開 / 關閉文件
- 設置定時器
- 通過 network sockets 傳輸數據
而且使用的是同一套 io_uring 接口。
4.4 ScyllaDB 與 io_uring
Scylla 重度依賴 direct I/O,從一開始就使用 linux-aio
。 在我們轉向 io_uring
的過程中,最開始測試顯示對某些 workloads,能取得 50% 以上的性能提升。 但深入研究之後發現,這是因爲我們之前的 linux-aio 用的不夠好。 這也揭示了一個經常被忽視的事實:獲得高性能沒有那麼難(前提是你得弄對了)。 在對比 io_uring
和 linux-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% 的提升 看上去不是太大,但對於希望壓榨出硬件所有性能的數據庫來說,還是非常寶貴的。
5 eBPF
eBPF 也是一個事件驅動框架(因此也是異步的),允許用戶空間程序動態向內核注入字節碼,主要有兩個使用場景:
eBPF 在內核 4.9 首次引入,4.19 以後功能已經很強大。更多關於 eBPF 的演進信息,可參考: (譯)大規模微服務利器:eBPF + Kubernetes(KubeCon, 2020)。
談到與 io_uring 的結合,就是用 bcc 之類的工具跟蹤一些 I/O 相關的內核函數,例如:
- Trace how much time an application spends sleeping, and what led to those sleeps. (
wakeuptime
) - Find all programs in the system that reached a particular place in the code (
trace
) - Analyze network TCP throughput aggregated by subnet (
tcpsubnet
) - Measure how much time the kernel spent processing softirqs (
softirqs
) - 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
級延遲存儲設備。
參考資料
- Efficient IO with io_uring, pdf
- Ringing in a new asynchronous I/O API, lwn.net
- The rapid growth of io_uring, lwn.net
- System call API, manpage
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://arthurchiao.art/blog/intro-to-io-uring-zh/