Linux 網(wǎng)絡(luò)編程之 I/O 模型

I/O 模型大致分為 5 類:同步阻塞 I/O,同步非阻塞 I/O,異步 I/O,I/O 復(fù)用,信號驅(qū)動。

  • 阻塞 I/O:如 recv、read,一直等,直到接收緩沖區(qū)有數(shù)據(jù)。
  • 非阻塞 I/O:設(shè)置描述符為非阻塞后,若接收緩沖區(qū)有數(shù)據(jù)則讀,無數(shù)據(jù)立即返回,但進程需要一直檢查是否可讀。
  • I/O 復(fù)用:它可以同時處理多個連接,原來單路的是一個處理完才能接入新的連接。人們常說的 select/poll/epoll 僅僅是查詢多路復(fù)用 I/O 狀態(tài)的方式。
  • 信號驅(qū)動:采用信號等待機制,不用監(jiān)視描述符了,也不用阻塞著等待數(shù)據(jù)到來。等待信號通知,調(diào)用相應(yīng)的信號處理函數(shù),這個信號告知應(yīng)用程序的是“現(xiàn)在可以進行 I/O 了”。
  • 異步 I/O:應(yīng)用程序發(fā)送 I/O 請求后不用等也不用主動發(fā)送獲取結(jié)果的請求,收到通知時,系統(tǒng)已經(jīng)把數(shù)據(jù)讀好了(是的,這個通知也不是告訴你數(shù)據(jù)到了去取吧,而是直接告知你 I/O 請求過程已經(jīng)結(jié)束了),應(yīng)用程序可以直接處理數(shù)據(jù)了。

1. 阻塞 I/O

幾乎所有的程序員第一次接觸到的網(wǎng)絡(luò)編程都是從 listen()、send()、recv() 開始的,這些接口都是阻塞型的(接口的使用介紹見《socket編程在TCP中的應(yīng)用》)。下面是一個簡單的“一問一答”型服務(wù)器:

Figure 1-1 單線程阻塞 I/O

當(dāng)用戶進程調(diào)用了 recv() 這個系統(tǒng)調(diào)用,kernel 就開始了 I/O 的第一個階段:準(zhǔn)備數(shù)據(jù)。對于網(wǎng)絡(luò) I/O 來說,很多時候數(shù)據(jù)在一開始還沒能完整到達,這個時候 kernel 就要等待足夠的數(shù)據(jù)到來。而在用戶進程這邊,整個進程會被阻塞。kernel 一直等到數(shù)據(jù)準(zhǔn)備好了,就將數(shù)據(jù)從 kernel 中拷貝到用戶內(nèi)存,然后返回結(jié)果,用戶進程才解除阻塞狀態(tài),重新運行起來。

稍微改進一下 Figure 1-1 中的 CS 模型,將服務(wù)器改為多線程的,可以同時為許多客戶端提供“一問一答”型服務(wù):

Figure 1-2 多線程阻塞 I/O

這樣任何一個連接的阻塞都不會影響其他的連接。主線程持續(xù)等待客戶端的連接請求,如果有連接,則創(chuàng)建新線程,并在新線程中提供為前例同樣的問答服務(wù)。

TCP 服務(wù)器不是一對一的嗎?這樣多次調(diào)用 accept() 沒關(guān)系嗎?

  • 一個端口肯定只能綁定一個 socket,但這個 socket 可能會產(chǎn)生很多“socket 連接”。要是服務(wù)器性能好一個端口就可以綁定無數(shù)個“socket連接”(其實也不是無數(shù)個,假設(shè)使用 IPv4 的話就是 IPv4 的地址空間 * 端口號個數(shù) = 248 個,假設(shè)服務(wù)器內(nèi)存夠存)。
  • 五元組(本地 IP,本地端口,遠程 IP,遠程端口,協(xié)議)唯一標(biāo)識服務(wù)器的一個 socket,其中協(xié)議通過 socket() 方法指定,然后本地 IP 和端口通過 bind() 方法綁定(想要多次綁定同一對 IP 端口一定會報錯),最后 accept() 方法綁定發(fā)來連接請求(connect())的客戶端 IP 和端口,想連多少連多少。
  • TCP 的一對一指的是 accept() 方法產(chǎn)生的連接套接字只能對應(yīng)一個客戶端,但監(jiān)聽套接字下可能已經(jīng)對應(yīng)了許多客戶端。

上述多線程的服務(wù)器模型似乎完美的解決了為多個客戶機提供問答服務(wù)的要求,但若要同時響應(yīng)成百上千路的連接請求,無論多線程還是多進程都會嚴重占據(jù)系統(tǒng)資源,降低響應(yīng)效率。很多程序員可能會考慮使用線程池連接池。這兩種技術(shù)都可以很好的降低系統(tǒng)開銷。

  • 線程池旨在減少創(chuàng)建和銷毀線程的頻率,維持一定合理數(shù)量的線程,并讓空閑的線程重新承擔(dān)新的執(zhí)行任務(wù)。
  • 連接池維持 socket 連接,盡量重用已有的連接,減少創(chuàng)建和關(guān)閉連接的頻率。

但是,“線程池”和“連接池”技術(shù)也只是在一定程度上緩解了頻繁調(diào)用 I/O 接口帶來的資源占用問題。而且“池”始終有其上限,當(dāng)請求大大超過上限時,“池”構(gòu)成的系統(tǒng)對外界的響應(yīng)并不比沒有“池”的時候好多少。所以使用“池”必須考慮其面臨的響應(yīng)規(guī)模,并根據(jù)響應(yīng)規(guī)模調(diào)整“池”的大小。

總之,多線程模型可以方便高效的解決小規(guī)模的服務(wù)請求,但面對大規(guī)模的服務(wù)請求,多線程模型也會遇到瓶頸,可以用非阻塞接口來嘗試解決這個問題。

2. 非阻塞接口

在 Linux 中,默認情況下所有的 socket 都是阻塞的,要設(shè)置成非阻塞的可以參考如下程序:

void setnonblocking(int sock) {
    int opts;
    opts = fcntl(sock, F_GETFL);    // 讀取文件狀態(tài)標(biāo)識
    if (opts < 0) {
        perror("fcntl(sock, GETFL)");
        exit(1);
    }
    opts = opts|O_NONBLOCK;
    if (fcntl(sock,F_SETFL,opts)<0) {    // 設(shè)置文件描述符狀態(tài)
        perror("fcntl(sock,SETFL,opts)");
        exit(1);
    }
}

非阻塞的接口相比于阻塞型接口的顯著差異在于,在被調(diào)用之后立即返回。當(dāng)用戶進程發(fā)出 read() 操作時,如果 kernel 中的數(shù)據(jù)還沒有準(zhǔn)備好,那么它并不會阻塞用戶進程,而是立刻返回一個結(jié)果。用戶進程判斷結(jié)果是一個 error 時,它就知道數(shù)據(jù)還沒有準(zhǔn)備好,于是它可以先做點別的,過一會再進行 read() 操作。一旦 kernel 中的數(shù)據(jù)準(zhǔn)備好了,并且又再次收到了用戶進程的 system call,那么它馬上就將數(shù)據(jù)拷貝到了用戶內(nèi)存,然后返回。

所以,在非阻塞式 I/O 中,用戶進程其實是需要不斷的主動詢問 kernel 數(shù)據(jù)準(zhǔn)備好了沒有。

在非阻塞狀態(tài)下,recv()read() 接口在被調(diào)用后立即返回,返回值代表了不同的含義:

  • 返回值大于 0,表示接受數(shù)據(jù)完畢,返回值即是接受到的字節(jié)數(shù);
  • 返回 0,表示連接已經(jīng)正常斷開,可以 close 掉這個連接套接字了;
  • 返回 -1,且 errno 等于 EAGAIN,表示讀取操作未完成,接收緩沖區(qū)暫時無數(shù)據(jù)可讀;
  • 返回 -1,且 errno 不等于 EAGAIN,表示 recv 操作遇到系統(tǒng)錯誤,錯誤值記錄在 errno 中。

I/O 復(fù)用

這種 I/O 方式也稱為事件驅(qū)動 I/O。它的好處就在于單個進程就可以同時處理多個網(wǎng)絡(luò)連接的 I/O。它的基本原理就是 select/poll/epoll 這個方法會不斷的輪詢所負責(zé)的所有 socket 描述符,當(dāng)某個 socket 有數(shù)據(jù)到達了,就通知用戶進程,用戶再調(diào)用 recv()/read(),就不會阻塞在 I/O 上了。

這部分的介紹見之前的總結(jié)《并發(fā)編程與 IO 復(fù)用》

I/O 復(fù)用其實也是阻塞的,只不過不是阻塞在 I/O 讀取上,而是阻塞在 select/poll/epoll 調(diào)用上。反正最終都會導(dǎo)致用戶的進程阻塞,那么阻塞在哪到底有什么意義呢?
復(fù)用最大的好處還是能處理更多的套接字,進程阻塞等待所有套接字中的人一個變?yōu)榭勺x即解除阻塞。也不需要像同步非阻塞 I/O 一樣你自己處理輪詢的邏輯。如果場景中不會有大量連接,那么復(fù)用當(dāng)然也就沒意義。

信號驅(qū)動

進程預(yù)先告知內(nèi)核,使得當(dāng)某個 socket fd 有事件發(fā)生時,內(nèi)核使用信號通知相關(guān)進程。這個信號與一個信號處理函數(shù)綁定,在信號處理函數(shù)內(nèi)執(zhí)行 I/O 操作。

信號驅(qū)動的作用在于在等待 I/O 可用的過程中可以執(zhí)行其它的指令。這種方法從理論上看是不錯,由于進程已經(jīng)休眠,就不會再占用 CPU,僅當(dāng) I/O 可用時它才恢復(fù)執(zhí)行。但是這種方法的問題在于信號處理的開銷有點大。若只是少數(shù)的請求還沒有問題,若是每分鐘收到 100 個請求,那就幾乎一直都在捕獲信號。每秒鐘捕獲上百個信號的開銷是相當(dāng)大的,不單是進程,對于內(nèi)核發(fā)送信號的開銷而言也是一樣的。

因此,在高性能的服務(wù)器編程中,用異步 I/O 來處理多個 I/O 更為高效。當(dāng)然,也可以用 I/O 復(fù)用模型來實現(xiàn)(epoll 是一個相當(dāng)高效的方法),但是對于 Regular File 來說,是不能使用 epoll 的,因為不能設(shè)置非阻塞模式(O_NOBLOCK 方式對于傳統(tǒng)文件句柄是無效的,但 epoll 的邊緣觸發(fā)模式必須設(shè)置為非阻塞)。

異步 I/O

用戶進程發(fā)起讀請求之后,立刻就可以開始去做其它的事。

從 kernel 的角度,當(dāng)它收到一個異步讀請求之后,首先它會立刻返回,所以不會對用戶進程產(chǎn)生任何阻塞。然后,kernel 會等待數(shù)據(jù)準(zhǔn)備完成,將數(shù)據(jù)拷貝到用戶內(nèi)存。當(dāng)這一切都完成之后,kernel 會給用戶進程發(fā)送一個 signal,告訴它讀操作完成了。

信號驅(qū)動 I/O 和異步 I/O 比較容易混淆,信號驅(qū)動雖然也是異步的但它不同于我們所說的異步 I/O(意思是說信號驅(qū)動的 I/O 過程仍然不是異步的),這兩者的過程其實看操作系統(tǒng)到底替應(yīng)用程序做了哪些事就能分辨出來:

模型 內(nèi)核 進程
信號驅(qū)動 I/O 模型 發(fā)送信號:I/O 能用了 接收到 I/O 能用的信號并執(zhí)行接下來的操作
異步 I/O 模型 等待這個 I/O 有消息了,接受到數(shù)據(jù) 從緩存中得到數(shù)據(jù)直接處理
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容