典型伺服器模式原理分析與實踐

oscarwin發表於2019-03-28

本文作為自己學習網路程式設計的總結筆記。打算分析一下主流伺服器模式的優缺點,及適用場景,每種模型實現一個回射伺服器。客戶端用同一個版本,服務端針對每種模型編寫對應的回射伺服器。

本文所有程式碼放在:github.com/oscarwin/mu…

單程式迭代伺服器

單程式迭代伺服器是我接觸網路程式設計編寫的第一個伺服器模型,雖然程式碼只有幾行,但是每一個套接字程式設計的函式都涉及到大量的知識,這裡我並不打算介紹每個套接字函式的功能,只給出一個套接字程式設計的基礎流程圖。

典型伺服器模式原理分析與實踐

有幾點需要解釋的是:

  • 伺服器呼叫listen函式以後,客戶端與服務端的3次握手是由核心自己完成的,不需要應用程式的干預。核心為所有的連線維護兩個個佇列,佇列的大小之和由listen函式的backlog引數決定。服務端收到客戶算的SYN請求後,會回覆一個SYN+ACK給客戶端,並往未完成佇列中插入一項。所以未完成佇列中的連線都是SYN_RCVD狀態的。當伺服器收到客戶端的ACK應答後,就將該連線從未完成佇列轉移到已完成佇列。

  • 當未完成佇列和已完成佇列滿了後,伺服器就會直接拒絕連線。常見的SYN洪水攻擊,就是通過大量的SYN請求,佔滿了該佇列,導致伺服器拒絕其他正常請求達到攻擊的目的。

  • accept函式會一直阻塞,直到已完成佇列不為空,然後從已完成佇列中取出一個完成連線的套接字。

多程式併發伺服器

單程式伺服器只能同時處理一個連線。新建立的連線會一直呆在已完成佇列裡,得不到處理。因此,自然想到通過多程式來實現同時處理多個連線。為每一個連線產生一個程式去處理,稱為PPC模式,即process per connection。其流程圖如下(圖片來自網路,侵刪):

典型伺服器模式原理分析與實踐

這種模式下有幾點需要注意:

  • 統一由父程式來accept連線,然後fork子程式處理讀寫
  • 父程式fork以後,立即關閉了連線套接字,而子程式則立即關閉了監聽套接字。因為父程式只處理連線,子程式只處理讀寫。linux在fork了以後,子程式會繼承父程式的檔案描述符,父程式關閉連線套接字後,檔案描述符的計數會減一,在子程式裡並沒有關閉,當子程式退出關閉連線套接字後,該檔案描述符才被關閉

這種模式存在的問題:

  • fork開銷大。程式fork的開銷太大,在fork時需要為子程式開闢新的程式空間,子程式還要從父程式那裡繼承許多的資源。儘管linux採用了寫時複製技術,總的來看,開銷還是很大
  • 只能支援較少的連線。程式是作業系統重要的資源,每個程式都要分配獨立的地址空間。在普遍的伺服器上,該模式只能支援幾百的連線。
  • 程式間通訊複雜。雖然linux有豐富的程式間通訊方法,但是這些方法使用起來都有些複雜。

核心程式碼段如下,完整程式碼在ppc_server目錄。

    while(1)
    {
        clilen = sizeof(stCliAddr);
        if ((iConnectFd = accept(iListenFd, (struct sockaddr*)&stCliAddr, &clilen)) < 0)
        {
            perror("accept error");
            exit(EXIT_FAILURE);
        }

        // 子程式
        if ((childPid = fork()) == 0)
        {
            close(iListenFd);

            // 客戶端主動關閉,傳送FIN後,read返回0,結束迴圈
            while((n = read(iConnectFd, buf, BUFSIZE)) > 0)
            {
                printf("pid: %d recv: %s\n", getpid(), buf);
                fflush(stdout);
                if (write(iConnectFd, buf, n) < 0)
                {
                    perror("write error");
                    exit(EXIT_FAILURE);
                }
            }

            printf("child exit, pid: %d\n", getpid());
            fflush(stdout);
            exit(EXIT_SUCCESS);
        }
        // 父程式
        else
        {
            close(iConnectFd);
        }
    }
複製程式碼

預先派生子程式伺服器

既然fork程式時的開銷比較大,因此很自然的一種優化方式是,在伺服器啟動的時候就預先派生子程式,即prefork。每個子程式自己進行accept,大概的流程圖如下(圖片來自網路,侵刪):

典型伺服器模式原理分析與實踐

相比於pcc模式,prefork在建立連線時的開銷小了很多,但是另外兩個問題——連線數有限和程式間通訊複雜的問題還是存在。除此之外,prefork模式還引入了新的問題,當有一個新的連線到來時,雖然只有一個程式能夠accept成功,但是所有的程式都被喚醒了,這個現象被稱為驚群。驚群導致不必要的上下文切換和資源的排程,應該儘量避免。好在linux2.6版本以後,已經解決了驚群的問題。對於驚群的問題,也可以在應用程式中解決,在accept之前加鎖,accept以後釋放鎖,這樣就可以保證同一時間只有一個程式阻塞accept,從而避免驚群問題。程式間加鎖的方式有很多,比如檔案鎖,訊號量,互斥量等。

無鎖版本的程式碼在prefork_server目錄。加鎖版本的程式碼在prefork_lock_server目錄,使用的是程式間共享的執行緒鎖。

多執行緒併發伺服器

執行緒是一種輕量級的程式(linux實現上派生程式和執行緒都是呼叫do_fork函式來實現),執行緒共享同一個程式的地址空間,因此建立執行緒時不需要像fork那樣,拷貝父程式的資源,維護獨立的地址空間,因此相比程式而言,多執行緒模型開銷要小很多。多執行緒併發伺服器模型與多程式併發伺服器模型類似。

典型伺服器模式原理分析與實踐

多執行緒併發伺服器模型,與多程式併發伺服器模型相比,開銷小了很多。但是同樣存在連線數很有限這個限制。除此之外,多執行緒程式還引入了新的問題

  • 多執行緒程式不如多程式程式穩定,一個執行緒崩潰可能導致整個程式崩潰,最終導致服務完全不可用。而多程式程式則不存在這樣的問題
  • 多程式程式共享了地址空間,省去了多程式程式之間複雜的通訊方法。但是卻需要對共享資源同時訪問時進行加鎖保護
  • 建立執行緒的開銷雖然比建立程式的開銷小,但是整體來說還是有一些開銷的。

預先派生執行緒伺服器

和預先派生子程式相似,可以通過預先派生執行緒來消除建立執行緒的開銷。

典型伺服器模式原理分析與實踐

預先派生執行緒的程式碼在pthread_server目錄。

reactor模式

前面提及的幾種模式都沒能解決的一個問題是——連線數有限。而IO多路複用就是用來解決海量連線數問題的,也就是所謂的C10K問題。

IO多路複用有三種實現方案,分別是select,poll和epoll,關於三者之間的區別就不在贅述,網路上已經有很多文章講這個的了,比如這篇文章 Linux IO模式及 select、poll、epoll詳解

epoll因為其可以開啟的檔案描述符不像select那樣受系統的限制,也不像poll那樣需要在核心態和使用者態之間拷貝event,因此效能最高,被廣泛使用。

epoll有兩種工作模式,一種是LT(level triggered)模式,一種是ET(edge triggered)模式。LT模式下通知可讀,加入來了4k的資料,但是隻讀了2k,那麼再次阻塞在epoll上時,還會再次通知。而ET模式下,如果只讀了2k,再次阻塞在epoll上時,就不會通知。因此,ET模式下一次讀就要把資料全部讀完。因此,只能採用非阻塞IO,在while迴圈中讀取這個IO,read或write返回EAGAIN。如果採用了非阻塞IO,read或write會一直阻塞,導致沒有阻塞在epoll_wait上,IO多路複用就失效了。非阻塞IO配合IO多路複用就是reactor模式。reactor是核反應堆的意思,光是聽這名字我就覺得牛不不要不要的了。

epoll編碼的核心程式碼,我直接從man命令裡的說明裡拷貝過來了,我們的實現在目錄reactor_server裡。

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Set up listening socket, 'listen_sock' (socket(),bind(), listen()) */

// 建立epoll控制程式碼
epollfd = epoll_create(10);
if (epollfd == -1) {
   perror("epoll_create");
   exit(EXIT_FAILURE);
}

// 將監聽套接字註冊到epoll上
ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
   perror("epoll_ctl: listen_sock");
   exit(EXIT_FAILURE);
}

for (;;) {
    // 阻塞在epoll_wait
   nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
   if (nfds == -1) {
       perror("epoll_pwait");
       exit(EXIT_FAILURE);
   }

   for (n = 0; n < nfds; ++n) {
       if (events[n].data.fd == listen_sock) {
           conn_sock = accept(listen_sock, (struct sockaddr *) &local, &addrlen);
           if (conn_sock == -1) {
               perror("accept");
               exit(EXIT_FAILURE);
           }
           
           // 將連線套接字設定為非阻塞、邊緣觸發,然後註冊到epoll上
           setnonblocking(conn_sock);
           ev.events = EPOLLIN | EPOLLET;
           ev.data.fd = conn_sock;
           if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                       &ev) == -1) {
               perror("epoll_ctl: conn_sock");
               exit(EXIT_FAILURE);
           }
       } else {
           do_use_fd(events[n].data.fd);
       }
   }
}
複製程式碼

然後我們再分析一下epoll的原理。

epoll_create建立了一個檔案描述符,這個檔案描述符實際是指向的一個紅黑樹。當用epoll_ctl函式去註冊檔案描述符時,就是往紅黑樹中插入一個節點,該節點中儲存了該檔案描述符的資訊。當某個檔案描述符準備好了,回去呼叫一個回撥函式ep_poll_callback將這個檔案描述符準備好的資訊放到rdlist裡,epoll_wait則阻塞於rdlist直到其中有資料。

典型伺服器模式原理分析與實踐

proactor模式

proactor模式就是採用非同步IO加上IO多路複用的方式。使用非同步IO,將讀寫的任務也交給了核心來做,當資料已經準備好了,使用者執行緒直接就可以用,然後處理業務邏輯就OK了。

多種模式的伺服器該如何選擇

常量連線常量請求,如:管理後臺,政府網站,可以使用ppc和tpc模式

常量連線海量請求,如:中介軟體,可以使用ppc和tpc模式

海量連線常量請求,如:入口網站,ppc和tpc不能滿足需求,可以使用reactor模式

海量連線海量請求,如:電商網站,秒殺業務等,ppc和tpc不能滿足需求,可以使用reactor模式

相關文章