Linux 網路IO 淺析

語言: CN / TW / HK

持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第22天。

背景介紹

  • 在網際網路的時代下,絕大部分資料都是通過網路來進行獲取的。

  • 在服務端的架構中,絕大部分資料也是通過網路來進行互動的。

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

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

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

什麼是IO

我們都知道在Linux的世界,一切皆檔案。

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

  • 在資訊的交換過程中,我們都是對這些流進行資料收發操作,簡稱為I/O操作。

  • 往流中讀取資料,系統呼叫Read,寫入資料,系統呼叫Write。

通常使用者程序的一個完整的IO分為兩個階段:

磁碟IO:

圖片

網路IO:

圖片

作業系統和驅動程式執行在核心空間,應用程式執行在使用者空間,兩者不能使用指標傳遞資料,因為Linux使用的虛擬記憶體機制,必須通過系統呼叫請求核心來完成IO動作。

IO有記憶體IO、網路IO和磁碟IO三種,通常我們說的IO指的是後兩者!

為什麼需要IO模型

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

  • 因為同步的通訊操作會阻塞同一個執行緒的其他任何操作,只有這個操作完成了之後,後續的操作才可以完成,所以出現了同步阻塞+多執行緒(每個Socket都建立一個執行緒對應),但是系統內執行緒數量是有限制的,同時執行緒切換很浪費時間,適合Socket少的情況。

因該需要出現IO模型。

Linux的IO模型

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

圖片

以使用者請求index.html檔案為例子說明

圖片

基本概念

使用者空間和核心空間

作業系統的核心是核心,獨立於普通的應用程式,可以訪問受保護的記憶體空間,也有訪問底層硬體裝置的所有許可權。

  • 為了保證核心的安全,使用者程序不能直接操作核心,作業系統將虛擬空間劃分為兩部分,一部分為核心空間,一部分為使用者空間。

程序切換

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

這種行為被稱為程序切換。

因此可以說,任何程序都是在作業系統核心的支援下執行的,是與核心緊密相關的。

程序的阻塞

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

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

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

檔案描述符

檔案描述符(File Descriptor)是電腦科學中的一個術語,是一個用於表述指向檔案的引用的抽象化概念。

檔案描述符在形式上是一個非負整數,實際上,它是一個索引值,指向核心為每一個程序所維護的該程序開啟檔案的記錄表。

  • 當程式開啟一個現有檔案或者建立一個新檔案時,核心向程序返回一個檔案描述符。

快取IO

大多數檔案系統的預設 IO 操作都是快取 IO。

其讀寫過程如下:

  • 讀操作:作業系統檢查核心的緩衝區有沒有需要的資料,如果已經快取了,那麼就直接從快取中返回;否則從磁碟、網絡卡等中讀取,然後快取在作業系統的快取中;

  • 寫操作:將資料從使用者空間複製到核心空間的快取中。這時對使用者程式來說寫操作就已經完成,至於什麼時候再寫到磁碟、網絡卡等中由作業系統決定,除非顯示地呼叫了 sync 同步命令。

假設核心空間快取無需要的資料,使用者程序從磁碟或網路讀資料分兩個階段:

  • 階段一: 核心程式從磁碟、網絡卡等讀取資料到核心空間快取區;

  • 階段二: 使用者程式從核心空間快取拷貝資料到使用者空間。

快取 IO 的缺點:

資料在傳輸過程中需要在應用程式地址空間和核心空間進行多次資料拷貝操作,這些資料拷貝操作所帶來的CPU以及記憶體開銷非常大。

同步阻塞

使用者空間的應用程式執行一個系統呼叫,這會導致應用程式阻塞,什麼也不幹,直到資料準備好,並且將資料從核心複製到使用者程序,最後程序再處理資料,在等待資料到處理資料的兩個階段,整個程序都被阻塞,不能處理別的網路IO。

  • 呼叫應用程式處於一種不再消費 CPU 而只是簡單等待響應的狀態,因此從處理的角度來看,這是非常有效的。

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

圖片

同步非阻塞

非阻塞的系統呼叫呼叫之後,程序並沒有被阻塞,核心馬上返回給程序,如果資料還沒準備好,此時會返回一個error。

  • 程序在返回之後,可以乾點別的事情,然後再發起系統呼叫。

  • 重複上面的過程,迴圈往復的進行系統呼叫。這個過程通常被稱之為輪詢。

  • 輪詢檢查核心資料,直到資料準備好,再拷貝資料到程序,進行資料處理。

  • 需要注意,拷貝資料整個過程,程序仍然是屬於阻塞的狀態。

  • 這種方式在程式設計中對Socket設定O_NONBLOCK即可。

圖片

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功能

  • 系統呼叫Sigaction執行訊號處理函式(非阻塞,立刻返回)

  • 資料就緒,生成Sigio訊號,通過訊號回撥通知應用來讀取資料

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

圖片

非同步非阻塞

非同步IO流程如下所示:

  • 當用戶執行緒呼叫了aio_read系統呼叫,立刻就可以開始去做其它的事,使用者執行緒不阻塞

  • 核心就開始了IO的第一個階段:準備資料。當核心一直等到資料準備好了,它就會將資料從核心核心緩衝區,拷貝到使用者緩衝區

  • 核心會給使用者執行緒傳送一個訊號,或者回呼叫戶執行緒註冊的回撥介面,告訴使用者執行緒Read操作完成了

  • 使用者執行緒讀取使用者緩衝區的資料,完成後續的業務操作

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

使用者程序進行aio_read系統呼叫之後,無論核心資料是否準備好,都會直接返回給使用者程序,然後使用者態程序可以去做別的事情。

等到資料準備好了,核心直接複製資料給程序,然後從核心向程序傳送通知。

對比訊號驅動IO,非同步IO的主要區別在於:

  • 訊號驅動由核心告訴我們何時可以開始一個IO操作(資料在核心緩衝區中),而非同步IO則由核心通知IO操作何時已經完成(資料已經在使用者空間中)。

非同步IO又叫做事件驅動IO,在Unix中,為非同步方式訪問檔案定義了一套庫函式,定義了AIO的一系列介面。

  • 使用aio_read或者aio_write發起非同步IO操作,使用aio_error檢查正在執行的IO操作的狀態。

目前Linux中AIO的核心實現只對檔案IO有效,如果要實現真正的AIO,需要使用者自己來實現。

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

圖片

Java網路IO模型

BIO

BIO是一個典型的網路程式設計模型,是通常我們實現一個服務端程式的方法,對應Linux核心的同步阻塞IO模型,傳送資料和接收資料的過程如下所示:

圖片

步驟如下:

  • 主執行緒accept請求

  • 請求到達,建立新的執行緒來處理這個套接字,完成對客戶端的響應

  • 主執行緒繼續accept下一個請求

服務端處理虛擬碼如下所示:

圖片

這是經典的一個連線對應一個執行緒的模型,之所以使用多執行緒,主要原因在於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:

  • 建立ServerSocketChannel監聽客戶端連線並繫結監聽埠,設定為非阻塞模式

  • 建立Reactor執行緒,建立多路複用器(Selector)並啟動執行緒

  • 將ServerSocketChannel註冊到Reactor執行緒的Selector上,監聽Accept事件

  • Selector線上程run方法中無線迴圈輪詢準備就緒的Key

  • Selector監聽到新的客戶端接入,處理新的請求,完成TCP三次握手,建立物理連線

  • 將新的客戶端連線註冊到Selector上,監聽讀操作,讀取客戶端傳送的網路訊息

  • 客戶端傳送的資料就緒則讀取客戶端請求,進行處理

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

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

圖片

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

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

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

AIO

JDK1.7引入NIO2.0,提供了非同步檔案通道和非同步套接字通道的實現。

  • 其底層在Windows上是通過IOCP實現,在Linux上是通過IO複用Epoll來模擬實現的。

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

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

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

  • AsynchronousServerSocketChannel(伺服器監聽通道)

  • AsynchronousSocketChannel(Socket套接字通道)。

具體過程如下所示:

  • 建立AsynchronousServerSocketChannel,繫結監聽埠

  • 呼叫AsynchronousServerSocketChannel的accpet方法,傳入自己實現的CompletionHandler,包括上一步,都是非阻塞的

  • 連線傳入,回撥CompletionHandler的completed方法,在裡面,呼叫AsynchronousSocketChannel的read方法,傳入負責處理資料的CompletionHandler

  • 資料就緒,觸發負責處理資料的CompletionHandler的completed方法,繼續做下一步處理即可

  • 寫入操作類似,也需要傳入CompletionHandler

圖片

「其他文章」