Java框架解說之BIO NIO AIO不同IO模型演進之路

引言

Netty作為高性能的網絡通信框架,它是IO模型演變過程中的產物。NettyJava NIO為基礎,是一種基於異步事件驅動的網絡通信應用框架,Netty用以快速開發高性能、高可靠的網絡服務器和客戶端程序,很多開源框架都選擇Netty作為其網絡通信模塊。本文主要通過分析IO模型的優化演進之路,比較不同IO模型的異同,讓大傢對於Java IO模型有著更加深刻的理解,我想這也是Netty如何實現高性能網絡通信理解的重要基礎。話不多說,我們趕緊發車瞭。

PS:文末有是彩蛋哦!

在這裡插入圖片描述

IO模型

1、什麼是IO

在闡述BIONIOAIO之前,我們先來看下到底什麼是IO模型。我們都知道無論是程序還是平臺,它們的功能高度抽象之後其實可以描述為這樣一個過程,即為通過外部條件以及數據的輸入,經過程序或者平臺的處理產生瞭新的輸出,IO模型實際上就是描述瞭計算機世界中的輸入和輸出過程的模式。

對於計算機來說,其鍵盤以及鼠標等就是輸入設備,顯示器以及磁盤等就是輸出設備。舉個栗子,如果我們在計算機上寫一篇設計文檔並進行保存,實際就是通過鍵盤對計算機進行瞭數據輸入,完成設計文檔後將其保存輸出到瞭計算機的磁盤上。

在這裡插入圖片描述

上圖中的IO描述,即為著名的計算機馮諾依曼體系,它大致描述瞭外部設備與計算機的IO交互過程。

2、應用程序IO交互

上文中我們介紹瞭計算機與外部設備交互的大致過程,那麼我們的應用程序是如何進行IO交互的呢?我們平時編寫的代碼不會獨立的存在,它總是被部署在linux服務器或者各種容器中,應用程序在服務器或者容器中啟動後再對外提供服務。因此網絡請求數據首先需要和計算機進行交互,才會被交由到對應的程序去進行後續的業務處理。

Linux的世界中,文件是用來描述Linux世界的,目錄文件、套接字等都是文件。那文件又是什麼鬼呢?文件實際就是二進制流,二進制流就是人類世界與計算機世界進行交互的數據媒介。應用從流中讀取數據即為read操作,當把流中的數據進行寫入的時候就是write操作。但是linux系統又是如何區分不同類型的文件呢?實際是通過文件描述符(File Descriptor)來進行區分,文件描述符其實就是個整數,這個整數實際是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。所以對這個整數的操作、就是對這個文件(流)的操作。

就拿網絡連接來說,我們創建一個網絡socket,通過系統調用(socket調用)會返回一個文件描述符(某個整數),那麼後續對socket的操作就會轉化為對這個描述符的操作,主要涉及的操作包括accept調用、read調用以及 write調用。這裡所說的各種調用就是程序通過Linux內核與計算機進行交互。那麼問題又來瞭,這個計算機內核又是什麼鬼。(PS:關於內核不是本文的重點,這裡就簡單和大傢說明下)

//socket函數
socket(PF_INET6,SOCK_STREAM,IPPROTO_IP)

但是實際上應用程序並不是直接從計算機中的網卡中獲取數據,也就是說大傢編寫的程序並不是直接操作計算機的底層硬件。

在這裡插入圖片描述

如上圖所示,在Linux的結構體系中,用戶的應用程序都是通過Linux Kernel內核來操作計算機硬件。那麼為什麼應用程序不能直接與底層硬件進行交互還需要在中間再加一層內核呢?主要有以下幾點考慮。

(1)計算機資源統一管理

Linux內核的作用就是進程調度管理,同時對cpu、內存等系統資源進行統一管理。因此內核管理的都是系統極其敏感的資源,采用內核制是為瞭實現系統的網絡通信,用戶管理,文件系統等安全穩定的進程管理,避免用戶應用程序破壞系統數據。

(2)底層硬件調用統一封裝

試想一下,如果沒有內核這層系統進程,那麼每個用戶應用程序和硬件交互的時候都需要自己實現對應的硬件驅動。這樣的設計很難讓人接受,按照面向對象的設計思想,硬件的管理統一由Kernel內核負責,Kernel向下管理所有的硬件設備,向上提供給用戶進程統一的系統調用,方便應用程序可以像程序調用一樣進行系統硬件交互。

在這裡插入圖片描述

3、5種IO模型

(1)阻塞型IO

當用戶應用進程發起系統調用之後,在內核數據沒有準備好的情況下,調用一直處於阻塞狀態,直到內核準備好數據後,將數據從內核態拷貝到用戶態,用戶應用進程獲取到數據後,本次調用才算完成。就好比你是外賣小哥,你到商傢去取餐,商傢的外賣還沒有準備好,所以你隻能在取餐的地方一直等待著,直到商傢將做好的外賣準備好,你才能拿瞭外賣去送餐。

在這裡插入圖片描述

(2)非阻塞型IO

非阻塞IO式基於輪詢機制的IO模型,應用進程不斷輪詢檢查內核數據是否準備好,如果沒有則返回EWOULDBLOCK,進程繼續發起recvfrom調用,此時應用可以去處理其他業務。當內核數據準備好後,將內核數據拷貝至用戶空間。這個過程就好比外賣小哥在等待取餐的時候不斷問商傢外賣做好瞭沒(這個外賣小哥比較著急,送餐時間比較臨近瞭),每隔30s問一次,直到外賣做好送到。

在這裡插入圖片描述

(3)多路復用IO

Linux主要提供瞭selectpoll以及epoll等多路復用I/O的實現方式,為什麼會有三個實現呢?實際上他們的出現都是有時間順序的,後者的出現都是為瞭解決前者在使用中出現的問題。
在實際場景中,後端服務器接收大量的socket連接,IO多路復用是實際是使用瞭內核提供的實現函數,在實現函數中有一個參數是文件描述符集合,對這些文件描述符(FD)進行循環監聽,當某個文件描述符(FD)就緒時,就對這個文件描述符進行處理。

下面我們分別看下selectpoll以及epoll這三個實現函數的實現原理:

select:
select是操作系統的提供的內核系統調用函數,通過它可以將一組FD傳給操作系統,操作系統對這組FD進行遍歷,當存在FD處於數據就緒狀態後,將其全部返回給調用方,這樣應用程序就可以對已經就緒的IO流進行處理瞭。

在這裡插入圖片描述

select在使用過程中存在一些問題:
(1)select最多隻能監聽1024個連接,支持的連接數較少;
(2)select並不會隻返回就緒的FD,而是需要用戶進程自己一個一個進行遍歷找到就緒的FD
(3)用戶進程在調用select時,都需要將FD集合從用戶態拷貝到內核態,當FD較多時資源開銷相對較大。

poll:
poll機制實際與select機制區別不大,隻是poll機制去除掉瞭監聽連接數1024的限制。

epoll:
epoll解決瞭select以及poll機制的大部分問題,主要體現在以下幾個方面:
(1)FD發現的變化:內核不再通過輪詢遍歷的方式找到就緒的FD,而是通過異步IO事件喚醒的方式,當socket有事件發生時,通過回調函數將就緒的FD加入到就緒事件鏈表中,從而避免瞭輪詢掃描FD集合;
(2)FD返回的變化:內核將已經就緒的FD返回給用戶,用戶應用程序不需要自己再遍歷找到就緒的FD
(3)FD拷貝的變化:epoll和內核共享同一塊內存,這塊內存中保存的就是那些已經可讀或者可寫的的文件描述符集合,這樣就減少瞭內核和程序的內存拷貝開銷。

在這裡插入圖片描述

(該圖片來自於網絡)

(4)信號驅動IO

系統存在一個信號捕捉函數,該信號捕捉函數與socket存在關聯關系,在用戶進程發起sigaction調用之後,用戶進程可以去處理其他的業務流程。當內核將數據準備好之後,用戶進程會接收到一個SIGIO信號,然後用戶進程中斷當前的任務發起recvfrom調用從內核讀取數據到用戶空間再進行數據處理。

在這裡插入圖片描述

(5)異步IO

所謂異步IO模型,就是用戶進程發起系統調用之後,不管內核對應的請求數據是否準備好,都不會阻塞當前進程,立即返回後進程可以繼續處理其他的業務。當內核準備好數據之後,系統會從內核復制數據到用戶空間,然後通過信號通知用戶進程進行數據讀取處理。

在這裡插入圖片描述

Java中的IO模型

上文中我們闡述瞭Linux本身存在的幾種IO模型,那麼對應到Java程序世界中,Java也有對應的IO模型,分別是BIONIO以及AIO三種IO模型。它們都提供瞭和IO有關的API,這些API實際也是依賴系統層面的IO完成數據處理的,因此JavaIO模型,實際就是對系統層面IO模型的封裝。接下來我們來一起看下Java的這幾種IO模型。

BIO

BIO即為Blocking IO,顧名思義就是阻塞型IO模型,當用戶進程向服務端發起請求後,一定等到服務端處理完成有數據返回給用戶,用戶進程才完成一次IO操作,否則就會阻塞住,像個癡心漢傻傻的一直等待數據返回,當數據完成返回後用戶線程才會解除block狀態,因此在整個數據讀取過程中會發生阻塞。

另外從下圖我們可以看出來,每一個客戶端連接,服務端都有對應的處理線程來處理對應的請求。還是以餐廳吃飯的例子,你到餐廳去吃飯,假如每來一個消費者,餐廳都用一個服務員來接待直到消費者吃飽喝足走出餐廳,那麼這個餐廳得配置多少個服務員才合適?這麼多服務員,餐廳的老板估計得賠的內褲都沒瞭。

因此在網絡連接不多的情況下,BIO還能發回作用。但是當連接數上來後,比如幾十萬甚至上百萬連接,BIO模型的IO交互就顯得心有餘而力不足瞭。當連接數不斷攀高時,BIO模型的IO交互方式存在以下幾種弊端。
(1)頻繁創建和銷毀大量的線程會消耗系統資源給服務器造成巨大的壓力;
(2)另外大量的處理線程會占用過多的JVM內存,你的程序不要幹其他事情瞭,都被大量連接線程給占滿瞭;
(3)實際上線程的上下文切換成本也是很高的。

基於BIO模型在處理大量連接時存在上述的問題,因此我們需要一種更加高效的線程模型來應對幾十萬甚至上百萬的客戶端連接。

在這裡插入圖片描述

NIO

通過上文的分析,由於在BIO模型下,Java中在進行IO操作時候是沒辦法知道什麼時候可以讀數據或者什麼時候可以寫數據,BIO又是一個實在孩子因此沒有什麼好的辦法隻能在哪裡傻等著。由於socket的讀寫操作不能進行中斷,因此當有新的連接到來時,隻能不斷創建新的線程來處理,從而導致存在性能問題。

那麼如何解決這個問題呢?我們都知道問題的根源就是BIO模型中我們不知道數據的讀取與寫入的時機,才導致的阻塞等待,那麼如果我們能夠知道數據讀寫的時機,是不是就不用傻傻的等著響應,也不用再創建新的線程來處理連接瞭。

在這裡插入圖片描述

為瞭提升IO交互效率,避免阻塞傻等的情況發生。Java 1.4中引入瞭NIO,對於NIO來說,有人稱之為Non-blocking IO,但是我更願意稱之為New IO。因為它是一種基於IO多路復用的IO模型,而不是簡單的同步非阻塞的IO模型。所謂IO多路復用指的就是用同一個線程處理大量連接,多路指的就是大量連接,復用指的就是使用一個線程來進行處理。

在這裡插入圖片描述

那我們先來看看同步非阻塞模型有什麼問題,NIO 的讀寫以及接受方法在等待數據就緒階段都是非阻塞的。如上文中的描述,同步非阻塞模式下應用進程不斷向內核發起調用,詢問內核數據完成準備。相對於同步阻塞模型有瞭一定的優化,通過不斷輪詢數據是否準備好,避免瞭調用阻塞。但是由於應用不斷進行系統IO調用,在此過程中十分消耗CPU,因此還有進一步優化的空間。此時就該IO多路復用模型上場一展拳腳瞭,而JavaNIO正是借助於此實現瞭IO性能的提升。(這裡以epoll機制來進行說明)

Java NIO基於通道和緩沖區的形式來處理流數據,借助於Linux操作系統的epoll機制,多路復用器selector就會不斷進行輪詢,當某個channel的事件(讀事件,寫事件,連接事件等等)準備就緒的時候,就是會找到這個channel對應的SelectionKey,去做相應的操作,進行數據的讀寫操作。

在這裡插入圖片描述

AIO

所謂AIO(Asynchronous IO)就是NIO第二代,它是在Java 7中引入的,是一種異步IO模型。異步IO模型是基於事件和回調機制實現的,當應用發起調用請求之後會直接返回不會阻塞在那裡,當後臺進行數據處理完成後,操作系統便會通知對應的線程來進行後續的數據處理。
從效率上來看,AIO 無疑是最高的,然而,美中不足的是目前作為廣大服務器使用的系統 linuxAIO 的支持還不完善,導致我們還不能愉快的使用 AIO 這項技術,Netty實際也是使用過AIO技術,但是實際並沒有帶來很大的性能提升,目前還是基於Java NIO實現的。

總結

本文主要從計算機IO交互出發,分別給大傢介紹瞭什麼是IO模型以及常見的五種IO模型,介紹瞭這幾種IO模型的優缺點,從系統優化演進的角度分析瞭Java BIONIO以及AIO演化之路。從設計者的角度分析Java BIO存在的不足。我們再來回顧下整個演進過程的脈絡。

在這裡插入圖片描述

在後續的文章中,筆者將繼續帶大傢深入研究的Netty作為高性能網絡通信框架的奇妙之處,敬請期待哦。

真正的大師永遠懷著一顆學徒的心

到此這篇關於Java框架解說之BIO NIO AIO不同IO模型演進之路的文章就介紹到這瞭,更多相關Java IO模型內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: