天天看點

「linux」伺服器處理網絡連接配接的架構演變

作者:linux技術棧

伺服器是現代軟體中非常重要的一個組成。伺服器,顧名思義,是提供服務的元件,那麼既然提供服務,那就要為衆人所知,不然大家怎麼能找到服務呢?就像我們想去吃麥當勞一樣,那我們首先得知道他在哪裡。是以,伺服器很重要的一個屬性就是需要釋出服務資訊,服務資訊包括提供的服務和服務位址。這樣大家才能知道需要什麼服務的時候,去哪裡找。對應到計算機中,服務位址就是ip+端口,但是ip和端口不容易記,不利于使用,是以又設計出DNS協定,這樣我們就可以使用域名來通路一個服務,DNS服務會根據域名解析出ip。解決了尋找服務的問題後,接下來的問題就是伺服器如何高效地處理連接配接。本文介紹伺服器處理連接配接的架構演進。

一個基于tcp協定的伺服器,基本的流程如下(本文皆為僞代碼)。

int socketfd = socket();bind(socketfd);listen(socketfd);           

執行完以上步驟,一個伺服器正式開始服務。下面我們看一下基于上面的模型,分析各種各樣的處理方法。

1 單程序accept

while(1) {
  int socketForCommunication  = accept(socketfd);
  handle(socketForCommunication);}           

上面是一個伺服器處理連接配接最樸素的模型,處理邏輯就是伺服器不斷地調用accept摘下完成三次握手的連接配接,然後處理,如果沒有連接配接則伺服器阻塞。我們看看這種模式的處理過程。假設有n個請求到來。那麼socket的結構是。

「linux」伺服器處理網絡連接配接的架構演變

;這時候程序從accept中被喚醒。然後拿到一個新的socket用于通信。結構變成

「linux」伺服器處理網絡連接配接的架構演變

;很多同學都了解三次握手是什麼,但是可能很少同學會深入思考或者看他的實作。衆所周知,一個伺服器啟動的時候,會監聽一個端口,其實就是建立了一個socket。那麼如果有一個連接配接到來的時候,我們通過accept就能拿到這個新連接配接對應的socket。那麼這個socket和監聽的socket是不是同一個呢?其實socket分為監聽型和通信型的。表面上,伺服器用一個端口實作了多個連接配接,但是這個端口是用于監聽的,底層用于和用戶端通信的其實是另一個socket。是以每一個連接配接過來,負責監聽的socket發現是一個建立連接配接的包(syn包),他就會生成一個新的socket與之通信(accept的時候傳回的那個)。監聽socket裡隻儲存了他監聽的ip和端口,通信socket首先從監聽socket中複制ip和端口,然後把用戶端的ip和端口也記錄下來,當下次收到一個資料包的時候,作業系統就會根據四元組從socket池子裡找到該socket,進而完成資料的處理。

言歸正傳,串行這種模式如果處理的過程中有調用了阻塞api,比如檔案io,就會影響後面請求的處理。可想而知,效率是有多低。而且并發量比較大的時候,監聽socket對應的隊列很快就會被占滿(已完成連接配接隊列有一個最大長度)。這是最簡單的模式,雖然伺服器的設計中肯定不會使用這種模式,但是他讓我們了解了一個伺服器處理請求的整體過程。

2 多程序模式

串行模式中,所有請求都在一個程序中排隊被處理,這是效率低下的原因。這時候我們可以把請求分給多個程序處理來提供效率,因為在串行處理的模式中,如果有檔案io操作,他就會阻塞主程序,進而阻塞後續請求的處理,在多程序的模式中,即使一個請求阻塞了程序,那麼作業系統會挂起該程序,接着排程其他程序執行,那麼其他程序就可以執行新的任務。多程序模式下分為幾種。

2.1 主程序accept,子程序處理請求

這種模式下,主程序負責摘取已完成連接配接的節點,然後把這個節點對應的請求交給子程序處理,邏輯如下。

1.  while(1) {  
2.      var socketForCommunication = accept(socket);  3.      if (fork() > 0) { // 忽略出錯處理
4.           continue;5.           // 父程序負責accept6.      } else {  7.          // 子程序8.          handle(socketForCommunication); 
9.        exit(); 
10.     }  
11. }           

這種模式下,每次來一個請求,就會建立一個程序去處理。這種模式比串行的稍微好了一點,每個請求獨立處理,假設a請求阻塞在檔案io,那麼不會影響b請求的處理,盡可能地做到了并發。他的瓶頸就是系統的程序數有限,如果有大量的請求,系統無法扛得住。再者,程序的開銷很大。對于系統來說是一個沉重的負擔。

相關視訊推薦

C++高性能伺服器6種網絡模型,每一種都很經典,你知道幾種?

網絡原理tcp/udp,網絡程式設計epoll/reactor,面試中正經“八股文”

2022年c++後端學習路線,含思維導圖詳細講解

學習位址:C/C++Linux伺服器開發/背景架構師【零聲教育】-學習視訊教程-騰訊課堂

需要C/C++ Linux伺服器架構師學習資料加qun812855908擷取(資料包括C/C++,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享

「linux」伺服器處理網絡連接配接的架構演變

2.2 子程序accept

這種模式不是等到請求來的時候再建立程序。而是在伺服器啟動的時候,就會建立多個程序。然後多個程序分别調用accept。這種模式的架構如下。

1.  for (let i = 0 ; i < 程序個數; i++) {  
2.      if (fork() > 0) {  
3.          // 父程序負責監控子程序4.      } else {  
5.          // 子程序處理請求6.          while(1) {  
7.              var socketForCommunication = accept(socket);  
8.              handle(socketForCommunication);  
9.          }  
10.     }  
11. }           

這種模式下多個子程序都阻塞在accept。如果這時候有一個請求到來,那麼所有的子程序都會被喚醒,但是首先被排程的子程序會首先摘下這個請求節點。後續的程序被喚醒後可能會遇到已經沒有請求可以處理。又進入睡眠,程序被無效喚醒,這是著名的驚群現象。架構如下。

「linux」伺服器處理網絡連接配接的架構演變

;改進方式就是在accpet之前加鎖,拿到鎖的程序才能進行accept。這樣就保證了隻有一個程序會阻塞在accept,nginx解決了這個問題。但是新版作業系統已經在核心層面解決了這個問題。每次隻會喚醒一個程序。

2.3 程序池模式

程序池模式就是伺服器啟動的時候,預先建立一定數量的程序,但是這些程序是worker程序。他不負責accept請求。他隻負責處理請求。主程序負責accept,他把accept傳回的socket交給worker程序處理。模式如下

「linux」伺服器處理網絡連接配接的架構演變

;這種模式的邏輯如下

1.  let fds = [[], [], []…程序個數];  
2.  let process = [];  
3.  for (let i = 0 ; i < 程序個數; i++) {  
4.      // 建立管道用于傳遞檔案描述符  5.      socketpair(fds[i]);  
6.      let pid;  
7.      if (pid = fork() > 0) {  
8.          // 父程序9.          process.push({pid, 其他字段});  
10.     } else {  
11.         let index = i;  
12.         // 子程序處理請求13.         while(1) {  
14.             // 從管道中讀取檔案描述符15.             var socket = read(fd[index][1]);  
16.             // 處理請求17.             handle(socket);  
18.         }  
19.     }  
20. }  
22. for (;;) {  
23.     var newSocket = accept(socket);  
24.     // 找出處理該請求的子程序25.     let i = findProcess();  
26.     // 傳遞檔案描述符27.     write(fds[i][0], newSocket);  
28. }           

使用程序池的模式時,主程序負責accept,然後把請求交給子程序處理,但是和多程序的模式2.1相比,程序池模式相對比較複雜,因為在多程序模式2.1中,當主程序收到一個請求的時候,實時fork一個子程序,這時候,這個子程序會繼承主程序中新請求對應的fd,是以他可以直接處理該fd對應的請求,在程序池的模式中,子程序是預先建立的,當主程序收到一個請求的時候,子程序中是無法拿得到該請求對應的fd的。這時候,需要主程序使用傳遞檔案描述符的技術把這個請求對應的fd傳給子程序。一個程序其實就是一個結構體task_struct,他有一個字段記錄了打開的檔案描述符,當我們通路一個檔案描述符的時候,作業系統就會根據fd的值,從task_struct中找到fd對應的底層資源,是以主程序給子程序傳遞檔案描述符的時候,傳遞的不僅僅是一個數字fd,因為如果僅僅這樣做,在子程序中該fd可能沒有對應任何資源,或者對應的資源和主程序中的是不一緻的。而傳遞檔案描述符,作業系統幫我們處理了很多事情,讓我們在子程序中可以通過fd通路到正确的資源,即主程序中收到的請求。

3 多線程模式

多線程模式和多程序模式是類似的,也是分為下面幾種

1 主程序accept,建立子線程處理

2 子線程accept

3 線程池

前面兩種和多程序模式中是一樣的,但是第三種比較特别,我們主要介紹第三種。在子程序模式時,每個子程序都有自己的task_struct,這就意味着在fork之後,每個程序負責維護自己的資料,而線程則不一樣,線程是共享主線程(主程序)的資料的,當主程序從accept中拿到一個fd的時候,傳給線程的話,線程是可以直接操作的。是以線上程池模式時,架構如下。

「linux」伺服器處理網絡連接配接的架構演變

;主程序負責accept請求,然後通過互斥的方式插入一個任務到共享隊列中,線程池中的子線程同樣是通過互斥的方式,從共享隊列中摘取節點進行處理。

4 事件驅動

現在很多伺服器(nginx,Nodejs,redis)都開始使用事件驅動模式去設計。從之前的設計模式中我們知道,為了應對大量的請求,伺服器需要大量的程序/線程。這個是個非常大的開銷。而事件驅動模式,一般是配合單程序(單線程),再多的請求,也是在一個程序裡處理的。但是因為是單程序,是以不适合cpu密集型,因為一個任務一直在占據cpu的話,後續的任務就無法執行了。他更适合io密集的(一般都會提供一個線程池,負責處理cpu或者阻塞型的任務)。大部分作業系統都提供了事件驅動的api。但是事件驅動在不同系統中實作不一樣。是以一般都會有一層抽象層抹平這個差異。這裡以linux的epoll為例子。

1.  // 建立一個epoll  2.  var epollFD = epoll_create();  
3.  /* 
4.   在epoll給某個檔案描述符注冊感興趣的事件,這裡是監聽的socket,注冊可讀事件,即連接配接到來 
5.   event = { 
6.      event: 可讀 
7.      fd:監聽socket 
8.      // 一些上下文 
9.   } 
10. */  
11. epoll_ctl(epollFD , EPOLL_CTL_ADD , socket, event);  
12. while(1) {  
13.     // 阻塞等待事件就緒,events儲存就緒事件的資訊,total是個數14.     var total= epoll_wait(epollFD , 儲存就緒事件的結構events, 事件個數, timeout);  
15.     for (let i = 0; i < total; i++) {  
16.         if (events[i].fd === 監聽socket) {  
17.             var newSocket = accpet(socket);  
18.             // 把新的socket也注冊到epoll,等待可讀,即可讀取用戶端資料19.             epoll_ctl(epollFD , EPOLL_CTL_ADD , newSocket, 可讀事件);  
20.         } else {  
21.             // 從events[i]中拿到一些上下文,執行相應的回調22.         }  
23.     }  
24. }           

這就是事件驅動模式的大緻過程。本質上是一個訂閱/釋出模式。伺服器通過注冊檔案描述符和事件到epoll中。epoll開始阻塞,等到epoll傳回的時候,他會告訴伺服器哪些fd的哪些事件觸發了。這時候伺服器周遊就緒事件,然後執行對應的回調,在回調裡可以再次注冊新的事件。就是這樣不斷驅動着。epoll的原理其實也類似事件驅動。epoll底層維護使用者注冊的事件和檔案描述符。epoll本身也會在檔案描述符對應的檔案/socket/管道處注冊一個回調。然後自身進入阻塞。等到别人通知epoll有事件發生的時候,epoll就會把fd和事件傳回給使用者。

1.  function epoll_wait() {  
2.      for 事件個數
3.          // 調用檔案系統的函數判斷4.          if (事件[i]中對應的檔案描述符中有某個使用者感興趣的事件發生?) {  
5.              插入就緒事件隊列  
6.          } else {  
7.              /*
8.               在事件[i]中的檔案描述符所對應的檔案/socket/管道等indeo節點注冊回調。
9.               即感興趣的事件觸發後回調epoll,回調epoll後,epoll把該event[i]插入
10.              就緒事件隊列傳回給使用者
11.              */12.         }  
13. }           

現在的伺服器的設計中還會涉及到協程。不過目前自己還沒有看過具體的實作,是以還無法介紹(想了解原理的話可以看libtask這個協程庫)。

5 reuseport端口複用

前面介紹的幾種模式中,在處理連接配接的方案上,大緻有下面幾種

1 單程序串行處理

2 主程序接收連接配接,分發給子程序處理。

3 子程序接收請求,有驚群現象。

從串行處理到多程序/多線程模式,在處理連接配接上有了很大的改進,但是依然存在一些問題,2中的問題是,雖然有多個子程序處理請求,但是隻有一個程序接收請求,這是遠遠不夠的。3中的問題是,多個子程序可以同時accept,首先會導緻驚群問題,其次,被喚醒處理連接配接的程序應該處理多少個連接配接也是一個問題,比如有10個連接配接,程序1被喚醒後是全部處理還是隻處理一個,把剩下的留給其他程序處理呢?即使新版的核心已經解決了驚群問題,但是被喚醒的程序應該處理多少個連接配接的問題依然存在,是以如何接收請求和分發請求是兩個可以改進的地方,新版linux支援reuseport特性後,使得處理請求的模式有了很大的改善。reuseport之前,一個socket是無法綁定到同一個位址的,通常的做法是主程序bind後,fork子程序,然後子程序listen。但是共享的是同一個socket。reuseport特性支援多個socket綁定到同一個位址,當連接配接到來時,作業系統會根據位址資訊找到一組socket,然後根據政策選擇一個socket,然後喚醒阻塞在該socket的程序。這樣之前多程序共享socket的模式下,被喚醒的程序應該處理多少個請求的問題也解決了,因為reuseport模式中,每個程序一個socket,對應一個請求隊列,核心會把請求分發到各個socket中,被socket喚醒的程序隻處理自己的監聽socket下的連接配接就行,架構如下

「linux」伺服器處理網絡連接配接的架構演變

這種模式在底層解決了多程序請求分發的問題,提高了處理請求的效率同時實作了負載均衡。

以上是伺服器處理請求的架構演變,伺服器作為對性能要求極高的軟體,在技術演變的過程中,不僅應用層做了很多改進,作業系統核心層面也做了很多改進。

繼續閱讀