硬核圖解網絡 IO 模型!

前言

文章會同步到個人網站,方便閱讀:https://xiaoflyfish.cn/

覺得不錯,希望點贊,在看,轉發支持一下,謝謝

背景介紹

而且作爲服務端的開發工程師來說,都會進行一系列服務設計、開發以及能力開放,而服務能力開放也是需要通過網絡來完成的,因此對網絡編程以及網絡 IO 模型都不會太陌生。

由於有很多優秀的框架(比如 Netty、HSF、Dubbo、Thrift 等)已經把底層網絡 IO 給封裝了,通過提供的 API 能力或者配置就能完成想要的服務能力開發,因此大部分工程師對網絡 IO 模型的底層不夠了解。

本文系統的講解了 Linux 內核的 IO 模型、Java 網絡 IO 模型以及兩者之間的關係!

什麼是 IO

我們都知道在 Linux 的世界,一切皆文件。

而文件就是一串二進制流,不管 Socket、FIFO、管道還是終端,對我們來說,一切都是流。

通常用戶進程的一個完整的 IO 分爲兩個階段:

磁盤 IO:

網絡 IO:

操作系統和驅動程序運行在內核空間,應用程序運行在用戶空間,兩者不能使用指針傳遞數據,因爲 Linux 使用的虛擬內存機制,必須通過系統調用請求內核來完成 IO 動作。

IO 有內存 IO、網絡 IO 和磁盤 IO 三種,通常我們說的 IO 指的是後兩者!

爲什麼需要 IO 模型

如果使用同步的方式來通信的話,所有的操作都在一個線程內順序執行完成,這麼做缺點是很明顯的:

因該需要出現 IO 模型。

Linux 的 IO 模型

在描述 Linux IO 模型之前,我們先來了解一下 Linux 系統數據讀取的過程:

以用戶請求 index.html 文件爲例子說明

基本概念

用戶空間和內核空間

操作系統的核心是內核,獨立於普通的應用程序,可以訪問受保護的內存空間,也有訪問底層硬件設備的所有權限。

進程切換

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

這種行爲被稱爲進程切換。

因此可以說,任何進程都是在操作系統內核的支持下運行的,是與內核緊密相關的。

進程的阻塞

正在執行的進程,由於期待的某些事件未發生,如請求系統資源失敗、等待某種操作的完成、新數據尚未到達或無新工作做等,則由系統自動執行阻塞原語 (Block),使自己由運行狀態變爲阻塞狀態。

可見,進程的阻塞是進程自身的一種主動行爲,也因此只有處於運行態的進程(獲得 CPU),纔可能將其轉爲阻塞狀態。

當進程進入阻塞狀態,是不佔用 CPU 資源的。

文件描述符

文件描述符(File Descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。

文件描述符在形式上是一個非負整數,實際上,它是一個索引值,指向內核爲每一個進程所維護的該進程打開文件的記錄表。

緩存 IO

大多數文件系統的默認 IO 操作都是緩存 IO。

其讀寫過程如下:

假設內核空間緩存無需要的數據,用戶進程從磁盤或網絡讀數據分兩個階段:

緩存 IO 的缺點:

數據在傳輸過程中需要在應用程序地址空間和內核空間進行多次數據拷貝操作,這些數據拷貝操作所帶來的 CPU 以及內存開銷非常大。

同步阻塞

用戶空間的應用程序執行一個系統調用,這會導致應用程序阻塞,什麼也不幹,直到數據準備好,並且將數據從內核複製到用戶進程,最後進程再處理數據,在等待數據到處理數據的兩個階段,整個進程都被阻塞,不能處理別的網絡 IO。

這也是最簡單的 IO 模型,在通常 FD 較少、就緒很快的情況下使用是沒有問題的。

同步非阻塞

非阻塞的系統調用調用之後,進程並沒有被阻塞,內核馬上返回給進程,如果數據還沒準備好,此時會返回一個 error。

IO 多路複用

IO 多路複用,這是一種進程預先告知內核的能力,讓內核發現進程指定的一個或多個 IO 條件就緒了,就通知進程。

使得一個進程能在一連串的事件上等待。

IO 複用的實現方式目前主要有 Select、Poll 和 Epoll。

僞代碼描述 IO 多路複用:

while(status == OK) { // 不斷輪詢
 ready_fd_list = io_wait(fd_list); //內核緩衝區是否有準備好的數據
 for(fd in ready_fd_list) {
  data = read(fd) // 有準備好的數據讀取到用戶緩衝區
  process(data)
 }
}

信號驅動

首先我們允許 Socket 進行信號驅動 IO,並安裝一個信號處理函數,進程繼續運行並不阻塞。

當數據準備好時,進程會收到一個 SIGIO 信號,可以在信號處理函數中調用 I/O 操作函數處理數據。

流程如下:

此種 IO 方式存在的一個很大的問題:Linux 中信號隊列是有限制的,如果超過這個數字問題就無法讀取數據

異步非阻塞

異步 IO 流程如下所示:

相對於同步 IO,異步 IO 不是順序執行。

用戶進程進行aio_read系統調用之後,無論內核數據是否準備好,都會直接返回給用戶進程,然後用戶態進程可以去做別的事情。

等到數據準備好了,內核直接複製數據給進程,然後從內核向進程發送通知。

對比信號驅動 IO,異步 IO 的主要區別在於:

異步 IO 又叫做事件驅動 IO,在 Unix 中,爲異步方式訪問文件定義了一套庫函數,定義了 AIO 的一系列接口。

目前 Linux 中 AIO 的內核實現只對文件 IO 有效,如果要實現真正的 AIO,需要用戶自己來實現。

目前有很多開源的異步 IO 庫,例如 libevent、libev、libuv。

Java 網絡 IO 模型

BIO

BIO 是一個典型的網絡編程模型,是通常我們實現一個服務端程序的方法,對應 Linux 內核的同步阻塞 IO 模型,發送數據和接收數據的過程如下所示:

步驟如下:

服務端處理僞代碼如下所示:

這是經典的一個連接對應一個線程的模型,之所以使用多線程,主要原因在於socket.accept()、socket.read()、socket.write()三個主要函數都是同步阻塞的。

當一個連接在處理 I/O 的時候,系統是阻塞的,如果是單線程的話必然就阻塞,但 CPU 是被釋放出來的,開啓多線程,就可以讓 CPU 去處理更多的事情。

其實這也是所有使用多線程的本質:

利用多核,當 I/O 阻塞時,但 CPU 空閒的時候,可以利用多線程使用 CPU 資源。

當面對十萬甚至百萬級連接的時候,傳統的 BIO 模型是無能爲力的。

隨着移動端應用的興起和各種網絡遊戲的盛行,百萬級長連接日趨普遍,此時,必然需要一種更高效的 I/O 處理模型。

NIO

JDK1.4 開始引入了 NIO 類庫,主要是使用 Selector 多路複用器來實現。

Selector 在 Linux 等主流操作系統上是通過 IO 複用 Epoll 實現的。

NIO 的實現流程,類似於 Select:

簡單處理模型是用一個單線程死循環選擇就緒的事件,會執行系統調用(Linux 2.6 之前是 Select、Poll,2.6 之後是 Epoll,Windows 是 IOCP),還會阻塞的等待新事件的到來。

新事件到來的時候,會在 Selector 上註冊標記位,標示可讀、可寫或者有連接到來,簡單處理模型的僞代碼如下所示:

NIO 由原來的阻塞讀寫(佔用線程)變成了單線程輪詢事件,找到可以進行讀寫的網絡描述符進行讀寫。

除了事件的輪詢是阻塞的(沒有可乾的事情必須要阻塞),剩餘的 I/O 操作都是純 CPU 操作,沒有必要開啓多線程。

並且由於線程的節約,連接數大的時候因爲線程切換帶來的問題也隨之解決,進而爲處理海量連接提供了可能。

AIO

JDK1.7 引入 NIO2.0,提供了異步文件通道和異步套接字通道的實現。

在 JAVA NIO 框架中,Selector 它負責代替應用查詢中所有已註冊的通道到操作系統中進行 IO 事件輪詢、管理當前註冊的通道集合,定位發生事件的通道等操作。

但是在 JAVA AIO 框架中,由於應用程序不是輪詢方式,而是訂閱 - 通知方式,所以不再需要 Selector(選擇器)了,改由 Channel 通道直接到操作系統註冊監聽 。

JAVA AIO 框架中,只實現了兩種網絡 IO 通道:

具體過程如下所示:

最後

覺得有收穫,希望幫忙點贊,轉發下哈,謝謝,謝謝

微信搜索:月伴飛魚,交個朋友

公衆號後臺回覆 666,可以獲得免費電子書籍

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/fCGPemISp9JFjYEjTgAhAw