深入解剖io_uring:Linux異步IO的終極武器
在 Linux 系統的世界里,I/O 操作的效率,始終是左右系統性能的關鍵因素。從一開始簡單的阻塞式 I/O,到后來的非阻塞 I/O,再到 I/O 多路復用技術的誕生,每一次技術變革,都在不斷突破 I/O 性能的瓶頸。在眾多 I/O 技術中,epoll 曾是高性能 I/O 的代表,它以事件驅動模式,高效地處理著大量并發連接,在 Nginx、Redis 等眾多知名項目中扮演著重要角色。但隨著數據量的爆炸式增長,以及應用場景日益復雜,epoll 在面對某些極端高并發場景時,也漸漸顯露出局限性。
就在這時候,io_uring 強勢登場。它作為 Linux 內核異步 I/O 領域的革新者,一出現便備受關注。io_uring 的目標,是打破傳統異步 I/O 模型的性能束縛,以顛覆性的設計理念,為 Linux 異步 I/O 領域帶來全新變革。
那么,io_uring 究竟有著怎樣的獨特之處,能讓開發者們對它寄予厚望?它與 epoll 相比,又在哪些方面實現了重大突破?現在,就讓我們一起深入探究,開啟這場從 epoll 到 io_uring 的技術探索之旅 。
Part1.Linux I/O 的前世今生
在 Linux 系統的發展歷程中,I/O 模型的演進是提升系統性能和效率的關鍵因素。從早期簡單的阻塞式 I/O 到如今復雜高效的 io_uring,每一次變革都解決了特定場景下的性能瓶頸問題。
1.1阻塞式 I/O(Blocking I/O)
阻塞式 I/O 是最基礎、最直觀的 I/O 模型。在這種模型下,當應用程序執行 I/O 操作(如 read 或 write)時,進程會被阻塞,直到 I/O 操作完成。例如,當從文件中讀取數據時,如果數據尚未準備好,進程就會一直等待,期間無法執行其他任務。這就好比你去餐廳點餐,然后一直在餐桌旁等待食物上桌,在等待的過程中什么也做不了。
阻塞式 I/O 的優點是編程簡單,邏輯清晰,但是在處理大量并發請求時,由于每個請求都可能阻塞進程,導致系統的并發處理能力極低,資源利用率也不高。在高并發的 Web 服務器場景中,如果使用阻塞式 I/O,每一個客戶端連接都需要一個獨立的線程來處理,當并發連接數增多時,線程資源將被大量消耗,系統性能會急劇下降。
1.2非阻塞式 I/O(Non - blocking I/O)
為了解決阻塞式 I/O 的問題,非阻塞式 I/O 應運而生。在非阻塞式 I/O 模型中,當應用程序執行 I/O 操作時,如果數據尚未準備好,系統不會阻塞進程,而是立即返回一個錯誤(如 EWOULDBLOCK 或 EAGAIN)。應用程序可以繼續執行其他任務,然后通過輪詢的方式再次嘗試 I/O 操作,直到數據準備好。
這就像你在餐廳點餐時,服務員告訴你需要等待一段時間,你可以先去做其他事情,然后時不時回來詢問食物是否準備好了。非阻塞式 I/O 提高了系統的并發處理能力,進程在等待 I/O 操作的過程中可以執行其他任務,但是頻繁的輪詢會消耗大量的 CPU 資源,增加了系統的開銷。而且非阻塞式 I/O 的編程復雜度較高,需要處理更多的錯誤和狀態判斷。
1.3 I/O 多路復用(I/O Multiplexing)
I/O 多路復用是在非阻塞式 I/O 的基礎上進一步發展而來的,它允許一個進程同時監視多個 I/O 描述符(如文件描述符、套接字等),當其中任何一個描述符就緒(即有數據可讀或可寫)時,進程就可以對其進行處理。常見的 I/O 多路復用技術有 select、poll 和 epoll。以 select 為例,應用程序通過調用 select 函數,將需要監視的 I/O 描述符集合傳遞給內核,內核會監視這些描述符的狀態,當有描述符就緒時,select 函數返回,應用程序再對就緒的描述符進行 I/O 操作。
這就好比你在餐廳同時點了多道菜,你只需要等待服務員一次性通知你哪些菜已經準備好了,然后去取相應的菜,而不需要每道菜都單獨詢問。I/O 多路復用大大提高了系統的并發處理能力,減少了線程資源的消耗,但是它也存在一些問題,如 select 和 poll 的性能會隨著監視的描述符數量增加而下降,epoll 雖然性能較好,但在高并發場景下,大量的事件處理也可能成為性能瓶頸。
1.4傳統 I/O 模型的局限性
傳統的 I/O 模型,無論是阻塞式 I/O、非阻塞式 I/O 還是 I/O 多路復用,在面對現代應用程序對高性能、高并發的需求時,都存在一定的局限性。它們的主要問題在于:
- 系統調用開銷大:每次 I/O 操作都需要進行系統調用,從用戶態切換到內核態,這會帶來一定的開銷。在高并發場景下,頻繁的系統調用會消耗大量的 CPU 資源。
- 數據拷貝次數多:在數據傳輸過程中,數據往往需要在用戶空間和內核空間之間多次拷貝,這不僅增加了數據傳輸的時間,也消耗了系統資源。
- 異步處理能力有限:雖然非阻塞式 I/O 和 I/O 多路復用在一定程度上實現了異步處理,但它們的異步程度還不夠徹底,仍然需要應用程序主動輪詢或等待事件通知,無法充分發揮硬件的性能。
為了突破這些局限性,Linux 內核引入了 io_uring,它代表了一種全新的異步 I/O 模型,為開發者提供了更高效、更強大的 I/O 處理能力。
Part2.io_uring 是什么
2.1定義與起源
io_uring 是 Linux 內核提供的高性能異步 I/O 框架,它在 Linux 5.1 版本中被引入,由 Jens Axboe 開發 。在 io_uring 出現之前,傳統的異步 I/O 模型,如 epoll 或者 POSIX AIO,在大規模 I/O 操作中效率較低,存在系統調用開銷大、數據拷貝次數多、異步處理能力有限等問題。為了解決這些問題,io_uring 應運而生,它的出現為 Linux 異步 I/O 領域帶來了新的解決方案,旨在提供更高效、更強大的 I/O 處理能力。
2.2設計目標與特點
統一網絡和磁盤異步 I/O:在 io_uring 之前,Linux 的網絡 I/O 和磁盤 I/O 使用不同的機制,這給開發者帶來了很大的不便。io_uring 的設計目標之一就是統一網絡和磁盤異步 I/O,使得開發者可以使用統一的接口來處理不同類型的 I/O 操作。這就像一個萬能的工具,無論你是處理網絡數據的傳輸,還是磁盤文件的讀寫,都可以使用 io_uring 這個工具,而不需要在不同的工具之間切換。
提供統一完善的異步 API:它提供了一套統一且完善的異步 API,簡化了異步 I/O 編程。在傳統的 I/O 模型中,開發者可能需要使用多個不同的函數和系統調用來實現異步 I/O,而且這些接口可能并不統一,容易出錯。io_uring 將這些復雜的操作封裝成了簡單易用的 API,開發者只需要調用這些 API,就可以輕松地實現異步 I/O 操作,降低了編程的難度和出錯的概率。
支持異步、輪詢、無鎖、零拷貝:io_uring 支持異步操作,應用程序在發起 I/O 請求后不必等待操作完成,可以繼續執行其他任務,提高了系統的并發處理能力;它還支持輪詢模式,不依賴硬件的中斷,通過調用 IORING_ENTER_GETEVENTS 不斷輪詢收割完成事件,減少了中斷開銷;同時,io_uring 采用了無鎖設計,避免了鎖競爭帶來的性能損耗;在數據傳輸過程中,io_uring 支持零拷貝技術,減少了數據在用戶空間和內核空間之間的拷貝次數,提高了數據傳輸的效率。例如,在一個文件傳輸的場景中,使用 io_uring 可以大大減少數據拷貝的時間,提高文件傳輸的速度。
2.3io_uring設計思路
1.解決“系統調用開銷大”的問題?
針對這個問題,考慮是否每次都需要系統調用。如果能將多次系統調用中的邏輯放到有限次數中來,就能將消耗降為常數時間復雜度。
2.解決“拷貝開銷大”的問題?
之所以在提交和完成事件中存在大量的內存拷貝,是因為應用程序和內核之間的通信需要拷貝數據,所以為了避免這個問題,需要重新考量應用與內核間的通信方式。我們發現,兩者通信,不是必須要拷貝,通過現有技術,可以讓應用與內核共享內存。
要實現核外與內核的零拷貝,最佳方式就是實現一塊內存映射區域,兩者共享一段內存,核外往這段內存寫數據,然后通知內核使用這段內存數據,或者內核填寫這段數據,核外使用這部分數據。因此,需要一對共享的ring buffer用于應用程序和內核之間的通信。
- 一塊用于核外傳遞數據給內核,一塊是內核傳遞數據給核外,一方只讀,一方只寫。
- 提交隊列SQ(submission queue)中,應用是IO提交的生產者,內核是消費者。
- 完成隊列CQ(completion queue)中,內核是IO完成的生產者,應用是消費者。
- 內核控制SQ ring的head和CQ ring的tail,應用程序控制SQ ring的tail和CQ ring的head
3.解決“API不友好”的問題?
問題在于需要多個系統調用才能完成,考慮是否可以把多個系統調用合而為一。有時候,將多個類似的函數合并并通過參數區分不同的行為是更好的選擇,而有時候可能需要將復雜的函數分解為更簡單的部分來進行重構。
如果發現函數中的某一部分代碼可以獨立出來成為一個單獨的函數,可以先進行這樣的提煉,然后再考慮是否需要進一步使用參數化方法重構。
Part3.io_uring原理剖析
3.1核心概念
1.環形緩沖區
io_uring 的核心是兩個環形緩沖區:提交隊列(Submission Queue,SQ)和完成隊列(Completion Queue,CQ)。這兩個隊列在內核態和用戶態之間共享,通過內存映射(mmap)的方式實現。
提交隊列(SQ)用于存放用戶程序提交的 I/O 請求。當用戶程序需要進行 I/O 操作時,它會創建一個提交隊列條目(Submission Queue Entry,SQE),并將其放入 SQ 中。每個 SQE 包含了 I/O 操作的詳細信息,如操作類型(讀、寫等)、文件描述符、緩沖區地址、數據長度等。
完成隊列(CQ)用于存放內核完成 I/O 操作后的結果。當內核完成一個 I/O 操作后,會將對應的完成隊列條目(Completion Queue Entry,CQE)放入 CQ 中。CQE 包含了 I/O 操作的返回值(如讀取或寫入的字節數、錯誤碼等)以及用戶在 SQE 中設置的用戶數據。
環形緩沖區的工作方式基于生產者 - 消費者模型。用戶程序是 SQ 的生產者,內核是 SQ 的消費者;內核是 CQ 的生產者,用戶程序是 CQ 的消費者。通過這種方式,io_uring 實現了用戶態和內核態之間高效的通信,減少了系統調用的次數和數據拷貝的開銷。例如,在傳統的 I/O 模型中,每次 I/O 操作都需要進行系統調用,從用戶態切換到內核態,而 io_uring 通過共享的環形緩沖區,用戶程序可以直接將 I/O 請求放入 SQ,內核從 SQ 中獲取請求并處理,處理完成后將結果放入 CQ,用戶程序再從 CQ 中獲取結果,避免了頻繁的系統調用和上下文切換。
2.異步 I/O 操作
io_uring 的異步 I/O 操作機制是其高性能的關鍵之一。在傳統的 I/O 模型中,當應用程序發起 I/O 請求后,通常需要等待 I/O 操作完成才能繼續執行其他任務,這期間應用程序會被阻塞。而在 io_uring 中,用戶程序提交 I/O 請求后,無需等待操作完成,就可以繼續執行其他任務。
當用戶程序將 I/O 請求寫入提交隊列(SQ)后,內核會異步地處理這些請求。內核會根據請求的類型和參數,執行相應的 I/O 操作,如從磁盤讀取數據或向網絡發送數據。在 I/O 操作執行過程中,用戶程序可以繼續執行其他代碼,不會被阻塞。當 I/O 操作完成后,內核會將操作結果寫入完成隊列(CQ),并通過事件通知機制(如 epoll)通知用戶程序。用戶程序可以通過輪詢 CQ 或等待事件通知的方式,獲取 I/O 操作的結果,并進行后續處理。這種異步操作方式使得應用程序能夠充分利用 CPU 資源,提高了系統的并發處理能力。
例如,在一個文件服務器中,當有多個客戶端同時請求讀取文件時,使用 io_uring 可以讓服務器在處理一個客戶端的 I/O 請求時,同時處理其他客戶端的請求,而不需要等待每個 I/O 請求都完成后再處理下一個,大大提高了服務器的響應速度和吞吐量。
3.批量操作與更多操作支持
io_uring 支持批量提交和處理 I/O 請求,這進一步提升了其性能。用戶程序可以一次性將多個 I/O 請求寫入提交隊列(SQ),然后通過一次系統調用(如 io_uring_enter)通知內核處理這些請求。內核會批量處理這些請求,并將結果批量寫入完成隊列(CQ)。這種批量操作方式減少了系統調用的次數和上下文切換的開銷,提高了 I/O 操作的效率。例如,在處理大量文件讀寫操作時,使用批量操作可以顯著減少系統調用的開銷,提高文件讀寫的速度。
此外,io_uring 支持的操作類型非常豐富,不僅包括傳統的文件 I/O 操作(如 read、write、open、close 等),還支持網絡相關的系統調用,如 send、recv、accept、connect 等。這使得開發者可以使用 io_uring 來構建高性能的網絡服務器和應用程序。在開發一個高并發的 Web 服務器時,可以使用 io_uring 來處理客戶端的連接請求、數據接收和發送等操作,充分發揮其高性能和異步處理的優勢。io_uring 還支持一些其他的系統調用,如文件系統的操作(如 fsync、fdatasync 等),為開發者提供了更強大的功能和更靈活的編程方式。
3.2工作原理
1.提交隊列(SQ)工作流程
用戶程序在進行 I/O 操作時,首先會與 io_uring 進行交互,將 I/O 請求寫入提交隊列(SQ)。具體步驟如下:
- 獲取空閑的 SQE:用戶程序通過調用 io_uring_get_sqe 函數,從提交隊列中獲取一個空閑的提交隊列條目(SQE)。這個過程類似于從一個空閑資源池中獲取一個資源,每個 SQE 都可以看作是一個承載 I/O 請求的 “容器”。
- 設置請求參數:獲取到 SQE 后,用戶程序會根據 I/O 操作的具體需求,設置 SQE 的各個參數。這些參數包括操作碼(opcode),用于指定 I/O 操作的類型,如讀操作(IORING_OP_READ)或寫操作(IORING_OP_WRITE);文件描述符(fd),指向要進行 I/O 操作的文件或套接字;偏移量(off),指定從文件或套接字的哪個位置開始執行 I/O 操作;緩沖區地址(addr),指向用戶空間中用于存放讀取數據或提供要寫入數據的緩沖區;數據長度(len),指定要讀取或寫入的數據量等。還可以設置一些其他的標志位和用戶自定義數據,以便在 I/O 操作完成后進行相關的處理。
- 將 SQE 索引放入 SQ:設置好 SQE 的參數后,用戶程序會將該 SQE 的索引放入提交隊列(SQ)中,并更新 SQ 的尾指針(tail)。這就像是將一個裝滿請求信息的 “包裹” 放入一個環形的傳送帶上,尾指針則表示傳送帶上最后一個 “包裹” 的位置。通過這種方式,用戶程序向內核表明有新的 I/O 請求需要處理。
2.完成隊列(CQ)工作流程
當內核完成 I/O 操作后,會將操作結果寫入完成隊列(CQ),用戶程序從 CQ 中獲取結果并進行處理,具體流程如下:
- 內核寫入 CQE:內核在完成 I/O 操作后,會創建一個完成隊列條目(CQE),并將 I/O 操作的結果信息填充到 CQE 中。這些結果信息包括操作的返回值(res),如果操作成功,res 表示實際傳輸的字節數;如果操作失敗,res 表示錯誤碼(通常是一個負值,其絕對值對應具體的錯誤類型)。CQE 中還包含用戶在提交 I/O 請求時設置的用戶數據(user_data),以便用戶程序在獲取結果時能夠識別該結果對應的是哪個 I/O 請求。內核將 CQE 放入完成隊列(CQ)中,并更新 CQ 的尾指針(tail),表示有新的完成事件可供用戶程序處理。
- 用戶程序獲取 CQE:用戶程序可以通過調用 io_uring_wait_cqe 函數來阻塞等待,直到 CQ 中有新的 CQE 可供處理;也可以通過調用 io_uring_peek_cqe 函數進行非阻塞地檢查,看是否有新的 CQE。當獲取到 CQE 后,用戶程序可以根據 CQE 中的結果信息進行相應的處理。如果 I/O 操作成功,用戶程序可以處理讀取到的數據或確認寫入操作已成功完成;如果 I/O 操作失敗,用戶程序可以根據錯誤碼進行錯誤處理,如重試操作或向用戶報告錯誤信息。
- 標記 CQE 為已處理:用戶程序處理完 CQE 后,需要調用 io_uring_cqe_seen 函數,將 CQ 的尾指針向前移動,標記該 CQE 已被處理,以便后續可以接收新的完成事件。這就像是在處理完一個任務后,將任務標記為已完成,以便系統可以繼續處理其他新的任務。
3.內核與用戶態交互
內核和用戶態之間通過共享內存的環形緩沖區(即提交隊列 SQ 和完成隊列 CQ)進行交互,這種交互方式極大地減少了系統調用和上下文切換的開銷,提高了 I/O 操作的效率,其原理如下:
- 共享內存映射:在初始化 io_uring 時,通過內存映射(mmap)機制,將提交隊列(SQ)和完成隊列(CQ)映射到用戶空間和內核空間。這樣,用戶程序和內核都可以直接訪問這兩個隊列,而不需要通過傳統的系統調用方式進行數據傳遞。這就好比在用戶空間和內核空間之間建立了一條 “高速公路”,數據可以直接在兩者之間快速傳輸,而不需要經過復雜的 “收費站”(系統調用)。
- 減少系統調用:在傳統的 I/O 模型中,每次 I/O 操作都需要進行多次系統調用,從用戶態切換到內核態,然后再切換回用戶態。而在 io_uring 中,用戶程序將 I/O 請求寫入 SQ 和從 CQ 獲取結果,都可以在用戶態完成,不需要頻繁地進行系統調用。只有在需要通知內核處理 SQ 中的請求時(如調用 io_uring_enter 函數),才會進行一次系統調用,大大減少了系統調用的次數。
- 減少上下文切換:上下文切換是指當操作系統從一個進程或線程切換到另一個進程或線程時,需要保存當前進程或線程的狀態信息,并恢復下一個進程或線程的狀態信息。在傳統 I/O 模型中,頻繁的系統調用會導致大量的上下文切換,消耗 CPU 資源。而 io_uring 通過共享內存的方式,減少了系統調用的次數,也就相應地減少了上下文切換的次數,使得 CPU 可以更專注于執行實際的 I/O 操作和用戶程序的邏輯代碼。
3.3系統調用詳解
io_uring的實現僅僅使用了三個syscall:io_uring_setup, io_uring_enter和io_uring_register。
這幾個系統調用接口都在io_uring.c文件中:
1.io_uring_setup
io_uring_setup 是用于初始化 io_uring 環境的系統調用。在使用 io_uring 進行異步 I/O 操作之前,首先需要調用 io_uring_setup 來創建一個 io_uring 實例。它接受兩個參數,第一個參數是期望的提交隊列(SQ)的大小,即隊列中可以容納的 I/O 請求數量;第二個參數是一個指向 io_uring_params 結構體的指針,該結構體用于返回 io_uring 實例的相關參數,如實際分配的 SQ 和完成隊列(CQ)的大小、隊列的偏移量等信息。
在調用 io_uring_setup 時,內核會為 io_uring 實例分配所需的內存空間,包括 SQ、CQ 以及相關的控制結構。同時,內核還會創建一些內部數據結構,用于管理和調度 I/O 請求。如果初始化成功,io_uring_setup 會返回一個文件描述符,這個文件描述符用于標識創建的 io_uring 實例,后續的 io_uring 系統調用(如 io_uring_enter、io_uring_register)將通過這個文件描述符來操作該 io_uring 實例。若初始化失敗,函數將返回一個負數,表示相應的錯誤代碼。
io_uring_setup():
SYSCALL_DEFINE2(io_uring_setup, u32, entries,
struct io_uring_params __user *, params)
{
return io_uring_setup(entries, params);
}- 功能:用于初始化和配置 io_uring 。
- 應用用途:在使用 io_uring 之前,首先需要調用此接口初始化一個 io_uring 環,并設置其參數。
2.io_uring_enter
io_uring_enter 是用于提交和等待 I/O 操作的系統調用。它的主要作用是將應用程序準備好的 I/O 請求提交給內核,并可以選擇等待這些操作完成。io_uring_enter 接受多個參數,其中包括 io_uring_setup 返回的文件描述符,用于指定要操作的 io_uring 實例;to_submit 參數表示要提交的 I/O 請求的數量,即從提交隊列(SQ)中取出并提交給內核的 SQE 的數量;min_complete 參數指定了內核在返回之前必須等待完成的 I/O 操作的最小數量;flags 參數則用于控制 io_uring_enter 的行為,例如可以設置是否等待 I/O 操作完成、是否獲取完成的 I/O 事件等。當調用 io_uring_enter 時,如果 to_submit 參數大于 0,內核會從 SQ 中取出相應數量的 SQE,并將這些 I/O 請求提交到內核中進行處理。
同時,如果設置了等待 I/O 操作完成的標志,內核會阻塞等待,直到至少有 min_complete 個 I/O 操作完成,然后將這些完成的操作結果放入完成隊列(CQ)中。應用程序可以通過檢查 CQ 來獲取這些完成的 I/O 請求的結果。通過 io_uring_enter,應用程序可以靈活地控制 I/O 請求的提交和等待策略,提高 I/O 操作的效率和靈活性。
io_uring_enter():
SYSCALL_DEFINE6(io_uring_enter, unsigned int, fd, u32, to_submit,
u32, min_complete, u32, flags, const void __user *, argp,
size_t, argsz)- 功能:用于提交和處理異步 I/O 操作。
- 應用用途:在向 io_uring 環中提交 I/O 操作后,通過調用此接口觸發內核處理這些操作,并獲取完成的操作結果。
3.io_uring_register
io_uring_register 用于注冊文件描述符或事件文件描述符到 io_uring 實例中,以便在后續的 I/O 操作中使用。它接受四個參數,第一個參數是 io_uring_setup 返回的文件描述符,用于指定要注冊到的 io_uring 實例;第二個參數 opcode 表示注冊的類型,例如可以是 IORING_REGISTER_FILES(注冊文件描述符集合)、IORING_REGISTER_BUFFERS(注冊內存緩沖區)、IORING_REGISTER_EVENTFD(注冊 eventfd 用于通知完成事件)等;
第三個參數 arg 是一個指針,根據 opcode 的類型不同,它指向不同的內容,如注冊文件描述符時,arg 指向一個包含文件描述符的數組;注冊緩沖區時,arg 指向一個描述緩沖區的結構體數組;第四個參數 nr_args 表示 arg 所指向的數組的長度。通過 io_uring_register 注冊文件描述符或緩沖區等資源后,內核在處理 I/O 請求時,可以直接訪問這些預先注冊的資源,而無需每次都重新設置相關信息,從而提高了 I/O 操作的效率。例如,在進行大量文件讀寫操作時,預先注冊文件描述符可以避免每次提交 I/O 請求時都進行文件描述符的查找和驗證,減少了系統開銷,提升了 I/O 性能。
io_uring_register():
SYSCALL_DEFINE4(io_uring_register, unsigned int, fd, unsigned int, opcode,
void __user *, arg, unsigned int, nr_args)- 功能:用于注冊文件描述符、緩沖區、事件文件描述符等資源到 io_uring 環中。
- 應用用途:在進行 I/O 操作之前,需要將相關的資源注冊到 io_uring 環中,以便進行后續的異步 I/O 操作。
3.4工作流程深度剖析
1.創建 io_uring 對象
使用 io_uring 進行異步 I/O 操作的第一步是創建 io_uring 對象。內核提供了io_uring_setup系統調用來初始化一個io_uring實例,創建SQ、CQ和SQ Array,entries參數表示的是SQ和SQArray的大小,CQ的大小默認是2 * entries。params參數既是輸入參數,也是輸出參數。
該函數返回一個file descriptor,并將io_uring支持的功能、以及各個數據結構在fd中的偏移量存入params。用戶根據偏移量將fd通過mmap內存映射得到一塊內核用戶共享的內存區域。這塊內存區域中,有io_uring的上下文信息:SQ信息、CQ信息和SQ Array信息。
int io_uring_setup(int entries, struct io_uring_params *params);這通過調用 io_uring_setup 系統調用來完成。在調用 io_uring_setup 時,用戶需要指定提交隊列(SQ)的大小,即期望的 I/O 請求隊列長度。內核會根據這個請求,為 io_uring 對象分配必要的內存空間,包括提交隊列(SQ)、完成隊列(CQ)以及相關的控制結構。內核會創建一個 io_ring_ctx 結構體對象,用于管理 io_uring 的上下文信息。
同時,還會創建一個 io_urings 結構體對象,該對象包含了 SQ 和 CQ 的具體實現,如隊列的頭部索引(head)、尾部索引(tail)、隊列大小等信息。在創建過程中,內核會初始化 SQ 和 CQ 的所有隊列項(SQE 和 CQE),并設置好相關的指針和標志位。如果用戶在調用 io_uring_setup 時設置了 IORING_SETUP_SQPOLL 標志位,內核還會創建一個 SQ 線程,用于從 SQ 隊列中獲取 I/O 請求并提交給內核處理。
創建完成后,io_uring_setup 會返回一個文件描述符,這個文件描述符是后續操作 io_uring 對象的關鍵標識,通過它可以進行 I/O 請求的提交、注冊文件描述符等操作。
2.準備 I/O 請求
在創建 io_uring 對象后,需要準備具體的 I/O 請求。這通常通過 io_uring_prep_XXX 系列函數來完成,這些函數用于準備不同類型的 I/O 請求,如 io_uring_prep_read 用于準備讀取操作,io_uring_prep_write 用于準備寫入操作,io_uring_prep_accept 用于準備異步接受連接操作等。
以 io_uring_prep_read 為例,它接受多個參數,包括指向提交隊列項(SQE)的指針、目標文件描述符、讀取數據的緩沖區地址、讀取的字節數以及文件中的偏移量等。函數會根據這些參數,將 I/O 請求的相關信息填充到 SQE 結構體中,包括設置操作類型(如 IORING_OP_READ)、目標文件描述符、緩沖區地址、數據長度、偏移量等字段。
除了基本的 I/O 操作參數外,還可以設置一些額外的標志位和選項,如 I/O 操作的優先級、是否使用直接 I/O 等,以滿足不同的應用需求。通過這些函數,應用程序可以靈活地構建各種類型的 I/O 請求,并將其準備好以便提交到內核中進行處理。
3.提交 I/O 請求
當 I/O 請求準備好后,需要將其提交到內核中執行。這通過調用 io_uring_submit 函數(內部調用 io_uring_enter 系統調用)來實現。在提交 I/O 請求時,首先應用程序會將準備好的 SQE 添加到提交隊列(SQ)中。SQ 是一個環形緩沖區,應用程序通過操作 SQ Ring 中的 tail 指針來將 SQE 放入隊列。具體來說,應用程序會將 tail 指向的 SQE 填充為準備好的 I/O 請求信息,然后將 tail 指針遞增,指向下一個空閑的 SQE 位置。在填充 SQE 時,需要注意按照 SQE 結構體的定義,正確設置各項字段,確保 I/O 請求的信息準確無誤。
默認情況下,使用 io_uring 提交 I/O 請求需要:
- 從SQ Arrary中找到一個空閑的SQE;
- 根據具體的I/O請求設置該SQE;
- 將SQE的數組索引放到SQ中;
- 調用系統調用io_uring_enter提交SQ中的I/O請求。

當所有要提交的 I/O 請求都添加到 SQ 中后,調用 io_uring_submit 函數,該函數會觸發 io_uring_enter 系統調用,將 SQ 中的 I/O 請求提交給內核。內核接收到請求后,會從 SQ 中獲取 SQE,并根據 SQE 中的信息執行相應的 I/O 操作。在這個過程中,由于 SQ 是用戶態和內核態共享的內存區域,避免了數據的多次拷貝和額外的系統調用開銷,提高了 I/O 請求提交的效率。
4.等待 IO 請求完成
提交 I/O 請求后,應用程序可以選擇等待請求完成。等待 I/O 請求完成有兩種主要方式。一種是使用 io_uring_wait_cqe 函數,該函數會阻塞調用線程,直到至少有一個 I/O 請求完成,并返回完成的完成隊列項(CQE)。當調用 io_uring_wait_cqe 時,它會檢查完成隊列(CQ)中是否有新完成的 I/O 請求。如果沒有,線程會進入阻塞狀態,直到內核將完成的 I/O 請求結果放入 CQ 中。一旦有新的 CQE 可用,io_uring_wait_cqe 會返回該 CQE,應用程序可以通過 CQE 獲取 I/O 操作的結果。
另一種方式是使用 io_uring_peek_batch_cqe 函數,它是非阻塞的,用于檢查 CQ 中是否有已經完成的 I/O 請求。如果有,它會返回已完成的 CQE 列表,應用程序可以根據返回的 CQE 進行相應的處理;如果沒有完成的請求,函數會立即返回,應用程序可以繼續執行其他任務,然后在適當的時候再次調用該函數檢查 CQ。這兩種方式為應用程序提供了靈活的等待策略,使其可以根據自身的業務需求和性能要求,選擇合適的方式來處理 I/O 請求的完成事件。
5.獲取 IO 請求結果
當 I/O 請求完成后,應用程序需要從完成隊列(CQ)中獲取結果。這可以通過 io_uring_peek_cqe 函數來實現。io_uring_peek_cqe 函數用于從 CQ 中獲取一個完成的 CQE,而不將其從隊列中移除。應用程序獲取到 CQE 后,可以根據 CQE 中的信息來處理完成的 I/O 請求。CQE 中包含了豐富的信息,如 I/O 操作的返回值、狀態碼、用戶自定義數據等。例如,對于文件讀取操作,CQE 中的返回值表示實際讀取的字節數,狀態碼用于指示操作是否成功,若操作失敗,狀態碼會包含具體的錯誤信息。
應用程序可以根據這些信息進行相應的處理,如讀取數據并進行后續的業務邏輯處理,或者在操作失敗時進行錯誤處理,如記錄錯誤日志、重新嘗試 I/O 操作等。在獲取 CQE 后,應用程序通常會根據 I/O 操作的類型和結果,執行相應的業務邏輯,以實現應用程序的功能需求。
6.釋放 IO 請求結果
在獲取并處理完 IO 請求結果后,需要釋放該結果,以便內核可以繼續使用完成隊列(CQ)。這通過調用 io_uring_cqe_seen 函數來實現。io_uring_cqe_seen 函數的作用是標記一個完成的 CQE 已經被處理,它會將 CQ Ring 中的 head 指針遞增,指向下一個未處理的 CQE。通過這種方式,內核可以知道哪些 CQE 已經被應用程序處理,從而可以繼續向 CQ 中放入新的完成結果。
在釋放 IO 請求結果時,需要注意確保已經完成了對 CQE 中信息的處理,避免在釋放后再次訪問已釋放的 CQE。同時,及時釋放 CQE 也有助于提高系統的性能和資源利用率,避免 CQ 隊列被占用過多而影響后續 I/O 請求結果的存儲和處理。通過正確地釋放 IO 請求結果,保證了 io_uring 的工作流程能夠持續高效地運行,為應用程序提供穩定的異步 I/O 服務。
Part4.io_uring 應用實例
4.1 io_uring應用場景
1.在高性能網絡服務中的應用
在高性能網絡服務領域,io_uring 展現出了強大的優勢,能夠顯著提升網絡服務的并發處理能力和性能。以 Web 服務器和代理服務器為例,它們通常需要處理大量的并發連接和數據傳輸。
在傳統的 Web 服務器中,如使用基于 epoll 的 I/O 模型,雖然可以通過事件驅動的方式處理多個連接,但在高并發情況下,仍然存在一定的局限性。例如,當有大量客戶端同時請求訪問網頁時,epoll 需要不斷地輪詢文件描述符,檢查是否有新的事件發生,這會消耗大量的 CPU 資源。而且,每次數據傳輸都可能涉及多次系統調用和數據拷貝,導致效率低下。
而引入 io_uring 后,Web 服務器的性能得到了大幅提升。io_uring 的異步 I/O 特性使得服務器在處理 I/O 請求時,無需阻塞等待操作完成,可以立即處理其他請求,從而大大提高了并發處理能力。在處理靜態文件請求時,服務器可以使用 io_uring 一次性提交多個文件讀取請求,內核在后臺異步地處理這些請求,并將結果放入完成隊列。服務器從完成隊列中獲取結果后,直接將數據發送給客戶端,減少了數據傳輸的延遲。io_uring 支持的零拷貝技術也減少了數據在用戶空間和內核空間之間的拷貝次數,提高了數據傳輸的效率。
對于代理服務器來說,io_uring 同樣具有重要意義。代理服務器需要在客戶端和目標服務器之間轉發數據,對數據傳輸的效率和并發處理能力要求極高。使用 io_uring,代理服務器可以更高效地處理大量的并發連接,減少數據轉發的延遲。在處理 HTTP 代理請求時,代理服務器可以利用 io_uring 的異步 I/O 和批量操作特性,同時處理多個客戶端的請求,快速地從目標服務器獲取數據并轉發給客戶端,提升了代理服務的性能和響應速度。
2.在數據庫系統中的應用
在數據庫系統中,I/O 操作是影響性能的關鍵因素之一。數據庫系統需要頻繁地進行數據的讀寫、索引的更新等操作,這些操作都涉及大量的 I/O。io_uring 為數據庫系統提供了高效的 I/O 支持,對提升數據庫性能起到了重要作用。
以關系型數據庫 PostgreSQL 為例,在傳統的 I/O 模型下,當進行數據寫入操作時,需要將數據從用戶空間拷貝到內核空間,然后再寫入磁盤。這個過程涉及多次系統調用和數據拷貝,會消耗大量的時間和資源。而且,在高并發寫入的情況下,由于鎖競爭等問題,會導致寫入性能下降。
而采用 io_uring 后,PostgreSQL 可以利用其異步 I/O 和批量操作特性,提高數據寫入的效率。數據庫可以一次性提交多個寫入請求,內核異步地處理這些請求,減少了等待時間。io_uring 的零拷貝技術也減少了數據拷貝的開銷,提高了寫入性能。在數據讀取方面,io_uring 同樣可以提高效率。當查詢數據時,數據庫可以通過 io_uring 異步地從磁盤讀取數據,在讀取數據的同時,數據庫可以繼續處理其他任務,如解析查詢語句、優化查詢計劃等,提高了數據庫的整體響應速度。
對于一些新興的分布式數據庫,如 TiDB,io_uring 的優勢更加明顯。分布式數據庫需要處理大量的分布式存儲節點之間的數據傳輸和同步,對 I/O 的性能和可靠性要求極高。io_uring 的高效異步 I/O 和批量操作能力,可以幫助分布式數據庫更好地處理這些復雜的 I/O 操作,提高系統的擴展性和性能。在數據同步過程中,io_uring 可以實現高效的數據傳輸,減少數據同步的延遲,保證分布式數據庫的數據一致性和可用性。
3.在大規模文件系統操作中的應用
在大規模文件系統操作場景中,如存儲服務和分布式文件系統,io_uring 也展現出了獨特的優勢。這些場景通常需要處理大量的文件讀寫、存儲和管理操作,對 I/O 性能的要求非常高。
以存儲服務為例,無論是對象存儲還是塊存儲,都需要頻繁地進行文件的讀寫操作。在傳統的 I/O 模型下,當處理大量文件請求時,系統調用開銷和數據拷貝開銷會成為性能瓶頸。例如,在對象存儲服務中,當用戶上傳或下載大量文件時,傳統的 I/O 模型可能會導致響應時間過長,用戶體驗不佳。
而引入 io_uring 后,存儲服務可以利用其異步 I/O 和批量操作特性,大大提高文件處理的效率。在處理文件上傳時,存儲服務可以使用 io_uring 一次性提交多個寫入請求,內核異步地將數據寫入存儲設備,減少了用戶等待時間。在文件下載時,io_uring 可以實現高效的文件讀取,快速地將數據傳輸給用戶。io_uring 的零拷貝技術也減少了數據傳輸過程中的開銷,提高了存儲服務的性能和吞吐量。
對于分布式文件系統,如 Ceph,io_uring 的應用可以提升整個文件系統的性能和可靠性。分布式文件系統需要處理多個存儲節點之間的數據分布和讀寫操作,對 I/O 的并發處理能力和數據一致性要求很高。io_uring 的異步 I/O 和批量操作能力,可以幫助分布式文件系統更好地管理和調度 I/O 請求,提高數據讀寫的效率。在處理大規模文件的讀寫時,io_uring 可以實現高效的數據傳輸和并行處理,減少文件操作的延遲,提升分布式文件系統的整體性能。
4.2 io_uring案例分析
1.簡單文件讀寫案例
⑴代碼實現
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/io_uring.h>
int main() {
struct io_uring ring;
struct io_uring_sqe *sqe;
struct io_uring_cqe *cqe;
int fd, ret;
// 打開文件
fd = open("example.txt", O_RDONLY);
if (fd < 0) {
perror("Failed to open file");
return 1;
}
// 初始化io_uring
io_uring_queue_init(8, &ring, 0);
// 獲取一個提交隊列條目
sqe = io_uring_get_sqe(&ring);
if (!sqe) {
fprintf(stderr, "Could not get sqe\n");
return 1;
}
// 準備異步讀操作
char *buf = malloc(1024);
io_uring_prep_read(sqe, fd, buf, 1024, 0);
// 提交請求
io_uring_submit(&ring);
// 等待完成
ret = io_uring_wait_cqe(&ring, &cqe);
if (ret < 0) {
perror("io_uring_wait_cqe");
return 1;
}
// 檢查結果
if (cqe->res < 0) {
fprintf(stderr, "Async read failed: %s\n", strerror(-cqe->res));
} else {
printf("Read %d bytes: %s\n", cqe->res, buf);
}
// 釋放資源
io_uring_cqe_seen(&ring, cqe);
io_uring_queue_exit(&ring);
close(fd);
free(buf);
return 0;
}代碼解讀
文件打開:fd = open("example.txt", O_RDONLY); 這行代碼使用 open 函數打開名為 example.txt 的文件,以只讀模式(O_RDONLY)打開。如果打開失敗,open 函數會返回一個負數,并通過 perror 函數打印錯誤信息,然后程序返回錯誤代碼 1。
io_uring 初始化:io_uring_queue_init(8, &ring, 0); 這行代碼用于初始化 io_uring 實例。其中,第一個參數 8 表示提交隊列(SQ)和完成隊列(CQ)的大小,即隊列中可以容納的 I/O 請求數量;第二個參數 &ring 是指向 io_uring 結構體的指針,用于存儲初始化后的 io_uring 實例;第三個參數 0 表示使用默認的初始化標志。
獲取提交隊列條目:sqe = io_uring_get_sqe(&ring); 從 io_uring 的提交隊列中獲取一個提交隊列項(SQE)。如果獲取失敗,io_uring_get_sqe 函數會返回 NULL,程序會打印錯誤信息并返回錯誤代碼 1。
準備異步讀操作:
char *buf = malloc(1024); //分配 1024 字節的內存空間,用于存儲讀取的文件數據。io_uring_prep_read(sqe, fd, buf, 1024, 0); 使用 io_uring_prep_read 函數準備一個異步讀操作。它接受五個參數,第一個參數 sqe 是之前獲取的提交隊列項;第二個參數 fd 是要讀取的文件描述符;第三個參數 buf 是用于存儲讀取數據的緩沖區;第四個參數 1024 表示要讀取的字節數;第五個參數 0 表示從文件的起始位置開始讀取。
提交請求:io_uring_submit(&ring); 將準備好的 I/O 請求提交到內核中執行。這個函數會觸發 io_uring_enter 系統調用,將提交隊列中的請求提交給內核。
等待完成:ret = io_uring_wait_cqe(&ring, &cqe); 等待 I/O 操作完成。這個函數會阻塞調用線程,直到至少有一個 I/O 請求完成,并返回完成的完成隊列項(CQE)。如果等待過程中出現錯誤,io_uring_wait_cqe 函數會返回一個負數,程序會通過 perror 函數打印錯誤信息并返回錯誤代碼 1。
檢查結果:
if (cqe->res < 0) 檢查 I/O 操作的結果。如果 cqe->res 小于 0,表示操作失敗,通過 fprintf 函數打印錯誤信息。else 分支表示操作成功,打印實際讀取的字節數和讀取到的數據。
釋放資源:
io_uring_cqe_seen(&ring, cqe); /* 知內核已經處理完一個完成事件,
釋放相關資源。這通過將完成隊列的頭部指針遞增來實現,以便內核可以繼續使用完成隊列。*/io_uring_queue_exit(&ring); 釋放 io_uring 實例所占用的資源,包括提交隊列和完成隊列等。
close(fd); 關閉之前打開的文件。
free(buf); 釋放之前分配的內存緩沖區。
2.網絡編程案例(TCP 服務器)
⑴代碼實現
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <liburing.h>
#define ENTRIES_LENGTH 4096
#define MAX_CONNECTIONS 1024
#define BUFFER_LENGTH 1024
char buf_table[MAX_CONNECTIONS][BUFFER_LENGTH] = {0};
enum {
READ,
WRITE,
ACCEPT,
};
struct conninfo {
int connfd;
int type;
};
void set_read_event(struct io_uring *ring, int fd, void *buf, size_t len, int flags) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd, buf, len, flags);
struct conninfo ci = {.connfd = fd,.type = READ};
memcpy(&sqe->user_data, &ci, sizeof(struct conninfo));
}
void set_write_event(struct io_uring *ring, int fd, const void *buf, size_t len, int flags) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_send(sqe, fd, buf, len, flags);
struct conninfo ci = {.connfd = fd,.type = WRITE};
memcpy(&sqe->user_data, &ci, sizeof(struct conninfo));
}
void set_accept_event(struct io_uring *ring, int fd, struct sockaddr *cliaddr, socklen_t *clilen, unsigned flags) {
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_accept(sqe, fd, cliaddr, clilen, flags);
struct conninfo ci = {.connfd = fd,.type = ACCEPT};
memcpy(&sqe->user_data, &ci, sizeof(struct conninfo));
}
int main() {
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if (listenfd == -1) return -1;
struct sockaddr_in servaddr, clientaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(9999);
if (-1 == bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr))) {
return -2;
}
listen(listenfd, 10);
struct io_uring_params params;
memset(?ms, 0, sizeof(params));
struct io_uring ring;
memset(&ring, 0, sizeof(ring));
/*初始化params 和 ring*/
io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ?ms);
socklen_t clilen = sizeof(clientaddr);
set_accept_event(&ring, listenfd, (struct sockaddr *)&clientaddr, &clilen, 0);
while (1) {
struct io_uring_cqe *cqe;
io_uring_submit(&ring);
int ret = io_uring_wait_cqe(&ring, &cqe);
struct io_uring_cqe *cqes[10];
int cqecount = io_uring_peek_batch_cqe(&ring, cqes, 10);
unsigned count = 0;
for (int i = 0; i < cqecount; i++) {
cqe = cqes[i];
count++;
struct conninfo ci;
memcpy(&ci, &cqe->user_data, sizeof(ci));
if (ci.type == ACCEPT) {
int connfd = cqe->res;
char *buffer = buf_table[connfd];
set_read_event(&ring, connfd, buffer, 1024, 0);
set_accept_event(&ring, listenfd, (struct sockaddr *)&clientaddr, &clilen, 0);
} else if (ci.type == READ) {
int bytes_read = cqe->res;
if (bytes_read == 0) {
close(ci.connfd);
} else if (bytes_read < 0) {
close(ci.connfd);
printf("client %d disconnected!\n", ci.connfd);
} else {
char *buffer = buf_table[ci.connfd];
set_write_event(&ring, ci.connfd, buffer, bytes_read, 0);
}
} else if (ci.type == WRITE) {
char *buffer = buf_table[ci.connfd];
set_read_event(&ring, ci.connfd, buffer, 1024, 0);
}
}
io_uring_cq_advance(&ring, count);
}
return 0;
}⑵代碼解讀
創建監聽套接字:int listenfd = socket(AF_INET, SOCK_STREAM, 0); 使用 socket 函數創建一個 TCP 套接字,AF_INET 表示使用 IPv4 協議,SOCK_STREAM 表示使用流式套接字(即 TCP 協議),0 表示默認協議。如果創建失敗,socket 函數會返回 -1,程序返回 -1。
綁定地址和端口:
填充服務器地址結構體 servaddr,包括地址族(AF_INET)、IP 地址(INADDR_ANY 表示綁定到所有可用的網絡接口)和端口號(9999)。
if (-1 == bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr))) 使用 bind 函數將創建的套接字綁定到指定的地址和端口。如果綁定失敗,bind 函數返回 -1,程序返回 -2。
監聽連接:listen(listenfd, 10); 使用 listen 函數開始監聽套接字,第二個參數 10 表示最大連接數,即允許同時存在的未處理連接請求的最大數量。
初始化 io_uring:
struct io_uring_params params; 和 struct io_uring ring; 分別定義了 io_uring 的參數結構體和實例結構體。
memset(?ms, 0, sizeof(params)); 和 memset(&ring, 0, sizeof(ring)); 初始化這兩個結構體的內容為 0。io_uring_queue_init_params(ENTRIES_LENGTH, &ring, ¶ms); 使用 io_uring_queue_init_params 函數初始化 io_uring 實例,ENTRIES_LENGTH 表示提交隊列和完成隊列的大小,&ring 是指向 io_uring 實例的指針,¶ms 是指向參數結構體的指針。
設置接受連接事件:set_accept_event(&ring, listenfd, (struct sockaddr *)&clientaddr, &clilen, 0); 調用 set_accept_event 函數設置一個接受連接的異步事件。在這個函數中,首先獲取一個提交隊列項(SQE),然后使用 io_uring_prep_accept 函數準備接受連接的請求,將相關信息(如監聽套接字、客戶端地址、地址長度等)填充到 SQE 中,并將自定義的連接信息結構體 conninfo 復制到 SQE 的用戶數據區域,用于標識該請求的類型和相關連接信息。
事件循環處理:
- while (1) 進入一個無限循環,用于持續處理 I/O 事件。
- io_uring_submit(&ring); 提交準備好的 I/O 請求到內核。
- int ret = io_uring_wait_cqe(&ring, &cqe); 等待 I/O 操作完成,獲取完成的完成隊列項(CQE)。
- struct io_uring_cqe *cqes[10]; 和 int cqecount = io_uring_peek_batch_cqe(&ring, cqes, 10); 使用 io_uring_peek_batch_cqe 函數嘗試批量獲取完成的 CQE,最多獲取 10 個。
遍歷獲取到的 CQE:
struct conninfo ci; 和 memcpy(&ci, &cqe->user_data, sizeof(ci)); 從 CQE 的用戶數據區域復制之前設置的連接信息結構體 conninfo。
根據連接信息中的類型(ci.type)進行不同的處理:
如果是 ACCEPT 類型,表示有新的連接請求被接受。獲取新的連接描述符 connfd,設置讀取事件,準備從新連接中讀取數據,并再次設置接受連接事件,以便繼續接受新的連接請求。如果是 READ 類型,表示有數據可讀。根據讀取的字節數進行處理,如果讀取到的字節數為 0,表示客戶端斷開連接,關閉連接;如果讀取失敗(字節數小于 0),也關閉連接并打印斷開連接的信息;如果讀取成功,設置寫入事件,將讀取到的數據回顯給客戶端。如果是 WRITE 類型,表示數據寫入完成,設置讀取事件,準備從客戶端讀取下一次的數據。
io_uring_cq_advance(&ring, count); 告知內核已經處理完 count 個完成事件,通過將完成隊列的頭部指針遞增 count 個位置,以便內核可以繼續使用完成隊列。
4.3性能對比測試
1.測試環境與方法
測試環境搭建:在一臺配備 Intel (R) Xeon (R) CPU E5 - 2682 v4 @ 2.50GHz 處理器、16GB 內存、運行 Linux 5.10 內核的服務器上進行測試。使用的存儲設備為 NVMe SSD,以確保 I/O 性能不受磁盤性能的過多限制。測試機器的網絡配置為千兆以太網,以保證網絡傳輸的穩定性。
2.測試方法設計
針對文件讀寫場景,使用 fio 工具進行測試。分別設置不同的 I/O 模式,包括阻塞 I/O、非阻塞 I/O、epoll 以及 io_uring。對于每種模式,進行多次測試,每次測試設置不同的文件大小(如 1MB、10MB、100MB)和 I/O 操作類型(如隨機讀、順序讀、隨機寫、順序寫)。在每次測試中,fio 工具會按照設定的參數進行 I/O 操作,并記錄操作的時間、吞吐量等性能指標。例如,在隨機讀測試中,fio 會隨機讀取文件中的數據塊,并統計單位時間內讀取的數據量。
在網絡編程場景下,搭建一個簡單的 echo 服務器模型,分別使用 epoll 和 io_uring 實現。客戶端通過多線程模擬大量并發連接,向服務器發送數據并接收服務器回顯的數據。在測試過程中,逐漸增加并發連接數,從 100 個連接開始,每次增加 100 個,直到達到 1000 個連接。使用 iperf 等工具測量不同并發連接數下的 QPS(每秒查詢率)、延遲等性能指標。iperf 工具會在客戶端和服務器之間建立 TCP 連接,發送一定量的數據,并記錄數據傳輸的速率、延遲等信息。
3.測試結果分析
文件讀寫性能:在小文件(1MB)讀寫測試中,阻塞 I/O 由于線程阻塞等待 I/O 操作完成,導致其吞吐量最低,平均吞吐量約為 50MB/s。非阻塞 I/O 雖然避免了線程阻塞,但頻繁的輪詢使得 CPU 利用率較高,且由于 I/O 操作的碎片化,其吞吐量也不高,平均約為 80MB/s。epoll 在處理多個文件描述符的 I/O 事件時,通過高效的事件通知機制,提高了 I/O 操作的效率,平均吞吐量達到 120MB/s。
Part5.io_uring與其他 I/O 模型對比
5.1與阻塞 I/O 對比
阻塞 I/O 是最基礎的 I/O 模型,當應用程序執行 I/O 操作(如 read 或 write)時,線程會被阻塞,直到 I/O 操作完成。在從磁盤讀取文件時,若數據尚未準備好,線程就會一直等待,期間無法執行其他任務。這就好比一個人在餐廳點餐,必須坐在餐桌旁等待食物上桌,期間什么其他事情都做不了。阻塞 I/O 的優點是編程簡單,邏輯清晰,但在高并發場景下,由于每個 I/O 請求都可能阻塞線程,導致系統的并發處理能力極低,資源利用率也不高。
而 io_uring 采用異步 I/O 機制,用戶程序提交 I/O 請求后,無需等待操作完成,就可以繼續執行其他任務。當 I/O 操作完成后,內核會將結果放入完成隊列(CQ),并通過事件通知機制通知用戶程序。這種方式大大提高了系統的并發處理能力,線程在等待 I/O 操作的過程中可以充分利用 CPU 資源執行其他任務。在一個高并發的 Web 服務器中,使用阻塞 I/O 時,每個客戶端連接都需要一個獨立的線程來處理,當并發連接數增多時,線程資源將被大量消耗,系統性能會急劇下降;而使用 io_uring,服務器可以同時處理多個客戶端的 I/O 請求,無需為每個請求創建單獨的線程,提高了資源利用率和系統的并發處理能力。
5.2與非阻塞 I/O 對比
非阻塞 I/O 允許應用程序在 I/O 操作未完成時立即返回,線程不會被阻塞。當應用程序執行 I/O 操作時,如果數據尚未準備好,系統會立即返回一個錯誤(如 EWOULDBLOCK 或 EAGAIN),應用程序可以繼續執行其他任務,然后通過輪詢的方式再次嘗試 I/O 操作,直到數據準備好。這就像在餐廳點餐時,服務員告知需要等待一段時間,你可以先去做其他事情,然后時不時回來詢問食物是否準備好了。非阻塞 I/O 提高了系統的并發處理能力,但頻繁的輪詢會消耗大量的 CPU 資源,增加了系統的開銷,而且編程復雜度較高,需要處理更多的錯誤和狀態判斷。
io_uring 雖然也是異步 I/O 模型,但與非阻塞 I/O 有很大的不同。io_uring 通過提交隊列(SQ)和完成隊列(CQ)實現了高效的異步 I/O 操作,減少了系統調用和上下文切換的開銷。用戶程序只需將 I/O 請求寫入 SQ,內核會異步處理這些請求,并將結果寫入 CQ,用戶程序從 CQ 中獲取結果,無需頻繁輪詢。在處理大量 I/O 請求時,非阻塞 I/O 的輪詢操作會導致 CPU 使用率急劇上升,而 io_uring 通過異步機制和事件通知,大大減少了 CPU 的消耗,提高了系統的性能和效率。io_uring 還支持批量操作和更多的系統調用,功能更加豐富和強大。
5.3與 epoll 對比
epoll 是 Linux 下常用的 I/O 多路復用技術,它允許一個進程同時監視多個 I/O 描述符,當其中任何一個描述符就緒(即有數據可讀或可寫)時,進程就可以對其進行處理。epoll 采用事件驅動模式,使用紅黑樹管理需要監聽的文件描述符,用一個事件隊列存放 I/O 就緒事件。調用 epoll_wait 時,內核將已就緒的事件從內核空間拷貝到用戶空間,用戶程序依次處理這些事件。若有大量事件就緒,需多次系統調用處理。
io_uring 和 epoll 在設計理念、實現機制和適用場景等方面都存在差異。從設計理念上看,epoll 主要用于 I/O 多路復用,解決一個進程監視多個文件描述符的問題;而 io_uring 是更廣泛的異步I/O 框架,不僅用于事件通知,還能直接執行 I/O 操作,旨在提高大規模并發I/O 操作性能。在實現機制上,epoll 通過內核和用戶空間的數據拷貝來傳遞事件信息,而 io_uring 基于兩個共享環形緩沖區(SQ 和 CQ),用戶程序將 I/O 請求寫入SQ,內核處理完 I/O 操作后把結果寫入 CQ,減少了用戶態到內核態的上下文切換次數,且支持批量提交和處理 I/O 請求 。
在性能方面,在高并發場景下,io_uring 性能優勢明顯,能極大減少用戶態到內核態的切換次數,測試顯示連接數 1000 及以上時,io_uring 性能開始超越 epoll,其極限性能單 core 在 24 萬 QPS 左右,而 epoll 單 core 只能達到 20 萬 QPS 左右 。在連接數超過 300 時,io_uring 的用戶態到內核態的切換次數基本可忽略不計 。
不過在某些特殊場景(如 meltdown 和 spectre 漏洞未修復時 ),io_uring 相對 epoll 的性能提升不明顯甚至略有下降 。在適用場景上,epoll 適用于事件驅動的網絡編程場景,如監視多個客戶端連接的服務器,像 Nginx、Redis 等都基于 epoll 構建;io_uring 則更適合處理網絡 I/O、文件 I/O、內存映射等多種場景,目標是實現 Linux 下一切基于文件概念的異步編程 。
io_uring 的編程復雜度相對較高,需要深入理解提交隊列和完成隊列的工作機制,手動管理 I/O 請求的提交、結果獲取,以及處理隊列初始化、事件提交與回收等操作,而 epoll 的編程相對簡單,開發者只需關注文件描述符的事件注冊(epoll_ctl)和事件處理(epoll_wait 返回后的邏輯) 。
Part6.使用 io_uring 的注意事項與挑戰
6.1內核版本要求
io_uring 是 Linux 內核提供的特性,對內核版本有一定的要求。要充分利用 io_uring 的全部功能,建議使用 Linux 5.10 及以上版本的內核 。在較低版本的內核中,可能不支持 io_uring,或者雖然支持但存在功能缺陷和性能問題。在 Linux 5.4 版本之前,io_uring 的某些功能可能不夠穩定,在處理某些復雜的 I/O 操作時可能會出現錯誤。如果你的系統內核版本較低,在考慮使用 io_uring 之前,需要先升級內核。內核升級過程可能會涉及到系統兼容性、驅動程序等一系列問題,需要謹慎操作。在升級內核之前,最好備份重要的數據,并在測試環境中進行充分的測試,確保升級后的系統能夠正常運行。
6.2編程復雜度
雖然 io_uring 提供了強大的功能,但直接使用 io_uring 的系統調用進行編程是比較復雜的。它涉及到對提交隊列(SQ)和完成隊列(CQ)的詳細操作,以及對各種 I/O 請求參數的設置。開發者需要深入理解 io_uring 的工作原理和機制,才能正確地使用它。例如,在設置提交隊列條目(SQE)時,需要準確地設置操作碼、文件描述符、緩沖區地址、數據長度等參數,任何一個參數設置錯誤都可能導致 I/O 操作失敗。在處理完成隊列條目(CQE)時,也需要正確地解析操作結果和錯誤碼,進行相應的處理。
為了簡化 io_uring 的使用,開發者可以借助 liburing 庫。liburing 庫是對 io_uring 系統調用的封裝,提供了更高級、更易用的 API。通過 liburing 庫,開發者可以更方便地初始化 io_uring 實例、提交 I/O 請求、獲取完成事件等。使用 liburing 庫中的 io_uring_queue_init 函數可以方便地初始化 io_uring 實例,使用 io_uring_get_sqe 函數可以從提交隊列中獲取一個空閑的 SQE,使用 io_uring_submit 函數可以提交 I/O 請求等。借助 liburing 庫,開發者可以降低編程的復雜度,提高開發效率,但同時也需要了解 liburing 庫的使用方法和相關的函數接口。
6.3應用適配難度
將現有的應用程序遷移到 io_uring 可能需要對代碼進行較大的修改,存在一定的適配難度。因為 io_uring 的編程模型與傳統的 I/O 模型有很大的不同,現有的應用程序可能是基于阻塞 I/O、非阻塞 I/O 或 I/O 多路復用等模型開發的,要遷移到 io_uring,需要重新設計和實現 I/O 相關的部分代碼。
在一個基于 epoll 的 Web 服務器中,要將其遷移到 io_uring,需要重新編寫事件處理邏輯、I/O 請求的提交和處理流程等。這不僅需要對 io_uring 有深入的理解,還需要對現有的應用程序架構有清晰的認識,確保遷移過程中不會影響應用程序的功能和穩定性。在遷移過程中,還可能會遇到一些兼容性問題,如與其他庫或組件的兼容性等,需要進行仔細的測試和調試。

























