溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊(cè)×
其他方式登錄
點(diǎn)擊 登錄注冊(cè) 即表示同意《億速云用戶服務(wù)條款》

IO底層原理的示例分析

發(fā)布時(shí)間:2021-06-17 14:17:48 來源:億速云 閱讀:163 作者:小新 欄目:開發(fā)技術(shù)

這篇文章主要為大家展示了“IO底層原理的示例分析”,內(nèi)容簡(jiǎn)而易懂,條理清晰,希望能夠幫助大家解決疑惑,下面讓小編帶領(lǐng)大家一起研究并學(xué)習(xí)一下“IO底層原理的示例分析”這篇文章吧。

一、混亂的 IO 概念

IO是Input和Output的縮寫,即輸入和輸出。廣義上的圍繞計(jì)算機(jī)的輸入輸出有很多:鼠標(biāo)、鍵盤、掃描儀等等。而我們今天要探討的是在計(jì)算機(jī)里面,主要是作用在內(nèi)存、網(wǎng)卡、硬盤等硬件設(shè)備上的輸入輸出操作。

談起IO的模型,大多數(shù)人腦子里肯定是一坨混亂的概念,“阻塞”、“非阻塞”,“同步”、“異步”有什么區(qū)別?很多同學(xué)傻傻分不清,有嘗試去搜索相關(guān)資料去探究真相,結(jié)果又被淹沒在茫茫的概念之中。

這里嘗試簡(jiǎn)單地去解釋下為啥會(huì)出現(xiàn)這種現(xiàn)象,其中一個(gè)很重要的原因就是大家看到的資料對(duì)概念的解釋都站在了不同的角度,有的站在了底層內(nèi)核的視角,有的直接在java層面或者Netty框架層面給大家介紹API,所以給大家造成了一定程度的困擾。

所以在開篇之前,還是要說下本文所站的視角,我們將會(huì)從底層內(nèi)核的層面給大家講解下IO。因?yàn)槿f變不離其宗,只有了解了底層原理,不管語言層面如何花里胡哨,我們都能以不變應(yīng)萬變。

二、用戶空間和內(nèi)核空間

為了便于大家理解復(fù)雜的IO以及零拷貝相關(guān)的技術(shù),我們還是得花點(diǎn)時(shí)間在回顧下操作系統(tǒng)相關(guān)的知識(shí)。這一節(jié)我們重點(diǎn)看下用戶空間和內(nèi)核空間,基于此后面我們才能更好地聊聊多路復(fù)用和零拷貝。

IO底層原理的示例分析

硬 件 層(Hardware)

包括和我們熟知的和IO相關(guān)的CPU、內(nèi)存、磁盤和網(wǎng)卡幾個(gè)硬件;

內(nèi)核空間(Kernel Space)

計(jì)算機(jī)開機(jī)后首先會(huì)運(yùn)行內(nèi)核程序,內(nèi)核程序占用的一塊私有的空間就是內(nèi)核空間,并且可支持訪問CPU所有的指令集(ring0 - ring3)以及所有的內(nèi)存空間、IO及硬件設(shè)備;

用戶空間(User Space)

每個(gè)普通的用戶進(jìn)程都有一個(gè)單獨(dú)的用戶空間,用戶空間只能訪問受限的資源(CPU的“保護(hù)模式”)也就是說用戶空間是無法直接操作像內(nèi)存、網(wǎng)卡和磁盤等硬件的;

如上所述,那我們可能會(huì)有疑問,用戶空間的進(jìn)程想要去訪問或操作磁盤和網(wǎng)卡該怎么辦呢?

為此,操作系統(tǒng)在內(nèi)核中開辟了一塊唯一且合法的調(diào)用入口“System Call Interface”,也就是我們常說的系統(tǒng)調(diào)用,系統(tǒng)調(diào)用為上層用戶提供了一組能夠操作底層硬件的API。這樣一來,用戶進(jìn)程就可以通過系統(tǒng)調(diào)用訪問到操作系統(tǒng)內(nèi)核,進(jìn)而就能夠間接地完成對(duì)底層硬件的操作。這個(gè)訪問的過程也即用戶態(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)建一個(gè)ServerSocket,并監(jiān)聽8080端口
while(true) {                                              // step2: 主線程進(jìn)入死循環(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ù)寫入
}

這段代碼可以簡(jiǎn)單理解成一下幾個(gè)步驟:

  • 創(chuàng)建ServerSocket,并監(jiān)聽8080端口;

  • 主線程進(jìn)入死循環(huán),用來阻塞監(jiān)聽客戶端的連接,socket.accept();

  • 數(shù)據(jù)讀取,socket.read();

  • 寫入數(shù)據(jù),socket.write();

問題:

以上三個(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”問題

“C10K”即“client 10k”用來指代數(shù)量龐大的客戶端;

BIO看上去非常的簡(jiǎn)單,事實(shí)上采用“BIO+線程池”來處理少量的并發(fā)請(qǐng)求還是比較合適的,也是最優(yōu)的。但是面臨數(shù)量龐大的客戶端和請(qǐng)求,這時(shí)候使用多線程的弊端就逐漸凸顯出來了:

  • 嚴(yán)重依賴線程,線程還是比較耗系統(tǒng)資源的(一個(gè)線程大約占用1M的空間);

  • 頻繁地創(chuàng)建和銷毀代價(jià)很大,因?yàn)樯婕暗綇?fù)雜的系統(tǒng)調(diào)用;

  • 線程間上下文切換的成本很高,因?yàn)榘l(fā)生線程切換前,需要保留上一個(gè)任務(wù)的狀態(tài),以便切回來的時(shí)候,可以再次加載這個(gè)任務(wù)的狀態(tài)。如果線程數(shù)量龐大,會(huì)造成線程做上下文切換的時(shí)間甚至大于線程執(zhí)行的時(shí)間,CPU負(fù)載變高。

3.3、NIO非阻塞模型

下面開始真正走向Java NIO或者Netty框架所描述的“非阻塞”,NIO叫Non-Blocking IO或者New IO,由于BIO可能會(huì)引入的大量線程,所以可以簡(jiǎn)單地理解NIO處理問題的方式是通過單線程或者少量線程達(dá)到處理大量客戶端請(qǐng)求的目的。為了達(dá)成這個(gè)目的,首先要做的就是把阻塞的過程非阻塞化。要想做到非阻塞,那必須得要有內(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è)我們剛剛說的的accept()方法阻塞的例子(Tips:java中的accept方法對(duì)應(yīng)的系統(tǒng)調(diào)用函數(shù)也叫accept),看下官方文檔對(duì)其非阻塞部分的描述。

IO底層原理的示例分析

官方文檔對(duì)accetp()系統(tǒng)調(diào)用的描述是通過把"flags"參數(shù)設(shè)成"SOCK_NONBLOCK"就可以達(dá)到非阻塞的目的,非阻塞之后線程會(huì)一直處理輪詢調(diào)用,這時(shí)候可以通過每次返回特殊的異常碼“EAGAIN”或"EWOULDBLOCK"告訴主程序還沒有連接到達(dá)可以繼續(xù)輪詢。

我們可以很容易想象程序非阻塞之后的一個(gè)大致過程。所以,非阻塞模式有個(gè)最大的特點(diǎn)就是:用戶進(jìn)程需要不斷去主動(dòng)詢問內(nèi)核數(shù)據(jù)準(zhǔn)備好了沒有!

下面我們通過一段偽代碼,看下這個(gè)調(diào)用過程:

// 循環(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)或讀寫數(shù)據(jù)做輪詢。有沒有一種方式來簡(jiǎn)化用戶空間for循環(huán)輪詢的過程呢?那就是我們下面要重點(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ù)傳遞過去就省去了頻繁的系統(tǒng)間調(diào)用。

多路復(fù)用實(shí)際也差不多就是這個(gè)實(shí)現(xiàn)思路,只不過入?yún)⑦@個(gè)“集合”需要你注冊(cè)/填寫感興趣的事件,讀fd、寫fd或者連接狀態(tài)的fd等,然后交給內(nèi)核幫你進(jìn)行處理。

那我們就具體來看看多路復(fù)用里面大家都可能聽過的幾個(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   - 用戶檢查可寫性
 *    *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)程提供了類似批量的接口,函數(shù)本身也會(huì)一直阻塞直到有fd為就緒狀態(tài)返回。下面我們來具體看下select()函數(shù)實(shí)現(xiàn),以便我們更好地分析它有哪些優(yōu)缺點(diǎn)。在select()函數(shù)的構(gòu)造器里,我們很容易看到"fd_set"這個(gè)入?yún)㈩愋?。它是用位圖算法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)位置上是否有讀寫事件,舉例:如果fd == 5,那么fd_set = 000001000。

同時(shí)fd_set定義了四個(gè)宏來處理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)。我們用偽代碼和圖片來描述下用戶進(jìn)程調(diào)用select()的過程:

IO底層原理的示例分析

假設(shè)fds為{1, 2, 3, 5, 7}對(duì)應(yīng)的bitmap為"01110101",拋給內(nèi)核空間輪詢,當(dāng)有讀寫事件時(shí)重新標(biāo)記同時(shí)停止阻塞,然后整體返回用戶空間。由此我們可以看到select()系統(tǒng)調(diào)用的弊端也是比較明顯的:

  • 復(fù)雜度O(n),輪詢的任務(wù)交給了內(nèi)核來做,復(fù)雜度并沒有變化,數(shù)據(jù)取出后也需要輪詢哪個(gè)fd上發(fā)生了變動(dòng);

  • 用戶態(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     - 等待時(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,并通過event變量注冊(cè)感興趣的可讀可寫事件(POLLIN、POLLOUT),最后把pollfd交給內(nèi)核。當(dāng)有讀寫事件觸發(fā)的時(shí)候,我們可以通過輪詢pollfd,判斷revent確定該fd是否發(fā)生了可讀可寫事件。

老樣子我們用偽代碼來描述下用戶進(jìn)程調(diào)用poll()的過程:

IO底層原理的示例分析

poll()相對(duì)于select(),主要的優(yōu)勢(shì)是使用了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都廣泛地使用了此種模式。接下來我們重點(diǎn)分析下,epoll()的實(shí)現(xiàn)采用了“三步走”策略,它們分別是epoll_create()、epoll_ctl()、epoll_wait()。

epoll_create()
/**
 * 返回專用的文件描述符
 */
int epoll_create(int size);

用戶進(jìn)程通過epoll_create()函數(shù)在內(nèi)核空間里面創(chuàng)建了一塊空間(為了便于理解,可以想象成創(chuàng)建了一塊白板),并返回了描述此空間的fd。

epoll_ctl()
/**
 * epoll_ctl()系統(tǒng)調(diào)用
 *
 * 參數(shù)列表:
 *     epfd       - 由epoll_create()返回的epoll專用的文件描述符
 *     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 );

剛剛我們說通過epoll_create()可以創(chuàng)建一塊具體的空間“白板”,那么通過epoll_ctl()我們可以通過自定義的epoll_event結(jié)構(gòu)體在這塊"白板上"注冊(cè)感興趣的事件了。

  • 注冊(cè) - EPOLL_CTL_ADD

  • 修改 - EPOLL_CTL_MOD

  • 刪除 - EPOLL_CTL_DEL

epoll_wait()
/**
 * epoll_wait()返回n個(gè)可讀可寫的fds
 *
 * 參數(shù)列表:
 *     epfd           - 由epoll_create()返回的epoll專用的文件描述符
 *     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ì)一直阻塞等待,直到硬盤、網(wǎng)卡等硬件設(shè)備數(shù)據(jù)準(zhǔn)備完成后發(fā)起硬中斷,中斷CPU,CPU會(huì)立即執(zhí)行數(shù)據(jù)拷貝工作,數(shù)據(jù)從磁盤緩沖傳輸?shù)絻?nèi)核緩沖,同時(shí)將準(zhǔn)備完成的fd放到就緒隊(duì)列中供用戶態(tài)進(jìn)行讀取。用戶態(tài)阻塞停止,接收到具體數(shù)量的可讀寫的fds,返回用戶態(tài)進(jìn)行數(shù)據(jù)處理。

整體過程可以通過下面的偽代碼和圖示進(jìn)一步了解:

IO底層原理的示例分析

epoll()基本上完美地解決了poll()函數(shù)遺留的兩個(gè)問題:

  • 沒有了頻繁的用戶態(tài)到內(nèi)核態(tài)的切換;

  • O(1)復(fù)雜度,返回的"nfds"是一個(gè)確定的可讀寫的數(shù)量,相比于之前循環(huán)n次來確認(rèn),復(fù)雜度降低了不少;

四、同步、異步

細(xì)心的朋友可能會(huì)發(fā)現(xiàn),本篇文章一直在解釋“阻塞”和“非阻塞”,“同步”、“異步”的概念沒有涉及,其實(shí)在很多場(chǎng)景下同步&異步和阻塞&非阻塞基本上是一個(gè)同義詞。阻塞和非阻塞適合從系統(tǒng)調(diào)用API層面來看,就像我們本文介紹的select()、poll()這樣的系統(tǒng)調(diào)用,同步和異步更適合站在應(yīng)用程序的角度來看。應(yīng)用程序在同步執(zhí)行代碼片段的時(shí)候結(jié)果不會(huì)立即返回,這時(shí)候底層IO操作不一定是阻塞的,也完全有可能是非阻塞。所以說:

  • 阻塞和非阻塞:讀寫沒有就緒或者讀寫沒有完成,函數(shù)是否要一直等待還是采用輪詢;

  • 同步和異步:同步是讀寫由應(yīng)用程序完成。異步是讀寫由操作系統(tǒng)來完成,并通過回調(diào)的機(jī)制通知應(yīng)用程序。

這邊順便提兩種大家可能會(huì)經(jīng)常聽到的模式:Reactor和Preactor。

  • Reactor 模式:主動(dòng)模式。

  • Preactor 模式:被動(dòng)模式。

以上是“IO底層原理的示例分析”這篇文章的所有內(nèi)容,感謝各位的閱讀!相信大家都有了一定的了解,希望分享的內(nèi)容對(duì)大家有所幫助,如果還想學(xué)習(xí)更多知識(shí),歡迎關(guān)注億速云行業(yè)資訊頻道!

向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點(diǎn)不代表本網(wǎng)站立場(chǎng),如果涉及侵權(quán)請(qǐng)聯(lián)系站長(zhǎng)郵箱:is@yisu.com進(jìn)行舉報(bào),并提供相關(guān)證據(jù),一經(jīng)查實(shí),將立刻刪除涉嫌侵權(quán)內(nèi)容。

io
AI