在分散式架構中,網路通訊是底層基礎,沒有網路,也就沒有所謂的分散式架構。只有通過網路才能使得一大片機器互相協作,共同完成一件事情。
同樣,在大規模的系統架構中,應用吞吐量上不去、網路存在通訊延遲、我們首先考慮的都是網路問題,因此網路的重要性不言而喻。
作為現代化應用型程式設計師,要開發一個網路通訊的應用,是非常簡單的。不僅僅有成熟的api,還有非常方便的通訊框架。
可能大家已經忘記了網路通訊的重要性,本篇文章會詳細分析網路通訊的底層原理!!
1.1 理解通訊的本質
如圖1-1所示,當我們通過瀏覽器訪問一個網址時,一段時間後該網址會渲染出訪問的內容,這個過程是怎麼實現的呢?
我想站在今天,在做的同學都知道,它是基於http協議來實現資料通訊的,這裡有兩個字很重要,就是“協議”。
兩個計算機之間要實現資料通訊,必須遵循同一種協議,否則,就像一箇中國人和一個外國人交流時,一個講英語另一個講解中文,肯定是無法正常交流。在計算機中,協議非常常見。
1.1.1 協議的組成
我們寫的Java程式碼,計算機能夠理解並且執行,原因是人和計算機之間遵循了同一種語言,那就是Java,如圖1-2所示,.java檔案最終編譯成.class檔案這個過程,也同樣涉及到協議。
所以,在計算機中,協議是指大家需要共同遵循的規則,只有實現統一規則之後,才能實現不同節點之間的資料通訊,從而讓計算機的應用更加強大。
組成一個協議,需要具備三個要素:
- 語法,就是這一段內容要符合一定的規則和格式。例如,括號要成對,結束要使用分號等。
- 語義,就是這一段內容要代表某種意義。例如數字減去數字是有意義的,數字減去文字一般來說就沒有意義。
- 時序,就是先幹啥,後幹啥。例如,可以先加上某個數值,然後再減去某個數值。
1.1.2 http協議
理解了協議的作用,那協議是長什麼樣的呢?
那麼再來看圖1-3的場景,人們通過瀏覽器訪問網站,用到了http協議。
http協議包含包含幾個部分:
- http請求組成
- 狀態行
- 請求頭
- 訊息主體
- http響應組成
- 狀態行
- 響應頭
- 響應正文
Http響應報文如圖1-4所示,那麼這個協議的三要素分別是:
- 語法: http協議的訊息體由狀態、頭部、內容組成。
- 語義: 比如狀態,200表示成功,404表示請求路徑不存在等,通訊雙方必須遵循該語義。
- 時序: 組成訊息體的三部分的排列順序,必須要有request,才會產生response。
而瀏覽器按照http協議做好了相關的處理後,才能讓大家通過網址訪問網路上的各種資訊。
1.1.3 常用的網路協議
DNS協議、Http協議、SSH協議、TCP協議、FTP協議等,這些都是大家比較常用的協議型別。無論哪種協議,本質上仍然是由協議的三要素組成,只是應用場景不同。
DNS、HTTP、HTTPS 所在的層我們稱為應用層。經過應用層封裝後,瀏覽器會將應用層的包交給下一層去完成,通過 socket 程式設計來實現。下一層是傳輸層。傳輸層有兩種協議,一種是無連線的協議 UDP,一種是面向連線的協議 TCP。對於通訊可靠性要求的場景來說,往往使用 TCP 協議。所謂的面向連線就是,TCP 會保證這個包能夠到達目的地。如果不能到達,就會重新傳送,直至到達。
1.3 TCP/IP通訊原理分析
一次網路通訊到底是怎麼完成的呢?
涉及到網路通訊,那我們一定會提到一個網路模型的概念,如圖1-5所示。表示TCP/IP的四層概念模型和OSI七層網路模型,它是一種概念模型,由國際標準化組織提出來的,試圖讓全世界範圍內的計算機能基於該網路標準實現互聯。
網路模型為什麼要分層呢?其實從我們現在的業務分層架構中就不難發現,任何系統一旦變得複雜,就都會採用分層設計。它的主要好處是
- 實現高內聚低耦合
- 每一層有自己單一的職責
- 提高可複用性和降低維護成本
1.2.1 http通訊過程的傳送資料包
由於我們的課程並不是專門來講網路,所以只是提及一下網路分層模型,為了讓大家更簡單的理解網路分層模型的工作原理,我們仍然以一次網路通訊的資料包傳輸為例進行分析,如圖1-6所示。
圖1-6的工作流程描述如下:
-
假設我們要登入某一個網站,此時基於Http協議會構建一個http協議報文,這個報文中按照http協議的規範組裝,其中包括要傳輸的使用者名稱和密碼。這個是屬於應用層協議。
-
經過應用層封裝後,瀏覽器會把應用層的包交給TCP/IP四層模型中的下一層,也就是傳輸層來完成,傳輸層有兩種協議:
- TCP協議,可靠的通訊協議,該協議會確保資料包能達到目的地
- UDP協議,不可靠通訊協議,可能會存在資料丟失
在http通訊中使用了TCP協議,TCP協議會有兩個埠,一個是瀏覽器監聽的埠,一個是目標伺服器程式的埠。作業系統會根據埠來判斷這個資料包應該分發給那個程式。
-
傳輸層封裝完成後,該資料包會技術交給網路層來處理,網路層協議是IP協議,IP協議中會包含源IP地址(也就是客戶端及其的IP)和目標伺服器的IP地址。
-
作業系統知道了目標IP地址後,就開始根據這個IP來尋找目標機器,而目標伺服器一定是部署在不同的地方,這種跨網路節點的訪問,需要經過閘道器(所謂閘道器就是一個網路到另外一個網路的關口)。
所以資料包首先需要先通過自己當前所在網路的閘道器出去,然後訪問到目標伺服器,但是在資料包傳輸到目標伺服器之前,需要再組裝MAC頭資訊。
Mac頭包含本地的Mac地址和目標伺服器的Mac地址,這個MAC地址怎麼獲得的呢?
-
獲取本機MAC地址的方法是,作業系統會傳送一個廣播訊息詢問閘道器地址(192.168.1.1)是誰?收到該廣播訊息的閘道器會回應一個MAC地址。這個廣播訊息是基於ARP協議實現的(這個協議簡單來說就是已知目標機器的ip,需要獲得目標機器的mac地址。(傳送一個廣播訊息,這個ip是誰的,請來認領。認領ip的機器會傳送一個mac地址的響應))。
為了避免每次都用 ARP 請求,機器本地也會進行 ARP 快取。當然機器會不斷地上線下線,IP 也可能會變,所以 ARP 的 MAC 地址快取過一段時間就會過期。
-
獲取遠端機器的MAC地址的方法也同樣是基於ARP協議實現的。
-
完成MAC地址組裝後,一個完整的資料包就構成了。這個時候會把這個資料包給到網路卡,網路卡再把這個資料包發出去,由於這個資料包中包含MAC地址,因此它能夠到達閘道器進行傳輸。閘道器收到包之後,會根據路由資訊,判斷下一步應該怎麼走。閘道器往往是一個路由器,到某個 IP 地址應該怎麼走,這個叫作路由表。
1.2.2 http通訊過程中的接收資料包
當資料包傳送到閘道器後,會根據閘道器的路由資訊判斷該資料包要傳輸到那個網段上。資料從客戶端傳送到目標伺服器,可能會經過多個閘道器,所以資料包根據閘道器路由進入到下一個閘道器後,繼續根據下一個閘道器的MAC地址尋找下下一個閘道器,直到到達目標網路伺服器上。
這個時候伺服器收到包之後,最後一個閘道器知道這個網路包就是要去當前區域網的,於是拿著目標IP通過ARP協議大喊一聲這是誰? 目標伺服器就會給閘道器回復一個MAC地址。 然後網路包在最後那個閘道器修改目標的MAC地址,通過這個MAC地址,網路包找到了目標伺服器。
當目標伺服器和MAC地址對上後,開始取出MAC頭資訊,接著把資料包傳送給作業系統的網路層。網路層會取出IP頭資訊,IP頭裡面會寫上一層封裝的是TCP協議,於是交給傳輸層來處理,實現過程如圖1-7所示。
在這一層中,對於收到的每個資料包都會有一個回覆,表示伺服器端已經收到了該資料包。如果過一段時間客戶端沒有收到該確認包,傳送端的 TCP 層會重新傳送這個包,還是上面的過程,直到最終收到回覆。
這個重試是TCP協議層來實現的,不需要我們應用來主動發起。
為什麼有了MAC層還要走IP層呢?
之前我們提到,mac地址是唯一的,那理論上,在任何兩個裝置之間,我應該都可以通過mac地址傳送資料,為什麼還需要ip地址?
mac地址就好像個人的身份證號,人的身份證號和人戶口所在的城市,出生的日期有關,但是和人所在的位置沒有關係,人是會移動的,知道一個人的身份證號,並不能找到它這個人,mac地址類似,它是和裝置的生產者,批次,日期之類的關聯起來,知道一個裝置的mac,並不能在網路中將資料傳送給它,除非它和傳送方的在同一個網路內。
所以要實現機器之間的通訊,我們還需要有ip地址的概念,ip地址表達的是當前機器在網路中的位置,類似於城市名+道路號+門牌號的概念。通過ip層的定址,我們能知道按何種路徑在全世界任意兩臺Internet上的的機器間傳輸資料。
1.4 詳解TCP可靠性通訊特性
我們知道,TCP協議是屬於可靠性通訊協議,它能夠確保資料包不被丟失。首先我們先了解一下TCP的三次握手和四次揮手。
1.4.1 TCP的三次握手
兩個節點需要進行資料通訊,首先得先建立連線。而在建立連線時,TCP採用了三次握手來實現連線建立。如圖1-8所示。
第一次握手(SYN=1, seq=x)
客戶端傳送一個 TCP的 SYN 標誌位置1的包,指明客戶端打算連線的伺服器的埠,以及初始序號 X,儲存在包頭的序列號(Sequence Number)欄位裡。傳送完畢後,客戶端進入 SYN_SEND 狀態。
第二次握手(SYN=1, ACK=1, seq=y, ACK num=x+1):
伺服器發回確認包(ACK)應答。即 SYN 標誌位和 ACK 標誌位均為1。伺服器端選擇自己 ISN 序列號,放到Seq 域裡,同時將確認序號(Acknowledgement Number)設定為客戶的 ISN 加1,即X+1。 傳送完畢後,伺服器端進入 SYN_RCVD 狀態。
第三次握手(ACK=1,ACK num=y+1)
客戶端再次傳送確認包(ACK),SYN標誌位為0,ACK標誌位為1,並且把伺服器發來 ACK的序號欄位+1,放在確定欄位中傳送給對方,並且在資料段放寫ISN發完畢後,客戶端進入 ESTABLISHED 狀態,當伺服器端接收到這個包時,也進入 ESTABLISHED 狀態,TCP握手結束。
1.4.2 TCP為什麼是三次握手?
TCP是全雙工,如果沒有第三次的握手,服務端不能確認客戶端是否ready,不知道什麼時候可以往客戶端發資料包。三次的握手剛好兩邊都互相確認對方已經ready。
我們假設網路的不可靠性,
A發起一個連線,當發起一個請求沒有得到反饋的時候,會有很多可能性,比如請求包丟失,或者超時,或者B沒有響應
由於A不能確認結果,於是再發,當有一個請求包到了B之後,A並不知道這個資料包已經到了B,所以可能還會重試。
所以B收到請求之後,知道了A的存在並且要和我建立連線,這個時候B會傳送ack給到A,告訴A我收到了請求包。
對於B來說,這個應答包也是一個網路通訊,我怎麼知道能不能到達A呢?所以這個時候B不能很主觀的認為連線已經建立好了,還需要等到A再次傳送應答包來確認。
1.4.3 TCP的四次揮手
如圖1-9所示,TCP的連線斷開,會通過所謂的四次揮手完成。
四次揮手錶示TCP斷開連線的時候,需要客戶端和服務端總共傳送4個包以確認連線的斷開;客戶端或伺服器均可主動發起揮手動作(因為TCP是一個全雙工協議),在 socket 程式設計中,任何一方執行 close() 操作即可產生揮手操作。
上述互動過程如下:
-
斷開的時候,我們可以看到,當A客戶端說說“我要斷開連線”,就進入 FIN_WAIT_1 的狀態。
-
B 服務端收到“我要斷開連線”的訊息後,傳送"知道了"給到A客戶端,就進入 CLOSE_WAIT 的狀態。
-
A 收到“B 說知道了”,就進入 FIN_WAIT_2 的狀態,如果這個時候 B 伺服器掛掉了,則 A 將永遠在這個狀態。TCP 協議裡面並沒有對這個狀態的處理,但是 Linux 有,可以調整 tcp_fin_timeout 這個引數,設定一個超時時間。
-
如果 B 伺服器正常,則傳送了“B 要關閉連線”的請求到達 A 時,A 傳送“知道 B 也要關閉連線”的 ACK 後,從 FIN_WAIT_2 狀態結束。
-
按說這個時候 A 可以退出了,但是最後的這個 ACK 萬一 B 收不到呢?則 B 會重新發一個“B 要關閉連線”,這個時候 A 已經跑路了的話,B 就再也收不到 ACK 了,因而 TCP 協議要求 A 最後等待一段時間 TIME_WAIT,這個時間要足夠長,長到如果 B 沒收到 ACK 的話,“B 說不玩了”會重發的,A 會重新發一個 ACK 並且足夠時間到達 B。
這個等待實現是2MSL,MSL 是 Maximum Segment Lifetime,報文最大生存時間,它是任何報文在網路上存在的最長時間,超過這個時間報文將被丟棄(此時A直接進入CLOSE狀態)。協議規定 MSL 為 2 分鐘,實際應用中常用的是 30 秒,1 分鐘和 2 分鐘等。
第一次揮手(FIN=1,seq=x)
假設客戶端想要關閉連線,客戶端傳送一個 FIN 標誌位置為1的包,表示自己已經沒有資料可以傳送了,但是仍然可以接受資料。傳送完畢後,客戶端進入 FIN_WAIT_1 狀態。
第二次揮手(ACK=1,ACKnum=x+1)
伺服器端確認客戶端的 FIN包,傳送一個確認包,表明自己接受到了客戶端關閉連線的請求,但還沒有準備好關閉連線。傳送完畢後,伺服器端進入 CLOSE_WAIT 狀態,客戶端接收到這個確認包之後,進入 FIN_WAIT_2 狀態,等待伺服器端關閉連線。
第三次揮手(FIN=1,seq=w)
伺服器端準備好關閉連線時,向客戶端傳送結束連線請求,FIN置為1。傳送完畢後,伺服器端進入 LAST_ACK 狀態,等待來自客戶端的最後一個ACK。
第四次揮手(ACK=1,ACKnum=w+1)
客戶端接收到來自伺服器端的關閉請求,傳送一個確認包,並進入 TIME_WAIT狀態,等待可能出現的要求重傳的 ACK包。伺服器端接收到這個確認包之後,關閉連線,進入 CLOSED 狀態。
【問題1】為什麼連線的時候是三次握手,關閉的時候卻是四次握手?
答:三次握手是因為因為當Server端收到Client端的SYN連線請求報文後,可以直接傳送SYN+ACK報文。其中ACK報文是用來應答的,SYN報文是用來同步的。但是關閉連線時,當Server端收到FIN報文時,很可能並不會立即關閉SOCKET(因為可能還有訊息沒處理完),所以只能先回復一個ACK報文,告訴Client端,"你發的FIN報文我收到了"。只有等到我Server端所有的報文都傳送完了,我才能傳送FIN報文,因此不能一起傳送。故需要四步握手。
【問題2】為什麼TIME_WAIT狀態需要經過2MSL(最大報文段生存時間)才能返回到CLOSE狀態?
答:雖然按道理,四個報文都傳送完畢,我們可以直接進入CLOSE狀態了,但是我們必須假象網路是不可靠的,有可以最後一個ACK丟失。所以TIME_WAIT狀態就是用來重發可能丟失的ACK報文。
1.4.4 TCP協議的報文傳輸
連線建立好之後,就開始進行資料包的傳輸了。那TCP作為一個可靠的通訊協議,如何保證訊息傳輸的可靠性呢?
TCP採用了訊息確認的方式來保證資料包文傳輸的安全性,也就是說客戶端傳送了資料包到服務端後,服務端會返回一個確認訊息給到客戶端,如果客戶端沒有收到確認包,則會重新再傳送。
為了保證順序性,每一個包都有一個 ID。在建立連線的時候,會商定起始的 ID 是什麼,然後按照 ID 一個個傳送。為了保證不丟包,對於傳送的包都要進行應答,但是這個應答也不是一個一個來的,而是會應答某個之前的 ID,表示都收到了,這種模式稱為累計確認或者累計應答(cumulative acknowledgment)
如圖1-10所示,為了記錄所有傳送的包和接收的包,TCP協議在傳送端和接收端分別拿會有傳送緩衝區和接收緩衝區,TCP的全雙工的工作模式及TCP的滑動視窗就是依賴於這兩個獨立的Buffer和該Buffer的填充狀態。
接收緩衝區把資料快取到核心,若應用程式一直沒有呼叫Socket的read方法進行讀取,那麼該資料會一直被快取在接收緩衝區內。不管程式是否讀取Socket,對端發來的資料都會經過核心接收並快取到Socket的核心接收緩衝區。
read所要做的工作,就是把核心接收緩衝區中的資料複製到應用層使用者的Buffer裡。程式呼叫Socket的send傳送資料的時候,一般情況下是將資料從應用層使用者的Buffer裡複製到Socket的核心傳送緩衝區,然後send就會在上層返回。換句話說,send返回時,資料不一定會被髮送到對端。
傳送端/接收端的緩衝區中是按照包的 ID 一個個排列,根據處理的情況分成四個部分。
- 第一部分:傳送了並且已經確認的。
- 第二部分:傳送了並且尚未確認的。需要等待確認後,才能移除。
- 第三部分:沒有傳送,但是已經等待傳送的。
- 第四部分:沒有傳送,並且暫時還不會傳送的。
這裡的第三部分和第四部分之所以做一個區分,其實是因為TCP採用做了流量控制,這裡採用了滑動視窗的方式來實現流量整形,避免出現資料擁堵的情況。
為了更好的理解資料包的通訊過程,我們通過下面這個網址來演示一下
1.4.5 滑動視窗協議
上述地址中動畫演示的部分,其實就是資料包傳送和確認機制,同時還涉及到互動視窗協議。
滑動視窗(Sliding window)是一種流量控制技術。早期的網路通訊中,通訊雙方不會考慮網路的擁擠情況直接傳送資料。由於大家不知道網路擁塞狀況,同時傳送資料,導致中間節點阻塞掉包,誰也發不了資料,所以就有了滑動視窗機制來解決此問題;傳送和接受方都會維護一個資料幀的序列,這個序列被稱作視窗
傳送視窗
就是傳送端允許連續傳送的幀的序號表。
傳送端可以不等待應答而連續傳送的最大幀數稱為傳送視窗的尺寸。
接收視窗
接收方允許接收的幀的序號表,凡落在 接收視窗內的幀,接收方都必須處理,落在接收視窗外的幀被丟棄。
接收方每次允許接收的幀數稱為接收視窗的尺寸。
1.5 理解阻塞通訊的本質
理解了TCP通訊的原理後,在Java中我們會採用Socket套接字來實現網路通訊,下面這段程式碼演示了Socket通訊的案例。
public class ServerSocketExample {
public static void main(String[] args) throws IOException {
final int DEFAULT_PORT = 8080;
ServerSocket serverSocket = null;
serverSocket = new ServerSocket(DEFAULT_PORT);
System.out.println("啟動服務,監聽埠:" + DEFAULT_PORT);
while (true) {
Socket socket = serverSocket.accept();
System.out.println("客戶端:" + socket.getPort() + "已連線");
new Thread(new Runnable() {
Socket socket;
public Runnable setSocket(Socket s){
this.socket=s;
return this;
}
@Override
public void run() {
try {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String clientStr = null; //讀取一行資訊
clientStr = bufferedReader.readLine();
System.out.println("客戶端發了一段訊息:" + clientStr);
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bufferedWriter.write("我已經收到你的訊息了");
bufferedWriter.flush(); //清空緩衝區觸發訊息傳送
} catch (IOException e) {
e.printStackTrace();
}
}
}.setSocket(socket)).start();
}
}
}
在我們講Redis的專題中詳細講到過,上述通訊是BIO模型,也就是阻塞通訊模型,阻塞主要體現的點是
- accept,阻塞等待客戶端連線
- io阻塞,阻塞等待客戶端的資料傳輸。
相信大家和我一樣有一些以後,這個阻塞和喚醒到底是怎麼回事,下面我們簡單來了解一下。
1.5.1 阻塞操作的本質
阻塞是指程式在等待某個事件發生之前的等待狀態,它是屬於作業系統層面的排程,我們通過下面操作來追蹤Java程式中有多少程式,每一個執行緒對核心產生了哪些操作。
strace,Linux作業系統中的指令
-
把ServerSocketExample.java,去掉package匯入頭,拷貝到linux伺服器的 /data/app目錄下。
-
使用javac ServerSocketExample.java進行編譯,得到.class檔案
-
使用下面這個命令來追蹤(開啟一個新視窗)
按照strace官網的描述, strace是一個可用於診斷、除錯和教學的Linux使用者空間跟蹤器。我們用它來監控使用者空間程式和核心的互動,比如系統呼叫、訊號傳遞、程式狀態變更等。
strace -ff -o out java ServerSocketExample
- -f 跟蹤目標程式,以及目標程式建立的所有子程式
- -o 把strace的輸出單獨寫到指定的檔案
-
上述指令執行完成後,會在/data/app目錄下得到很多out.*的檔案,每個檔案代表一個執行緒。因為Java本身是多執行緒的。
[root@localhost app]# ll total 748 -rw-r--r--. 1 root root 14808 Aug 23 12:51 out.33320 //最小的表示主執行緒 -rw-r--r--. 1 root root 186893 Aug 23 12:51 out.33321 -rw-r--r--. 1 root root 961 Aug 23 12:51 out.33322 -rw-r--r--. 1 root root 917 Aug 23 12:51 out.33323 -rw-r--r--. 1 root root 833 Aug 23 12:51 out.33324 -rw-r--r--. 1 root root 819 Aug 23 12:51 out.33325 -rw-r--r--. 1 root root 23627 Aug 23 12:53 out.33326 -rw-r--r--. 1 root root 1326 Aug 23 12:51 out.33327 -rw-r--r--. 1 root root 1144 Aug 23 12:51 out.33328 -rw-r--r--. 1 root root 1270 Aug 23 12:51 out.33329 -rw-r--r--. 1 root root 8136 Aug 23 12:53 out.33330 -rw-r--r--. 1 root root 8158 Aug 23 12:53 out.33331 -rw-r--r--. 1 root root 6966 Aug 23 12:53 out.33332 -rw-r--r--. 1 root root 1040 Aug 23 12:51 out.33333 -rw-r--r--. 1 root root 445489 Aug 23 12:53 out.33334
-
開啟out.33321這個檔案(主執行緒後面的一個檔案),shift+g到該檔案的尾部,可以看到如下內容。
下面這些方法,都是屬於系統呼叫,也就是呼叫作業系統提供的核心指令觸發相關的操作。
# 建立socket fd socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5 .... # 繫結8888埠 bind(5, {sa_family=AF_INET6, sin6_port=htons(8888), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0 # 建立一個socket並監聽申請的連線, 5表示sockfd,50表示等待佇列的最大長度 listen(5, 50) = 0 mprotect(0x7f21d00df000, 4096, PROT_READ|PROT_WRITE) = 0 write(1, "\345\220\257\345\212\250\346\234\215\345\212\241\357\274\214\347\233\221\345\220\254\347\253\257\345\217\243\357\274\23288"..., 34) = 34 write(1, "\n", 1) = 1 lseek(3, 58916778, SEEK_SET) = 58916778 read(3, "PK\3\4\n\0\0\10\0\0U\23\213O\336\274\205\24X8\0\0X8\0\0\25\0\0\0", 30) = 30 lseek(3, 58916829, SEEK_SET) = 58916829 read(3, "\312\376\272\276\0\0\0004\1\367\n\0\6\1\37\t\0\237\1 \t\0\237\1!\t\0\237\1\"\t\0"..., 14424) = 14424 # poll, 把當前的檔案指標掛到等待佇列,檔案指標指的是fd=5,簡單來說就是讓當前程式阻塞,直到有事件觸發喚醒 * events: 表示請求事件,POLLIN(普通或優先順序帶資料可讀)、POLLERR,發生錯誤。 poll([{fd=5, events=POLLIN|POLLERR}], 1, -1
從這個程式碼中可以看到,Socket的accept方法最終是呼叫系統的poll函式來實現執行緒阻塞的。
通過在linux伺服器輸入 man 2 poll
man: 幫助手冊
2: 表示系統呼叫相關的函式
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函式,它可以等待一組檔案描述符中的IO就緒事件
-
通過下面命令訪問socket server。
telnet 192.168.221.128 8888
這個時候通過tail -f out.33321這個檔案,發現被阻塞的poll()方法,被POLLIN事件喚醒了,表示監聽到了一次連線。
poll([{fd=5, events=POLLIN|POLLERR}], 1, -1) = 1 ([{fd=5, revents=POLLIN}]) accept(5, {sa_family=AF_INET6, sin6_port=htons(53778), inet_pton(AF_INET6, "::ffff:192.168.221.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 6
1.5.2 阻塞被喚醒的過程
如圖1-12所示,網路資料包通過網線傳輸到目標伺服器的網路卡,再通過2所示的硬體電路傳輸,最終把資料寫入到記憶體中的某個地址上,接著網路卡通過中斷訊號通知CPU有資料到達,作業系統就知道當前有新的資料包傳遞過來,於是CPU開始執行中斷程式,中斷程式的主要邏輯是
- 先把網路卡接收到的資料寫入到對應的Socket接收緩衝區中
- 再喚醒被阻塞在poll()方法上的執行緒
1.5.3 阻塞的整體原理分析
作業系統為了支援多工處理,所以實現了程式排程功能,執行中的程式表示獲得了CPU的使用權,當程式(執行緒)因為某些操作導致阻塞時,就會釋放CPU使用權,使得作業系統能夠多工的執行。
當多個程式是執行狀態等待CPU排程時,這些程式會儲存到一個可執行佇列中,如圖1-13所示。
當程式A呼叫poll()方法阻塞時,作業系統會把當前程式A從工作佇列移動到Socket的等待佇列中(將程式A的指標指向等待佇列,後續需要進行喚醒),此時A被阻塞,CPU繼續執行下一個程式。
當Socket收到資料時,等待該Socket FD的程式會收到被喚醒,如圖1-15所示,計算機通過網路卡接收到客戶端傳過來的資料,網路卡會把這個資料寫入到記憶體,然後再通過中斷訊號通知CPU有資料到達,於是CPU開始執行中斷程式。
當發生了中斷,就意味著需要作業系統的介入,開展管理工作。由於作業系統的管理工作(如程式切換、分配IO裝置)需要使用特權指令,因此CPU要從使用者態轉換為核心態。中斷就可以使CPU從使用者態轉換為核心態,使作業系統獲得計算機的控制權。因此,有了中斷,才能實現多道程式併發執行。
此處的中斷程式主要有兩項功能,先將網路資料寫入到對應 Socket 的接收緩衝區裡面(步驟 ④),再喚醒程式 A(步驟 ⑤),重新將程式 A 放入工作佇列中。
1.5 Linux中的select/poll模型本質
前面在1.4節中講的其實是Recv()方法,它只能監視單個Socket。而在實際應用中,這種單Socket監聽很明顯會影響到客戶端連線數,所以我們需要尋找一種能夠同時監聽多個Socket的方法,而select/poll就是在這個背景下產生的,其中poll方法在前面的案例中就講過,預設情況下使用poll模型。
先來了解一下select模型,由於在前面的分析中我們知道Recv()只能實現對單個socket的監聽,當客戶端連線數較多的時候,會導致吞吐量非常低,所以我們想,能不能實現同時監聽多個socket,只要任何一個socket連線存在IO就緒事件,就觸發程式的喚醒。
如圖1-16所示,假設程式同時監聽socket1和socket2這兩個socket連線,那麼當應用程式呼叫select方法後,作業系統會把程式A分別指向這連個個socket的等待佇列中。當任何一個Socket收到資料後,中斷程式會喚醒對應的程式。
當程式 A 被喚醒後,它知道至少有一個 Socket 接收了資料。程式只需遍歷一遍 Socket 列表,就可以得到就緒的 Socket。
select模式有二個問題,
- 就是每次呼叫select都需要將程式加入到所有監視器socket的等待佇列,每次喚醒都需要從等待佇列中移除,這裡涉及到兩次遍歷,有一定的效能開銷。
- 程式被喚醒後,並不知道哪些socket收到了資料,所以還需要遍歷一次所有的socket,得到就緒的socket列表
由於這兩個問題產生的效能影響,所以select預設規定只能監視1024個socket,雖然可以通過修改監視的檔案描述符數量,但是這樣會降低效率。而poll模式和select基本是一樣,最大的區別是poll沒有最大檔案描述符限制。
1.6 Linux中的epoll模型
有沒有更加高效的方法,能夠減少遍歷也能達到同時監聽多個fd的目的呢?epoll模型就可以解決這個問題。
epoll 其實是event poll的組合,它和select最大的區別在於,epoll會把哪個socket發生了什麼樣的IO事件通知給應用程式,所以epoll實際上就是事件驅動,具體原理如圖1-17所示。
在epoll中提供了三個方法分別是epoll_create、epoll_ctl、epoll_wait。具體執行流程如下
- 首先呼叫epoll_create方法,在核心建立一個eventpoll物件,這個物件會維護一個epitem集合,它是一個紅黑樹結構。這個集合簡單理解成fd集合。
- 接著呼叫epoll_ctl函式將當前fd封裝成epitem加入到eventpoll物件中,並給這個epitem加入一個回撥函式註冊到核心。當這個fd收到網路IO事件時,會把該fd對應的epitem加入到eventpoll中的就緒列表rdlist(雙向連結串列)中。同時再喚醒被阻塞的程式A。
- 程式A繼續呼叫epoll_wait方法,直接讀取epoll中就緒佇列rdlist中的epitem,如果rdlist佇列為空,則阻塞等待或者等待超時。
從epoll的原理中可以得知,由於rdlist的存在,使得程式A被喚醒後知道哪些Socket(fd)發生了IO事件,從而在不需要遍歷的情況下獲取所有就緒的socket連線。
版權宣告:本部落格所有文章除特別宣告外,均採用 CC BY-NC-SA 4.0 許可協議。轉載請註明來自
Mic帶你學架構
!
如果本篇文章對您有幫助,還請幫忙點個關注和贊,您的堅持是我不斷創作的動力。歡迎關注「跟著Mic學架構」公眾號公眾號獲取更多技術乾貨!