I-O 多路複用,從來沒遇到過這麼明白的文章

大家好,我是濤哥。

很多對技術有追求的讀者朋友,做到一定階段後都希望技術有所精進。有些讀者朋友可能會研究一些中間件的技術架構和實現原理。比如,Nginx 爲什麼能同時支撐數萬乃至數十萬的連接?爲什麼單工作線程的 Redis 性能比多線程的 Memcached 還要強?Dubbo 的底層實現是怎樣的,爲什麼他的通信效率非常高?

實際上,上面的一些問題都和網絡模型相關。本文從基礎的概念和網絡編程開始,循序漸進講解 Linux 五大網絡模型,包括耳熟能詳的多路複用 IO 模型。相信讀完本文,大家會對網絡編程和網絡模型有一個較清晰的理解。

基本概念

我們先來了解幾個基本概念。

什麼是 I/O?

所謂的 I/O(Input/Output)操作實際上就是輸入輸出的數據傳輸行爲。程序員最關注的主要是磁盤 IO 和網絡 IO,因爲這兩個 IO 操作和應用程序的關係最直接最緊密。

磁盤 IO:磁盤的輸入輸出,比如磁盤和內存之間的數據傳輸。

網絡 IO:不同系統間跨網絡的數據傳輸,比如兩個系統間的遠程接口調用。

下面這張圖展示了應用程序中發生 IO 的具體場景:

通過上圖,我們可以瞭解到 IO 操作發生的具體場景。一個請求過程可能會發生很多次的 IO 操作:

1,頁面請求到服務器會發生網絡 IO

2,服務之間遠程調用會發生網絡 IO

3,應用程序訪問數據庫會發生網絡 IO

4,數據庫查詢或者寫入數據會發生磁盤 IO

阻塞與非阻塞

所謂阻塞,就是發出一個請求不能立刻返回響應,要等所有的邏輯全處理完才能返回響應。

非阻塞反之,發出一個請求立刻返回應答,不用等處理完所有邏輯。

內核空間與用戶空間

在 Linux 中,應用程序穩定性遠遠比不上操作系統程序,爲了保證操作系統的穩定性,Linux 區分了內核空間和用戶空間。可以這樣理解,內核空間運行操作系統程序和驅動程序,用戶空間運行應用程序。Linux 以這種方式隔離了操作系統程序和應用程序,避免了應用程序影響到操作系統自身的穩定性。這也是 Linux 系統超級穩定的主要原因。

所有的系統資源操作都在內核空間進行,比如讀寫磁盤文件,內存分配和回收,網絡接口調用等。所以在一次網絡 IO 讀取過程中,數據並不是直接從網卡讀取到用戶空間中的應用程序緩衝區,而是先從網卡拷貝到內核空間緩衝區,然後再從內核拷貝到用戶空間中的應用程序緩衝區。對於網絡 IO 寫入過程,過程則相反,先將數據從用戶空間中的應用程序緩衝區拷貝到內核緩衝區,再從內核緩衝區把數據通過網卡發送出去。

Socket(套接字)

Socket 可以理解成,在兩個應用程序進行網絡通信時,一個應用程序將數據寫入 Socket,然後通過網卡把數據發送到另外一個應用程序的 Socket 中。

所有的網絡協議都是基於 Socket 進行通信的,不管是 TCP 還是 UDP 協議,應用層的 HTTP 協議也不例外。這些協議都需要基於 Socket 實現網絡通信。5 種網絡 IO 模型也都要基於 Socket 實現網絡通信。實際上,HTTP 協議是建立在 TCP 協議之上的應用層協議。HTTP 協議負責如何包裝數據,而 TCP 協議負責如何傳輸數據。

絕大部分編程語言,都支持 Socket 編程,例如 Java,Php,Python 等等。而這些語言的 Socket SDK 都是基於操作系統提供的 socket() 函數來實現的。不管是 Linux 還是 windows,都提供了相應的 socket() 函數。

Socket 編程過程

我們來看看 Socket 編程過程是怎樣的。

不管 Java、Python 還是 Php,很多編程語言都支持 Socket 編程。Linux,Windows 等操作系統都開放了網絡編程接口。只不過,各種編程語言對底層操作系統提供的網絡編程接口做了封裝而已。

從服務端開始,服務端首先調用 socket() 函數,按指定的網絡協議和傳輸協議創建 Socket ,例如創建一個網絡協議爲 IPv4,傳輸協議爲 TCP 的 Socket。接着調用 bind() 函數,給這個 Socket 綁定一個 IP 地址和端口,綁定這兩個的目的是什麼?

綁定完 IP 地址和端口後,就可以調用 listen() 函數進行監聽。如果我們要判定服務器上某個網絡程序有沒有啓動,可以通過 netstat 命令查看對應的端口號是否被監聽。

服務端進入了監聽狀態後,通過調用 accept() 函數,來從內核獲取客戶端的連接,如果沒有客戶端連接,則會阻塞等待客戶端連接的到來。

那客戶端是怎麼發起連接的呢?客戶端在創建好 Socket 後,調用 connect()函數發起連接,該函數的參數要指明服務端的 IP 地址和端口號,然後衆所周知的 TCP 三次握手就開始了。

連接建立後,客戶端和服務端就開始相互傳輸數據了,雙方可以通過 read()和 write() 函數來讀寫數據。

基於 TCP 協議的 Socket 編程過程就結束了,整個過程如下圖所示:

網絡 IO 模型

5 種 Linux 網絡 IO 模型包括:同步阻塞 IO、同步非阻塞 IO、多路複用 IO、信號驅動 IO 和異步 IO。

同步阻塞 IO

我們先看一下傳統阻塞 IO。在 Linux 中,默認情況下所有 socket 都是阻塞模式的。當用戶線程調用系統函數 read(),內核開始準備數據(從網絡接收數據),內核準備數據完成後,數據從內核拷貝到用戶空間的應用程序緩衝區,數據拷貝完成後,請求才返回。從發起 read 請求到最終完成內核到應用程序的拷貝,整個過程都是阻塞的。爲了提高性能,可以爲每個連接都分配一個線程。因此,在大量連接的場景下就需要大量的線程,會造成巨大的性能損耗,這也是傳統阻塞 IO 的最大缺陷。

同步非阻塞 IO

用戶線程在發起 Read 請求後立即返回,不用等待內核準備數據的過程。如果 Read 請求沒讀取到數據,用戶線程會不斷輪詢發起 Read 請求,直到數據到達(內核準備好數據)後才停止輪詢。非阻塞 IO 模型雖然避免了由於線程阻塞問題帶來的大量線程消耗,但是頻繁的重複輪詢大大增加了請求次數,對 CPU 消耗也比較明顯。這種模型在實際應用中很少使用。

多路複用 IO 模型

多路複用 IO 模型,建立在多路事件分離函數 select,poll,epoll 之上。在發起 read 請求前,先更新 select 的 socket 監控列表,然後等待 select 函數返回(此過程是阻塞的,所以說多路複用 IO 並非完全非阻塞)。當某個 socket 有數據到達時,select 函數返回。此時用戶線程才正式發起 read 請求,讀取並處理數據。這種模式用一個專門的監視線程去檢查多個 socket,如果某個 socket 有數據到達就交給工作線程處理。由於等待 Socket 數據到達過程非常耗時,所以這種方式解決了阻塞 IO 模型一個 Socket 連接就需要一個線程的問題,也不存在非阻塞 IO 模型忙輪詢帶來的 CPU 性能損耗的問題。多路複用 IO 模型的實際應用場景很多,比如大家耳熟能詳的 Java NIO,Redis,Nginx 以及 Dubbo 採用的通信框架 Netty 都採用了這種模型。

下圖是基於 select 函數 Socket 編程的詳細流程。

用一句話解釋多路複用模型。多路:可以理解成多個網絡連接(TCP 連接)。複用:服務端反覆使用同一個線程去監聽所有網絡連接中是否有 IO 事件(如果有 IO 事件就交給工作線程從對應的連接中讀取並處理數據)。

信號驅動 IO 模型

信號驅動 IO 模型,應用進程使用 sigaction 函數,內核會立即返回,也就是說內核準備數據的階段應用進程是非阻塞的。內核準備好數據後向應用進程發送 SIGIO 信號,接到信號後數據被複制到應用程序進程。

採用這種方式,CPU 的利用率很高。不過這種模式下,在大量 IO 操作的情況下可能造成信號隊列溢出導致信號丟失,造成災難性後果。

異步 IO 模型

異步 IO 模型的基本機制是,應用進程告訴內核啓動某個操作,內核操作完成後再通知應用進程。在多路複用 IO 模型中,socket 狀態事件到達,得到通知後,應用進程纔開始自行讀取並處理數據。在異步 IO 模型中,應用進程得到通知時,內核已經讀取完數據並把數據放到了應用進程的緩衝區中,此時應用進程

直接使用數據即可。

很明顯,異步 IO 模型性能很高。不過到目前爲止,異步 IO 和信號驅動 IO 模型應用並不多見,傳統阻塞 IO 和多路複用 IO 模型還是目前應用的主流。Linux2.6 版本後才引入異步 IO 模型,目前很多系統對異步 IO 模型支持尚不成熟。很多應用場景採用多路複用 IO 替代異步 IO 模型。

本文完,請大家繼續關注濤哥後續的技術原創!

作者簡介:曾任職於阿里巴巴,每日優鮮等互聯網公司,任技術總監,15 年電商互聯網經歷。

更多幹貨請關注微信公衆號:二馬讀書

歡迎留言,和濤哥在線交流

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