c++實現基於單進程單客戶編程模型的echo程序

       一個客戶一個子進程,是阻塞式網絡編程,適合於長連接,不適合短連接、併發數不大的情況,尤其不適合fork()的開銷大於本身服務的情況.

編程模型

(1)迭代服務器,TCP迭代服務器總是完全處理完某個客戶的請求之後纔會轉向爲下一個客戶服務,因此對下一個用戶的響應時間不夠好,比較適合短連接服務(一般會有一方主動斷開TCP連接);如果在單核處理器上,由於其沒有進程控制的時間,可以使用該程序作爲基準測試程序,來比較其他模型的服務器程序的運行時間,進而可以得到進程的控制時間;

(2)併發服務器1,它通常在accept阻塞,阻塞返回後調用fork派生一個子進程來處理每個客戶,每一個客戶一個進程,因此客戶數目的限制在於子進程數目的限制;即使在寫時複製的情況下,fork的開銷的還是很大的,當有數萬的連接時,弊端就比較明顯了;

(3)併發服務器2,它與併發服務器1類似,只不過預先派生一定數量爲N的子進程,同時也監聽,這樣當各個客戶連接到達時,這些子進程就能夠立即爲它們服務,無需fork開銷;但是如果連接數等於N時(注,父進程不參與服務),此時子進程將會完全被使用完,新到的連接需等到下一個子進程可用,但是如果連接隊列的數目還未到達listen調用的backlog數,雖然此時客戶連接已經被內核完成三次握手,但是無法被服務,需要等到子進程執行到accept纔可返回那麼客戶端將會明顯察覺到服務器在響應時間上的惡化,雖然connect可能會立即返回,第一個請求在一段時間之後纔會被服務器處理;當然服務端還有可能會有驚羣問題,如果多個子進程都阻塞在accept,當一個客戶連接到達時,所有的這些子進程都會被喚醒,這是因爲所有的子進程的監聽描述符都指向同一個socket結構,此時這些子進程將會爭用這個連接,最終所有的子進程被喚醒,內核進程調度到的第一個子進程將會獲得這個客戶連接,而其他幾個子進程將會繼續睡眠(有些內核這些子進程可能根本不會喚醒,或直接accept返回一個錯誤);據說Linux系統已經在內核態已解決驚羣問題;

(4)併發服務器3,它與併發服務器2類似,只不過在accpet加上一段鎖,使得accept這段代碼成爲臨界區;由原先的accept爭用變成了鎖爭用,最終只有一個進程阻塞在accept上;這樣其他進程進入臨界區後,將會獨佔accept的使用;

(5)併發服務器4,它使用主進程分發機制;子進程不進行accept調用,統一由父進程accept然後將連接描述符傳遞給對應的子進程,然後由由其服務;父進程可使用普通的輪轉法來選擇子進程來進行連接描述符的傳遞;

特點

(1)TCP是一個全雙工的協議,同時支持read()和write();而阻塞式網絡編程中,服務器主進程通常阻塞在accept上,而由子進程具體負責與客戶端通信,客戶端進程通常阻塞在read系統調用上,等待客戶端發來的命令;這樣就需要服務端和客戶端的編程需要相互配合起來;假設客戶端進程由於錯誤的程序邏輯,雙方有可能出現通信死鎖情況;此外,某些客戶端繼續阻塞讀連接數據,又需要讀鍵盤輸入,如果阻塞的讀連接,那麼是無法從鍵盤讀輸入的;

(2)服務器爲每一個連接準備一個進程,一個這樣的連接將會獨佔這樣的進程,服務器的開銷較大;

(3)一個客戶一個進程模型,關鍵的特點在於fork()後對監聽描述符的影響;併發服務器1僅有父進程監聽後產生連接描述符,然後由fork後的子進程來處理;而併發服務器2,3中的所有進程都進行監聽(父進程可以不監聽),因此涉及到對accept的爭用;併發服務器4仍是隻有父進程進行accept,然後通過文件描述符的傳遞,使得子進程獲得連接描述符;

實現內容

(1)下面是針對併發服務器1的具體實現;

(2)實現的內容是一個echo服務器,由客戶端從鍵盤輸入相關內容,發送給服務器,然後由服務器收到後轉發至客戶端,客戶端打印至終端;

(3)服務器不主動斷開連接,而由客戶端從鍵盤獲得EOF或客戶端退出後,服務器也將會退出;

下面爲TcpServer實現

TcpServer頭文件

<span style="font-size:14px;">class TcpServer final  
{  
public:  
  TcpServer(const TcpServer&) = delete;  
  TcpServer& operator=(const TcpServer&) = delete;  
  explicit TcpServer(const struct sockaddr_in& serverAddr);  
  ~TcpServer();  
  void start();  
  static void signalHandle(int sig);  
private:  
  void _service(int conn);  
  const int _listenfd;  
  const struct sockaddr_in _serverAddress;  
  bool _started;  
};  </span>
說明幾點:

(1)TcpServer不具有值語義,具有對象語義,拷貝一個TcpSever並不能讓系統多一個一模一樣的TcpServer主進程服務器(包括所管理的孩子進程),假設拷貝了一份TcpServer,那麼系統中僅僅是多了對TcpServer本身數據的拷貝,而主進程和孩子進程仍然是不變的,因此對象拷貝是禁止的;繼承也是被禁止的,因爲TcpServer不具有多態語意;分別使用C++11新特性中delete和final來禁止拷貝和繼承;

(2)_listenfd作爲監聽描述符;_serverAddress作爲綁定的服務器端地址,需要在TcpServer顯式指定;

(3)  static void signalHandle(int sig);作爲SIGCHID信號的處理函數,對結束的子進程進行處理,立即釋放最後的資源,否則若沒有該信號的處理函數,孩子進程退出時,若父進程不及時回收,孩子進程仍將佔用少部分資源;

構造函數

<span style="font-size:14px;">TcpServer::TcpServer(const struct sockaddr_in& serverAddr):  
    _listenfd(sockets::createBlockingSocket()),  
    _serverAddress(serverAddr),  
    _started(false)  
{  
  assert(_listenfd >= 0);  
  sockets::setReuseAddr(_listenfd);  
  sockets::signal(SIGCHLD, &TcpServer::signalHandle);  
}  </span>

說明幾點:

(1)sockets中的相關函數的定義與實現,請見完整代碼;

(2)初始化_listenfd監聽描述符,綁定_serverAddress服務器地址後;將服務器地址設置爲可重用,這樣bind一個正處於TIME_WAIT狀態上的連接會失敗,因此使用setReuseAddr()設置socket的SO_REUSEADDR選項;

(3) sockets::signal(SIGCHLD, &TcpServer::signalHandle);設置SIGCHLD信號處理程序;其中TcpServer::signalHandle爲靜態成員函數,這樣才能保證與普通函數具有相同的語義

服務器啓動
  1. void TcpServer::start()  
    {  
      assert(!_started);  
      
      sockets::bind(_listenfd, _serverAddress);  
      sockets::listen(_listenfd);  
      
      printf("TcpServer start...\n");  
      
      _started = true;  
      while (_started)  
        {  
          int connfd = sockets::accept(_listenfd, NULL);     //so far, we will not concern client address  
      
          if (connfd >= 0)  
            {  
              _service(connfd);  
            }  
          else  
            {  
              printf("In TcpServer::_service, accept error : %s\n", strerror_r(errno, g_errorbuf, sizeof g_errorbuf));  
            }  
      
          ::close(connfd);  
        }  
    }  
說明幾點:

(1)首先調用bind進行地址綁定,以及listen進行監聽,然後進入主服務程序;

(2)如果沒有客戶端連接,那accept將會阻塞,這樣就會帶來弊端,主進程不能服務於其他程序;當有客戶端連接到來,accept將會返回一個連接描述符;

(3)在_service將進行fork調用以及相關服務,最後調用exit()進行退出;請注意最後的::close(connfd),並不會關閉connfd描述符,僅僅是將connfd上引用計數遞減爲1,因爲fork()後,子進程必然爲遞增connfd的引用計數;如果子進程close或exit會將connfd的引用計數減爲0,麼服務器內核將會發送FIN報文給客戶端,這是從ESTABLISHED狀態變爲FIN_WAIT_1狀態,對應服務器主動斷開連接的情況;但是如果客戶端已經發送了Fin報文,那麼connfd減爲0,那麼服務器端將發送FIN報文,從CLOSE_WAIT變爲LAST_ACK狀態,對應服務器端被動斷開連接的情況;本服務器實現是服務器被動接受連接,這樣可以將TIME_WAIT的時間留給客戶端,但是會帶來客戶端故意不斷開連擊的情況;

服務實現
  1. void TcpServer::_service(int connfd)  
    {  
      if (::fork() == 0)  
        {  
          sockets::close(_listenfd);     //important  
      
          char buf[20];  
          int n;  
          while ((n = sockets::read(connfd, buf, sizeof buf)) > 0) {  
            sockets::writen(connfd, buf, n);  
          }   
            
          ::exit(0);  
        }  
    }  

說明幾點:

(1)服務內容,主要就是將從客戶端讀取的內容直接轉發至對應的連接,由於read和write屬於阻塞操作,可以保證接收到的字節全部轉發至客戶端;

(3)最後當read到0,說明客戶端已經斷開連接;服務器執行::exit(0)將會使內核發送Fin報文,服務器端將會從CLOSE_WAIT變爲LAST_ACK狀態;

處理殭屍進程
  1. void TcpServer::signalHandle(int signo)  
    {  
      int stat;  
      pid_t pid;  
      while ((pid = waitpid(-1, &stat, WNOHANG)) > 0)  
        {  
          printf("child %d terminated\n", pid);  
        }  
    }  
說明幾點:

(1)殭屍進程爲:每一個子進程終止後,並不完全釋放它的所有資源,保留一些如進程ID,終止狀態,資源利用信息(CPU時間等),因爲這些終止後的信息父進程可能要使用記錄它們的終止信息;如果父進程終止後,未收回這些子進程,繼承這些子進程的init進程將會清理它們,而本身該父進程由其父親負責清理;

(2)殭屍進程畢竟佔用資源,耗費內存和資源,每一個子進程終止時都會發送SIGCHLD信號給父進程,因此父進程可建立SIGCHLD可使用wait系列函數來處理已經終止的孩子進程;

(3)使用waitpid(),並使用WNOHANG來進行非阻塞的進行處理終止進程,主要是因爲當主進程當多個進程在信號處理程序前提交SIGCHLD信號時,信號處理程序只會執行一次,使用while循環,可以循環遍歷處理每一個已經終止的進程;而在信號處理期間的終止進程提交的SIGCHLD將會被阻塞等到返回到主程序後,發現又有SIGCHLD信號,進而又會調用信號處理程序來處理終止的子進程;

析構函數
  1. TcpServer::~TcpServer()  
    {  
      if (_started)  
        signalHandle(SIGCHLD);  
      
      _started = false;  
    }  
TcpClient實現如下
頭文件
  1. class TcpClient final  
    {  
    public:  
      TcpClient(const TcpClient&) = delete;  
      TcpClient& operator=(const TcpClient&) = delete;  
      
      TcpClient(const struct sockaddr_in* clientAddr = NULL);  
      
      void request(const struct sockaddr_in& serverAddr);  
    private:  
      
      const int _connectfd;  
      const struct sockaddr_in* _clientAddress;  
    };  

說明幾點:

(1)TcpClient分別使用C++11新特性中delete和final來禁止拷貝和繼承;

(2)_connectfd作爲連接描述符;_clientAddress作爲綁定的客戶端地址,可以爲NULL,這樣內核將會隨機選取綁定到客戶端的地址;

請求實現
  1. void TcpClient::request(const struct sockaddr_in& serverAddr)  
    {  
      int err = sockets::connect(_connectfd, serverAddr);  
      if (err < 0)  
        {  
          printf("In TcpClient::request, connect error : %s\n",  strerror_r(errno, g_errorbuf, sizeof g_errorbuf));  
          return;  
        }  
      
      char buf[10];  
      while (fgets(buf, sizeof buf, stdin))  
        {  
      
          int n =  sockets::writen(_connectfd, buf, strlen(buf) + 1);  
          printf("client send (%d bytes): %s\n", n , buf);  
            
          if ((n = sockets::readn(_connectfd, buf, n)) == 0)    //receive Fin  
            {  
              printf("server exit\n");  
              break;  
            }  
      
          printf("client receive : %s\n", buf);  
        }  
    }  
說明幾點:

(1)客戶端首先從鍵盤讀入一行數據,注意若數據不足8個字節,可以完全發送成功,因爲‘\n’,'\0'在fgets均需要字節數;而如果大於8個字節,根據現有的程序邏輯,fgets將會執行循環,第二次執行fgets將第一次未存入的字符繼續加入到緩衝中繼續發送;

(2)由於writen將保證緩衝區發送,readn將從連接再次讀入剛纔發送的緩衝區,如果此時讀取0,說明服務器端發送了Fin報文,那就說明服務器意外終止了,那麼客戶端也將會退出;


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章