一文搞懂什么是阻塞IO、信號驅(qū)動IO、Reactor模型、零拷貝
基礎(chǔ)IO
如何從數(shù)據(jù)傳輸方式理解IO流?
從數(shù)據(jù)傳輸方式或者說是運輸方式角度看,可以將 IO 類分為:
- 字節(jié)流, 字節(jié)流讀取單個字節(jié),字符流讀取單個字符(一個字符根據(jù)編碼的不同,對應的字節(jié)也不同,如 UTF-8 編碼中文漢字是 3 個字節(jié),GBK編碼中文漢字是 2 個字節(jié)。)
- 字符流, 字節(jié)流用來處理二進制文件(圖片、MP3、視頻文件),字符流用來處理文本文件(可以看做是特殊的二進制文件,使用了某種編碼,人可以閱讀)。
字節(jié)是給計算機看的,字符才是給人看的
- 字節(jié)流
圖片
image.png
- 字符流
圖片
- 字節(jié)轉(zhuǎn)字符?
圖片
如何從數(shù)據(jù)操作上理解IO流?
從數(shù)據(jù)來源或者說是操作對象角度看,IO 類可以分為:
圖片
Java IO設(shè)計上使用了什么設(shè)計模式?
裝飾者模式:所謂裝飾,就是把這個裝飾者套在被裝飾者之上,從而動態(tài)擴展被裝飾者的功能。
- 裝飾者舉例
設(shè)計不同種類的飲料,飲料可以添加配料,比如可以添加牛奶,并且支持動態(tài)添加新配料。每增加一種配料,該飲料的價格就會增加,要求計算一種飲料的價格。
下圖表示在 DarkRoast 飲料上新增新添加 Mocha 配料,之后又添加了 Whip 配料。DarkRoast 被 Mocha 包裹,Mocha 又被 Whip 包裹。它們都繼承自相同父類,都有 cost() 方法,外層類的 cost() 方法調(diào)用了內(nèi)層類的 cost() 方法。
圖片
image.png
- 以 InputStream 為例
- InputStream 是抽象組件;
- FileInputStream 是 InputStream 的子類,屬于具體組件,提供了字節(jié)流的輸入操作;
- FilterInputStream 屬于抽象裝飾者,裝飾者用于裝飾組件,為組件提供額外的功能。例如 BufferedInputStream 為 FileInputStream 提供緩存的功能。
圖片
image.png
實例化一個具有緩存功能的字節(jié)流對象時,只需要在 FileInputStream 對象上再套一層 BufferedInputStream 對象即可。
FileInputStream fileInputStream = new FileInputStream(filePath);
BufferedInputStream bufferedInputStream = new BufferedInputStream(fileInputStream);
DataInputStream 裝飾者提供了對更多數(shù)據(jù)類型進行輸入的操作,比如 int、double 等基本類型。
5種IO模型
什么是阻塞?什么是同步?
- 阻塞IO 和 非阻塞IO
這兩個概念是程序級別的。主要描述的是程序請求操作系統(tǒng)IO操作后,如果IO資源沒有準備好,那么程序該如何處理的問題: 前者等待;后者繼續(xù)執(zhí)行(并且使用線程一直輪詢,直到有IO資源準備好了)
- 同步IO 和 非同步IO
這兩個概念是操作系統(tǒng)級別的。主要描述的是操作系統(tǒng)在收到程序請求IO操作后,如果IO資源沒有準備好,該如何響應程序的問題: 前者不響應,直到IO資源準備好以后;后者返回一個標記(好讓程序和自己知道以后的數(shù)據(jù)往哪里通知),當IO資源準備好以后,再用事件機制返回給程序。
什么是Linux的IO模型?
網(wǎng)絡IO的本質(zhì)是socket的讀取,socket在linux系統(tǒng)被抽象為流,IO可以理解為對流的操作。剛才說了,對于一次IO訪問(以read舉例),數(shù)據(jù)會先被拷貝到操作系統(tǒng)內(nèi)核的緩沖區(qū)中,然后才會從操作系統(tǒng)內(nèi)核的緩沖區(qū)拷貝到應用程序的地址空間。所以說,當一個read操作發(fā)生時,它會經(jīng)歷兩個階段:
- 第一階段:等待數(shù)據(jù)準備 (Waiting for the data to be ready)。
- 第二階段:將數(shù)據(jù)從內(nèi)核拷貝到進程中 (Copying the data from the kernel to the process)。
對于socket流而言,
- 第一步:通常涉及等待網(wǎng)絡上的數(shù)據(jù)分組到達,然后被復制到內(nèi)核的某個緩沖區(qū)。
- 第二步:把數(shù)據(jù)從內(nèi)核緩沖區(qū)復制到應用進程緩沖區(qū)。
網(wǎng)絡應用需要處理的無非就是兩大類問題,網(wǎng)絡IO,數(shù)據(jù)計算。相對于后者,網(wǎng)絡IO的延遲,給應用帶來的性能瓶頸大于后者。網(wǎng)絡IO的模型大致有如下幾種:
- 同步阻塞IO(bloking IO)
- 同步非阻塞IO(non-blocking IO)
- 多路復用IO(multiplexing IO)
- 信號驅(qū)動式IO(signal-driven IO)
- 異步IO(asynchronous IO)
圖片
什么是同步阻塞IO?
應用進程被阻塞,直到數(shù)據(jù)復制到應用進程緩沖區(qū)中才返回。
- 舉例理解
你早上去買有現(xiàn)炸油條,你點單,之后一直等店家做好,期間你啥其它事也做不了。(你就是應用級別,店家就是操作系統(tǒng)級別, 應用被阻塞了不能做其它事)
- Linux 中IO圖例
圖片
image.png
什么是同步非阻塞IO?
應用進程執(zhí)行系統(tǒng)調(diào)用之后,內(nèi)核返回一個錯誤碼。應用進程可以繼續(xù)執(zhí)行,但是需要不斷的執(zhí)行系統(tǒng)調(diào)用來獲知 I/O 是否完成,這種方式稱為輪詢(polling)。
- 舉例理解
你早上去買現(xiàn)炸油條,你點單,點完后每隔一段時間詢問店家有沒有做好,期間你可以做點其它事情。(你就是應用級別,店家就是操作系統(tǒng)級別,應用可以做其它事情并通過輪詢來看操作系統(tǒng)是否完成)
- Linux 中IO圖例
圖片
image.png
什么是多路復用IO?
系統(tǒng)調(diào)用可能是由多個任務組成的,所以可以拆成多個任務,這就是多路復用。
- 舉例理解
你早上去買現(xiàn)炸油條,點單收錢和炸油條原來都是由一個人完成的,現(xiàn)在他成了瓶頸,所以專門找了個收銀員下單收錢,他則專注在炸油條。(本質(zhì)上炸油條是耗時的瓶頸,將他職責分離出不是瓶頸的部分,比如下單收銀,對應到系統(tǒng)級別也時一樣的意思)
- Linux 中IO圖例
使用 select 或者 poll 等待數(shù)據(jù),并且可以等待多個套接字中的任何一個變?yōu)榭勺x,這一過程會被阻塞,當某一個套接字可讀時返回。之后再使用 recvfrom 把數(shù)據(jù)從內(nèi)核復制到進程中。
它可以讓單個進程具有處理多個 I/O 事件的能力。又被稱為 Event Driven I/O,即事件驅(qū)動 I/O。
圖片
有哪些多路復用IO?
目前流程的多路復用IO實現(xiàn)主要包括四種: select、poll、epoll、kqueue。下表是他們的一些重要特性的比較:
IO模型 | 相對性能 | 關(guān)鍵思路 | 操作系統(tǒng) | JAVA支持情況 |
select | 較高 | Reactor | windows/Linux | 支持,Reactor模式(反應器設(shè)計模式)。Linux操作系統(tǒng)的 kernels 2.4內(nèi)核版本之前,默認使用select;而目前windows下對同步IO的支持,都是select模型 |
poll | 較高 | Reactor | Linux | Linux下的JAVA NIO框架,Linux kernels 2.6內(nèi)核版本之前使用poll進行支持。也是使用的Reactor模式 |
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6內(nèi)核版本及以后使用epoll進行支持;Linux kernels 2.6內(nèi)核版本之前使用poll進行支持;另外一定注意,由于Linux下沒有Windows下的IOCP技術(shù)提供真正的 異步IO 支持,所以Linux下使用epoll模擬異步IO |
kqueue | 高 | Proactor | Linux | 目前JAVA的版本不支持 |
多路復用IO技術(shù)最適用的是“高并發(fā)”場景,所謂高并發(fā)是指1毫秒內(nèi)至少同時有上千個連接請求準備好。其他情況下多路復用IO技術(shù)發(fā)揮不出來它的優(yōu)勢。另一方面,使用JAVA NIO進行功能實現(xiàn),相對于傳統(tǒng)的Socket套接字實現(xiàn)要復雜一些,所以實際應用中,需要根據(jù)自己的業(yè)務需求進行技術(shù)選擇。
什么是信號驅(qū)動IO?
應用進程使用 sigaction 系統(tǒng)調(diào)用,內(nèi)核立即返回,應用進程可以繼續(xù)執(zhí)行,也就是說等待數(shù)據(jù)階段應用進程是非阻塞的。內(nèi)核在數(shù)據(jù)到達時向應用進程發(fā)送 SIGIO 信號,應用進程收到之后在信號處理程序中調(diào)用 recvfrom 將數(shù)據(jù)從內(nèi)核復制到應用進程中。
相比于非阻塞式 I/O 的輪詢方式,信號驅(qū)動 I/O 的 CPU 利用率更高。
- 舉例理解
你早上去買現(xiàn)炸油條,門口排隊的人多,現(xiàn)在引入了一個叫號系統(tǒng),點完單后你就可以做自己的事情了,然后等叫號就去拿就可以了。(所以不用再去自己頻繁跑去問有沒有做好了)
- Linux 中IO圖例
圖片
image.png
什么是異步IO?
相對于同步IO,異步IO不是順序執(zhí)行。用戶進程進行aio_read系統(tǒng)調(diào)用之后,無論內(nèi)核數(shù)據(jù)是否準備好,都會直接返回給用戶進程,然后用戶態(tài)進程可以去做別的事情。等到socket數(shù)據(jù)準備好了,內(nèi)核直接復制數(shù)據(jù)給進程,然后從內(nèi)核向進程發(fā)送通知。IO兩個階段,進程都是非阻塞的。
- 舉例理解
你早上去買現(xiàn)炸油條, 不用去排隊了,打開美團外賣下單,然后做其它事,一會外賣自己送上門。(你就是應用級別,店家就是操作系統(tǒng)級別, 應用無需阻塞,這就是非阻塞;系統(tǒng)還可能在處理中,但是立刻響應了應用,這就是異步)
- Linux 中IO圖例
(Linux提供了AIO庫函數(shù)實現(xiàn)異步,但是用的很少。目前有很多開源的異步IO庫,例如libevent、libev、libuv)
圖片
什么是Reactor模型?
大多數(shù)網(wǎng)絡框架都是基于Reactor模型進行設(shè)計和開發(fā),Reactor模型基于事件驅(qū)動,特別適合處理海量的I/O事件。
- 傳統(tǒng)的IO模型?
這種模式是傳統(tǒng)設(shè)計,每一個請求到來時,大致都會按照:請求讀取->請求解碼->服務執(zhí)行->編碼響應->發(fā)送答復 這個流程去處理。
圖片
服務器會分配一個線程去處理,如果請求暴漲起來,那么意味著需要更多的線程來處理該請求。若請求出現(xiàn)暴漲,線程池的工作線程數(shù)量滿載那么其它請求就會出現(xiàn)等待或者被拋棄。若每個小任務都可以使用非阻塞的模式,然后基于異步回調(diào)模式。這樣就大大提高系統(tǒng)的吞吐量,這便引入了Reactor模型。
- Reactor模型中定義的三種角色:
- Reactor:負責監(jiān)聽和分配事件,將I/O事件分派給對應的Handler。新的事件包含連接建立就緒、讀就緒、寫就緒等。
- Acceptor:處理客戶端新連接,并分派請求到處理器鏈中。
- Handler:將自身與事件綁定,執(zhí)行非阻塞讀/寫任務,完成channel的讀入,完成處理業(yè)務邏輯后,負責將結(jié)果寫出channel??捎觅Y源池來管理。
- 單Reactor單線程模型
Reactor線程負責多路分離套接字,accept新連接,并分派請求到handler。Redis使用單Reactor單進程的模型。
圖片
image.png
消息處理流程:
- Reactor對象通過select監(jiān)控連接事件,收到事件后通過dispatch進行轉(zhuǎn)發(fā)。
- 如果是連接建立的事件,則由acceptor接受連接,并創(chuàng)建handler處理后續(xù)事件。
- 如果不是建立連接事件,則Reactor會分發(fā)調(diào)用Handler來響應。
- handler會完成read->業(yè)務處理->send的完整業(yè)務流程。
- 單Reactor多線程模型
將handler的處理池化。
圖片
- 多Reactor多線程模型
主從Reactor模型:主Reactor用于響應連接請求,從Reactor用于處理IO操作請求,讀寫分離了。
圖片
什么是Java NIO?
NIO主要有三大核心部分:Channel(通道),Buffer(緩沖區(qū)), Selector。傳統(tǒng)IO基于字節(jié)流和字符流進行操作,而NIO基于Channel和Buffer(緩沖區(qū))進行操作,數(shù)據(jù)總是從通道讀取到緩沖區(qū)中,或者從緩沖區(qū)寫入到通道中。Selector(選擇區(qū))用于監(jiān)聽多個通道的事件(比如:連接打開,數(shù)據(jù)到達)。因此,單個線程可以監(jiān)聽多個數(shù)據(jù)通道。
NIO和傳統(tǒng)IO(一下簡稱IO)之間第一個最大的區(qū)別是,IO是面向流的,NIO是面向緩沖區(qū)的。
圖片
image.png
零拷貝
傳統(tǒng)的IO存在什么問題?為什么引入零拷貝的?
如果服務端要提供文件傳輸?shù)墓δ埽覀兡芟氲降淖詈唵蔚姆绞绞牵簩⒋疟P上的文件讀取出來,然后通過網(wǎng)絡協(xié)議發(fā)送給客戶端。
傳統(tǒng) I/O 的工作方式是,數(shù)據(jù)讀取和寫入是從用戶空間到內(nèi)核空間來回復制,而內(nèi)核空間的數(shù)據(jù)是通過操作系統(tǒng)層面的 I/O 接口從磁盤讀取或?qū)懭搿?/p>
代碼通常如下,一般會需要兩個系統(tǒng)調(diào)用:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
代碼很簡單,雖然就兩行代碼,但是這里面發(fā)生了不少的事情。
圖片
首先,期間共發(fā)生了 4 次用戶態(tài)與內(nèi)核態(tài)的上下文切換,因為發(fā)生了兩次系統(tǒng)調(diào)用,一次是 read() ,一次是 write(),每次系統(tǒng)調(diào)用都得先從用戶態(tài)切換到內(nèi)核態(tài),等內(nèi)核完成任務后,再從內(nèi)核態(tài)切換回用戶態(tài)。
上下文切換到成本并不小,一次切換需要耗時幾十納秒到幾微秒,雖然時間看上去很短,但是在高并發(fā)的場景下,這類時間容易被累積和放大,從而影響系統(tǒng)的性能。
其次,還發(fā)生了 4 次數(shù)據(jù)拷貝,其中兩次是 DMA 的拷貝,另外兩次則是通過 CPU 拷貝的,下面說一下這個過程:
- 第一次拷貝,把磁盤上的數(shù)據(jù)拷貝到操作系統(tǒng)內(nèi)核的緩沖區(qū)里,這個拷貝的過程是通過 DMA 搬運的。
- 第二次拷貝,把內(nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到用戶的緩沖區(qū)里,于是我們應用程序就可以使用這部分數(shù)據(jù)了,這個拷貝到過程是由 CPU 完成的。
- 第三次拷貝,把剛才拷貝到用戶的緩沖區(qū)里的數(shù)據(jù),再拷貝到內(nèi)核的 socket 的緩沖區(qū)里,這個過程依然還是由 CPU 搬運的。
- 第四次拷貝,把內(nèi)核的 socket 緩沖區(qū)里的數(shù)據(jù),拷貝到網(wǎng)卡的緩沖區(qū)里,這個過程又是由 DMA 搬運的。
我們回過頭看這個文件傳輸?shù)倪^程,我們只是搬運一份數(shù)據(jù),結(jié)果卻搬運了 4 次,過多的數(shù)據(jù)拷貝無疑會消耗 CPU 資源,大大降低了系統(tǒng)性能。
這種簡單又傳統(tǒng)的文件傳輸方式,存在冗余的上文切換和數(shù)據(jù)拷貝,在高并發(fā)系統(tǒng)里是非常糟糕的,多了很多不必要的開銷,會嚴重影響系統(tǒng)性能。
所以,要想提高文件傳輸?shù)男阅?,就需要減少「用戶態(tài)與內(nèi)核態(tài)的上下文切換」和「內(nèi)存拷貝」的次數(shù)。
mmap + write怎么實現(xiàn)的零拷貝?
在前面我們知道,read() 系統(tǒng)調(diào)用的過程中會把內(nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到用戶的緩沖區(qū)里,于是為了減少這一步開銷,我們可以用 mmap() 替換 read() 系統(tǒng)調(diào)用函數(shù)。
buf = mmap(file, len);
write(sockfd, buf, len);
mmap() 系統(tǒng)調(diào)用函數(shù)會直接把內(nèi)核緩沖區(qū)里的數(shù)據(jù)「映射」到用戶空間,這樣,操作系統(tǒng)內(nèi)核與用戶空間就不需要再進行任何的數(shù)據(jù)拷貝操作。
圖片
image.png
具體過程如下:
- 應用進程調(diào)用了 mmap() 后,DMA 會把磁盤的數(shù)據(jù)拷貝到內(nèi)核的緩沖區(qū)里。接著,應用進程跟操作系統(tǒng)內(nèi)核「共享」這個緩沖區(qū);
- 應用進程再調(diào)用 write(),操作系統(tǒng)直接將內(nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到 socket 緩沖區(qū)中,這一切都發(fā)生在內(nèi)核態(tài),由 CPU 來搬運數(shù)據(jù);
- 最后,把內(nèi)核的 socket 緩沖區(qū)里的數(shù)據(jù),拷貝到網(wǎng)卡的緩沖區(qū)里,這個過程是由 DMA 搬運的。
我們可以得知,通過使用 mmap() 來代替 read(), 可以減少一次數(shù)據(jù)拷貝的過程。
但這還不是最理想的零拷貝,因為仍然需要通過 CPU 把內(nèi)核緩沖區(qū)的數(shù)據(jù)拷貝到 socket 緩沖區(qū)里,而且仍然需要 4 次上下文切換,因為系統(tǒng)調(diào)用還是 2 次。
sendfile怎么實現(xiàn)的零拷貝?
在 Linux 內(nèi)核版本 2.1 中,提供了一個專門發(fā)送文件的系統(tǒng)調(diào)用函數(shù) sendfile(),函數(shù)形式如下:
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前兩個參數(shù)分別是目的端和源端的文件描述符,后面兩個參數(shù)是源端的偏移量和復制數(shù)據(jù)的長度,返回值是實際復制數(shù)據(jù)的長度。
首先,它可以替代前面的 read() 和 write() 這兩個系統(tǒng)調(diào)用,這樣就可以減少一次系統(tǒng)調(diào)用,也就減少了 2 次上下文切換的開銷。
其次,該系統(tǒng)調(diào)用,可以直接把內(nèi)核緩沖區(qū)里的數(shù)據(jù)拷貝到 socket 緩沖區(qū)里,不再拷貝到用戶態(tài),這樣就只有 2 次上下文切換,和 3 次數(shù)據(jù)拷貝。如下圖:
圖片
但是這還不是真正的零拷貝技術(shù),如果網(wǎng)卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技術(shù)(和普通的 DMA 有所不同),我們可以進一步減少通過 CPU 把內(nèi)核緩沖區(qū)里的數(shù)據(jù)拷貝到 socket 緩沖區(qū)的過程。
你可以在你的 Linux 系統(tǒng)通過下面這個命令,查看網(wǎng)卡是否支持 scatter-gather 特性:
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on
于是,從 Linux 內(nèi)核 2.4 版本開始起,對于支持網(wǎng)卡支持 SG-DMA 技術(shù)的情況下, sendfile() 系統(tǒng)調(diào)用的過程發(fā)生了點變化,具體過程如下:
- 第一步,通過 DMA 將磁盤上的數(shù)據(jù)拷貝到內(nèi)核緩沖區(qū)里;
- 第二步,緩沖區(qū)描述符和數(shù)據(jù)長度傳到 socket 緩沖區(qū),這樣網(wǎng)卡的 SG-DMA 控制器就可以直接將內(nèi)核緩存中的數(shù)據(jù)拷貝到網(wǎng)卡的緩沖區(qū)里,此過程不需要將數(shù)據(jù)從操作系統(tǒng)內(nèi)核緩沖區(qū)拷貝到 socket 緩沖區(qū)中,這樣就減少了一次數(shù)據(jù)拷貝;
所以,這個過程之中,只進行了 2 次數(shù)據(jù)拷貝,如下圖:
圖片
這就是所謂的零拷貝(Zero-copy)技術(shù),因為我們沒有在內(nèi)存層面去拷貝數(shù)據(jù),也就是說全程沒有通過 CPU 來搬運數(shù)據(jù),所有的數(shù)據(jù)都是通過 DMA 來進行傳輸?shù)摹?/p>
零拷貝技術(shù)的文件傳輸方式相比傳統(tǒng)文件傳輸?shù)姆绞剑瑴p少了 2 次上下文切換和數(shù)據(jù)拷貝次數(shù),只需要 2 次上下文切換和數(shù)據(jù)拷貝次數(shù),就可以完成文件的傳輸,而且 2 次的數(shù)據(jù)拷貝過程,都不需要通過 CPU,2 次都是由 DMA 來搬運。