C++實現基於IO複用模型的echo服務器

       IO複用,即IO multiplexing,是使用select/poll/epoll這一系列的多路選擇器,可使進程或線程能夠處理多個連接;實質上不是複用IO,而是多個IO複用進程或線程;

編程模型

(1)單進程IO複用服務器,它適合IO密集型的應用,不適合計算密集性的應用,因爲計算需要耗費CPU資源,使得其他IO連接無法得到服務網絡延遲也可能會大一些,本來進行一次read系統調用可以獲得數據,現在需要一次poll和一次read才能獲得數據;

(2)IO複用服務器+多線程服務器,它(1)的基礎上,收到的計算請求不在主進程中進行處理,而是創建新的線程中進行處理;如果爲一個連接上的多個請求而創建多個線程處理,會有次序的問題,多個請求會分發給多個線程去計算,這樣就會佔滿全部的CPU;而且線程的創建數目是受限的,因此不宜創建過多的請求;

(3)在(2)的基礎上,同一個連接上的計算請求都發往同一個線程中去計算;但是這樣併發的連接數將受限於線程的數目;與(2)不同的是,一個含有很多請求的連接,只會固定的在同一個線程進行處理,只會佔用一個CPU,讓其它CPU可以用於其他連接的請求處理;

(4)IO複用服務器+計算線程池;將IO全部工作都放在一個線程中處理,其他的計算任務交給線程池;線程池的另一個作用可以執行阻塞操作,因爲有的操作可能沒有對應的非阻塞操作,或必須需要等待同步;

(5)在(4)的基礎上,IO複用只在一個線程中來處理,可以換成在多個線程進行IO處理,有一個主線程accept後分發連接給各個IO處理線程,小規模計算可以在當前線程中進行,否則放入線程池中去計算;

(6)在(1)的基礎上,使用多進程進行IO複用,各個工作進程相互獨立,便於升級

特點

(1)IO複用幾乎要使用非阻塞IO,否則一旦線程在處理具體邏輯時阻塞在某系統調用,如read,那麼該線程的其他連接即使可讀或可寫,也無法得到服務;IO複用既然使用的是非阻塞IO,那麼不能保證每次讀寫都可以完全,那麼需要應用層緩衝;

(2)IO複用使得一個進程或線程,幾乎不處於阻塞狀態,高效利用CPU資源,避免爲每一個連接創建進程或線程,從而引起資源緊張,進程切換等不必要的開銷;

(3)IO複用可以處理鍵盤輸入和socket可讀的情況,可以簡化程序編寫的邏輯;

實現內容

(1)本節實現的網絡模型爲單進程IO複用服務器;

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

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

TcpServer服務端實現

TcpServer接口

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 _serviceRead(int connfd, size_t i);  
  void _serviceWrite(int connfd, size_t i);  
  void _closeConnection(size_t& index);  
  void _newConnection();  
  
  const int _listenfd;  
  const struct sockaddr_in _serverAddress;  
  bool _started;  
  unsigned int _usercount;  
  
  std::vector<struct pollfd> _pollfds;  
  std::map<int, std::vector<char>> _buffers;  
};  
說明幾點:

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

(2)_listenfd作爲監聽描述符;_serverAddress作爲綁定的服務器端地址,需要在TcpServer顯式指定;_usercount表示當前poll需要IO複用的文件描述符號個數;

(3)_pollfds存放各文件描述符需要對應pollfd結構的集合;_buffers存放每一個連接描述符的對應的應用層緩衝

(4)_serviceRead()對應連接讀可訪問的處理,而_serviceWrite()對應寫可訪問的處理;

(5)_newConnection()對應新連接到來的處理,_closeConnection()對應連接關閉;

構造函數

TcpServer::TcpServer(const struct sockaddr_in& serverAddr):  
    _listenfd(sockets::createBlockingSocket()),  
    _serverAddress(serverAddr),  
    _started(false),  
    _usercount(0)  
{  
  assert(_listenfd >= 0);  
  sockets::setReuseAddr(_listenfd);  
  sockets::signal(SIGCHLD, &TcpServer::signalHandle);  
  _pollfds.resize(100);  
}  
說明幾點:

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

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

(3)_pollfds首先默認初始化爲100,當_usercount大於該大小時,將使用resize()以兩倍的速度增長;

服務器啓動

void TcpServer::start()  
{  
  assert(!_started);  
  sockets::bind(_listenfd, _serverAddress);  
  sockets::listen(_listenfd);  
  
  _pollfds[0].fd = _listenfd;  
  _pollfds[0].events = POLLIN | POLLERR;  
  _pollfds[0].revents = 0;  
  
  printf("TcpServer start...\n");  
  ++_usercount;  
  _started = true;  
  
  while (_started)  
    {  
      printf("poll wait...\n");  
      int ret = ::poll(&(*_pollfds.begin()), _usercount, -1);  
      if (ret < 0)  
        {  
          printf("failed in TcpServer::start, poll error :%s\n", strerror_r(errno, g_errorbuf, sizeof g_errorbuf));  
          continue;  
        }  
  
      for (size_t i = 0; i < _usercount; ++i)  
        {  
          if (_pollfds[i].fd == _listenfd && _pollfds[i].revents & POLLIN)  
            {  
              printf("server new conn\n");  
              _newConnection();  
            }  
          else if (_pollfds[i].revents & POLLERR)  
            {  
              int err = sockets::socketError(_pollfds[i].fd);  
              printf("socket fd[%d] error: %s\n", _pollfds[i].fd, strerror_r(err, g_errorbuf, sizeof g_errorbuf));  
            }  
          else if (_pollfds[i].revents & POLLRDHUP)  
            {  
              printf("a client fd[%d] conenction down\n", _pollfds[i].fd);  
              _closeConnection(i);  
            }  
          else if (_pollfds[i].revents & POLLIN)  
            {  
              printf("server conn can read\n");  
              int conn = _pollfds[i].fd;  
              _serviceRead(conn, i);  
            }  
          else if (_pollfds[i].revents & POLLOUT)  
            {  
              printf("server conn can write\n");  
              int conn = _pollfds[i].fd;  
              _serviceWrite(conn, i);  
            }  
        }  
  
    }  
}  
說明幾點:

(1)首先構造監聽描述符 _pollfds[0].fd = _listenfd;_pollfds[0].events = POLLIN | POLLERR;用於可讀以及錯誤的監聽,不需要用於寫;

(2)當poll返回時,說明有相應的描述符已經準備好了;對於監聽描述符判斷,if (_pollfds[i].fd == _listenfd && _pollfds[i].revents & POLLIN)說明有新的連接到來,具體處理函數見下文;

(3)對於出錯的描述符,if (_pollfds[i].revents & POLLERR),使用getsocketopt獲取對應的出錯碼,並輸出;

(4)對於if (_pollfds[i].revents & POLLRDHUP),說明客戶端斷開了連接,此時觸發服務器斷開連接;具體處理函數見下文:

(5)對於連接描述符可讀,if (_pollfds[i].revents & POLLIN),說明客戶端發送的數據已經在Tcp的內核接收緩衝區,具體處理函數見下文;對於連接描述符可寫,if (_pollfds[i].revents & POLLOUT),說明Tcp的內核發送緩衝區可以讓我們存放對應的發送給客戶端的數據,具體處理函數見下文;(注,一般應用層要設置發送緩衝區和接收緩衝區,但是本程序爲了簡化相關邏輯,只使用一個緩衝區,讀寫只能互斥的訪問這個緩衝區,即緩衝區爲空時,連接可以寫入數據至緩衝區,否則要等到緩衝區的數據發送完爲空);

新連接處理

void TcpServer::_newConnection()  
{  
  int connfd = sockets::accept(_listenfd, NULL);     //so far, we will not concern client address  
  
  if (connfd >= 0)  
    {  
      if (_usercount == _pollfds.size())  
        _pollfds.resize(2 * _pollfds.size());  
  
      _pollfds[_usercount].fd = connfd;  
      _pollfds[_usercount].events = POLLIN | POLLERR | POLLRDHUP;  
      _pollfds[_usercount].revents = 0;  
      ++_usercount;  
  
      sockets::setNonBlockingFd(connfd);  
      _buffers[connfd] = std::vector<char>(20);  
    }  
  else  
    {  
      printf("In TcpServer::_newConnection, accept error : %s\n", strerror_r(errno, g_errorbuf, sizeof g_errorbuf));  
    }  
  
}  

說明幾點:

(1)使用accept獲取新連接,將獲取的新連接,如果此時_pollfds已經放滿,那麼就擴大_pollfds的空間;

(2)設置對應的連接描述符的相關值, _pollfds[_usercount].events = POLLIN | POLLERR | POLLRDHUP;表明該連接是不關注寫可用的;

(3)並使用sockets::setNonBlockingFd(connfd);將該連接描述符connfd設置成非阻塞方式,加入到_pollfds中;將每一個新到連接的緩衝區設置成20個字節;

連接關閉

  1. void TcpServer::_closeConnection(size_t& i)  
    {  
      sockets::close(_pollfds[i].fd);  
      std::map<int, std::vector<char>>::iterator iter = _buffers.find(_pollfds[i].fd);  
      if (iter != _buffers.end())  
        {  
          _buffers.erase(iter);  
        }  
      
      _pollfds[i] = _pollfds[_usercount - 1];  
      --i;  
      --_usercount;  
    }  <span style="font-size: 11.9999990463257px; line-height: 13.1999998092651px; font-family: Consolas, 'Courier New', Courier, mono, serif; background-color: inherit;"> </span>

    說明幾點:

    (1)使用close關閉對應的連接,並將該連接在map中對應映射的緩衝區清除;

    (2)將該連接在_pollfds與最後一個連接的pollfd相對換,並遞減_usercount;

讀服務

void TcpServer::_serviceRead(int connfd, size_t i)  
{  
  char buf[20];  
  int n = sockets::read(connfd, buf, sizeof buf);  
  
  if (n <= 0)  
    return;  
  
  std::vector<char>& buffer = _buffers[connfd];  
  buffer.resize(n);  
  std::copy(buf, buf + n, buffer.begin());  
  
  _pollfds[i].events &= ~POLLIN;  
  _pollfds[i].events |= POLLOUT;  
}  

說明幾點:

(1)使用read先將客戶端發來的數據讀入緩衝中,並將該緩衝中的值拷貝到該連接所對應的緩衝區,必須使用引用到對應的連接緩衝區;

(2)由於該緩衝獨佔訪問,需要關閉讀關注,打開寫關注;否則若不關閉讀關注,那麼讀入的數據可能會擾亂對應的發送數據;

(3)爲了效率,其實可以不關閉讀關注,將讀寫訪問同步,每次將socket數據讀入到緩衝的尾部,但是這樣會造成整個緩衝區過大,而且寫指針指向的緩衝區的前部過大,可通過循環緩衝來解決;

寫服務

void TcpServer::_serviceWrite(int connfd, size_t i)  
{  
  std::vector<char>& buffer = _buffers[connfd];  
     
  int n = sockets::write(connfd, &(*buffer.begin()), buffer.size());  
    
  if (n == static_cast<int>(buffer.size()))  
    {  
      _pollfds[i].events |= POLLIN;  
      _pollfds[i].events &= ~POLLOUT;  
    }  
  else if (n >= 0)  
    {  
      std::copy(buffer.begin() + n, buffer.end(), buffer.begin());  
      buffer.resize(buffer.size() - n);  
    }  
  else if (n < 0)  
    {  
      printf("In TcpServer::_serviceWrite, write error : %s\n", strerror_r(errno, g_errorbuf, sizeof g_errorbuf));  
      _pollfds[i].events |= POLLIN;  
      _pollfds[i].events &= ~POLLOUT;  
    }  
}  
說明幾點:

(1)使用write將該連接緩衝區(必須使用引用)的數據拷貝到對應的該連接的內核發送緩衝區,有可能不能完全寫入;

(2)若完全寫入,則關閉寫關注,打開讀關注;否則繼續關注寫,並移動剩餘的數據至緩衝區頭;若出錯,那就關閉寫關注,打開讀關注,因爲有可能還有讀數據需要處理;

(3)爲了效率,其實可以不關閉讀關注,將讀寫訪問同步,每次將socket數據讀入到緩衝的尾部,但是這樣會造成整個緩衝區過大,而且寫指針指向的緩衝區的前部過大,可通過循環緩衝來解決;

TcpClient客戶端的實現

與一個客戶一個進程的TcpClient客戶端一樣,不再贅述;










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