java基础巩固-宇宙第一AiYWM:为了维持生计,四大基础之OS_Part_2整起~IO们那些事【包括五种IO模型:(BIO、NIO、IO多路复用、信号驱动、AIO);零拷贝、事件处理及并发等模型】
PART0.前情提要:
- 通常用戶進程的一個完整的IO分為兩個階段(IO有內存IO、網絡IO和磁盤IO三種,通常我們說的IO指的是后兩者!):【操作系統和驅動程序運行在內核空間,應用程序運行在用戶空間,兩者不能使用指針傳遞數據,因為Linux使用的虛擬內存機制,必須通過系統調用請求內核來完成IO動作。】
- 內存IO:
- 磁盤IO:
- 和磁盤打交道就是費事,但也沒辦法,咱們缺容量呀。咱們在 Java 中 IO 流分為輸入流和輸出流,根據數據的處理方式又分為字節流和字符流。根據擒賊先擒王的手法,咱們把Java IO 流的 40 多個類的如下 4 個抽象類基類先抓住,【Java IO 流的 40 多個類都是從如下 4 個抽象類基類中派生出來的】。
- InputStream/Reader: 所有的輸入流的基類,InputStream是字節輸入流【用于從源頭(通常是文件)讀取數據(字節信息)到內存中,java.io.InputStream抽象類是所有字節輸入流的父類。】,Reader是字符輸入流【不管是文件讀寫還是網絡發送接收,信息的最小存儲單元都是字節。 那為什么 I/O 流操作要分為字節流操作和字符流操作呢?原因主要是有時候如果我們不知道編碼類型就很容易出現亂碼問題,I/O 流就干脆提供了一個直接操作字符的接口,方便我們平時對字符進行流操作。如果音頻文件、圖片等媒體文件用字節流比較好,如果涉及到字符的話使用字符流比較好。字符流默認采用的是 Unicode 編碼,我們可以通過構造方法自定義編碼。utf8 :英文占 1 字節,中文占 3 字節,unicode:任何字符都占 2 個字節,gbk:英文占 1 字節,中文占 2 字節】
- InputStream
- InputStream 常用方法 :
- 通過 readAllBytes() 讀取輸入流所有字節并將其直接賦值給一個 String 對象// 新建一個 BufferedInputStream 對象 BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt")); // 讀取文件的內容并復制到 String 對象中 String result = new String(bufferedInputStream.readAllBytes()); System.out.println(result);
- 一般我們是不會直接單獨使用 FileInputStream ,通常會配合 BufferedInputStream字節緩沖輸入流
- DataInputStream 用于讀取指定類型數據,不能單獨使用,必須結合 FileInputStream
- ObjectInputStream 用于從輸入流中讀取 Java 對象(反序列化,用于序列化和反序列化的類必須實現 Serializable 接口,對象中如果有屬性不想被序列化,使用 transient 修飾),ObjectOutputStream 用于將對象寫入到輸出流(序列化)
- InputStream 常用方法 :
- Reader:Reader 用于讀取文本, InputStream 用于讀取原始字節。
- Reader常用方法:
- InputStreamReader 是字節流轉換為字符流的橋梁,其子類 FileReader 是基于該基礎上的封裝,可以直接操作字符文件。
- Reader常用方法:
- InputStream
- OutputStream/Writer: 所有輸出流的基類,OutputStream是字節輸出流【OutputStream用于將數據(字節信息)寫入到目的地(通常是文件),java.io.OutputStream抽象類是所有字節輸出流的父類。】,Writer是字符輸出流
- FileOutputStream
- FileOutputStream 是最常用的字節輸出流對象,可直接指定文件路徑,可以直接輸出單字節數據,也可以輸出指定的字節數組。FileOutputStream 通常也會配合 BufferedOutputStream字節緩沖輸出流
- DataOutputStream 用于寫入指定類型數據,不能單獨使用,必須結合 FileOutputStream
- FileOutputStream 是最常用的字節輸出流對象,可直接指定文件路徑,可以直接輸出單字節數據,也可以輸出指定的字節數組。FileOutputStream 通常也會配合 BufferedOutputStream字節緩沖輸出流
- Writer:
- OutputStreamWriter 是字符流轉換為字節流的橋梁,其子類 FileWriter 是基于該基礎上的封裝,可以直接將字符寫入到文件
- FileOutputStream
- 字節緩沖流:IO 操作是很消耗性能的,所以緩沖流用來將數據加載至緩沖區,一次性讀取/寫入多個字節,從而避免頻繁的 IO 操作,提高流的傳輸效率
- 字節緩沖流采用了裝飾器模式 來增強 InputStream 和OutputStream子類對象的功能。【字節流和字節緩沖流的性能差別主要體現在我們使用兩者的時候都是調用 write(int b) 和 read() 這兩個一次只讀取一個字節的方法的時候。由于字節緩沖流內部有緩沖區(字節數組),因此,字節緩沖流會先將讀取到的字節存放在緩存區,大幅減少 IO 次數,提高讀取效率。】
- 如果是調用 read(byte b[]) 和 write(byte b[], int off, int len) 這兩個寫入一個字節數組的方法的話,只要字節數組的大小合適,兩者的性能差距其實不大,基本可以忽略。
- BufferedInputStream(字節緩沖輸入流):
- BufferedInputStream 內部維護了一個緩沖區,這個緩沖區實際就是一個字節數組
- BufferedInputStream 內部維護了一個緩沖區,這個緩沖區實際就是一個字節數組
- BufferedOutputStream(字節緩沖輸入流):
- BufferedOutputStream 內部也維護了一個緩沖區,并且,這個緩存區的大小也是 8192 字節
- 字符緩沖流:
- BufferedReader (字符緩沖輸入流)和 BufferedWriter(字符緩沖輸出流)類似于 BufferedInputStream(字節緩沖輸入流)和BufferedOutputStream(字節緩沖輸入流),內部都維護了一個字節數組作為緩沖區。不過,前者主要是用來操作字符信息
- 打印流:
- System.out.println(“Hello!”);System.out 實際是用于獲取一個 PrintStream 對象,print方法實際調用的是 PrintStream 對象的 write 方法。PrintStream 屬于字節打印流,與之對應的是 PrintWriter (字符打印流)。PrintStream 是 OutputStream 的子類,PrintWriter 是 Writer 的子類。
- 隨機訪問流:隨機訪問流指的是 支持隨意跳轉到文件的任意位置進行讀寫的 RandomAccessFile
- 文件內容指的是文件中實際保存的數據,元數據則是用來描述文件屬性比如文件的大小信息、創建和修改時間。
- RandomAccessFile 中 有一個文件指針 用來表示 下一個將要被寫入或者讀取的字節所處的位置。我們可以通過 RandomAccessFile 的 seek(long pos) 方法來設置文件指針的偏移量(距文件開頭 pos 個字節處)。如果想要獲取文件指針當前的位置的話,可以使用 getFilePointer() 方法。
- RandomAccessFile 比較常見的一個應用就是實現大文件的 斷點續傳 。何謂斷點續傳?簡單來說就是上傳文件中途暫停或失敗(比如遇到網絡問題)之后,不需要重新上傳,只需要上傳那些未成功上傳的文件分片即可。分片(先將文件切分成多個文件分片)上傳是斷點續傳的基礎。
- InputStream/Reader: 所有的輸入流的基類,InputStream是字節輸入流【用于從源頭(通常是文件)讀取數據(字節信息)到內存中,java.io.InputStream抽象類是所有字節輸入流的父類。】,Reader是字符輸入流【不管是文件讀寫還是網絡發送接收,信息的最小存儲單元都是字節。 那為什么 I/O 流操作要分為字節流操作和字符流操作呢?原因主要是有時候如果我們不知道編碼類型就很容易出現亂碼問題,I/O 流就干脆提供了一個直接操作字符的接口,方便我們平時對字符進行流操作。如果音頻文件、圖片等媒體文件用字節流比較好,如果涉及到字符的話使用字符流比較好。字符流默認采用的是 Unicode 編碼,我們可以通過構造方法自定義編碼。utf8 :英文占 1 字節,中文占 3 字節,unicode:任何字符都占 2 個字節,gbk:英文占 1 字節,中文占 2 字節】
- 和磁盤打交道就是費事,但也沒辦法,咱們缺容量呀。咱們在 Java 中 IO 流分為輸入流和輸出流,根據數據的處理方式又分為字節流和字符流。根據擒賊先擒王的手法,咱們把Java IO 流的 40 多個類的如下 4 個抽象類基類先抓住,【Java IO 流的 40 多個類都是從如下 4 個抽象類基類中派生出來的】。
- 網絡IO:
- 客戶端和服務器能在網絡中通信,那必須得使用 Socket 編程來支持跨主機間通信。Socket這個貨叫插口,其實我覺得就是個,比如咱們自己家里有兩臺路由器要通信,你不能拿一條線直接粘到路由器屁股上吧,肯定是兩臺路由器屁股上都有口,然后一條線這邊插好那邊插好,然后就可以跨主機通信了唄,路由器屁股上開的口我覺得就可以看作是Socket。
- 創建 Socket 的時候,可以指定網絡層使用的是 IPv4 還是 IPv6,傳輸層使用的是 TCP 還是 UDP。 不管是那種,反正肯定是 服務器的程序要先跑起來,然后等待客戶端的連接和數據
- 我們先來看看服務端的 Socket 編程過程是怎樣的。或者叫**TCP Socket**。它基本只能一對一通信,因為使用的是同步阻塞的方式,當服務端在還沒處理完一個客戶端的網絡 I/O 時,或者 讀寫操作發生阻塞時,其他客戶端是無法與服務端連接的。可如果我們服務器只能服務一個客戶,那這樣就太浪費資源了,于是我們要改進這個網絡 I/O 模型,以支持更多的客戶端。
- 或者說,先看看 客戶端和服務端的基于 TCP 的通信流程:
- 服務端的偽代碼:
- 在 LInux 中一切皆文件,socket 也不例外,每個打開的文件都有讀寫緩沖區,對文件執行read()、write()時的具體流程如下:
- 服務端的偽代碼:
- 可以看到 傳統的 socket 通信會阻塞在 connect,accept,read/write 這幾個操作上,這樣的話如果 server 是單進程/線程的話,只要 server 阻塞,就不能再接收其他 client 的處理了,由此可知傳統的 socket 無法支持 C10K
- 針對傳統 IO 模型缺陷的改進,主要有兩種:
- 多進程/線程模型
- IO 多路程復用:下面有
- 高并發即我們所說的 C10K(一個server 服務 1w 個 client),C10M。高并發架構其實有一些很通用的架構設計,如無鎖化,緩存等。
- 經典的 C10K 問題:如果服務器的內存只有 2 GB,網卡是千兆的,能支持并發 1 萬請求嗎【單機同時處理 1 萬個請求的問題。】從硬件資源角度看,對于 2GB 內存千兆網卡的服務器,如果每個請求處理占用不到 200KB 的內存和 100Kbit 的網絡帶寬就可以滿足并發 1 萬個請求。不過,要想真正實現 C10K 的服務器,要考慮的地方在于服務器的網絡 I/O 模型,效率低的模型,會加重系統開銷。基于進程或者線程模型的,其實還是有問題的。新到來一個 TCP 連接,就需要分配一個進程或者線程,那么如果要達到 C10K,意味著要一臺機器維護 1 萬個連接,相當于要維護 1 萬個進程/線程,操作系統就算死扛也是扛不住的。
- 針對傳統 IO 模型缺陷的改進,主要有兩種:
- 我們先來看看服務端的 Socket 編程過程是怎樣的。或者叫**TCP Socket**。它基本只能一對一通信,因為使用的是同步阻塞的方式,當服務端在還沒處理完一個客戶端的網絡 I/O 時,或者 讀寫操作發生阻塞時,其他客戶端是無法與服務端連接的。可如果我們服務器只能服務一個客戶,那這樣就太浪費資源了,于是我們要改進這個網絡 I/O 模型,以支持更多的客戶端。
- 服務器單機理論最大能連接多少個客戶端?
- TCP 連接是由四元組唯一確認的,這個四元組就是:本機IP, 本機端口, 對端IP, 對端端口。服務器作為服務方,通常會在本地固定監聽一個端口,等待客戶端的連接。 因此服務器的本地 IP 和端口是固定的,于是對于服務端 TCP 連接的四元組只有對端 IP 和端口是會變化的,所以最大 TCP 連接數 = 客戶端 IP 數×客戶端端口數。對于 IPv4,客戶端的 IP 數最多為 2 的 32 次方,客戶端的端口數最多為 2 的 16 次方,也就是服務端單機最大 TCP 連接數約為 2 的 48 次方。但是服務器肯定承載不了那么大的連接數,主要會受兩個方面的限制:
- fd(文件描述符):Socket 實際上是一個文件,也就會對應一個文件描述符。在 Linux 下,單個進程打開的文件描述符數是有限制的,沒有經過修改的值一般都是 1024,不過我們可以通過 ulimit 增大文件描述符的數目;
- 在 Linux 中無論是文件,socket,還是管道,設備等,一切皆文件,Linux 抽象出了一個 VFS(virtual file system) 層,屏蔽了所有的具體的文件,VFS 提供了統一的接口給上層調用,這樣應用層只與 VFS 打交道,極大地方便了用戶的開發,仔細對比你會發現,這和 Java 中的面向接口編程很類似【fd 的值從 0 開始,其中 0,1,2 是固定的,分別指向標準輸入(指向鍵盤),標準輸出/標準錯誤(指向顯示器),之后每打開一個文件,fd 都會從 3 開始遞增,但需要注意的是 fd 并不一定都是遞增的,如果關閉了文件,之前的 fd 是可以被回收利用的】
- 在 Linux 中無論是文件,socket,還是管道,設備等,一切皆文件,Linux 抽象出了一個 VFS(virtual file system) 層,屏蔽了所有的具體的文件,VFS 提供了統一的接口給上層調用,這樣應用層只與 VFS 打交道,極大地方便了用戶的開發,仔細對比你會發現,這和 Java 中的面向接口編程很類似【fd 的值從 0 開始,其中 0,1,2 是固定的,分別指向標準輸入(指向鍵盤),標準輸出/標準錯誤(指向顯示器),之后每打開一個文件,fd 都會從 3 開始遞增,但需要注意的是 fd 并不一定都是遞增的,如果關閉了文件,之前的 fd 是可以被回收利用的】
- 系統內存,每個 TCP 連接在內核中都有對應的數據結構,意味著每個連接都是會占用一定內存的;
- fd(文件描述符):Socket 實際上是一個文件,也就會對應一個文件描述符。在 Linux 下,單個進程打開的文件描述符數是有限制的,沒有經過修改的值一般都是 1024,不過我們可以通過 ulimit 增大文件描述符的數目;
- TCP 連接是由四元組唯一確認的,這個四元組就是:本機IP, 本機端口, 對端IP, 對端端口。服務器作為服務方,通常會在本地固定監聽一個端口,等待客戶端的連接。 因此服務器的本地 IP 和端口是固定的,于是對于服務端 TCP 連接的四元組只有對端 IP 和端口是會變化的,所以最大 TCP 連接數 = 客戶端 IP 數×客戶端端口數。對于 IPv4,客戶端的 IP 數最多為 2 的 32 次方,客戶端的端口數最多為 2 的 16 次方,也就是服務端單機最大 TCP 連接數約為 2 的 48 次方。但是服務器肯定承載不了那么大的連接數,主要會受兩個方面的限制:
- 創建 Socket 的時候,可以指定網絡層使用的是 IPv4 還是 IPv6,傳輸層使用的是 TCP 還是 UDP。 不管是那種,反正肯定是 服務器的程序要先跑起來,然后等待客戶端的連接和數據
- 基于 Linux 一切皆文件的理念,在內核中 Socket 也是以文件的形式存在的,也是有對應的文件描述符,每個打開的文件也都有讀寫緩沖區。每個文件都有一個 inode,Socket 文件的 inode 指向了內核中的 Socket 結構,在這個結構體里有兩個隊列,分別是發送隊列和接收隊列,這個兩個隊列里面保存的是一個個 struct sk_buff,用鏈表的組織形式串起來。sk_buff 可以表示各個層的數據包,在應用層數據包叫 data,在 TCP 層我們稱為 segment,在 IP 層我們叫 packet,在數據鏈路層稱為 frame,這不就和計算機網絡穿起來了嘛。協議棧采用的是分層結構,上層向下層傳遞數據時需要增加包頭,下層向上層數據時又需要去掉包頭,如果每一層都用一個結構體,那在層之間傳遞數據的時候,就要發生多次拷貝,這將大大降低 CPU 效率。于是,為了在層級之間傳遞數據時,不發生拷貝,只用 sk_buff 一個結構體來描述所有的網絡包,那它是如何做到的呢?是通過調整 sk_buff 中 data 的指針。【當接收報文時,從網卡驅動開始,通過協議棧層層往上傳送數據報,通過增加 skb->data 的值,來逐步剝離協議首部+++++當要發送報文時,創建 sk_buff 結構體,數據緩存區的頭部預留足夠的空間,用來填充各層首部,在經過各下層協議時,通過減少 skb->data 的值來增加協議首部。】
- 客戶端和服務器能在網絡中通信,那必須得使用 Socket 編程來支持跨主機間通信。Socket這個貨叫插口,其實我覺得就是個,比如咱們自己家里有兩臺路由器要通信,你不能拿一條線直接粘到路由器屁股上吧,肯定是兩臺路由器屁股上都有口,然后一條線這邊插好那邊插好,然后就可以跨主機通信了唄,路由器屁股上開的口我覺得就可以看作是Socket。
PART1:Unix 常見的五種IO模型:網絡編程中的五個 I/O 模型:同步阻塞 I/O(BIO)、同步非阻塞 I/O(NIO)、 I/O 多路復用、信號驅動、異步非阻塞 I/O(AIO))【只有 AIO 為異步 IO,其他都是同步 IO】,最常用的就是同步阻塞BIO 和 IO 多路復用
-
Unix 常見的IO模型:對于一次IO訪問(以read舉例),數據會先被 拷貝到操作系統內核的緩沖區中,然后 才會從操作系統內核的緩沖區拷貝到應用程序的地址空間。
- 無論是阻塞 I/O、非阻塞 I/O,還是基于非阻塞 I/O 的多路復用以及信號驅動都是同步調用。因為 它們在 read 調用時,內核將數據從內核空間拷貝到應用程序空間,過程都是需要等待的,也就是說這個過程是同步的,如果內核實現的拷貝效率不高,read 調用就會在這個同步過程中等待比較長的時間
- 【5中I/O中,I/O 阻塞、I/O非阻塞、I/O復用、SIGIO 都會在不同程度上阻塞應用程序,而只有異步I/O模型在整個操作期間都不會阻塞應用程序。】
- 真正的異步 I/O 是「內核數據準備好」和「數據從內核態拷貝到用戶態」這兩個過程都不用等待
- POSIX 規范中定義了同步I/O 和異步I/O的術語:
- 同步I/O : 需要進程去真正的去操作I/O
- 異步I/O:內核在I/O操作完成后再通知應用進程操作結果
- 但是如果使用同步的方式來通信的話,所有的操作都在一個線程內順序執行完成,這么做缺點是很明顯的:因為同步的通信操作會阻塞同一個線程的其他任何操作,只有這個操作完成了之后,后續的操作才可以完成,所以出現了同步阻塞+多線程(每個Socket都創建一個線程對應),但是系統內線程數量是有限制的,同時線程切換很浪費時間,適合Socket少的情況,因該需要出現IO模型。
- 【5中I/O中,I/O 阻塞、I/O非阻塞、I/O復用、SIGIO 都會在不同程度上阻塞應用程序,而只有異步I/O模型在整個操作期間都不會阻塞應用程序。】
- 阻塞 IO 和 IO 多路復用最為常用,原因如下:
- 在系統內核的支持上,現在大多數系統內核都會支持阻塞 IO、非阻塞 IO 和 IO 多路復用,但像信號驅動 IO、異步 IO,只有高版本的 Linux 系統內核才會支持。
- 在編程語言上,無論 C++ 還是 Java,在高性能的網絡編程框架的編寫上,大多數都是基于 Reactor 模式,其中最為典型的便是 Java 的 Netty 框架,而 Reactor 模式是基于 IO 多路復用的。當然,在非高并發場景下,同步阻塞 IO 是最為常見的
- 當一個read操作發生時,會經歷兩個階段:或者說I/O 是分為兩個過程的【或者說當應用程序發起 I/O 調用后,會經歷兩個步驟:】
- 過程一:內核等待數據準備就緒 (Waiting for the data to be ready)【內核程序要從磁盤、網卡等讀取數據到內核空間緩存區;】
- 傳統的IO流程,包括read和write的過程:
- read:把數據從磁盤讀取到內核緩沖區,再從內核緩沖區拷貝到用戶緩沖區
- write:先把數據寫入到socket緩沖區,最后寫入網卡設備。
- 傳統的IO流程,包括read和write的過程:
- 過程二:內核或者說用戶程序將數據從內核空間緩存拷貝到用戶空間的進程中 (Copying the data from the kernel to the process)【大多數文件系統的默認 IO 操作都是緩存 IO。緩存 IO 的缺點:數據在傳輸過程中需要在應用程序地址空間和內核空間進行多次數據拷貝操作,這些數據拷貝操作所帶來的CPU以及內存開銷非常大。】
- 過程一:內核等待數據準備就緒 (Waiting for the data to be ready)【內核程序要從磁盤、網卡等讀取數據到內核空間緩存區;】
- 正式因為這兩個階段,linux系統產生了下面五種網絡模式的方案:【阻塞 I/O 會阻塞在過程 1 和過程 2,非阻塞 I/O 和基于非阻塞 I/O 的多路復用只會阻塞在過程 2,所以這三個都可以認為是同步 I/O;異步 I/O 則不同,過程 1 和過程 2 都不會阻塞。】【主要原因在于socket.accept()、socket.read()、socket.write()三個主要函數都是同步阻塞的。當一個連接在處理I/O的時候,系統是阻塞的,如果是單線程的話必然就阻塞,但CPU是被釋放出來的,開啟多線程,就可以讓CPU去處理更多的事情。利用多核,當I/O阻塞時,但CPU空閑的時候,可以利用多線程使用CPU資源。】
- Unix 常見的五種IO模型之一:同步阻塞式IO模型BIO(blocking IO model):在linux中,默認情況下所有的IO操作都是blocking
- 計算機有內核,內核可以接收客戶端來的連接【客戶端的所有連接是先到達內核】,建立連接就會產生文件描述符,原來內核中有read函數可以讀文件描述符,這個read操作是在一個線程或者進程中讀,而來一個客戶端請求后服務端就會new一個新線程,一個線程對應一個連接,利用CPU的時間片輪轉去處理當前的用戶讀寫操作,但是線程資源畢竟是有限的。socket在這個時期是block的,數據包不能返回一直在阻塞,這就是對應的早期的BIO。這樣就浪費了計算機硬件。NIO此時出來了,內核此時發生變化了,內核升級
- yum install man man-pages:這條命令可以看linux中命令的實現原理。利用man 2 socket可以看到
- 到了NIO之后,此時說明文件描述符可以是nonblock。然后由于是非阻塞的,我就可以單線程或者單進程,在這個單個里面寫一個while循環,read fd1,有沒有數據,fd1說額沒有。好,那我就繼續read fd2,fd2說有數據,拿著有的東西進行處理,處理完之后繼續去用戶空間輪詢read fdx【輪詢操作發生在用戶空間哦】,但是拿出來處理都由我自己來弄,就叫做同步+非阻塞,
- 那現在如果有10000個fd,在用戶空間內的用戶進程需要輪詢調用10000次kernel,這么多次系統調用,太浪費了,所以內核進行升級,增加一個系統調用,select
- 然后,繼續可以man 2 select看一下這個系統調用
- 雖然select是,比如1000個客戶端連接請求,你告訴我里面50個準備好數據了,我去挨個讀這50個,節省了用戶態到內核態的切換。但是select歸根到底有缺點,往下看咯
- yum install man man-pages:這條命令可以看linux中命令的實現原理。利用man 2 socket可以看到
- 通常把阻塞的文件描述符(file descriptor,fd)稱之為阻塞I/O。默認條件下,創建的socket fd是阻塞的,針對阻塞I/O調用系統接口,可能因為等待的事件沒有到達而被系統掛起,直到等待的事件觸發調用接口才返回,例如,tcp socket的connect調用會阻塞至第三次握手成功(不考慮socket 出錯或系統中斷)
- 在JDK1.4推出JavaNIO之前,基于Java的所有Socket通信都采用了同步阻塞模式(BIO),這種一請求一應答的通信模型簡化了上層的應用開發,但是BIO在性能和可靠性方面卻存在著巨大的瓶頸。因此,在很長一段時間里,大型的應用服務器都采用C或者C++語言開發,因為它們可以直接使用操作系統提供的異步I/O或者AIO能力。當并發訪問量增大、響應時間延遲增大之后,采用JavaBIO開發的服務端軟件只有通過硬件的不斷擴容來滿足高并發和低時延,它極大地增加了企業的成本,并且隨著集群規模的不斷膨脹,系統的可維護性也面臨巨大的挑戰,只能通過采購性能更高的硬件服務器來解決問題,這會導致惡性循環。正是由于Java傳統BIO的拙劣表現,才使得Java支持非阻塞I/O的呼聲日漸高漲,最終,JDK1.4版本提供了新的NIO類庫,Java 終于也可以支持非阻塞I/O 了。
- 一個典型的讀操作流程大概是這樣:
- 當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據(對于網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來),而數據被拷貝到操作系統內核的緩沖區中是需要一個過程的,這個過程需要等待。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶空間的緩沖區以后,然后kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
- 同步阻塞 BIO 模型中,應用程序發起 read 調用后,會一直阻塞,直到內核把數據拷貝到用戶空間。
- 同步阻塞 BIO 模型中,應用程序發起 read 調用后,會一直阻塞,直到內核把數據拷貝到用戶空間。
- 當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據(對于網絡IO來說,很多時候數據在一開始還沒有到達。比如,還沒有收到一個完整的UDP包。這個時候kernel就要等待足夠的數據到來),而數據被拷貝到操作系統內核的緩沖區中是需要一個過程的,這個過程需要等待。而在用戶進程這邊,整個進程會被阻塞(當然,是進程自己選擇的阻塞)。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶空間的緩沖區以后,然后kernel返回結果,用戶進程才解除block的狀態,重新運行起來。
- blocking IO的特點就是在IO執行的下兩個階段的時候都被block了。BIO 模型有兩處阻塞的地方
- 等待數據準備就緒 (Waiting for the data to be ready) 阻塞 (服務端阻塞等待客戶端發起連接。也就是通過 serverSocket.accept()方法服務端等待用戶發連接請求過來。)
- 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process) 阻塞(連接成功后,工作線程阻塞讀取客戶端 Socket 發送數據。也就是服務端通過 in.readLine() 從網絡中讀客戶端發送過來的數據,這個地方也會阻塞。如果客戶端已經和服務端建立了一個連接,但客戶端遲遲不發送數據,那么服務端的 readLine() 操作會一直阻塞,造成資源浪費。)
- BIO模型的特點:或者說BIO模型的缺點:
- (Socket 連接數量受限,不適用于高并發場景)缺乏彈性伸縮能力,當客戶端并發訪問量增加后,服務端的線程個數和客戶端并發訪問數呈1 : 1的正比關系,由于線程是Java虛擬機非常寶貴的系統資源,當線程數膨脹之后,系統的性能將急劇下降,隨著并發訪問量的繼續增大,系統會發生線程堆棧溢出、創建新線程失敗等問題,并最終導致進程宕機或者僵死,不能對外提供服務。顯而易見,如果我們要構建高性能、低時延、支持大并發的應用系統,使用同步阻塞I/O模型是無法滿足性能線性增長和可靠性的。當面對十萬甚至百萬級連接的時候,傳統的BIO模型是無能為力的。隨著移動端應用的興起和各種網絡游戲的盛行,百萬級長連接日趨普遍,此時,必然需要一種更高效的I/O處理模型NIO。
- 有兩處阻塞,分別是等待用戶發起連接,和等待用戶發送數據。這不是個好事情,你想呀,你去買東西,別人給你取盒煙讓你等了一個小時,然后你掃碼時網不太好,又讓你等了半小時,你不得不付現金,他給你找零錢又讓你等了一小時…。所以肯定得解決這個兩個地方阻塞的問題。用NIO網絡模型(NIO網絡模型操作上是用一個線程處理多個連接,使得每一個工作線程都可以處理多個客戶端的 Socket 請求,這樣工作線程的利用率就能得到提升,所需的工作線程數量也隨之減少。此時 NIO 的線程模型就變為 1 個工作線程對應多個客戶端 Socket 的請求,這就是所謂的 I/O多路復用。)來解決。
- 另外補充一點,網絡編程中,通常把可能永遠阻塞的系統API調用 稱為慢系統調用,典型的如 accept、recv、select等。慢系統調用在阻塞期間可能被信號中斷而返回錯誤,相應的errno 被設置為EINTR,我們需要處理這種錯誤,解決辦法有:
- 重啟系統調用:
- 信號處理
- 重啟系統調用:
- 先理一理哈,在計算機網絡這篇中提到了 應用程序建立連接后通過OS實現的TCP協議的socket接口給服務器發了數據(假設咱們服務器上用的服務器軟件是Tomcat),服務器會利用自己體內的endPoint的實現去socket(OS實現的TCP協議的socket接口)中拿到數據,然后再解析成為一個又一個請求,再交給tomcat去處理,處理完響應給客戶端。
- BI/O 模型典型的Java實現: 基于 BIO 的文件復制程序:字節流方式、字符流方式、字符緩沖,按行讀取、隨機讀寫(RandomAccessFile)
public class BIOSever { //在服務端創建一個 ServerSocket 對象ServerSocket ss = new ServerSocket();// 綁定端口 9090,然后啟動運行服務端,然后阻塞等待客戶端發起連接請求,直到有客戶端的連接發送過來之后。當有客戶端的連接請求后,服務端會啟動一個新線程 ServerTaskThread,用新創建的線程去處理當前用戶的讀寫操作。ss.bind(new InetSocketAddress("localhost", 9090));System.out.println("server started listening " + PORT);try {Socket s = null;while (true) {// 阻塞等待客戶端發送連接請求,直到有客戶端的連接發送過來之后,accept() 方法返回Socket s = ss.accept();new Thread(new ServerTaskThread(s)).start();}} catch (Exception e) {...} finally {if (ss != null) {ss.close();ss = null;} } /* *當有客戶端的連接請求后,服務端會啟動一個新線程 ServerTaskThread,用新創建的線程去處理當前用戶的讀寫操作。 */ public class ServerTaskThread implements Runnable {...while (true) {// 阻塞等待客戶端發請求過來String readLine = in.readLine();if (readLine == null) {break;}...}... }- 除了上面這種寫法,RPC這里也可以對Socket網絡通信代碼進行封裝,體現咱們的封裝性
- 使用 Java NIO 包組成一個簡單的客戶端-服務端網絡通訊所需要的 ServerSocketChannel、SocketChannel 和 Buffer,一個完整的可運行的例子:(例子來自javadoop老師)
- SocketHandler:【來一個新的連接,我們就新開一個線程來處理這個連接,之后的操作全部由那個線程來完成。】
- 客戶端 SocketChannel 的使用:
- SocketHandler:【來一個新的連接,我們就新開一個線程來處理這個連接,之后的操作全部由那個線程來完成。】
- 上面這個例子的性能瓶頸或者說問題:非阻塞 IO應運而生
- 計算機有內核,內核可以接收客戶端來的連接【客戶端的所有連接是先到達內核】,建立連接就會產生文件描述符,原來內核中有read函數可以讀文件描述符,這個read操作是在一個線程或者進程中讀,而來一個客戶端請求后服務端就會new一個新線程,一個線程對應一個連接,利用CPU的時間片輪轉去處理當前的用戶讀寫操作,但是線程資源畢竟是有限的。socket在這個時期是block的,數據包不能返回一直在阻塞,這就是對應的早期的BIO。這樣就浪費了計算機硬件。NIO此時出來了,內核此時發生變化了,內核升級
- Unix 常見的五種IO模型之二:同步非阻塞式IO模型(noblocking IO model)NIO(一般很少直接使用這種模型,而是在其他 I/O 模型中使用非阻塞 I/O 這一特性。這種方式對單個 I/O 請求意義不大,但給 I/O 多路復用鋪平了道路):linux下,可以通過設置socket使其變為non-blocking。NIO 的日常操作,這個文章有例子:文件復制、文件復制—映射方式、文件復制—零拷貝方式、
- 把非阻塞的文件描述符稱為非阻塞I/O。可以通過設置SOCK_NONBLOCK標記創建非阻塞的socket fd,或者使用fcntl將fd設置為非阻塞。
- 對非阻塞fd調用系統接口時,不需要等待事件發生而立即返回,事件沒有發生,接口返回-1,此時需要通過errno的值來區分是否出錯。不同的接口,立即返回時的errno值不盡相同,如,recv、send、accept errno通常被設置為EAGIN 或者EWOULDBLOCK,connect 則為EINPRO- GRESS 。
- 對非阻塞fd調用系統接口時,不需要等待事件發生而立即返回,事件沒有發生,接口返回-1,此時需要通過errno的值來區分是否出錯。不同的接口,立即返回時的errno值不盡相同,如,recv、send、accept errno通常被設置為EAGIN 或者EWOULDBLOCK,connect 則為EINPRO- GRESS 。
- NIO 也稱新 IO 或者非阻塞 IO(Non-Blocking IO)【NIO 中的 N 可以理解為 Non-blocking,不單純是 New。】。Java 中的 NIO 于 Java 1.4 中引入,對應 java.nio 包,提供了 Channel , Selector,Buffer 等抽象。傳統 IO 是面向輸入/輸出流編程的,而 NIO 是面向通道編程的,或者說它是支持面向緩沖的,基于通道的 I/O 操作方法。 對于高負載、高并發的(網絡)應用,應使用 NIO 。
- NIO 的 3 個核心概念或者說Java NIO 中三大組件 Buffer、Channel、Selector:Channel、Buffer、Selector:。
- Channel(通道):
- 所有的 NIO 操作始于通道,通道是數據來源或數據寫入的目的地,主要地,我們將關心 java.nio 包中實現的以下幾個 Channel:
- FileChannel:
- FileChannel 是不支持非阻塞的。
- FileChannel 是不支持非阻塞的。
- SocketChannel:
- 可以將 SocketChannel 理解成一個 TCP 客戶端。雖然這么理解有點狹隘,因為我們在介紹 ServerSocketChannel 的時候會看到另一種使用方式。【SocketChannel 了,它不僅僅是 TCP 客戶端,它代表的是一個網絡通道,可讀可寫。】//打開一個 TCP 連接: SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("http://aiminhuqaqq.fun/", 80)); /**上面的這行代碼等價于下面的兩行: *打開一個通道:SocketChannel socketChannel = SocketChannel.open(); *發起連接:socketChannel.connect(new InetSocketAddress("http://aiminhuqaqq.fun/", 80)); */
- SocketChannel 的讀寫和 FileChannel 沒什么區別,就是操作緩沖區。
- ServerSocketChannel:
- SocketChannel 是 TCP 客戶端,這里說的 ServerSocketChannel 就是對應的服務端。
- ServerSocketChannel 用于監聽機器端口,管理從這個端口進來的 TCP 連接。
- ServerSocketChannel 不和 Buffer 打交道了,因為它并不實際處理數據,它一旦接收到請求后,實例化 SocketChannel,之后在這個連接通道上的數據傳遞它就不管了,因為它需要繼續監聽端口,等待下一個連接
- DatagramChannel:
- UDP 和 TCP 不一樣,DatagramChannel 一個類處理了服務端和客戶端。
- 監聽端口:
- 發送數據:
- FileChannel:
- Channel 與Buffer 打交道,讀操作的時候將 Channel 中的數據填充到 Buffer 中,而寫操作時將 Buffer 中的數據寫入到 Channel 中。
- channel 實例的兩個方法:
- channel.read(buffer);
- channel.write(buffer);
- channel.read(buffer);
- channel 實例的兩個方法:
- Channel 是對 IO 輸入/輸出系統的抽象,是 IO 源與目標之間的連接通道,NIO 的通道類似于傳統 IO 中的各種“流”,用于讀取和寫入。與 InputStream 和 OutputStream 不同的是,Channel 是雙向的,既可以讀,也可以寫,且支持異步操作。這契合了操作系統的特性,比如 linux 底層通道就是雙向的。此外 Channel 還提供了 map() 方法,通過該方法可以將“一塊”數據直接映射到內存中。因此也有人說,NIO 是面向塊處理的,而傳統 I/O 是面向流處理的。
- 所有的 NIO 操作始于通道,通道是數據來源或數據寫入的目的地,主要地,我們將關心 java.nio 包中實現的以下幾個 Channel:
- Buffer(緩沖):
- Buffer 本質上就是一個容器,其底層持有了一個具體類型的數組來存放具體數據。或者說一個 Buffer 本質上是內存中的一塊,我們可以將數據寫入這塊內存,之后從這塊內存獲取數據。。從 Channel 中取數據或者向 Channel 中寫數據都需要通過 Buffer。在 Java 中 Buffer 是一個抽象類,除 boolean 之外的基本數據類型都提供了對應的 Buffer 實現類。比較常用的是 ByteBuffer 和 CharBuffer。
- java.nio定義的幾個Buffer的實現:
- Buffer 中的幾個重要屬性和幾個重要方法:就像數組有數組容量,每次訪問元素要指定下標,Buffer 中也有幾個重要屬性:position、limit、capacity。
- position:position 的初始值是 0,每往 Buffer 中寫入一個值,position 就自動加 1,代表下一次的寫入位置,所以 position 最后會指向最后一次寫入的位置的后面一個,如果 Buffer 寫滿了,那么 position 等于 capacity(position 從 0 開始)。讀操作的時候也是類似的,每讀一個值,position 就自動加 1。
- 從 寫操作模式到讀操作模式切換的時候(flip()),position 都會歸零,這樣就可以從頭開始讀寫了
- rewind():會重置 position 為 0,通常用于重新從頭讀寫 Buffer。和rewind相近的有:
- clear():有點重置 Buffer 的意思,相當于重新實例化了一樣。clear() 方法會重置幾個屬性,但是我們要看到,clear() 方法并不會將 Buffer 中的數據清空,只不過后續的寫入會覆蓋掉原來的數據,也就相當于清空了數據了。
- compact():和 clear() 一樣的是,它們都是在準備往 Buffer 填充新的數據之前調用。compact() 方法有點不一樣,調用這個方法以后,會先處理還沒有讀取的數據,也就是 position 到 limit 之間的數據(還沒有讀過的數據),先將這些數據移到左邊,然后在這個基礎上再開始寫入。很明顯,此時 limit 還是等于 capacity,position 指向原來數據的右邊
- clear():有點重置 Buffer 的意思,相當于重新實例化了一樣。clear() 方法會重置幾個屬性,但是我們要看到,clear() 方法并不會將 Buffer 中的數據清空,只不過后續的寫入會覆蓋掉原來的數據,也就相當于清空了數據了。
- 從 寫操作模式到讀操作模式切換的時候(flip()),position 都會歸零,這樣就可以從頭開始讀寫了
- limit:寫操作模式下,limit 代表的是最大能寫入的數據,這個時候 limit 等于 capacity。寫結束后,切換到讀模式,此時的 limit 等于 Buffer 中實際的數據大小,因為 Buffer 不一定被寫滿了
- capacity:
- mark:除了 position、limit、capacity 這三個基本的屬性外,還有一個常用的屬性就是 mark。
- mark 用于臨時保存 position 的值,每次調用 mark() 方法都會將 mark 設值為當前的 position,便于后續需要的時候使用。
- mark 用于臨時保存 position 的值,每次調用 mark() 方法都會將 mark 設值為當前的 position,便于后續需要的時候使用。
- position:position 的初始值是 0,每往 Buffer 中寫入一個值,position 就自動加 1,代表下一次的寫入位置,所以 position 最后會指向最后一次寫入的位置的后面一個,如果 Buffer 寫滿了,那么 position 等于 capacity(position 從 0 開始)。讀操作的時候也是類似的,每讀一個值,position 就自動加 1。
- 初始化 Buffer:
- 每個 Buffer 實現類都提供了一個靜態方法 allocate(int capacity) 幫助我們快速實例化一個 Buffer
- 另外,我們經常使用 wrap 方法來初始化一個 Buffer。
- 每個 Buffer 實現類都提供了一個靜態方法 allocate(int capacity) 幫助我們快速實例化一個 Buffer
- 填充 Buffer:
- 各個 Buffer 類都提供了一些 put 方法用于將數據填充到 Buffer 中,如 ByteBuffer 中的幾個 put 方法:
- 對于 Buffer 來說,另一個常見的操作中就是,我們要將來自 Channel 的數據填充到 Buffer 中,在系統層面上,這個操作我們稱為讀操作,因為數據是從外部(文件或網絡等)讀到內存中。
- 提取 Buffer 中的值:
- 每往 Buffer 中寫入一個值,position 就自動加 1,代表下一次的寫入位置,所以 position 最后會指向最后一次寫入的位置的后面一個,如果 Buffer 寫滿了,那么 position 等于 capacity(position 從 0 開始)
- 如果要讀 Buffer 中的值,需要切換模式,從寫入模式切換到讀出模式。注意,通常在說 NIO 的讀操作的時候,我們說的是從 Channel 中讀數據到 Buffer 中,對應的是對 Buffer 的寫入操作
- 每往 Buffer 中寫入一個值,position 就自動加 1,代表下一次的寫入位置,所以 position 最后會指向最后一次寫入的位置的后面一個,如果 Buffer 寫滿了,那么 position 等于 capacity(position 從 0 開始)
- 各個 Buffer 類都提供了一些 put 方法用于將數據填充到 Buffer 中,如 ByteBuffer 中的幾個 put 方法:
- Buffer 本質上就是一個容器,其底層持有了一個具體類型的數組來存放具體數據。或者說一個 Buffer 本質上是內存中的一塊,我們可以將數據寫入這塊內存,之后從這塊內存獲取數據。。從 Channel 中取數據或者向 Channel 中寫數據都需要通過 Buffer。在 Java 中 Buffer 是一個抽象類,除 boolean 之外的基本數據類型都提供了對應的 Buffer 實現類。比較常用的是 ByteBuffer 和 CharBuffer。
- Selector:JDK1.4開始引入了NIO類庫,主要是使用Selector多路復用器來實現。Selector在Linux等主流操作系統上是通過IO復用Epoll實現的。通過Selector多路復用器,只需要一個線程便可以管理多個客戶端連接【非阻塞 IO 的核心在于使用一個 Selector 來管理多個通道,可以是 SocketChannel,也可以是 ServerSocketChannel,將各個通道注冊到 Selector 上,指定監聽的事件。之后可以只用一個線程來輪詢這個 Selector,看看上面是否有通道是準備好的,當通道準備好可讀或可寫,然后才去開始真正的讀寫,這樣速度就很快了。我們就完全沒有必要給每個通道都起一個線程。】。主要點見下述內容。
- Selector 建立在非阻塞的基礎之上,大家經常聽到的 多路復用 在 Java 世界中指的就是Selector,用于實現一個線程管理多個 Channel。
- NIO 中 Selector 是對底層操作系統實現的一個抽象,管理通道狀態其實都是底層系統實現的
- select:上世紀 80 年代就實現了,它支持注冊 FD_SETSIZE(1024) 個 socket,在那個年代肯定是夠用的,不過現在嘛,肯定是不行了
- poll:1997 年,出現了 poll 作為 select 的替代者,最大的區別就是,poll 不再限制 socket 數量。
- select 和 poll 都有一個共同的問題,那就是它們都只會告訴你有幾個通道準備好了,但是不會告訴你具體是哪幾個通道。所以,一旦知道有通道準備好以后,自己還是需要進行一次掃描,顯然這個不太好,通道少的時候還行,一旦通道的數量是幾十萬個以上的時候,掃描一次的時間都很可觀了,時間復雜度 O(n)。所以,后來才催生了以下epoll實現。
- epoll:2002 年隨 Linux 內核 2.5.44 發布,epoll 能直接返回具體的準備好的通道,時間復雜度 O(1)。
- 除了 Linux 中的 epoll,2000 年 FreeBSD 出現了 Kqueue,還有就是,Solaris 中有 /dev/poll。Windows 平臺的非阻塞 IO 使用 select,我們也不必覺得 Windows 很落后,在 Windows 中 IOCP 提供的異步 IO 是比較強大的。
- Selector一些基本的接口操作:
- 首先,我們 開啟一個 Selector選擇器或者叫多路復用器:Selector selector = Selector.open();
- 將 Channel 注冊到 Selector 上。Selector 建立在非阻塞模式之上,所以注冊到 Selector 的 Channel 必須要支持非阻塞模式,FileChannel 不支持非阻塞,我們這里討論最常見的 SocketChannel 和 ServerSocketChannel。
- Selector常用的幾個方法:
- Channel(通道):
- NIO 網絡模型,非阻塞IO,操作上是用一個線程處理多個連接,使得每一個工作線程都可以處理多個客戶端的 Socket 請求,這樣工作線程的利用率就能得到提升,所需的工作線程數量也隨之減少。此時 NIO 的線程模型就變為 1 個工作線程對應多個客戶端 Socket 的請求,這就是所謂的 I/O多路復用。使用 I/O 多路復用時,最好搭配非阻塞 I/O 一起使用【多路復用 API 返回的事件并不一定可讀寫的,如果使用阻塞 I/O, 那么在調用 read/write 時則會發生程序阻塞,因此最好搭配非阻塞 I/O,以便應對極少數的特殊情況】
- JDK1.4開始引入了NIO類庫,主要是使用Selector多路復用器來實現。Selector在Linux等主流操作系統上是通過IO復用Epoll實現的。
- 通過Selector多路復用器,只需要一個線程便可以管理多個客戶端連接。當客戶端數據到了之后,才會為其服務。
- NIO 比 BIO 提高了服務端工作線程的利用率,并增加了一個調度者,來實現 Socket 連接與 Socket 數據讀寫之間的分離。
- JDK 1.4提供了對非阻塞I/O (NIO)的支持,JDK1.5_ update10版本使用epoll替代了傳統的select/poll,極大地提升了NIO通信的性能。與Socket類和ServerSocket類相對應,NIO也提供了SocketChannel和ServerSocketChannel兩種不同的套接字通道實現,這兩種新增的通道都支持阻塞和非阻塞兩種模式。阻塞模式使用非常簡單,但是性能和可靠性都不好,非阻塞模式則正好相反。開發人員一般可以根據自己的需要來選擇合適的模式,一般來說,低負載、低并發的應用程序可以選擇同步阻塞I/O以降低編程復雜度,但是對于高負載、高并發的網絡應用,需要使用NIO的非阻塞模式進行開發。
- NIO采用多路復用技術,一個多路復用器Selector可以同時輪詢多個Channel,由于JDK使用了epoll(0代替傳統的select實現,所以它并沒有最大連接句柄1024/2048的限制。這也就意味著只需要一個線程負責Selector的輪詢,就可以接入成千上萬的客戶端,這確實是個非常巨大的進步。
- NIO采用多路復用技術,一個多路復用器Selector可以同時輪詢多個Channel,由于JDK使用了epoll(0代替傳統的select實現,所以它并沒有最大連接句柄1024/2048的限制。這也就意味著只需要一個線程負責Selector的輪詢,就可以接入成千上萬的客戶端,這確實是個非常巨大的進步。
- NIO的實現流程,類似于Select:【新事件到來的時候,會在Selector上注冊標記位,標示可讀、可寫或者有連接到來。NIO由原來的阻塞讀寫(占用線程)變成了單線程輪詢事件,找到可以進行讀寫的網絡描述符進行讀寫。除了事件的輪詢是阻塞的(沒有可干的事情必須要阻塞),剩余的I/O操作都是純CPU操作,沒有必要開啟多線程。并且由于線程的節約,連接數大的時候因為線程切換帶來的問題也隨之解決,進而為處理海量連接提供了可能。】
- 創建ServerSocketChannel監聽客戶端連接并綁定監聽端口,設置為非阻塞模式
- 創建Reactor線程,創建多路復用器(Selector)并啟動線程
- 將ServerSocketChannel注冊到Reactor線程的Selector上,監聽Accept事件
- Selector在線程run方法中無線循環輪詢準備就緒的Key
- Selector監聽到新的客戶端接入,處理新的請求,完成TCP三次握手,建立物理連接
- 將新的客戶端連接注冊到Selector上,監聽讀操作,讀取客戶端發送的網絡消息
- 客戶端發送的數據就緒則讀取客戶端請求,進行處理
- 既然服務端的工作線程可以服務于多個客戶端的連接請求,那么具體由哪個工作線程服務于哪個客戶端請求呢?
- 這時就需要一個調度者去監控所有的客戶端連接,比如當圖中的客戶端 A 的輸入已經準備好后,就由這個調度者 ,也就是Selector 選擇器去通知服務端的工作線程,告訴它們由工作線程 1 去服務于客戶端 A 的請求。這種思路就是 NIO 編程模型的基本原理,調度者就是 Selector 選擇器【selector的作用就是配合一個線程來管理多個channel,獲取這些channel.上發生的事件,這些channel工作在非阻塞模式下,不會讓線程吊死在一個channel上。適合連接數特別多,但流量低的場景(low traffic)】。
- 升級為線程池版,解決上面問題,阻塞式I/O
- 線程升級為線程池版,線程池再升級為selector版:selector的作用就是配合一個線程來管理多個channel,獲取這些channel.上發生的事件,這些channel工作在非阻塞模式下,不會讓線程吊死在一個channel上。所以用了selector后防止了線程吊死在同一顆樹上。適合連接數特別多,但流量低的場景(low traffic)】
- 升級為線程池版,解決上面問題,阻塞式I/O
- 這時就需要一個調度者去監控所有的客戶端連接,比如當圖中的客戶端 A 的輸入已經準備好后,就由這個調度者 ,也就是Selector 選擇器去通知服務端的工作線程,告訴它們由工作線程 1 去服務于客戶端 A 的請求。這種思路就是 NIO 編程模型的基本原理,調度者就是 Selector 選擇器【selector的作用就是配合一個線程來管理多個channel,獲取這些channel.上發生的事件,這些channel工作在非阻塞模式下,不會讓線程吊死在一個channel上。適合連接數特別多,但流量低的場景(low traffic)】。
- 通過Selector多路復用器,只需要一個線程便可以管理多個客戶端連接。當客戶端數據到了之后,才會為其服務。
- socket設置為 NONBLOCK(非阻塞)就是告訴內核,當所請求的I/O操作無法完成時,不要將進程睡眠,而是返回一個錯誤碼(EWOULDBLOCK) ,這樣請求就不會阻塞
- 當用戶進程調用了recvfrom這個系統調用,如果kernel中的數據還沒有準備好,那么它并不會block用戶進程,而是立刻返回一個EWOULDBLOCK error。從用戶進程角度講 ,它發起一個read操作后,并不需要等待,而是馬上就得到了一個結果。用戶進程判斷結果是一個EWOULDBLOCK error時,它就知道數據還沒有準備好,于是它 可以再次發送read操作。一旦kernel中的數據準備好了,并且又再次收到了用戶進程的system call,那么它馬上就將數據拷貝到了用戶空間緩沖區,然后返回。可以看到,I/O 操作函數將不斷的測試數據是否已經準備好,如果沒有準備好,繼續輪詢,直到數據準備好為止。整個 I/O 請求的過程中,雖然用戶線程每次發起 I/O 請求后可以立即返回,但是為了等到數據,仍需要不斷地輪詢、重復請求,消耗了大量的 CPU 的資源
- non blocking IO的特點是用戶進程需要不斷的主動詢問kernel數據好了沒有:
- 等待數據準備就緒 (Waiting for the data to be ready) 「這一步是非阻塞的」
- 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process) 「這一步是阻塞的」
- 等待數據準備就緒 (Waiting for the data to be ready) 「這一步是非阻塞的」
- 將BIO那個例子改裝為NIO的。
- 客戶端 SocketChannel 的使用:
- 客戶端 SocketChannel 的使用:
- 把非阻塞的文件描述符稱為非阻塞I/O。可以通過設置SOCK_NONBLOCK標記創建非阻塞的socket fd,或者使用fcntl將fd設置為非阻塞。
- Unix 常見的五種IO模型之三:IO復用式IO模型(IO multiplexing model):也叫,I/O 多路復用( IO multiplexing):NIO不停問問問,給人煩壞了,把CPU資源也消耗的差不多了,然后神獸繼續究極進化,從BIO—>NIO—>多路復用
- 在I/O編程過程中,當需要同時處理多個客戶端接入請求時,可以利用多線程或者I/O多路復用技術進行處理。I/O多路復用技術通過把多個I/O的阻塞復用到同一個select的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。
- 與傳統的多線程/多進程模型相比,I/O 多路復用的最大優勢是系統開銷小,系統不需要創建新的額外進程或者線程,也不需要維護這些進程和線程的運行,降低了系統的維護工作量,節省了系統資源。
- IO 多路復用模型中,線程首先發起 select 調用,詢問內核數據是否準備就緒,等內核把數據準備好了,用戶線程再發起 read 調用,相當于IO 多路復用模型,通過減少無效的系統調用,減少了對 CPU 資源的消耗。read 調用的過程(數據從內核空間 -> 用戶空間)還是阻塞的。【相當于 IO復用模型核心思路:系統給我們提供一類函數(如我們耳濡目染的select、poll、epoll函數),它們可以同時監控多個fd的操作,任何一個返回內核數據就緒,應用進程再發起recvfrom系統調用】
- IO多路復用是指 通過一種機制監視多個文件描述符fd【文件描述符在形式上是一個非負整數,實際上,它是一個索引值,指向內核為每一個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者創建一個新文件時,內核向進程返回一個文件描述符】,一旦某個文件描述符fd就緒(一般是讀就緒或者寫就緒),或者說內核一旦發現進程指定的一個或者多個IO條件準備讀取,它就通知該進程,進行相應的讀寫操作。
- (IO多路復用模型指的是:使用 單個進程同時處理多個網絡連接IO,他的原理就是select、poll、epoll 不斷輪詢所負責的所有 socket,當某個socket有數據到達了,就通知用戶進程。該模型的優勢并不是對于單個連接能處理得更快,而是在于能處理更多的連接。)
- 多路指的是多個 Socket 連接,就是指多個通道,也就是多個網絡連接的 IO
- 復用指的是復用一個線程,多個通道或者多個網絡連接的IO可以注冊到或這說復用在一個復用器上
- (IO多路復用模型指的是:使用 單個進程同時處理多個網絡連接IO,他的原理就是select、poll、epoll 不斷輪詢所負責的所有 socket,當某個socket有數據到達了,就通知用戶進程。該模型的優勢并不是對于單個連接能處理得更快,而是在于能處理更多的連接。)
- I/O多路復用有兩種事件觸發模式,分別是 邊緣觸發(edge-triggered,ET) 和 水平觸發(level-triggered,LT)。邊緣觸發的效率比水平觸發的效率要高,因為邊緣觸發可以減少 epoll_wait 的系統調用次數,系統調用也是有一定的開銷的的,畢竟也存在上下文的切換。【可以看看騰訊云社區的范蠡老師的epoll LT 模式和 ET 模式詳解】
- 使用邊緣觸發模式時,當被監控的 Socket 描述符上有可讀事件發生時,服務器端只會從 epoll_wait 中蘇醒一次,即使進程沒有調用 read 函數從內核讀取數據,也依然只蘇醒一次,因此 我們程序要保證一次性將內核緩沖區的數據讀取完【驛站只發一條短信不會發第二條第三條讓你去取快遞的模式就叫邊緣觸發】。
- 如果使用邊緣觸發模式,I/O 事件發生時只會通知一次,而且我們不知道到底能讀寫多少數據,所以在收到通知后應盡可能地讀寫數據,以免錯失讀寫的機會。因此,我們會循環從文件描述符讀寫數據,那么如果文件描述符是阻塞的,沒有數據可讀寫時,進程會阻塞在讀寫函數那里,程序就沒辦法繼續往下執行。所以,邊緣觸發模式一般和非阻塞 I/O 搭配使用,程序會一直執行 I/O 操作,直到系統調用(如 read 和 write)返回錯誤,錯誤類型為 EAGAIN 或 EWOULDBLOCK。
- 使用水平觸發模式時,當被監控的 Socket 上有可讀事件發生時,服務器端不斷地從 epoll_wait 中蘇醒,直到內核緩沖區數據被 read 函數讀完才結束,目的是告訴我們有數據需要讀取。如果使用水平觸發模式,當內核通知文件描述符可讀寫時,接下來還可以繼續去檢測它的狀態,看它是否依然可讀或可寫。所以在收到通知后,沒必要一次執行盡可能多的讀寫操作【如果快遞箱發現你的快遞沒有被取出,它就會不停地發短信通知你,直到你取出了快遞,它才消停,這個就是水平觸發的方式】
- 使用邊緣觸發模式時,當被監控的 Socket 描述符上有可讀事件發生時,服務器端只會從 epoll_wait 中蘇醒一次,即使進程沒有調用 read 函數從內核讀取數據,也依然只蘇醒一次,因此 我們程序要保證一次性將內核緩沖區的數據讀取完【驛站只發一條短信不會發第二條第三條讓你去取快遞的模式就叫邊緣觸發】。
- Linux 系統中的 select、poll、epoll等系統調用都是 I/O 多路復用的機制。(IO multiplexing就是我們常說的select,poll,epoll,有些地方也稱這種IO方式為event driven IO。)
- select/poll/epol獲取網絡事件的過程:在獲取事件時,先把我們要關心的連接傳給內核,再由內核檢測:
- 如果沒有事件發生,線程只需阻塞在這個系統調用,而無需像線程池那樣輪訓調用 read 操作來判斷是否有數據
- 如果有事件發生,內核會返回產生了事件的連接,線程就會從阻塞狀態返回,然后在用戶態中再處理這些連接對應的業務即可。
- select/epoll的好處就在于單個process就可以同時處理多個網絡連接的IO。它的基本原理就是select,poll,epoll這些個function會不斷的輪詢所負責的所有socket,當某個socket有數據到達了,就通知用戶進程
- 目前支持 IO 多路復用的系統調用有 select,epoll 等等。select 系統調用,目前幾乎在所有的操作系統上都有支持。
- select 調用 :內核提供的系統調用,select 它支持一次查詢多個系統調用的可用狀態。幾乎所有的操作系統都支持
- 應用進程通過調用select函數,可以同時監控多個fd,在select函數監控的fd中,只要有任何一個數據狀態準備就緒了,select函數就會返回可讀狀態,這時應用進程再發起recvfrom請求去讀取數據。
- epoll 調用 :linux 2.6 內核,epoll 屬于 select 調用的增強版本,優化了 IO 的執行效率
- select 調用 :內核提供的系統調用,select 它支持一次查詢多個系統調用的可用狀態。幾乎所有的操作系統都支持
- 目前支持 IO 多路復用的系統調用有 select,epoll 等等。select 系統調用,目前幾乎在所有的操作系統上都有支持。
- select、poll 和 epoll 之間的區別:(select,poll,epoll 都是 IO 多路復用的機制, select,poll,epoll 本質上都是同步 I/O,因為他們都需要在讀寫事件就緒后自己負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步 I/O 則無需自己負責進行讀寫,異步 I/O 的實現會負責把數據從內核拷貝到用戶空間。)
- select:【多個網絡連接的 IO 可以注冊到一個復用器(select)上,當用戶進程調用了 select復用器,那么整個進程會被阻塞。同時,內核會“監視”所有 select復用器 負責的 socket,當任何一個 socket 中的數據準備好了,select 復用器就會返回再從內核中拿數據。這個時候用戶進程再調用 read 操作,將數據從內核中拷貝到用戶進程。用戶可以注冊多個 socket,然后不斷地調用 select 讀取被激活的 socket,即可達到在同一個線程內同時處理多個 IO 請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。好比我們去餐廳吃飯,這次我們是幾個人一起去的,我們專門留了一個人在餐廳排號等位,其他人就去逛街了,等排號的朋友通知我們可以吃飯了,我們就直接去享用了。】
- 時間復雜度 O(n)。
- select/poll 只有水平觸發模式
- select 僅僅知道有 I/O 事件發生,但 并不知道是哪幾個流,所以 只能無差別輪詢所有流,找出能讀出數據或者寫入數據的流,并對其進行操作。所以 select 具有 O(n) 的無差別輪詢復雜度,同時處理的流越多,無差別輪詢時間就越長。除了這個,select還有幾個缺點:
- poll:因為存在連接數限制,所以后來又提出了poll。與select相比,poll解決了連接數限制問題。但是呢,select和poll一樣,還是需要通過遍歷文件描述符來獲取已經就緒的socket。如果同時連接的大量客戶端,在一時刻可能只有極少處于就緒狀態,伴隨著監視的描述符數量的增長,效率也會線性下降。
- 時間復雜度 O(n)
- select/poll 只有水平觸發模式
- poll 本質上和 select 沒有區別,它將用戶傳入的數組拷貝到內核空間,然后**查詢每個 fd 對應的設備狀態, 但是它沒有最大連接數的限制**,原因是它是基于鏈表來存儲的。
- epoll:為了解決select/poll存在的問題,多路復用模型epoll誕生,epoll采用事件驅動來實現【epoll先通過epoll_ctl()來注冊一個fd(文件描述符),一旦基于某個fd就緒時,內核會采用回調機制,迅速激活這個fd,當進程調用epoll_wait()時便得到通知。這里去掉了遍歷文件描述符的坑爹操作,而是采用監聽事件回調的機制。這就是epoll的亮點。】int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)int epfd = epoll_create(...);//先用e poll_create 創建一個 epoll對象 epfd
epoll_ctl(epfd, ...); //再通過 epoll_ctl 將所有需要監聽的socket添加到epfd中。epoll 在內核里使用紅黑樹來跟蹤進程所有待檢測的文件描述字,把需要監控的 socket 通過 epoll_ctl() 函數加入內核中的紅黑樹里while(1) {int n = epoll_wait(...);//最后調用 epoll_wait 等待數據。epoll 使用事件驅動的機制,內核里維護了一個鏈表來記錄就緒事件,當某個 socket 有事件發生時,通過回調函數內核會將其加入到這個就緒事件列表中,當用戶調用 epoll_wait() 函數時,只會返回有事件發生的文件描述符的個數,不需要像 select/poll 那樣輪詢掃描整個 socket 集合,大大提高了檢測的效率。for(接收到數據的socket){//處理}
}
- 時間復雜度 O(1)
- epoll 可以理解為 event poll,不同于忙輪詢和無差別輪詢,epoll 會把哪個流發生了怎樣的 I/O 事件通知我們。所以說 epoll 實際上是事件驅動(每個事件關聯上 fd)的。
- epoll 默認的觸發模式是水平觸發,但是可以根據應用場景設置為邊緣觸發模式。
- 經典的 C10K 問題:如果服務器的內存只有 2 GB,網卡是千兆的,能支持并發 1 萬請求嗎【單機同時處理 1 萬個請求的問題。】從硬件資源角度看,對于 2GB 內存千兆網卡的服務器,如果每個請求處理占用不到 200KB 的內存和 100Kbit 的網絡帶寬就可以滿足并發 1 萬個請求。不過,要想真正實現 C10K 的服務器,要考慮的地方在于服務器的網絡 I/O 模型,效率低的模型,會加重系統開銷。基于進程或者線程模型的,其實還是有問題的。新到來一個 TCP 連接,就需要分配一個進程或者線程,那么如果要達到 C10K,意味著要一臺機器維護 1 萬個連接,相當于要維護 1 萬個進程/線程,操作系統就算死扛也是扛不住的。
- select:【多個網絡連接的 IO 可以注冊到一個復用器(select)上,當用戶進程調用了 select復用器,那么整個進程會被阻塞。同時,內核會“監視”所有 select復用器 負責的 socket,當任何一個 socket 中的數據準備好了,select 復用器就會返回再從內核中拿數據。這個時候用戶進程再調用 read 操作,將數據從內核中拷貝到用戶進程。用戶可以注冊多個 socket,然后不斷地調用 select 讀取被激活的 socket,即可達到在同一個線程內同時處理多個 IO 請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。好比我們去餐廳吃飯,這次我們是幾個人一起去的,我們專門留了一個人在餐廳排號等位,其他人就去逛街了,等排號的朋友通知我們可以吃飯了,我們就直接去享用了。】
- select/poll/epol獲取網絡事件的過程:在獲取事件時,先把我們要關心的連接傳給內核,再由內核檢測:
- IO多路復用適用如下場合:
- 多路復用 IO 是在高并發場景中使用最為廣泛的一種 IO 模型,如 Java 的 NIO、Redis、Nginx 的底層實現就是此類 IO 模型的應用,經典的 Reactor 模式也是基于此類 IO 模型。
- 最常用的I/O事件通知機制就是I/O復用(I/O multiplexing)。Linux 環境中使用select/poll/epoll 實現I/O復用,I/O復用接口本身是阻塞的,在應用程序中通過I/O復用接口向內核注冊fd所關注的事件,當關注事件觸發時,通過I/O復用接口的返回值通知到應用程序。
- 以recv為例。I/O復用接口可以同時監聽多個I/O事件以提高事件處理效率。
- 以recv為例。I/O復用接口可以同時監聽多個I/O事件以提高事件處理效率。
- 當客戶處理多個描述字時(一般是交互式輸入和網絡套接口),必須使用I/O復用
- 當一個客戶同時處理多個套接口時,而這種情況是可能的,但很少出現
- 如果一個TCP服務器既要處理監聽套接口,又要處理已連接套接口,一般也要用到I/O復用
- 如果一個服務器即要處理TCP,又要處理UDP,一般要使用I/O復用
- 如果一個服務器要處理多個服務或多個協議,一般要使用I/O復用
- 與多進程和多線程技術相比,I/O多路復用技術的最大優勢是系統開銷小,系統不必創建進程/線程,也不必維護這些進程/線程,從而大大減小了系統的開銷
- 當用戶進程調用了select,那么整個進程會被block,而同時,kernel會“監視”所有select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。所以,I/O 多路復用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函數就可以返回。這個圖和blocking IO的圖其實并沒有太大的不同,事實上因為IO多路復用多了添加監視 socket,以及調用 select 函數的額外操作,效率更差。還更差一些。因為這里需要使用兩個system call (select 和 recvfrom),而blocking IO只調用了一個system call (recvfrom)。但是,使用 select 以后最大的優勢是用戶可以在一個線程內同時處理多個 socket 的 I/O 請求。用戶可以注冊多個 socket,然后不斷地調用 select 讀取被激活的 socket,即可達到在同一個線程內同時處理多個 I/O 請求的目的。而在同步阻塞模型中,必須通過多線程的方式才能達到這個目的。所以,如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優勢并不是對于單個連接能處理得更快,而是在于能處理更多的連接。)在IO multiplexing Model中,實際中,對于每一個socket,一般都設置成為non-blocking,但是,如上圖所示,整個用戶的process其實是一直被block的。只不過process是被select這個函數block,而不是被socket IO給block。
- 因此對于IO多路復用模型來說:
- 等待數據準備就緒 (Waiting for the data to be ready) 「阻塞」
- 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process) 「阻塞」
- 在I/O編程過程中,當需要同時處理多個客戶端接入請求時,可以利用多線程或者I/O多路復用技術進行處理。I/O多路復用技術通過把多個I/O的阻塞復用到同一個select的阻塞上,從而使得系統在單線程的情況下可以同時處理多個客戶端請求。
- Unix 常見的五種IO模型之四:異步非阻塞 I/O(asynchronous IO)AIO:linux下的asynchronous IO的流程
- POSIX規范定義了一組異步操作I/O的接口,不用關心fd 是阻塞還是非阻塞,異步I/O是由內核接管應用層對fd的I/O操作。異步I/O向應用層通知I/O操作完成的事件,這與前面介紹的I/O 復用模型、SIGIO模型通知事件就緒的方式明顯不同。以aio_read 實現異步讀取IO數據為例,在等待I/O操作完成期間,不會阻塞應用程序。
- 異步其實之前咱們就接觸過:通常,我們會有一個線程池用于執行異步任務,提交任務的線程將任務提交到線程池就可以立馬返回,不必等到任務真正完成。如果想要知道任務的執行結果,通常是通過傳遞一個回調函數的方式,任務結束后去調用這個函數。同樣的原理,Java 中的異步 IO 也是一樣的,都是由一個線程池來負責執行任務,然后使用回調或自己去查詢結果。異步 IO 主要是為了控制線程數量,減少過多的線程帶來的內存消耗和 CPU 在線程調度上的開銷。
- 異步 IO 一定存在一個線程池,這個線程池負責接收任務、處理 IO 事件、回調等。這個線程池就在 group 內部【AsynchronousChannelGroup 這個類】,group 一旦關閉,那么相應的線程池就會關閉。AsynchronousServerSocketChannels 和 AsynchronousSocketChannels 是屬于 group 的,當我們調用 AsynchronousServerSocketChannel 或 AsynchronousSocketChannel 的 open() 方法的時候,相應的 channel 就屬于默認的 group,這個 group 由 JVM 自動構造并管理。
- 配置這個默認的 group,可以在 JVM 啟動參數中指定以下系統變量:
- 使用自己定義的 group,這樣可以對其中的線程進行更多的控制,使用以下幾個方法:
- group 的使用:
- 配置這個默認的 group,可以在 JVM 啟動參數中指定以下系統變量:
- AsynchronousFileChannels 不屬于 group。但是它們也是關聯到一個線程池的,如果不指定,會使用系統默認的線程池,如果想要使用指定的線程池,可以在實例化的時候使用以下方法:
- 異步 IO 一定存在一個線程池,這個線程池負責接收任務、處理 IO 事件、回調等。這個線程池就在 group 內部【AsynchronousChannelGroup 這個類】,group 一旦關閉,那么相應的線程池就會關閉。AsynchronousServerSocketChannels 和 AsynchronousSocketChannels 是屬于 group 的,當我們調用 AsynchronousServerSocketChannel 或 AsynchronousSocketChannel 的 open() 方法的時候,相應的 channel 就屬于默認的 group,這個 group 由 JVM 自動構造并管理。
- 異步其實之前咱們就接觸過:通常,我們會有一個線程池用于執行異步任務,提交任務的線程將任務提交到線程池就可以立馬返回,不必等到任務真正完成。如果想要知道任務的執行結果,通常是通過傳遞一個回調函數的方式,任務結束后去調用這個函數。同樣的原理,Java 中的異步 IO 也是一樣的,都是由一個線程池來負責執行任務,然后使用回調或自己去查詢結果。異步 IO 主要是為了控制線程數量,減少過多的線程帶來的內存消耗和 CPU 在線程調度上的開銷。
- AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改進版 NIO 2,它是異步 IO 模型。【JDK 7 對原有的 NIO 進行了改進。第一個改進是提供了全面的文件 I/O 相關 API。第二個改進是增加了異步的基于 Channel 的 IO 機制】
- 異步 IO 是基于事件和回調機制實現的,也就是應用操作之后會直接返回,不會堵塞在那里,當后臺處理完成,操作系統會通知相應的線程進行后續的操作。
- 異步 IO 是基于事件和回調機制實現的,也就是應用操作之后會直接返回,不會堵塞在那里,當后臺處理完成,操作系統會通知相應的線程進行后續的操作。
- 用戶進程發起aio_read調用之后,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它發現一個asynchronous read之后,首先它會立刻返回,所以不會對用戶進程產生任何block。然后,kernel會等待數據準備完成,然后將數據拷貝到用戶內存,當這一切都完成之后,kernel會給用戶進程發送一個signal,告訴它read操作完成了
- 異步 I/O 模型使用了 Proactor 設計模式實現了這一機制。因此對異步IO模型來說:
- 等待數據準備就緒 (Waiting for the data to be ready) 「非阻塞」
- 將數據從內核拷貝到進程中 (Copying the data from the kernel to the process) 「非阻塞」
- 異步 I/O 模型使用了 Proactor 設計模式實現了這一機制。因此對異步IO模型來說:
- AIO:JDK1.7引入NIO2.0,提供了異步文件通道和異步套接字通道的實現。其底層在Windows上是通過IOCP實現,在Linux上是通過IO復用Epoll來模擬實現的。在JAVA NIO框架中,Selector它負責代替應用查詢中所有已注冊的通道到操作系統中進行IO事件輪詢、管理當前注冊的通道集合,定位發生事件的通道等操作。但是在JAVA AIO框架中,由于應用程序不是輪詢方式,而是訂閱-通知方式,所以不再需要Selector(選擇器)了,改由Channel通道直接到操作系統注冊監聽 。【More New IO,或稱 NIO.2,隨 JDK 1.7 發布,包括了引入異步 IO 接口和 Paths 等文件訪問接口。】
- JAVA AIO框架中,只實現了兩種網絡IO通道:
- AsynchronousServerSocketChannel(服務器監聽通道)
- 這個類對應的是非阻塞 IO 的 ServerSocketChannel。
- 用回調函數的方式寫一個簡單的服務端:
- ChannelHandler 類
- 自定義的 Attachment 類:
- 接下來可以接收客戶端請求了
- ChannelHandler 類
- AsynchronousSocketChannel(Socket套接字通道)
- 使用 AsynchronousSocketChannel 的方式和非阻塞 IO 基本類似。
- AsynchronousFileChannel:異步的文件 IO
- 文件 IO 在所有的操作系統中都不支持非阻塞模式,但是我們可以對文件 IO 采用異步的方式來提高性能。
- AsynchronousFileChannel 里面的一些重要的接口:
- AsynchronousServerSocketChannel(服務器監聽通道)
- Java 異步 IO 提供了兩種使用方式,分別是 返回 java.util.concurrent.Future 實例和使用CompletionHandler 回調函數。
- 返回 java.util.concurrent.Future 實例:JDK 線程池就是這么使用的
- Future 接口的幾個方法語義:
- Future 接口的幾個方法語義:
- 提供 CompletionHandler 回調函數:
- java.nio.channels.CompletionHandler 接口定義:
- java.nio.channels.CompletionHandler 接口定義:
- 返回 java.util.concurrent.Future 實例:JDK 線程池就是這么使用的
- JAVA AIO框架中,只實現了兩種網絡IO通道:
- 異步 I/O 并沒有涉及到 PageCache,所以使用異步 I/O 就意味著要繞開 PageCache。繞開 PageCache 的 I/O 叫 直接 I/O,使用 PageCache 的 I/O 則叫緩存 I/O。通常,對于磁盤,異步 I/O 只支持直接 I/O。在高并發的場景下,針對大文件的傳輸的方式,應該使用「異步 I/O + 直接 I/O」來替代零拷貝技術就可以無阻塞地讀取文件了。
- 傳輸文件的時候,我們要根據文件的大小來使用不同的方式:
- 傳輸 大文件 的時候,使用「異步 I/O + 直接 I/O」
- 傳輸小文件的時候,則使用「零拷貝技術」
- 傳輸文件的時候,我們要根據文件的大小來使用不同的方式:
- POSIX規范定義了一組異步操作I/O的接口,不用關心fd 是阻塞還是非阻塞,異步I/O是由內核接管應用層對fd的I/O操作。異步I/O向應用層通知I/O操作完成的事件,這與前面介紹的I/O 復用模型、SIGIO模型通知事件就緒的方式明顯不同。以aio_read 實現異步讀取IO數據為例,在等待I/O操作完成期間,不會阻塞應用程序。
- Unix 常見的五種IO模型之五:信號驅動式IO模型(signal-driven IO model)
- 除了I/O復用方式通知I/O事件,還可以通過SIGIO信號來通知I/O事件。兩者不同的是,在等待數據達到期間,I/O復用是會阻塞應用程序,而SIGIO方式是不會阻塞應用程序的。
- 首先我們允許 socket 進行信號驅動 I/O,并安裝一個信號處理函數,進程繼續運行并不阻塞。當數據準備好時,進程會收到一個SIGIO信號,可以在信號處理函數中調用 I/O 操作函數處理數據。
- 除了I/O復用方式通知I/O事件,還可以通過SIGIO信號來通知I/O事件。兩者不同的是,在等待數據達到期間,I/O復用是會阻塞應用程序,而SIGIO方式是不會阻塞應用程序的。
- Unix 常見的五種IO模型之一:同步阻塞式IO模型BIO(blocking IO model):在linux中,默認情況下所有的IO操作都是blocking
- 無論是阻塞 I/O、非阻塞 I/O,還是基于非阻塞 I/O 的多路復用以及信號驅動都是同步調用。因為 它們在 read 調用時,內核將數據從內核空間拷貝到應用程序空間,過程都是需要等待的,也就是說這個過程是同步的,如果內核實現的拷貝效率不高,read 調用就會在這個同步過程中等待比較長的時間
-
零拷貝技術:上面咱們看到了 應用進程的一次完整的讀寫操作,都需要在用戶空間與內核空間中來回拷貝,并且每一次拷貝,都需要 CPU 進行一次上下文切換(由用戶進程切換到系統內核,或由系統內核切換到用戶進程),這樣是不是很浪費 CPU 和性能呢?那有沒有什么方式,可以減少進程間的數據拷貝,提高數據傳輸的效率呢?========零拷貝技術【零拷貝是指計算機執行IO操作時,CPU不需要將數據從一個存儲區域復制到另一個存儲區域,從而可以減少上下文切換以及CPU的拷貝時間。它是一種I/O操作優化技術。取消用戶空間與內核空間之間的數據拷貝操作,應用進程每一次的讀寫操作,都可以通過一種方式,讓應用進程直接向用戶空間寫入或者讀取數據,(效果就如同直接向內核空間寫入或者讀取數據一樣,然后再通過 DMA 將內核中的數據拷貝到網卡,或將網卡中的數據 copy 到內核)】
- 傳統IO的讀寫流程,包括了4次上下文切換(4次用戶態和內核態的切換),4次數據拷貝(兩次CPU拷貝以及兩次的DMA拷貝)。零拷貝只是減少了用戶態/內核態的切換次數以及CPU拷貝的次數
- 升級到epoll后,為了溝通有沒有數據還是得用戶態內核態把fd相關數據拷來拷去,所以為了減少拷貝的次數,并且用mmap這個系統調用實現了一個用戶態和內核態共享的空間,
- 是不是用戶空間與內核空間都將數據寫到一個地方,就不需要拷貝了?此時有沒有想到虛擬內存?零拷貝有兩種解決方式,分別是 mmap+write 方式和 sendfile 方式,mmap+write 方式的核心原理就是通過虛擬內存來解決的
- 實現零拷貝的兩種方式:
- mmap + write:
- mmap就是用了虛擬內存這個特點,mmap將內核中的讀緩沖區與用戶空間的緩沖區進行映射,以減少數據拷貝次數!
- mmap+write實現的零拷貝,I/O發生了4次用戶空間與內核空間的上下文切換,以及3次數據拷貝(包括了2次DMA拷貝和1次CPU拷貝)。
- mmap+write實現的零拷貝,I/O發生了4次用戶空間與內核空間的上下文切換,以及3次數據拷貝(包括了2次DMA拷貝和1次CPU拷貝)。
- read() 系統調用的過程中會把內核緩沖區的數據拷貝到用戶的緩沖區里,于是為了減少這一步開銷,我們可以用 mmap() 替換 read() 系統調用函數。mmap() 系統調用函數會直接把內核緩沖區里的數據映射到用戶空間,這樣,操作系統內核與用戶空間就不需要再進行任何的數據拷貝操作。
- mmap就是用了虛擬內存這個特點,mmap將內核中的讀緩沖區與用戶空間的緩沖區進行映射,以減少數據拷貝次數!
- sendfile
- 在 Linux 內核版本 2.1 中,提供了一個專門發送文件的系統調用函數 sendfile():ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它的前兩個參數分別是目的端和源端的文件描述符,后面兩個參數是源端的偏移量和復制數據的長度,返回值是實際復制數據的長度。首先,sendfile可以替代前面的 read() 和 write() 這兩個系統調用,這樣就可以減少一次系統調用,也就減少了 2 次上下文切換的開銷。其次,該sendfile系統調用和mmap一樣,可以直接把內核緩沖區里的數據拷貝到 socket 緩沖區里,不再拷貝到用戶態,這樣就只有 2 次上下文切換,和 3 次數據拷貝。
- sendfile實現的零拷貝,I/O發生了2次用戶空間與內核空間的上下文切換,以及3次數據拷貝。其中3次數據拷貝中和mmap+write一樣,包括了2次DMA拷貝和1次CPU拷貝。
- 帶有DMA收集拷貝功能的sendfile:但是這還不是真正的零拷貝技術,如果網卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技術(和普通的 DMA 有所不同),我們可以進一步減少通過 CPU 把內核緩沖區里的數據拷貝到 socket 緩沖區的過程。【linux 2.4版本之后,對sendfile做了優化升級,引入SG-DMA技術,SG-DMA技術其實就是對DMA拷貝加入了scatter/gather操作,它可以直接從內核空間緩沖區中將數據讀取到網卡。使用這個特點搞零拷貝,即還可以多省去一次CPU拷貝。】
- sendfile+DMA scatter/gather實現的零拷貝,I/O發生了2次用戶空間與內核空間的上下文切換,以及2次數據拷貝。其中2次數據拷貝都是包DMA拷貝。這就是真正的 零拷貝(Zero-copy) 技術,全程都沒有通過CPU來搬運數據,所有的數據都是通過DMA來進行傳輸的。
- 從 Linux 內核 2.4 版本開始起,對于支持網卡支持 SG-DMA 技術的情況下, sendfile() 系統調用的過程發生了點變化:采用了零拷貝
- sendfile+DMA scatter/gather實現的零拷貝,I/O發生了2次用戶空間與內核空間的上下文切換,以及2次數據拷貝。其中2次數據拷貝都是包DMA拷貝。這就是真正的 零拷貝(Zero-copy) 技術,全程都沒有通過CPU來搬運數據,所有的數據都是通過DMA來進行傳輸的。
- sendfile實現的零拷貝,I/O發生了2次用戶空間與內核空間的上下文切換,以及3次數據拷貝。其中3次數據拷貝中和mmap+write一樣,包括了2次DMA拷貝和1次CPU拷貝。
- Kafka 這個開源項目,就利用了零拷貝技術,從而大幅提升了 I/O 的吞吐率,這也是 Kafka 在處理海量數據為什么這么快的原因之一,它調用了 Java NIO 庫里的 transferTo 方法。如果 Linux 系統支持 sendfile() 系統調用,那么 transferTo() 實際上最后就會使用到 sendfile() 系統調用函數。
- Nginx 也支持零拷貝技術,一般默認是開啟零拷貝技術,這樣有利于提高文件傳輸的效率
- 在 Linux 內核版本 2.1 中,提供了一個專門發送文件的系統調用函數 sendfile():ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);它的前兩個參數分別是目的端和源端的文件描述符,后面兩個參數是源端的偏移量和復制數據的長度,返回值是實際復制數據的長度。首先,sendfile可以替代前面的 read() 和 write() 這兩個系統調用,這樣就可以減少一次系統調用,也就減少了 2 次上下文切換的開銷。其次,該sendfile系統調用和mmap一樣,可以直接把內核緩沖區里的數據拷貝到 socket 緩沖區里,不再拷貝到用戶態,這樣就只有 2 次上下文切換,和 3 次數據拷貝。
- mmap + write:
- 零拷貝更細致的理論點這里,然后ctrl+F,搜零拷貝
- 這個零拷貝是操作系統層面上的零拷貝,主要目標是避免用戶空間與內核空間之間的數據拷貝操作,可以提升 CPU 的利用率。
- Netty 相關的零拷貝技術
- RPC 框架在網絡通信框架的選型上,我們最優的選擇是基于 Reactor 模式實現的框架,如 Java 語言,首選的便是 Netty 框架。
- Netty 的零拷貝則不大一樣,他完全站在了用戶空間上,也就是 JVM 上,Netty 的零拷貝主要是偏向于數據操作的優化上。
- 在傳輸過程中,RPC 并不會把請求參數的所有二進制數據整體一下子發送到對端機器上,中間可能會拆分成好幾個數據包,也可能會合并其他請求的數據包,所以消息都需要有邊界。那么一端的機器收到消息之后,就需要對數據包進行處理,根據邊界對數據包進行分割和合并,最終獲得一條完整的消息。收到消息后,對數據包的分割和合并是在用戶空間【因為對數據包的處理工作都是由應用程序來處理的】。這里也是會存在拷貝操作的,但是 不是在用戶空間與內核空間之間的拷貝,是用戶空間內部內存中的拷貝處理操作。Netty 的零拷貝就是為了解決這個問題,在用戶空間對數據操作進行優化
- Netty 的零拷貝則不大一樣,他完全站在了用戶空間上,也就是 JVM 上,Netty 的零拷貝主要是偏向于數據操作的優化上。
- Netty 是怎么對數據操作進行優化的呢?
- Netty 提供了 CompositeByteBuf 類,它可以 將多個 ByteBuf 合并為一個邏輯上的 ByteBuf,避免了各個 ByteBuf 之間的拷貝【ByteBuf 支持 slice 操作,因此可以將 ByteBuf 分解為多個共享同一個存儲區域的 ByteBuf,避免了內存的拷貝】。
- 通過 wrap 操作,我們可以將 byte[] 數組、ByteBuf、ByteBuffer 等包裝成一個 Netty ByteBuf 對象, 進而避免拷貝操作。
- Netty 框架中很多內部的 ChannelHandler 實現類,都是通過 CompositeByteBuf、slice、wrap 操作來處理 TCP 傳輸中的拆包與粘包問題的。etty 的 ByteBuffer 可以采用 Direct Buffers,使用堆外直接內存進行 Socket 的讀寫操作,最終的效果與虛擬內存所實現的效果是一樣的。Netty 還提供 FileRegion 中包裝 NIO 的 FileChannel.transferTo() 方法實現了零拷貝,這與 Linux 中的 sendfile 方式在原理上也是一樣的。
- 傳統IO的讀寫流程,包括了4次上下文切換(4次用戶態和內核態的切換),4次數據拷貝(兩次CPU拷貝以及兩次的DMA拷貝)。零拷貝只是減少了用戶態/內核態的切換次數以及CPU拷貝的次數
-
除了上面基本的I/O模型之外,還有如下集中模型:
- 事件處理模型:Reactor 和Proactor兩種事件處理模型
- 網絡設計模式中,如何處理各種I/O事件是其非常重要的一部分,Reactor 和Proactor兩種事件處理模型應運而生。上面提到將I/O分為同步I/O 和 異步I/O,可以使用同步I/O實現Reactor模型,使用異步I/O實現Proactor模型。
- Reactor事件處理模型:Reactor模型是同步I/O事件處理的一種常見模型
- 一個典型的Reactor模型類圖結構
- Reactor的核心思想:將關注的I/O事件注冊到多路復用器上,一旦有I/O事件觸發,將事件分發到事件處理器中、執行就緒I/O事件對應的處理函數中。模型中有三個重要的組件:
- 多路復用器:由操作系統提供接口,Linux提供的I/O復用接口有select、poll、epoll;
- 事件分離器:將多路復用器返回的就緒事件分發到事件處理器中;
- 事件處理器:處理就緒事件處理函數
- Reactor模型工作的簡化流程:
- 一個典型的Reactor模型類圖結構
- Proactor事件處理模型:
- 與Reactor不同的是,Proactor使用異步I/O系統接口將I/O操作托管給操作系統,Proactor模型中分發處理異步I/O完成事件,并調用相應的事件處理接口來處理業務邏輯
- Proactor類結構:
- Proactor模型的簡化的工作流程:
- 同步I/O模擬Proactor:
- 并發模式:多線程、多進程的編程的模式【多進程/線程模型】
- 在I/O密集型的程序,采用并發方式可以提高CPU的使用率,可采用多進程和多線程兩種方式實現并發。當前有高效的兩種并發模式,半同步/半異步模式、Follower/Leader模式。
- 多進程/多線程模型:
- 通過這種方式確實解決了單進程 server 阻塞無法處理其他 client 請求的問題,但眾所周知 fork 創建子進程是非常耗時的,包括頁表的復制,進程切換時頁表的切換等都非常耗時,每來一個請求就創建一個進程顯然是無法接受的。為了節省進程創建的開銷,于是有人提出把多進程改成多線程,創建線程(使用 pthread_create)的開銷確實小了很多,但同樣的,線程與進程一樣,都需要占用堆棧等資源,而且碰到阻塞,喚醒等都涉及到用戶態,內核態的切換,這些都極大地消耗了性能
- 多進程/多線程模型:
- 半同步/半異步模式:
- 并發模式中的“同步”、“異步”與 I/O模型中的“同步”、“異步”是兩個不同的概念:
- 并發模式中,“同步”指程序按照代碼順序執行,“異步”指程序依賴事件驅動,如圖12 所示并發模式的“同步”執行和“異步”執行的讀操作;
- 同步讀操作示意圖
- 異步讀操作示意圖
- 同步讀操作示意圖
- I/O模型中,“同步”、“異步”用來區分I/O操作的方式,是主動通過I/O操作拿到結果,還是由內核異步的返回操作結果。
- 并發模式中,“同步”指程序按照代碼順序執行,“異步”指程序依賴事件驅動,如圖12 所示并發模式的“同步”執行和“異步”執行的讀操作;
- 半同步/半異步工作流程
- 半同步/半反應堆模式
- 考慮將兩種事件處理模型,即Reactor和Proactor,與幾種I/O模型結合在一起,那么半同步/半異步模式就演變為半同步/半反應堆模式。
- 使用Reactor的方式:
- 工作流程:
- 工作流程:
- 將Reactor替換為Proactor
- 工作流程:
- 工作流程:
- 半同步/半反應堆模式有明顯的缺點:
- 半同步/半反應堆模式的演變模式:
- 考慮將兩種事件處理模型,即Reactor和Proactor,與幾種I/O模型結合在一起,那么半同步/半異步模式就演變為半同步/半反應堆模式。
- 并發模式中的“同步”、“異步”與 I/O模型中的“同步”、“異步”是兩個不同的概念:
- Follower/Leader模式
- Follower/Leader是多個工作線程輪流進行事件監聽、事件分發、處理事件的模式。在Follower/Leader模式工作的任何一個時間點,只有一個工作線程處理成為Leader ,負責I/O事件監聽,而其他線程都是Follower,并等待成為Leader。
- Leader/Follow模式的工作線程的三種狀態的轉移關系
- Follower/Leader是多個工作線程輪流進行事件監聽、事件分發、處理事件的模式。在Follower/Leader模式工作的任何一個時間點,只有一個工作線程處理成為Leader ,負責I/O事件監聽,而其他線程都是Follower,并等待成為Leader。
- 在I/O密集型的程序,采用并發方式可以提高CPU的使用率,可采用多進程和多線程兩種方式實現并發。當前有高效的兩種并發模式,半同步/半異步模式、Follower/Leader模式。
- Swoole異步網絡模型分析
- 事件處理模型:Reactor 和Proactor兩種事件處理模型
巨人的肩膀:
Linux網絡編程
B站OS課程各位老師
操作系統概論
https://xiaolincoding.com/
很好的一篇文章,既講了Java 中 IO 相關的理論知識,并通過多個代碼案例加深了理解。很贊
https://learn.lianglianglee.com/%E6%96%87%E7%AB%A0/Java%20NIO%E6%B5%85%E6%9E%90.md
Javadoop
javaGuide
CS-Note
清華大學OS課
在 Windows 操作系統中,提供了一個叫做 I/O Completion Ports 的方案,通常簡稱為 IOCP,操作系統負責管理線程池,其性能非常優異,所以在 Windows 中 JDK 直接采用了 IOCP 的支持,使用系統支持,把更多的操作信息暴露給操作系統,也使得操作系統能夠對我們的 IO 進行一定程度的優化。
程序員田螺老師的零拷貝詳解
總結
以上是生活随笔為你收集整理的java基础巩固-宇宙第一AiYWM:为了维持生计,四大基础之OS_Part_2整起~IO们那些事【包括五种IO模型:(BIO、NIO、IO多路复用、信号驱动、AIO);零拷贝、事件处理及并发等模型】的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python 编程笔记(本人出品,必属精
- 下一篇: 想在社会上混 就记住这20句