如何完成一次 IO - Linux I/O詳解

如何完成一次 IO

哪個男孩不想來一場異步非阻塞的甜蜜戀愛?

21 點,你打開微信,開心地對女孩說:“晚上好”。女孩說:“我在洗澡”。

你抱着手機等待晚點聊,此刻,你是阻塞的,也是同步的。爲什麼?

寫在前面

談起 IO, Javaer 會說起 BIO、NIO、AIO,也會提到同步異步、阻塞非阻塞。但到底什麼是 IO, IO又是怎麼完成的?

1. 什麼是 I/O

學術的說 I/O 是信息處理系統(計算機)與外界(人或信息處理系統)間的通信。如計算機,即 CPU 訪問任何寄存器和 Cache 等封裝以外的數據資源都可當成 I/O ,包括且不限於內存,磁盤,顯卡。

軟件開發中的 I/O 則常指磁盤、網絡 IO。

Unix 系統下,不論是標準輸入還是藉助套接字接受網絡輸入,都有兩個步驟:

  1. 等待數據準備好**(Waiting for the data to be ready)**
  2. 從內核向進程複製數據**(Copying the data from the kernel to the process)**

等待數據準備好還比較好理解,從內核向進程複製數據是什麼東東?

2. 計算機內存

計科、軟工的同學都知道,修電腦是我們的對口工種,加內存條這種事更是入職基本要求。這的內存條又叫物理內存。那一般來說,有實就有虛,所以就有虛擬內存。

2.1 虛擬內存

操作系統中進程間是共享 CPU 和內存資源的,就需要一套完善的內存管理機制防止進程間內存泄漏。

現代操作系統提供了對主存的抽象概念:虛擬內存(Virtual Memory)。虛擬內存爲每個進程提供一個一致私有的地址空間,每個進程擁有一片連續完整的內存空間,讓進程有種在獨享主存的美好錯覺。

實際上,虛擬內存通常被分隔成多個物理內存碎片,還有部分暫存在外部磁盤存儲器,在需要時進行數據交換,加載到物理內存中來。大致如下圖:

當用戶進程發出內存申請請求,系統會爲進程分配虛擬地址,並創建內存映射放入頁表中,如果對應的數據不在物理內存上就會發生缺頁異常,需要把進程需要的數據從磁盤上拷貝到物理內存中。

2.2 內核空間與用戶空間

上圖有看到,虛擬內存分爲內核和用戶地址空間兩部分,因爲需要避免用戶進程直接操作內核。

操作系統的核心是內核,獨立於普通應用程序,可訪問受保護的內存空間,也可訪問底層硬件設備。 在 Linux 系統中,內核模塊運行在內核空間,當進程經過系統調用而陷入內核代碼中執行時,稱進程處於內核運行態,即內核態;反之,運行在用戶空間執行用戶自己的代碼時,處於用戶態
image-20200614181334371

上圖可以看到,應用程序和內核間無法直接通信,必須通過系統調用,而系統調用的成本很高

當用戶進程想要執行 IO 操作時,由於沒有執行這些操作的權限,只能發起系統調用請求操作系統幫忙完成。而系統調用會產生中斷陷入到內核,也就是進行了一次上下文切換操作。

2.3 進程切換

到了內核,爲了控制進程執行,內核必須有能力掛起正在 CPU 上運行的進程,並恢復以前掛起的某個進程的執行。這種行爲被稱爲進程切換

需要注意:這裏的進程切換和上文 2.2 的用戶態轉內核態的上下文切換並不一樣,後者只是同一個進程的 CPU 權限等級的修改。

進程是資源分配的基本單位, 因此進程切換時,需保存、裝載各種狀態數據等資源, 代價就比較高。

3. Linux I/O 讀寫方式

現在我們知道用戶進程需要通過系統調用轉爲內核態,才能在 CPU 上運行,進而訪問底層如磁盤等硬件設備。其中磁盤等 I/O 設備的控制器中有寄存器,負責與 CPU 進行通信。

那麼,I/O 設備與 CPU 能用哪些方法進行通信呢?主要通過兩種。

3.1 I/O中斷

在 DMA 技術出現前,應用程序與磁盤間的 I/O 操作都通過 CPU 中斷完成。外部存儲設備採用中斷方式主動通知 CPU,CPU 負責拷貝數據到內核緩衝區,再拷貝到用戶緩衝區,每次就會有上下文切換的開銷及 CPU 拷貝的時間

screen-1527758

3.2 DMA

DMA 全稱叫直接內存存取(Direct Memory Access),是一種允許外圍設備直接訪問系統主存的機制。

CPU 通知 DMA 控制器拷貝外部存儲設備數據到內核緩衝區,完成後再通知 CPU 拷貝到用戶緩衝區。和 I/O 中斷方式相比,改由內存來執行外部存儲器數據的 I/O 操作,減輕了CPU負擔,且 CPU 讀取內存比讀取外部存儲設備速度要快。
目前大多數硬件設備,包括磁盤、網卡、聲卡等都支持 DMA 技術。
screen-1531490

4. 零拷貝

一次 I/O ,無論是讀還是寫數據,都要經過硬盤 - 內核 - 用戶空間,有了 DMA,磁盤到內核空間的拷貝問題得以解決,CPU 可以摸會魚了。但用戶空間和內核空間之間的傳輸怎麼辦呢,CPU 覺得要做就做一個摸魚到下班的 CPU。於是有了零拷貝。

零拷貝是基於 DMA 的, 其目的就是優化多次數據拷貝的過程,避免 CPU 將數據從一塊存儲拷貝到另外一塊存儲。有 3 個實現思路:

  1. 用戶態直接 I/O : 應用程序直接訪問硬件存儲,內核只輔助數據傳輸。硬件上的數據直接拷貝給用戶空間,也就不存在內核空間緩衝區和用戶空間緩衝區間的數據拷貝了。
  2. 減少數據拷貝次數:在數據傳輸過程中,減少數據在用戶空間緩衝區和系統內核空間緩衝區之間的 CPU 拷貝次數,同時也避免數據在內核空間內部的 CPU 拷貝。
  3. 寫時複製:多個進程共享同一塊數據時,如果某進程要對這份數據修改,那將其拷貝到自己的進程地址空間中。

下面來看看這三種思路的具體實現。

1. 傳統 I/O

先來看看傳統方式,在進行一次讀寫時共涉及了4次上下文切換,2次 DMA 拷貝以及2次 CPU 拷貝。
screen-1535441

2. 用戶態直接IO

這是第一種思路,使應用進程或處於用戶態下的庫函數跨過內核直接訪問硬件,內核在數據傳輸過程除了進行必要的虛擬存儲配置工作外,不參與任何其他工作。
screen-1535947

但只適用於不需要內核緩衝區處理的應用程序,這些應用程序通常在進程地址空間有自己的數據緩存機制,又稱爲自緩存應用程序,如數據庫管理系統。其次,因 CPU 和磁盤 I/O 之間的性能差距,就會造成資源的浪費,一般是會配合異步 I/O 使用。

3. mmap

這屬於第二類優化,減少了 1 次 CPU 拷貝。MMAP 是數據不會到達用戶空間內存,只會存在於系統空間的內存上,用戶空間與系統空間共用同一個緩衝區,兩者通過映射關聯。screen-1773287

整個 MMAP 過程,發生了 4 次上下文切換 + 1 次 CPU 拷貝 + 2 次 DMA 拷貝。

4. sendfile

這也是第二類優化。用戶進程不需要單獨調用 read/write ,而是直接調用 sendfile() ,sendfile 再幫用戶調用 read/write 操作。數據可以直接在內核空間進行 I/O 傳輸,省去了數據在用戶空間和內核空間之間的拷貝。

與 mmap 內存映射方式不同的是, sendfile() 調用中數據對用戶空間是完全不可見的。也就是說,這是一次完全意義上的數據傳輸過程。
image-20200610154454498

整個過程發生 2 次上下文切換,1 次 CPU 拷貝和 2 次 DMA 拷貝。

5. sendfile + DMA gather copy

在前面的 sendfile() 方式中,CPU 仍需要一次拷貝,從 Linux 2.4 版本開始,DMA 自帶了收集功能,可以將對應的數據描述信息(內存地址、地址偏移量)記錄到相應的網絡緩衝區( socket buffer),由DMA 根據這些信息直接將內核緩衝區的數據拷貝到網卡設備中,省下了最後一次 CPU 拷貝。

image-20200610154613272

這次只發生 2 次上下文切換 + 2 次 DMA 數據拷貝。

6. splice

sendfile 只適用於將數據從文件拷貝到網卡上,限定了使用範圍。

splice 系統調用可以在內核空間的讀緩衝區和網絡緩衝區之間建立管道,支持任意兩個文件之間互連,可以在操作系統地址空間中整塊地移動數據。

image-20200610161033841

同樣發生 2 次上下文切換 + 2 次 DMA 數據拷貝。

7. 寫時複製

這個就是第三種思路了,COW 寫時複製。

當用戶進程有寫操作時,就把這塊共享的內存空間複製一份到其他區域,給寫進程專用。這種方法在能夠降低系統開銷,如果某個進程永遠不會對數據進行更改,那就永遠不需要拷貝。

Java 中的實現

其實這個策略對於 Javaer 來說不應該陌生,在解決併發問題時,最簡單的策略莫過於不變性模式,對象一旦被創建之後,狀態就不再發生變化。

比如 包裝類和 String 的線程安全就是依賴不變性,基於享元模式創建對象池,讀的時候是共用的(這也是爲什麼包裝類不適合做鎖的原因),寫的時候比如Stringreplace() ,並沒有更改原字符串裏面數組的內容,而是創建了一個新字符串,這就是寫時複製策略了。

尤其是從 Java8 開始的函數式編程,基礎就是不可變性,所以修改操作都需要 COW 策略。當然早期 Java 就有類似容器,比如CopyOnWriteArrayList, 不過實現有點笨,不是按需複製。

8. 對比

拷貝方式 CPU拷貝 DMA拷貝 上下文切換
傳統方式 2 2 4
mmap 1 2 4
sendfile 1 2 2
sendfile + DMA gather copy 0 2 2
splice 0 2 2

此刻,CPU 覺得還行。

5. Unix IO模型

前面說了那麼多,想必現在應該知道 I/O 是怎麼一回事了,接着再瞧瞧啥叫阻塞啥叫同步。

5.1 阻塞 IO - 同步阻塞

image-20200610163917210

等待數據、拷貝數據都是處於阻塞狀態的。 這就是同步阻塞

5.2 非阻塞 - 同步非阻塞

image-20200610164001260

在I/O執行的第一個階段(等待數據)不會阻塞線程,但在第二階段(複製數據)會阻塞。

這就是同步非阻塞,其實就是輪詢,當數據沒準備好則返回 EWOULDBLOCK

5.3 信號驅動 - 同步非阻塞

image-20200610164056472

前一個非阻塞模型中,需要調用者輪詢,怎麼避免呢?

首先要開啓 socket 的信號驅動式 IO 功能,應用進程通過 sigaction 系統調用註冊 SIGIO 信號處理函數,該系統調用會立即返回。當數據準備好時,內核會爲該進程產生一個 SIGIO 信號通知,之後再把數據拷貝到用戶空間中。

這也是同步非阻塞。雖然等待數據期間用戶態進程不被阻塞,但當收到信號通知時是阻塞並拷貝數據,所以還是同步的。

5.4 多路複用 - 同步阻塞

也稱事件驅動IO,在單個線程裏同時監控多個套接字,通過 select 或 poll 輪詢查看所負責的所有 socket,當某個 socket 有數據到達了,就通知用戶進程。

image-20200610164547883

多個進程的 IO 可以註冊到同一個管道上,關鍵是select函數,多個進程的 IO 可以註冊到同一個select上,當用戶進程調用該selectselect會監聽所有註冊好的 IO,如果所有被監聽的 IO 需要的數據都沒有準備好時,調用進程會阻塞,等待有套接字變爲可讀。當任意一個 IO 需要的數據準備好後,即當有套接字可讀以後,select調用就會返回,然後進程再通過recvfrom來把對應的數據拷貝到用戶進程緩衝區。

**IO 複用模型,並沒向內核註冊信號處理函數,所以是阻塞的。**進程在發出select後,要等到select監聽的所有 IO 操作中的至少一個需要的數據準備好,纔會返回,也需要再次發送請求去進行文件拷貝。整個用戶進程其實是一直被阻塞的,但 IO 複用的優勢在於可以等待多個描述符就緒。

IO 複用的特點是進行了兩次系統調用,進程先是阻塞在 select 上,再阻塞在讀操作的第二個階段上。這是同步阻塞的。

多路複用機制還是值得細說的,比如重點的 select/poll/epoll,這裏就不展開了,有興趣的可以自行閱讀相關資料。

5.5 異步IO - 異步非阻塞

image-20200610170026760

如圖, 用戶進程在發起調用後,內核會立即返回。接着用戶進程就幹別的事去了。

然後內核等待數據準備完畢,自動將數據拷貝到用戶內存,接着給用戶進程發了個信號,通知 IO 操作已完成,這纔是五個 I/O 模型中唯一一個異步模型。

可能會有疑問,爲啥信號驅動模型是同步模型,這是因爲信號驅動是由內核通知何時啓動一個 IO 操作,還需要用戶進程再拷貝數據。而異步 IO 是由內核是在所有工作做完後,通知 IO 操作已完成。

異步 IO 特點是 IO 執行的兩個階段(等待數據、拷貝數據)都由內核去完成,用戶進程無需干預,也不會被阻塞。這就是異步非阻塞了。也就是 Java 中的 AIO。

5.6 模型比較
image-20200610170140700

6. Java 及其他

前面說了這麼多,或許你更想知道 “AIO 是不是異步”,“哪個框架用了這些東西”。

1. BIO

BIO 屬於同步阻塞,一客戶端一線程。該模型下常見優化的方案就是用線程池。

2. NIO

NIO 屬於同步非阻塞,收到的請求會先註冊到多路複用器 Selector 上,多路複用器輪詢直到連接有 I/O 請求時才啓動一個線程進行處理。也就是前文中的多路複用 I/O 模型,雖然說多路複用模型是阻塞的,但在 NIO 這裏,因爲有Selector,read 和 write 操作都是非阻塞的,其中 Selector 其實就是 select/poll/epoll 的外包類。

image-20200610173230680

不僅如此,NIO 除了面向流和非阻塞外,還有一個效率高的原因就是前文中也有提到的零拷貝。

NIO 中的 Channel(通道)相當於操作系統中的內核緩衝區, Buffer 就相當於操作系統中的用戶空間緩衝區。零拷貝在 NIO 這裏重要的是兩個實現:

  • FileChannel.map() : 基於內存映射 mmap 方式一種實現,可以把一個文件從 position 位置開始的 size 大小的區域映射爲內存映像文件。
  • FileChannel.transferTo() : 通過調用 sendfile 方式實現的零拷貝。

關於 NIO 還有一個常見的實現。那就是 Netty , Netty 是一個高性能、異步事件驅動的 NIO 框架,但爲啥不直接用 JDK 中的 NIO ,而要再造輪子呢,那當然是 Netty 比 JDK NIO 做的更多,比如解決了粘包半包、斷連和 idle 處理、支持流量整形等。

另外說起 NIO 的零拷貝,消息隊列現在基本是標配,常用有 Kafka、RocketMQ、RabbitMQ,排名按性能分先後。其中 Kafka 和 RocketMQ 分別是基於 sendfilemmap + write實現的零拷貝,這也是吞吐量較大的原因之一。

3. AIO

AIO 屬於異步非阻塞。在 NIO 的基礎上引入了新的異步通道的概念,並提供了異步文件通道和異步套接字通道的實現。

7. 總結

OK,到了這裏,文章要結束了。

本文主要講述的其實是 Linux IO 的基本原理,這其中會涉及到 IO 模型、零拷貝、Java IO 等等,而這些比如 NIO 的多路複用、 Netty 的 Reactor 模型、Kafka 的高性能都值得用更多的文字去闡述,更多的時間去學習。

我覺得,無論是技術還是生活,如果能把自己的知識或資源串起來,就是一件很棒的事。就像從 Linux IO 出發,看到內存條想到修電腦(笑~~);看到零拷貝寫時複製想起 Java 併發實現;看到不可變想到對象池想到 GC;看到多路複用 IO 模型想起 NIO…希望自己有一天能夠做到。

8. 最後

讀到這裏,大概九點十五,開頭九點發出的“晚上好”有了下文嗎?

如果沒有的話,不妨大膽假設,其實女孩並沒有去洗澡。

那這是一次什麼 I/O 呢?

參考

  • 《現代操作系統》
  • 《UNIX網絡編程.卷1》 6章第 2 節 IO 模型
  • 零拷貝實現 https://rianico.tech/2019/12/03/Linux零拷貝實現
  • NIO效率高的原理之零拷貝與直接內存映射https://cloud.tencent.com/developer/article/1488087
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章