伺服器是現代軟體中非常重要的一個組成。伺服器,顧名思義,是提供服務的元件,那麼既然提供服務,那就要為衆人所知,不然大家怎麼能找到服務呢?就像我們想去吃麥當勞一樣,那我們首先得知道他在哪裡。是以,伺服器很重要的一個屬性就是需要釋出服務資訊,服務資訊包括提供的服務和服務位址。這樣大家才能知道需要什麼服務的時候,去哪裡找。對應到計算機中,服務位址就是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的結構是。
;這時候程序從accept中被喚醒。然後拿到一個新的socket用于通信。結構變成
;很多同學都了解三次握手是什麼,但是可能很少同學會深入思考或者看他的實作。衆所周知,一個伺服器啟動的時候,會監聽一個端口,其實就是建立了一個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等),免費分享
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。如果這時候有一個請求到來,那麼所有的子程序都會被喚醒,但是首先被排程的子程序會首先摘下這個請求節點。後續的程序被喚醒後可能會遇到已經沒有請求可以處理。又進入睡眠,程序被無效喚醒,這是著名的驚群現象。架構如下。
;改進方式就是在accpet之前加鎖,拿到鎖的程序才能進行accept。這樣就保證了隻有一個程序會阻塞在accept,nginx解決了這個問題。但是新版作業系統已經在核心層面解決了這個問題。每次隻會喚醒一個程序。
2.3 程序池模式
程序池模式就是伺服器啟動的時候,預先建立一定數量的程序,但是這些程序是worker程序。他不負責accept請求。他隻負責處理請求。主程序負責accept,他把accept傳回的socket交給worker程序處理。模式如下
;這種模式的邏輯如下
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的時候,傳給線程的話,線程是可以直接操作的。是以線上程池模式時,架構如下。
;主程序負責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下的連接配接就行,架構如下
這種模式在底層解決了多程序請求分發的問題,提高了處理請求的效率同時實作了負載均衡。
以上是伺服器處理請求的架構演變,伺服器作為對性能要求極高的軟體,在技術演變的過程中,不僅應用層做了很多改進,作業系統核心層面也做了很多改進。