一文徹底搞懂IO底層原理
一、混亂的 IO 概念
IO是Input和Output的縮寫(xiě),即輸入和輸出。廣義上的圍繞計(jì)算機(jī)的輸入輸出有很多:鼠標(biāo)、鍵盤(pán)、掃描儀等等。而我們今天要探討的是在計(jì)算機(jī)里面,主要是作用在內(nèi)存、網(wǎng)卡、硬盤(pán)等硬件設(shè)備上的輸入輸出操作。
談起IO的模型,大多數(shù)人腦子里肯定是一坨混亂的概念,“阻塞”、“非阻塞”,“同步”、“異步”有什么區(qū)別?很多同學(xué)傻傻分不清,有嘗試去搜索相關(guān)資料去探究真相,結(jié)果又被淹沒(méi)在茫茫的概念之中。
這里嘗試簡(jiǎn)單地去解釋下為啥會(huì)出現(xiàn)這種現(xiàn)象,其中一個(gè)很重要的原因就是大家看到的資料對(duì)概念的解釋都站在了不同的角度,有的站在了底層內(nèi)核的視角,有的直接在java層面或者Netty框架層面給大家介紹API,所以給大家造成了一定程度的困擾。
所以在開(kāi)篇之前,還是要說(shuō)下本文所站的視角,我們將會(huì)從底層內(nèi)核的層面給大家講解下IO。因?yàn)槿f(wàn)變不離其宗,只有了解了底層原理,不管語(yǔ)言層面如何花里胡哨,我們都能以不變應(yīng)萬(wàn)變。
二、用戶空間和內(nèi)核空間
為了便于大家理解復(fù)雜的IO以及零拷貝相關(guān)的技術(shù),我們還是得花點(diǎn)時(shí)間在回顧下操作系統(tǒng)相關(guān)的知識(shí)。這一節(jié)我們重點(diǎn)看下用戶空間和內(nèi)核空間,基于此后面我們才能更好地聊聊多路復(fù)用和零拷貝。

硬 件 層(Hardware)
包括和我們熟知的和IO相關(guān)的CPU、內(nèi)存、磁盤(pán)和網(wǎng)卡幾個(gè)硬件;
內(nèi)核空間(Kernel Space)
計(jì)算機(jī)開(kāi)機(jī)后首先會(huì)運(yùn)行內(nèi)核程序,內(nèi)核程序占用的一塊私有的空間就是內(nèi)核空間,并且可支持訪問(wèn)CPU所有的指令集(ring0 - ring3)以及所有的內(nèi)存空間、IO及硬件設(shè)備;
用戶空間(User Space)
每個(gè)普通的用戶進(jìn)程都有一個(gè)單獨(dú)的用戶空間,用戶空間只能訪問(wèn)受限的資源(CPU的“保護(hù)模式”)也就是說(shuō)用戶空間是無(wú)法直接操作像內(nèi)存、網(wǎng)卡和磁盤(pán)等硬件的;
如上所述,那我們可能會(huì)有疑問(wèn),用戶空間的進(jìn)程想要去訪問(wèn)或操作磁盤(pán)和網(wǎng)卡該怎么辦呢?
為此,操作系統(tǒng)在內(nèi)核中開(kāi)辟了一塊唯一且合法的調(diào)用入口“System Call Interface”,也就是我們常說(shuō)的系統(tǒng)調(diào)用,系統(tǒng)調(diào)用為上層用戶提供了一組能夠操作底層硬件的API。這樣一來(lái),用戶進(jìn)程就可以通過(guò)系統(tǒng)調(diào)用訪問(wèn)到操作系統(tǒng)內(nèi)核,進(jìn)而就能夠間接地完成對(duì)底層硬件的操作。這個(gè)訪問(wèn)的過(guò)程也即用戶態(tài)到內(nèi)核態(tài)的切換。常見(jiàn)的系統(tǒng)調(diào)用有很多,比如:內(nèi)存映射mmap()、文件操作類(lèi)的open()、IO讀寫(xiě)read()、write()等等。
三、IO模型
3.1、BIO(Blocking IO)
我們先看一下大家都熟悉的BIO模型的 Java 偽代碼:
ServerSocket serverSocket = new ServerSocket(8080); // step1: 創(chuàng)建一個(gè)ServerSocket,并監(jiān)聽(tīng)8080端口
while(true) { // step2: 主線程進(jìn)入死循環(huán)
Socket socket = serverSocket.accept(); // step3: 線程阻塞,開(kāi)啟監(jiān)聽(tīng)
BufferedReader reader = new BufferedReader(nwe InputStreamReader(socket.getInputStream()));
System.out.println("read data: " + reader.readLine()); // step4: 數(shù)據(jù)讀取
PrintWriter print = new PrintWriter(socket.getOutputStream(), true);
print.println("write data"); // step5: socket數(shù)據(jù)寫(xiě)入
}
這段代碼可以簡(jiǎn)單理解成一下幾個(gè)步驟:
- 創(chuàng)建ServerSocket,并監(jiān)聽(tīng)8080端口;
- 主線程進(jìn)入死循環(huán),用來(lái)阻塞監(jiān)聽(tīng)客戶端的連接,socket.accept();
- 數(shù)據(jù)讀取,socket.read();
- 寫(xiě)入數(shù)據(jù),socket.write();
問(wèn)題:
以上三個(gè)步驟:accept(...)、read(...)、write(...)都會(huì)造成線程阻塞。上述這個(gè)代碼使用了單線程,會(huì)導(dǎo)致主線程會(huì)直接夯死在阻塞的地方。
優(yōu)化:
我們要知道一點(diǎn)“進(jìn)程的阻塞是不會(huì)消耗CPU資源的”,所以在多核的環(huán)境下,我們可以創(chuàng)建多線程,把接收到的請(qǐng)求拋給多線程去處理,這樣就有效地利用了計(jì)算機(jī)的多核資源。甚至為了避免創(chuàng)建大量的線程處理請(qǐng)求,我們還可以進(jìn)一步做優(yōu)化,創(chuàng)建一個(gè)線程池,利用池化技術(shù),對(duì)暫時(shí)處理不了的請(qǐng)求做一個(gè)緩沖。
3.2、“C10K”問(wèn)題
“C10K”即“client 10k”用來(lái)指代數(shù)量龐大的客戶端;
BIO看上去非常的簡(jiǎn)單,事實(shí)上采用“BIO+線程池”來(lái)處理少量的并發(fā)請(qǐng)求還是比較合適的,也是最優(yōu)的。但是面臨數(shù)量龐大的客戶端和請(qǐng)求,這時(shí)候使用多線程的弊端就逐漸凸顯出來(lái)了:
- 嚴(yán)重依賴線程,線程還是比較耗系統(tǒng)資源的(一個(gè)線程大約占用1M的空間);
- 頻繁地創(chuàng)建和銷(xiāo)毀代價(jià)很大,因?yàn)樯婕暗綇?fù)雜的系統(tǒng)調(diào)用;
- 線程間上下文切換的成本很高,因?yàn)榘l(fā)生線程切換前,需要保留上一個(gè)任務(wù)的狀態(tài),以便切回來(lái)的時(shí)候,可以再次加載這個(gè)任務(wù)的狀態(tài)。如果線程數(shù)量龐大,會(huì)造成線程做上下文切換的時(shí)間甚至大于線程執(zhí)行的時(shí)間,CPU負(fù)載變高。
3.3、NIO非阻塞模型
下面開(kāi)始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能會(huì)引入的大量線程,所以可以簡(jiǎn)單地理解NIO處理問(wèn)題的方式是通過(guò)單線程或者少量線程達(dá)到處理大量客戶端請(qǐng)求的目的。為了達(dá)成這個(gè)目的,首先要做的就是把阻塞的過(guò)程非阻塞化。要想做到非阻塞,那必須得要有內(nèi)核的支持,同時(shí)需要對(duì)用戶空間的進(jìn)程暴露系統(tǒng)調(diào)用函數(shù)。所以,這里的“非阻塞”可以理解成系統(tǒng)調(diào)用API級(jí)別的,而真正底層的IO操作都是阻塞的,我們后面會(huì)慢慢介紹。
事實(shí)上,內(nèi)核已經(jīng)對(duì)“非阻塞”做好了支持,舉個(gè)我們剛剛說(shuō)的的accept()方法阻塞的例子(Tips:java中的accept方法對(duì)應(yīng)的系統(tǒng)調(diào)用函數(shù)也叫accept),看下官方文檔對(duì)其非阻塞部分的描述。

官方文檔對(duì)accetp()系統(tǒng)調(diào)用的描述是通過(guò)把"flags"參數(shù)設(shè)成"SOCK_NONBLOCK"就可以達(dá)到非阻塞的目的,非阻塞之后線程會(huì)一直處理輪詢調(diào)用,這時(shí)候可以通過(guò)每次返回特殊的異常碼“EAGAIN”或"EWOULDBLOCK"告訴主程序還沒(méi)有連接到達(dá)可以繼續(xù)輪詢。
我們可以很容易想象程序非阻塞之后的一個(gè)大致過(guò)程。所以,非阻塞模式有個(gè)最大的特點(diǎn)就是:用戶進(jìn)程需要不斷去主動(dòng)詢問(wèn)內(nèi)核數(shù)據(jù)準(zhǔn)備好了沒(méi)有!
下面我們通過(guò)一段偽代碼,看下這個(gè)調(diào)用過(guò)程:
// 循環(huán)遍歷
while(1) {
// 遍歷fd集合
for (fdx in range(fd1, fdn)) {
// 如果fdx有數(shù)據(jù)
if (null != fdx.data) {
// 進(jìn)行讀取和處理
read(fdx)&handle(fdx);
}
}
}
這種調(diào)用方式也暴露出非阻塞模式的最大的弊端,就是需要讓用戶進(jìn)程不斷切換到內(nèi)核態(tài),對(duì)連接狀態(tài)或讀寫(xiě)數(shù)據(jù)做輪詢。有沒(méi)有一種方式來(lái)簡(jiǎn)化用戶空間for循環(huán)輪詢的過(guò)程呢?那就是我們下面要重點(diǎn)介紹的IO多路復(fù)用模型。
3.4、IO多路復(fù)用模型
非阻塞模型會(huì)讓用戶進(jìn)程一直輪詢調(diào)用系統(tǒng)函數(shù),頻繁地做內(nèi)核態(tài)切換。想要做優(yōu)化其實(shí)也比較簡(jiǎn)單,我們假想個(gè)業(yè)務(wù)場(chǎng)景,A業(yè)務(wù)系統(tǒng)會(huì)調(diào)用B的基礎(chǔ)服務(wù)查詢單個(gè)用戶的信息。隨著業(yè)務(wù)的發(fā)展,A的邏輯變復(fù)雜了,需要查100個(gè)用戶的信息。很明顯,A希望B提供一個(gè)批量查詢的接口,用集合作為入?yún)?,一次性把?shù)據(jù)傳遞過(guò)去就省去了頻繁的系統(tǒng)間調(diào)用。
多路復(fù)用實(shí)際也差不多就是這個(gè)實(shí)現(xiàn)思路,只不過(guò)入?yún)⑦@個(gè)“集合”需要你注冊(cè)/填寫(xiě)感興趣的事件,讀fd、寫(xiě)fd或者連接狀態(tài)的fd等,然后交給內(nèi)核幫你進(jìn)行處理。
那我們就具體來(lái)看看多路復(fù)用里面大家都可能聽(tīng)過(guò)的幾個(gè)系統(tǒng)調(diào)用- select()、poll()、epoll()。
3.4.1、select()
select()構(gòu)造函數(shù)信息如下所示:
/** * select()系統(tǒng)調(diào)用 * * 參數(shù)列表: * nfds - 值為最大的文件描述符+1 * *readfds - 用戶檢查可讀性 * *writefds - 用戶檢查可寫(xiě)性 * *exceptfds - 用于檢查外帶數(shù)據(jù) * *timeout - 超時(shí)時(shí)間的結(jié)構(gòu)體指針 */ int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
官方文檔對(duì)select()的描述:
DESCRIPTION
select() and pselect() allow a program to monitor multiple file descriptors, waiting until one or more of the file descriptors become "ready" for some class of I/O operation (e.g.,input possible). A file descriptor is considered ready if it is possible to perform the corresponding I/O operation (e.g., read(2)) without blocking.
select()允許程序監(jiān)控多個(gè)fd,阻塞等待直到一個(gè)或多個(gè)fd到達(dá)"就緒"狀態(tài)。
內(nèi)核使用select()為用戶進(jìn)程提供了類(lèi)似批量的接口,函數(shù)本身也會(huì)一直阻塞直到有fd為就緒狀態(tài)返回。下面我們來(lái)具體看下select()函數(shù)實(shí)現(xiàn),以便我們更好地分析它有哪些優(yōu)缺點(diǎn)。在select()函數(shù)的構(gòu)造器里,我們很容易看到"fd_set"這個(gè)入?yún)㈩?lèi)型。它是用位圖算法bitmap實(shí)現(xiàn)的,使用了一個(gè)大小固定的數(shù)組(fd_set設(shè)置了FD_SETSIZE固定長(zhǎng)度為1024),數(shù)組中的每個(gè)元素都是0和1這樣的二進(jìn)制byte,0,1映射fd對(duì)應(yīng)位置上是否有讀寫(xiě)事件,舉例:如果fd == 5,那么fd_set = 000001000。
同時(shí)fd_set定義了四個(gè)宏來(lái)處理bitmap:
- FD_ZERO(&set);// 初始化,清空的作用,使集合中不含任何fd
- FD_SET(fd, &set);//將fd加入set集合,給某個(gè)位置賦值的操作
- FD_CLR(fd, &set);//將fd從set集合中清除,去掉某個(gè)位置的值
- FD_ISSET(fd, &set);//校驗(yàn)?zāi)澄恢玫膄d是否在集合中
使用bitmap算法的好處非常明顯,運(yùn)算效率高,占用內(nèi)存少(使用了一個(gè)byte,8bit)。我們用偽代碼和圖片來(lái)描述下用戶進(jìn)程調(diào)用select()的過(guò)程:

假設(shè)fds為{1, 2, 3, 5, 7}對(duì)應(yīng)的bitmap為"01110101",拋給內(nèi)核空間輪詢,當(dāng)有讀寫(xiě)事件時(shí)重新標(biāo)記同時(shí)停止阻塞,然后整體返回用戶空間。由此我們可以看到select()系統(tǒng)調(diào)用的弊端也是比較明顯的:
- 復(fù)雜度O(n),輪詢的任務(wù)交給了內(nèi)核來(lái)做,復(fù)雜度并沒(méi)有變化,數(shù)據(jù)取出后也需要輪詢哪個(gè)fd上發(fā)生了變動(dòng);
- 用戶態(tài)還是需要不斷切換到內(nèi)核態(tài),直到所有的fds數(shù)據(jù)讀取結(jié)束,整體開(kāi)銷(xiāo)依然很大;
- fd_set有大小的限制,目前被硬編碼成了1024;
- fd_set不可重用,每次操作完都必須重置;
3.4.2、poll()
poll()構(gòu)造函數(shù)信息如下所示:
/**
* poll()系統(tǒng)調(diào)用
*
* 參數(shù)列表:
* *fds - pollfd結(jié)構(gòu)體
* nfds - 要監(jiān)視的描述符的數(shù)量
* timeout - 等待時(shí)間
*/
int poll(struct pollfd *fds, nfds_t nfds, int *timeout);
### pollfd的結(jié)構(gòu)體
struct pollfd{
int fd;// 文件描述符
short event;// 請(qǐng)求的事件
short revent;// 返回的事件
}
官方文檔對(duì)poll()的描述:
DESCRIPTION
poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.
poll() 非常像select(),它也是阻塞等待直到一個(gè)或多個(gè)fd到達(dá)"就緒"狀態(tài)。
看官方文檔描述可以知道,poll()和select()是非常相似的,唯一的區(qū)別在于poll()摒棄掉了位圖算法,使用自定義的結(jié)構(gòu)體pollfd,在pollfd內(nèi)部封裝了fd,并通過(guò)event變量注冊(cè)感興趣的可讀可寫(xiě)事件(POLLIN、POLLOUT),最后把pollfd交給內(nèi)核。當(dāng)有讀寫(xiě)事件觸發(fā)的時(shí)候,我們可以通過(guò)輪詢pollfd,判斷revent確定該fd是否發(fā)生了可讀可寫(xiě)事件。
老樣子我們用偽代碼來(lái)描述下用戶進(jìn)程調(diào)用poll()的過(guò)程:

poll()相對(duì)于select(),主要的優(yōu)勢(shì)是使用了pollfd的結(jié)構(gòu)體:
- 沒(méi)有了bitmap大小1024的限制;
- 通過(guò)結(jié)構(gòu)體中的revents置位;
但是用戶態(tài)到內(nèi)核態(tài)切換及O(n)復(fù)雜度的問(wèn)題依舊存在。
3.4.3、epoll()
epoll()應(yīng)該是目前最主流,使用范圍最廣的一組多路復(fù)用的函數(shù)調(diào)用,像我們熟知的Nginx、Redis都廣泛地使用了此種模式。接下來(lái)我們重點(diǎn)分析下,epoll()的實(shí)現(xiàn)采用了“三步走”策略,它們分別是epoll_create()、epoll_ctl()、epoll_wait()。
epoll_create()
/** * 返回專(zhuān)用的文件描述符 */ int epoll_create(int size);
用戶進(jìn)程通過(guò)epoll_create()函數(shù)在內(nèi)核空間里面創(chuàng)建了一塊空間(為了便于理解,可以想象成創(chuàng)建了一塊白板),并返回了描述此空間的fd。
epoll_ctl()
/** * epoll_ctl()系統(tǒng)調(diào)用 * * 參數(shù)列表: * epfd - 由epoll_create()返回的epoll專(zhuān)用的文件描述符 * op - 要進(jìn)行的操作例如注冊(cè)事件,可能的取值:注冊(cè)-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、刪除-EPOLL_CTL_DEL * fd - 關(guān)聯(lián)的文件描述符 * event - 指向epoll_event的指針 */ int epoll_ctl(int epfd, int op, int fd , struce epoll_event *event );
剛剛我們說(shuō)通過(guò)epoll_create()可以創(chuàng)建一塊具體的空間“白板”,那么通過(guò)epoll_ctl()我們可以通過(guò)自定義的epoll_event結(jié)構(gòu)體在這塊"白板上"注冊(cè)感興趣的事件了。
- 注冊(cè) - EPOLL_CTL_ADD
- 修改 - EPOLL_CTL_MOD
- 刪除 - EPOLL_CTL_DEL
epoll_wait()
/** * epoll_wait()返回n個(gè)可讀可寫(xiě)的fds * * 參數(shù)列表: * epfd - 由epoll_create()返回的epoll專(zhuān)用的文件描述符 * epoll_event - 要進(jìn)行的操作例如注冊(cè)事件,可能的取值:注冊(cè)-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、刪除-EPOLL_CTL_DEL * maxevents - 每次能處理的事件數(shù) * timeout - 等待I/O事件發(fā)生的超時(shí)值;-1相當(dāng)于阻塞,0相當(dāng)于非阻塞。一般用-1即可 */ int epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);
epoll_wait()會(huì)一直阻塞等待,直到硬盤(pán)、網(wǎng)卡等硬件設(shè)備數(shù)據(jù)準(zhǔn)備完成后發(fā)起硬中斷,中斷CPU,CPU會(huì)立即執(zhí)行數(shù)據(jù)拷貝工作,數(shù)據(jù)從磁盤(pán)緩沖傳輸?shù)絻?nèi)核緩沖,同時(shí)將準(zhǔn)備完成的fd放到就緒隊(duì)列中供用戶態(tài)進(jìn)行讀取。用戶態(tài)阻塞停止,接收到具體數(shù)量的可讀寫(xiě)的fds,返回用戶態(tài)進(jìn)行數(shù)據(jù)處理。
整體過(guò)程可以通過(guò)下面的偽代碼和圖示進(jìn)一步了解:

epoll()基本上完美地解決了poll()函數(shù)遺留的兩個(gè)問(wèn)題:
- 沒(méi)有了頻繁的用戶態(tài)到內(nèi)核態(tài)的切換;
- O(1)復(fù)雜度,返回的"nfds"是一個(gè)確定的可讀寫(xiě)的數(shù)量,相比于之前循環(huán)n次來(lái)確認(rèn),復(fù)雜度降低了不少;
四、同步、異步
細(xì)心的朋友可能會(huì)發(fā)現(xiàn),本篇文章一直在解釋“阻塞”和“非阻塞”,“同步”、“異步”的概念沒(méi)有涉及,其實(shí)在很多場(chǎng)景下同步&異步和阻塞&非阻塞基本上是一個(gè)同義詞。阻塞和非阻塞適合從系統(tǒng)調(diào)用API層面來(lái)看,就像我們本文介紹的select()、poll()這樣的系統(tǒng)調(diào)用,同步和異步更適合站在應(yīng)用程序的角度來(lái)看。應(yīng)用程序在同步執(zhí)行代碼片段的時(shí)候結(jié)果不會(huì)立即返回,這時(shí)候底層IO操作不一定是阻塞的,也完全有可能是非阻塞。所以說(shuō):
- 阻塞和非阻塞:讀寫(xiě)沒(méi)有就緒或者讀寫(xiě)沒(méi)有完成,函數(shù)是否要一直等待還是采用輪詢;
- 同步和異步:同步是讀寫(xiě)由應(yīng)用程序完成。異步是讀寫(xiě)由操作系統(tǒng)來(lái)完成,并通過(guò)回調(diào)的機(jī)制通知應(yīng)用程序。
這邊順便提兩種大家可能會(huì)經(jīng)常聽(tīng)到的模式:Reactor和Preactor。
- Reactor 模式:主動(dòng)模式。
- Preactor 模式:被動(dòng)模式。
五、總結(jié)
本篇文章從底層講解了下從BIO到NIO的一個(gè)過(guò)程,著重介紹了IO多路復(fù)用的幾個(gè)系統(tǒng)調(diào)用select()、poll()、epoll(),分析了下各自的優(yōu)劣,技術(shù)都是持續(xù)發(fā)展演進(jìn)的,目前也有很多的痛點(diǎn)。
以上就是一文徹底搞懂IO底層原理的詳細(xì)內(nèi)容,更多關(guān)于IO底層原理的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
c++中cin/cout與scanf/printf的區(qū)別比較
這篇文章主要介紹了c++中cin/cout與scanf/printf的區(qū)別比較,需要的朋友可以參考下2017-06-06
Qt中正則表達(dá)式的常見(jiàn)用法(QRegularExpression類(lèi))
正則表達(dá)式即一個(gè)文本匹配字符串的一種模式,Qt中使用QRegExp類(lèi)進(jìn)行模式匹配,下面這篇文章主要給大家介紹了關(guān)于Qt中正則表達(dá)式的常見(jiàn)用法,文中介紹的是QRegularExpression類(lèi)的相關(guān)資料,需要的朋友可以參考下2024-05-05
數(shù)據(jù)結(jié)構(gòu)與算法中二叉樹(shù)子結(jié)構(gòu)的詳解
這篇文章主要介紹了數(shù)據(jù)結(jié)構(gòu)與算法中二叉樹(shù)子結(jié)構(gòu)的詳解的相關(guān)資料,需要的朋友可以參考下2017-04-04
帶你了解C++的動(dòng)態(tài)內(nèi)存分配
今天小編就為大家分享一篇關(guān)于關(guān)于C++動(dòng)態(tài)分配內(nèi)存的介紹,小編覺(jué)得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來(lái)看看吧2021-08-08
C++深入探索內(nèi)聯(lián)函數(shù)inline與auto關(guān)鍵字的使用
本篇文章主要包括內(nèi)聯(lián)函數(shù)和auto關(guān)鍵字。其中,內(nèi)斂函數(shù)包括概念,特性等;auto關(guān)鍵字的使用規(guī)則,使用場(chǎng)景等,接下來(lái)讓我們深入了解2022-05-05

