一文徹底搞懂IO底層原理
一、混亂的 IO 概念
IO是Input和Output的縮寫,即輸入和輸出。廣義上的圍繞計算機的輸入輸出有很多:鼠標(biāo)、鍵盤、掃描儀等等。而我們今天要探討的是在計算機里面,主要是作用在內(nèi)存、網(wǎng)卡、硬盤等硬件設(shè)備上的輸入輸出操作。
談起IO的模型,大多數(shù)人腦子里肯定是一坨混亂的概念,“阻塞”、“非阻塞”,“同步”、“異步”有什么區(qū)別?很多同學(xué)傻傻分不清,有嘗試去搜索相關(guān)資料去探究真相,結(jié)果又被淹沒在茫茫的概念之中。
這里嘗試簡單地去解釋下為啥會出現(xiàn)這種現(xiàn)象,其中一個很重要的原因就是大家看到的資料對概念的解釋都站在了不同的角度,有的站在了底層內(nèi)核的視角,有的直接在java層面或者Netty框架層面給大家介紹API,所以給大家造成了一定程度的困擾。
所以在開篇之前,還是要說下本文所站的視角,我們將會從底層內(nèi)核的層面給大家講解下IO。因為萬變不離其宗,只有了解了底層原理,不管語言層面如何花里胡哨,我們都能以不變應(yīng)萬變。
二、用戶空間和內(nèi)核空間
為了便于大家理解復(fù)雜的IO以及零拷貝相關(guān)的技術(shù),我們還是得花點時間在回顧下操作系統(tǒng)相關(guān)的知識。這一節(jié)我們重點看下用戶空間和內(nèi)核空間,基于此后面我們才能更好地聊聊多路復(fù)用和零拷貝。
硬 件 層(Hardware)
包括和我們熟知的和IO相關(guān)的CPU、內(nèi)存、磁盤和網(wǎng)卡幾個硬件;
內(nèi)核空間(Kernel Space)
計算機開機后首先會運行內(nèi)核程序,內(nèi)核程序占用的一塊私有的空間就是內(nèi)核空間,并且可支持訪問CPU所有的指令集(ring0 - ring3)以及所有的內(nèi)存空間、IO及硬件設(shè)備;
用戶空間(User Space)
每個普通的用戶進程都有一個單獨的用戶空間,用戶空間只能訪問受限的資源(CPU的“保護模式”)也就是說用戶空間是無法直接操作像內(nèi)存、網(wǎng)卡和磁盤等硬件的;
如上所述,那我們可能會有疑問,用戶空間的進程想要去訪問或操作磁盤和網(wǎng)卡該怎么辦呢?
為此,操作系統(tǒng)在內(nèi)核中開辟了一塊唯一且合法的調(diào)用入口“System Call Interface”,也就是我們常說的系統(tǒng)調(diào)用,系統(tǒng)調(diào)用為上層用戶提供了一組能夠操作底層硬件的API。這樣一來,用戶進程就可以通過系統(tǒng)調(diào)用訪問到操作系統(tǒng)內(nèi)核,進而就能夠間接地完成對底層硬件的操作。這個訪問的過程也即用戶態(tài)到內(nèi)核態(tài)的切換。常見的系統(tǒng)調(diào)用有很多,比如:內(nèi)存映射mmap()、文件操作類的open()、IO讀寫read()、write()等等。
三、IO模型
3.1、BIO(Blocking IO)
我們先看一下大家都熟悉的BIO模型的 Java 偽代碼:
ServerSocket serverSocket = new ServerSocket(8080); // step1: 創(chuàng)建一個ServerSocket,并監(jiān)聽8080端口 while(true) { // step2: 主線程進入死循環(huán) Socket socket = serverSocket.accept(); // step3: 線程阻塞,開啟監(jiān)聽 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ù)寫入 }
這段代碼可以簡單理解成一下幾個步驟:
- 創(chuàng)建ServerSocket,并監(jiān)聽8080端口;
- 主線程進入死循環(huán),用來阻塞監(jiān)聽客戶端的連接,socket.accept();
- 數(shù)據(jù)讀取,socket.read();
- 寫入數(shù)據(jù),socket.write();
問題:
以上三個步驟:accept(...)、read(...)、write(...)都會造成線程阻塞。上述這個代碼使用了單線程,會導(dǎo)致主線程會直接夯死在阻塞的地方。
優(yōu)化:
我們要知道一點“進程的阻塞是不會消耗CPU資源的”,所以在多核的環(huán)境下,我們可以創(chuàng)建多線程,把接收到的請求拋給多線程去處理,這樣就有效地利用了計算機的多核資源。甚至為了避免創(chuàng)建大量的線程處理請求,我們還可以進一步做優(yōu)化,創(chuàng)建一個線程池,利用池化技術(shù),對暫時處理不了的請求做一個緩沖。
3.2、“C10K”問題
“C10K”即“client 10k”用來指代數(shù)量龐大的客戶端;
BIO看上去非常的簡單,事實上采用“BIO+線程池”來處理少量的并發(fā)請求還是比較合適的,也是最優(yōu)的。但是面臨數(shù)量龐大的客戶端和請求,這時候使用多線程的弊端就逐漸凸顯出來了:
- 嚴(yán)重依賴線程,線程還是比較耗系統(tǒng)資源的(一個線程大約占用1M的空間);
- 頻繁地創(chuàng)建和銷毀代價很大,因為涉及到復(fù)雜的系統(tǒng)調(diào)用;
- 線程間上下文切換的成本很高,因為發(fā)生線程切換前,需要保留上一個任務(wù)的狀態(tài),以便切回來的時候,可以再次加載這個任務(wù)的狀態(tài)。如果線程數(shù)量龐大,會造成線程做上下文切換的時間甚至大于線程執(zhí)行的時間,CPU負(fù)載變高。
3.3、NIO非阻塞模型
下面開始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能會引入的大量線程,所以可以簡單地理解NIO處理問題的方式是通過單線程或者少量線程達(dá)到處理大量客戶端請求的目的。為了達(dá)成這個目的,首先要做的就是把阻塞的過程非阻塞化。要想做到非阻塞,那必須得要有內(nèi)核的支持,同時需要對用戶空間的進程暴露系統(tǒng)調(diào)用函數(shù)。所以,這里的“非阻塞”可以理解成系統(tǒng)調(diào)用API級別的,而真正底層的IO操作都是阻塞的,我們后面會慢慢介紹。
事實上,內(nèi)核已經(jīng)對“非阻塞”做好了支持,舉個我們剛剛說的的accept()方法阻塞的例子(Tips:java中的accept方法對應(yīng)的系統(tǒng)調(diào)用函數(shù)也叫accept),看下官方文檔對其非阻塞部分的描述。
官方文檔對accetp()系統(tǒng)調(diào)用的描述是通過把"flags"參數(shù)設(shè)成"SOCK_NONBLOCK"就可以達(dá)到非阻塞的目的,非阻塞之后線程會一直處理輪詢調(diào)用,這時候可以通過每次返回特殊的異常碼“EAGAIN”或"EWOULDBLOCK"告訴主程序還沒有連接到達(dá)可以繼續(xù)輪詢。
我們可以很容易想象程序非阻塞之后的一個大致過程。所以,非阻塞模式有個最大的特點就是:用戶進程需要不斷去主動詢問內(nèi)核數(shù)據(jù)準(zhǔn)備好了沒有!
下面我們通過一段偽代碼,看下這個調(diào)用過程:
// 循環(huán)遍歷 while(1) { // 遍歷fd集合 for (fdx in range(fd1, fdn)) { // 如果fdx有數(shù)據(jù) if (null != fdx.data) { // 進行讀取和處理 read(fdx)&handle(fdx); } } }
這種調(diào)用方式也暴露出非阻塞模式的最大的弊端,就是需要讓用戶進程不斷切換到內(nèi)核態(tài),對連接狀態(tài)或讀寫數(shù)據(jù)做輪詢。有沒有一種方式來簡化用戶空間for循環(huán)輪詢的過程呢?那就是我們下面要重點介紹的IO多路復(fù)用模型。
3.4、IO多路復(fù)用模型
非阻塞模型會讓用戶進程一直輪詢調(diào)用系統(tǒng)函數(shù),頻繁地做內(nèi)核態(tài)切換。想要做優(yōu)化其實也比較簡單,我們假想個業(yè)務(wù)場景,A業(yè)務(wù)系統(tǒng)會調(diào)用B的基礎(chǔ)服務(wù)查詢單個用戶的信息。隨著業(yè)務(wù)的發(fā)展,A的邏輯變復(fù)雜了,需要查100個用戶的信息。很明顯,A希望B提供一個批量查詢的接口,用集合作為入?yún)?,一次性把?shù)據(jù)傳遞過去就省去了頻繁的系統(tǒng)間調(diào)用。
多路復(fù)用實際也差不多就是這個實現(xiàn)思路,只不過入?yún)⑦@個“集合”需要你注冊/填寫感興趣的事件,讀fd、寫fd或者連接狀態(tài)的fd等,然后交給內(nèi)核幫你進行處理。
那我們就具體來看看多路復(fù)用里面大家都可能聽過的幾個系統(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 - 用戶檢查可寫性 * *exceptfds - 用于檢查外帶數(shù)據(jù) * *timeout - 超時時間的結(jié)構(gòu)體指針 */ int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
官方文檔對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)控多個fd,阻塞等待直到一個或多個fd到達(dá)"就緒"狀態(tài)。
內(nèi)核使用select()為用戶進程提供了類似批量的接口,函數(shù)本身也會一直阻塞直到有fd為就緒狀態(tài)返回。下面我們來具體看下select()函數(shù)實現(xiàn),以便我們更好地分析它有哪些優(yōu)缺點。在select()函數(shù)的構(gòu)造器里,我們很容易看到"fd_set"這個入?yún)㈩愋?。它是用位圖算法bitmap實現(xiàn)的,使用了一個大小固定的數(shù)組(fd_set設(shè)置了FD_SETSIZE固定長度為1024),數(shù)組中的每個元素都是0和1這樣的二進制byte,0,1映射fd對應(yīng)位置上是否有讀寫事件,舉例:如果fd == 5,那么fd_set = 000001000。
同時fd_set定義了四個宏來處理bitmap:
- FD_ZERO(&set);// 初始化,清空的作用,使集合中不含任何fd
- FD_SET(fd, &set);//將fd加入set集合,給某個位置賦值的操作
- FD_CLR(fd, &set);//將fd從set集合中清除,去掉某個位置的值
- FD_ISSET(fd, &set);//校驗?zāi)澄恢玫膄d是否在集合中
使用bitmap算法的好處非常明顯,運算效率高,占用內(nèi)存少(使用了一個byte,8bit)。我們用偽代碼和圖片來描述下用戶進程調(diào)用select()的過程:
假設(shè)fds為{1, 2, 3, 5, 7}對應(yīng)的bitmap為"01110101",拋給內(nèi)核空間輪詢,當(dāng)有讀寫事件時重新標(biāo)記同時停止阻塞,然后整體返回用戶空間。由此我們可以看到select()系統(tǒng)調(diào)用的弊端也是比較明顯的:
- 復(fù)雜度O(n),輪詢的任務(wù)交給了內(nèi)核來做,復(fù)雜度并沒有變化,數(shù)據(jù)取出后也需要輪詢哪個fd上發(fā)生了變動;
- 用戶態(tài)還是需要不斷切換到內(nèi)核態(tài),直到所有的fds數(shù)據(jù)讀取結(jié)束,整體開銷依然很大;
- 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 - 等待時間 */ int poll(struct pollfd *fds, nfds_t nfds, int *timeout); ### pollfd的結(jié)構(gòu)體 struct pollfd{ int fd;// 文件描述符 short event;// 請求的事件 short revent;// 返回的事件 }
官方文檔對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(),它也是阻塞等待直到一個或多個fd到達(dá)"就緒"狀態(tài)。
看官方文檔描述可以知道,poll()和select()是非常相似的,唯一的區(qū)別在于poll()摒棄掉了位圖算法,使用自定義的結(jié)構(gòu)體pollfd,在pollfd內(nèi)部封裝了fd,并通過event變量注冊感興趣的可讀可寫事件(POLLIN、POLLOUT),最后把pollfd交給內(nèi)核。當(dāng)有讀寫事件觸發(fā)的時候,我們可以通過輪詢pollfd,判斷revent確定該fd是否發(fā)生了可讀可寫事件。
老樣子我們用偽代碼來描述下用戶進程調(diào)用poll()的過程:
poll()相對于select(),主要的優(yōu)勢是使用了pollfd的結(jié)構(gòu)體:
- 沒有了bitmap大小1024的限制;
- 通過結(jié)構(gòu)體中的revents置位;
但是用戶態(tài)到內(nèi)核態(tài)切換及O(n)復(fù)雜度的問題依舊存在。
3.4.3、epoll()
epoll()應(yīng)該是目前最主流,使用范圍最廣的一組多路復(fù)用的函數(shù)調(diào)用,像我們熟知的Nginx、Redis都廣泛地使用了此種模式。接下來我們重點分析下,epoll()的實現(xiàn)采用了“三步走”策略,它們分別是epoll_create()、epoll_ctl()、epoll_wait()。
epoll_create()
/** * 返回專用的文件描述符 */ int epoll_create(int size);
用戶進程通過epoll_create()函數(shù)在內(nèi)核空間里面創(chuàng)建了一塊空間(為了便于理解,可以想象成創(chuàng)建了一塊白板),并返回了描述此空間的fd。
epoll_ctl()
/** * epoll_ctl()系統(tǒng)調(diào)用 * * 參數(shù)列表: * epfd - 由epoll_create()返回的epoll專用的文件描述符 * op - 要進行的操作例如注冊事件,可能的取值:注冊-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 );
剛剛我們說通過epoll_create()可以創(chuàng)建一塊具體的空間“白板”,那么通過epoll_ctl()我們可以通過自定義的epoll_event結(jié)構(gòu)體在這塊"白板上"注冊感興趣的事件了。
- 注冊 - EPOLL_CTL_ADD
- 修改 - EPOLL_CTL_MOD
- 刪除 - EPOLL_CTL_DEL
epoll_wait()
/** * epoll_wait()返回n個可讀可寫的fds * * 參數(shù)列表: * epfd - 由epoll_create()返回的epoll專用的文件描述符 * epoll_event - 要進行的操作例如注冊事件,可能的取值:注冊-EPOLL_CTL_ADD、修改-EPOLL_CTL_MOD、刪除-EPOLL_CTL_DEL * maxevents - 每次能處理的事件數(shù) * timeout - 等待I/O事件發(fā)生的超時值;-1相當(dāng)于阻塞,0相當(dāng)于非阻塞。一般用-1即可 */ int epoll_wait(int epfd, struce epoll_event *event , int maxevents, int timeout);
epoll_wait()會一直阻塞等待,直到硬盤、網(wǎng)卡等硬件設(shè)備數(shù)據(jù)準(zhǔn)備完成后發(fā)起硬中斷,中斷CPU,CPU會立即執(zhí)行數(shù)據(jù)拷貝工作,數(shù)據(jù)從磁盤緩沖傳輸?shù)絻?nèi)核緩沖,同時將準(zhǔn)備完成的fd放到就緒隊列中供用戶態(tài)進行讀取。用戶態(tài)阻塞停止,接收到具體數(shù)量的可讀寫的fds,返回用戶態(tài)進行數(shù)據(jù)處理。
整體過程可以通過下面的偽代碼和圖示進一步了解:
epoll()基本上完美地解決了poll()函數(shù)遺留的兩個問題:
- 沒有了頻繁的用戶態(tài)到內(nèi)核態(tài)的切換;
- O(1)復(fù)雜度,返回的"nfds"是一個確定的可讀寫的數(shù)量,相比于之前循環(huán)n次來確認(rèn),復(fù)雜度降低了不少;
四、同步、異步
細(xì)心的朋友可能會發(fā)現(xiàn),本篇文章一直在解釋“阻塞”和“非阻塞”,“同步”、“異步”的概念沒有涉及,其實在很多場景下同步&異步和阻塞&非阻塞基本上是一個同義詞。阻塞和非阻塞適合從系統(tǒng)調(diào)用API層面來看,就像我們本文介紹的select()、poll()這樣的系統(tǒng)調(diào)用,同步和異步更適合站在應(yīng)用程序的角度來看。應(yīng)用程序在同步執(zhí)行代碼片段的時候結(jié)果不會立即返回,這時候底層IO操作不一定是阻塞的,也完全有可能是非阻塞。所以說:
- 阻塞和非阻塞:讀寫沒有就緒或者讀寫沒有完成,函數(shù)是否要一直等待還是采用輪詢;
- 同步和異步:同步是讀寫由應(yīng)用程序完成。異步是讀寫由操作系統(tǒng)來完成,并通過回調(diào)的機制通知應(yīng)用程序。
這邊順便提兩種大家可能會經(jīng)常聽到的模式:Reactor和Preactor。
- Reactor 模式:主動模式。
- Preactor 模式:被動模式。
五、總結(jié)
本篇文章從底層講解了下從BIO到NIO的一個過程,著重介紹了IO多路復(fù)用的幾個系統(tǒng)調(diào)用select()、poll()、epoll(),分析了下各自的優(yōu)劣,技術(shù)都是持續(xù)發(fā)展演進的,目前也有很多的痛點。
以上就是一文徹底搞懂IO底層原理的詳細(xì)內(nèi)容,更多關(guān)于IO底層原理的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
C語言標(biāo)準(zhǔn)時間與秒單位相互轉(zhuǎn)換
這篇文章主要介紹了C語言標(biāo)準(zhǔn)時間與秒單位相互轉(zhuǎn)換,秒單位與標(biāo)準(zhǔn)時間的轉(zhuǎn)換方式,這份代碼一般用在嵌入式單片機里比較多,比如:設(shè)置RTC時鐘的時間,從RTC里讀取秒單位時間后,需要轉(zhuǎn)換成標(biāo)準(zhǔn)時間顯示。下文分享需要的小伙伴可以參考一下2022-05-05在Visual Studio Code中使用CSSComb格式化CSS文件的教程
這篇文章主要介紹了在Visual Studio Code中使用CSSComb格式化CSS文件,本文通過實例代碼給大家介紹的非常詳細(xì),對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-03-03C++實現(xiàn)LeetCode(312.打氣球游戲)
這篇文章主要介紹了C++實現(xiàn)LeetCode(312.打氣球游戲),本篇文章通過簡要的案例,講解了該項技術(shù)的了解與使用,以下就是詳細(xì)內(nèi)容,需要的朋友可以參考下2021-07-07Linux下實現(xiàn)C++操作Mysql數(shù)據(jù)庫
由于工作需要抽出一周的時間來研究C/C++訪問各種數(shù)據(jù)庫的方法,并打算封裝一套數(shù)據(jù)庫操作類,現(xiàn)在奉上最簡單的一部分:在Linux下訪問MySQL數(shù)據(jù)庫。2017-05-05