網絡編程中的關鍵問題總結

網絡編程中的關鍵問題總結

總結下網絡編程中關鍵的細節問題,包含鏈接創建、鏈接斷開、消息到達、發送消息等等;html

鏈接創建

包括服務端接受 (accept) 新鏈接和客戶端成功發起 (connect) 鏈接。
accept接受鏈接的問題在本文最後會聊到,這裏談談connect的關鍵點;
使用非阻塞鏈接創建須要注意:
connect/select返回後,可能沒有鏈接上;須要再次確認是否成功鏈接;git

步驟爲:github

  1. 使用異步connect直接鏈接一次,由於使用了非阻塞,函數馬上返回;
  2. 檢查返回值,爲0成功鏈接,不然加入到select/epoll中監控;
  3. 當有寫事件時,鏈接成功;立即可讀又可寫時,多是有錯誤或者鏈接成功後有數據已經發過來;因此,此時,須要用getsockopt()讀取socket的錯誤選項,二次確認是否真的鏈接成功:
Fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
error = 0;
if ( (n = connect(sockfd, saptr, salen)) < 0)
    if (errno != EINPROGRESS)
        return(-1);

/* Do whatever we want while the connect is taking place. */
if (n == 0)
    goto done;    /* connect completed immediately */

if ( (n = Select(sockfd+1, &rset, &wset, NULL,
                 nsec ? &tval : NULL)) == 0) {
    close(sockfd);        /* timeout */
    errno = ETIMEDOUT;
    return(-1);
}

if (FD_ISSET(sockfd, &rset) || FD_ISSET(sockfd, &wset)) {
    len = sizeof(error);
            //二次確認是否真的鏈接成功
    if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len) < 0)
        return(-1);            /* Solaris pending error */
} else
    err_quit("select error: sockfd not set");

鏈接斷開

包括主動斷開 (close 或 shutdown) 和被動斷開 (read 返回 0)。編程

當打算關閉網絡鏈接時,如何能知道對方已經發送了數據本身尚未收到?
在TCP層面解決:主動關閉的時候只使用半關閉shutdown(), 這樣,服務端這邊之時關閉了寫端,還能夠正常讀;客戶端收到關閉的信號後(read返回0),會再調用shutdown關閉整個鏈接;
在應用層面解決:雙方經過某個標記協商,在標記以後再也不讀寫數據,這樣就能夠徹底的關閉鏈接了;json

關閉鏈接時須要注意的:
是否還有未發送的數據,須要保證應用緩衝區中的數據都發送完畢以後再關閉緩衝區;
TCP緩存區不用咱們考慮,由於在調用shutdown或close的時候,TCP的實現是會將TCP的發送緩衝區中的數據都發送出去,而後再發送FIN報文(也多是組合成一個報文發送);緩存

消息到達

消息到達是最重要的事件;對它的處理決定了網絡編程的風格:是阻塞仍是非阻塞、分包的處理、應用層的緩衝如何設計等等;服務器

處理分包

所謂分包,就是在一個個字節流消息中如何區分出一個個消息來;
常見的分包方法有:網絡

  1. 固定長度;
  2. 特殊的結尾符,好比字符串的\0,或者回車換行等;
  3. 固定的消息頭中指定後續的消息的長度,而後跟上一個消息體內容;
  4. 使用協議自己的格式,好比json格式頭尾配對(XML也同樣);

字節序轉換注意字節對齊

若是傳輸的是二進制類型,在字節流的緩存區中直接強轉可能core dump;由於有的系統訪問地址須要字節對齊,不能在任意地址上訪問二進制類型(如整形),合理的方式是將其copy到一個本地變量中,而後再作字節序的轉換:數據結構

int32_t peekInt32() const
{
    assert(readableBytes() >= sizeof(int32_t));
    int32_t be32 = 0;
    ::memcpy(&be32,readerIndex_, sizeof(be32) );
    return be32toh(be32);
}

應用層緩存區的實現

數據到達時處理須要注意:
socket讀事件來到,必須一次將全部的數據都讀完,不然會形成一直有可讀事件,形成busy-loop;讀到的數據固然就須要有個應用層的緩衝區來存放;
由於應用的緩存區是有限的,能夠默認設置一個大小,好比2kb,或者根本就不設置初始大小,用多少分配多少;muduo中使用的是vector 來做爲緩存區,能夠動態增加;多線程

muduo buffer使用的技巧:
buffe採用了vector自動增加的數據結構;
從系統內核中調用的時候,在應用層須要有足夠大的緩衝區,最好能一次將系統recv到的緩衝區給讀空,一次系統調用就搞定一切事情;
而應用緩衝區考慮到有不少個併發的可能,針對每一個鏈接一次都分配較大的緩衝區浪費嚴重,陳碩推薦使用readv一次讀入到兩個地址中,首先將第一個地址填滿,若是還有更多數據,就寫入到臨時緩衝區中,而後append到應用緩衝區;

讀的時候使用readv,局部使用一個足夠大的額外空間(64KB),這樣,一次讀取就足以將socket中的緩存區讀空(通常不會超過64K,tcp buffer若是確實要設置大的緩存區,須要調整系統參數);若是數據很少,可能內部buffer就裝下了,沒有額外操做,不然,多的數據讀到了外部的緩存區,再append到內部緩存區:

ssize_t Buffer::readFd(int fd, int* savedErrno)
{
  // saved an ioctl()/FIONREAD call to tell how much to read
  char extrabuf[65536];
  struct iovec vec[2];
  const size_t writable = writableBytes();
  vec[0].iov_base = begin()+writerIndex_;
  vec[0].iov_len = writable;
  vec[1].iov_base = extrabuf;
  vec[1].iov_len = sizeof extrabuf;
  // when there is enough space in this buffer, don't read into extrabuf.
  // when extrabuf is used, we read 128k-1 bytes at most.
  const int iovcnt = (writable < sizeof extrabuf) ? 2 : 1;
  //只有一次系統調用:這裏的實現比較巧妙
  const ssize_t n = sockets::readv(fd, vec, iovcnt);
  if (n < 0)
  {
    *savedErrno = errno;
  }
  else if (implicit_cast<size_t>(n) <= writable)
  {
    writerIndex_ += n;
  }
  else
  {
    writerIndex_ = buffer_.size();
    append(extrabuf, n - writable);
  }
  // if (n == writable + sizeof extrabuf)
  // {
  //   goto line_30;
  // }
  return n;
}

發送消息

網絡編程中數據發送比數據接受要難處理;
數據的接收,只須要peek足夠的數據後,就能夠從應用緩衝區接收出來,而後處理;而數據的發送,還須要考慮對方接受緩慢的狀況,致使tcp發送緩衝區累積,最終致使應用緩衝區累積;

舉個例子:某客戶端對echo服務器只發送,但故意不接收;
客戶端若是隻是發送,但從不接收的話,那麼這邊發送過去的報文,首先會致使客戶端的tcp接收緩衝區滿,而後經過ack報文告訴服務器端,這邊的滑動窗口爲0了,不能再發了;後續客戶端發送的報文就把服務器端TCP發送緩衝區積滿,而後累積應用層的發送緩衝區(由於是非阻塞),最終致使服務端的應用緩存區滿或者內存撐爆;

須要發送數據的時候,優先直接調用write()發送,若是發送不成功,或沒有所有發送完畢,才加入到發送緩存區,等待可寫事件到來後發送;
直接調用write()發送數據時,須要先將本次須要發送的數據添加到緩存區,而後發送緩存區,不可直接發送本次數據(由於緩存區中可能有遺留的數據未發送完)

void TcpConnection::handleWrite()
{
  loop_->assertInLoopThread();
  if (channel_->isWriting())
  {
      //注意,這裏只調用了一次write,而沒有反覆調用write直到出現EAGAIN錯誤,
      //緣由是若是第一次調用沒有發送徹底部的數據,第二次調用幾乎確定是EAGAIN錯誤,
      //所以這裏減小了一次系統調用,這麼作不影響正確性,卻可以下降系統時延
    ssize_t n = sockets::write(channel_->fd(),
                               outputBuffer_.peek(),
                               outputBuffer_.readableBytes());
    if (n > 0)
    {
      outputBuffer_.retrieve(n);
      if (outputBuffer_.readableBytes() == 0)
      {
          //若是發送緩存區爲空,再也不關注寫事件,避免 busy loop 
        channel_->disableWriting();
        //若是還有寫完成以後的回調,加入待執行回調隊列
        if (writeCompleteCallback_)
        {
          loop_->queueInLoop(boost::bind(writeCompleteCallback_, shared_from_this()));
        }
        //若是此時正在關閉,調用shutdownInLoop 繼續執行關閉過程
        if (state_ == kDisconnecting)
        {
          shutdownInLoop();
        }
      }
    }
    else
    {
      LOG_SYSERR << "TcpConnection::handleWrite";
      // if (state_ == kDisconnecting)
      // {
      //   shutdownInLoop();
      // }
    }
  }
  else
  {
    LOG_TRACE << "Connection fd = " << channel_->fd()
              << " is down, no more writing";
  }
}

消息發送完畢

對於低流量的服務,能夠沒必要關心這個事件;另外,這裏「發送完畢」是指將數據寫入操做系統的緩衝區,後續由 TCP 協議棧負責數據的發送與重傳,不表明對方已經收到數據。

其它問題

IO multiplexing 是否能夠配合阻塞套接字使用?

通常都配合非阻塞socket使用,若是使用阻塞IO,可能在讀寫事件上阻塞當前線程,形成沒法繼續處理已經就緒的事件;
初學網絡編程可能都會有這個想法,select返回後,若是是讀事件,那麼這時候tcp讀緩衝區確定是有數據,這時即便使用阻塞套接字來read,應該也不會阻塞;但這樣忽略了一個點,緩衝區確實是有數據,可是極可能到達的數據並不知足你要求讀的數據大小,這樣read調用仍是會阻塞,直到有足夠的數據才返回;
那麼,對於數據讀不能夠,對accept()總能夠吧,鏈接事件返回,通常都是有新用戶接入,這時候阻塞的accept()應該老是可以返回;但在某些狀況下,可能對方剛鏈接上就斷開了,並給服務端發送了一個RST請求,形成服務端這邊將已經就緒的鏈接請求又移除了,這樣的場景下,select返回,可是accept卻沒法獲取新的鏈接,形成阻塞,直到下一個鏈接請求到來;(這方面的例子詳見《UNIX網絡編程卷1:套接字聯網API》16.6節非阻塞accept() )
因此任什麼時候候,IO multiplexing都須要配合非阻塞IO使用;

零拷貝的實現

對於內核層的實現,底層調用的是系統調用sendFile()方法;
zerocopy技術省去了將操做系統的read buffer拷貝到程序的buffer, 以及從程序buffer拷貝到socket buffer的步驟, 直接將 read buffer 拷貝到 socket buffer;
image
詳見:http://www.cnblogs.com/zemliu/p/3695549.html

應用層上的實現,對於自定義的結構,通常是交換內部指針(使用C++11,可使用move操做來實現高效交換結構體)
若是是vector等結構,使用其成員函數swap()就能達到高效的交換(相似C++11中的move操做);
例如muduo中buffer實現:經過swap實現了緩存區的指針交換,從而達到數據交換的目的,而不用拷貝緩衝區;

void swap(Buffer& rhs)
{
    buffer_.swap(rhs.buffer_); // std::vector<char> buffer_;
    std::swap(readerIndex_, rhs.readerIndex_);
    std::swap(writerIndex_, rhs.writerIndex_);
}

epoll使用LT

epoll使用是LT而非ET,緣由以下:

  1. LT編程方便,select的經驗均可一樣適用;
  2. 讀的時候只須要一次系統調用,而ET必須讀到EAGAIN錯誤;減小一次系統調用,下降時延;

通常認爲 edge-trigger 模式的優點在於可以減小 epoll 相關係統調用,這話不假,但網絡服務程序裏可不是隻有 epoll 相關係統調用,爲了繞過餓死問題,edge-trigger 模式下用戶要自行進行 read/write 循環處理,這其中增長的系統調用和減小的 epoll 系統調用加起來,整體性能收益究竟如何?只有實際測量才知道,沒法一律而論。爲了下降處理邏輯複雜度,經常使用的事件處理庫大部分都選擇了 level-trigger 模式(如 libevent、boost::asio、muduo等)

參考

《UNIX網絡編程卷1:套接字聯網API》
《Linux多線程服務端編程:使用muduo網絡庫》

Posted by: 大CC | 31DEC,2015
博客:blog.me115.com [訂閱]
Github:大CC

相關文章
相關標籤/搜索