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

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

本文全部代碼放在:github.com/oscarwin/mu…linux

單進程迭代服務器

單進程迭代服務器是我接觸網絡編程編寫的第一個服務器模型,雖然代碼只有幾行,可是每個套接字編程的函數都涉及到大量的知識,這裏我並不打算介紹每一個套接字函數的功能,只給出一個套接字編程的基礎流程圖。git

有幾點須要解釋的是:github

  • 服務器調用listen函數之後,客戶端與服務端的3次握手是由內核本身完成的,不須要應用程序的干預。內核爲全部的鏈接維護兩個個隊列,隊列的大小之和由listen函數的backlog參數決定。服務端收到客戶算的SYN請求後,會回覆一個SYN+ACK給客戶端,並往未完成隊列中插入一項。因此未完成隊列中的鏈接都是SYN_RCVD狀態的。當服務器收到客戶端的ACK應答後,就將該鏈接從未完成隊列轉移到已完成隊列。編程

  • 當未完成隊列和已完成隊列滿了後,服務器就會直接拒絕鏈接。常見的SYN洪水攻擊,就是經過大量的SYN請求,佔滿了該隊列,致使服務器拒絕其餘正常請求達到攻擊的目的。segmentfault

  • accept函數會一直阻塞,直到已完成隊列不爲空,而後從已完成隊列中取出一個完成鏈接的套接字。bash

多進程併發服務器

單進程服務器只能同時處理一個鏈接。新創建的鏈接會一直呆在已完成隊列裏,得不處處理。所以,天然想到經過多進程來實現同時處理多個鏈接。爲每個鏈接產生一個進程去處理,稱爲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_wait上時,x系統還會再次通知該文件可讀。而ET模式下,若是隻讀了2k的數據,而後就退出並從新阻塞在epoll上時,系統不會通知該文件可讀,除非又有新的數據發送過來。所以,ET模式下每次通知可讀時就要把發送過來的數據所有讀完。這個特性使得ET模式下只能採用非阻塞IO,在while循環中讀取這個文件描述符中的數據,直到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模式

相關文章
相關標籤/搜索