服務器端編程心得(六)—— 關於網絡編程的一些實用技巧和細節

這些年,接觸了形形色色的項目,寫了很多網絡編程的代碼,從windows到linux,跌進了很多坑,因爲網絡編程涉及不少細節和技巧,一直想寫篇文章來總結下這方面的心得與經驗,但願對來者有一點幫助,那就善莫大焉了。 本文涉及的平臺包括windows和linux,下面開始啦。linux

1、非阻塞的的connect()函數如何編寫面試

咱們知道用connect()函數默認是阻塞的,直到三次握手創建以後,或者實在連不上超時返回,期間程序執行流一直阻塞在那裏。那麼如何利用connect()函數編寫非阻塞的鏈接代碼呢?算法

不管在windows仍是linux平臺均可以採起如下思路來實現:sql

  1. 建立socket時,將socket設置成非阻塞模式,具體如何設置可參考我這個系列的文章《服務器編程心得(四)—— 如何將socket設置爲非阻塞模式》;數據庫

  2. 接着調用connect()進行鏈接,若是connect()能當即鏈接成功,則返回0;若是此刻不能當即鏈接成功,則返回-1(windows上返回SOCKET_ERROR也等於-1),這個時候錯誤碼是WSAEWOULDBLOCK(windows平臺),或者是EINPROGRESS(linux平臺),代表當即暫時不能完成。編程

  3. 接着調用select()函數在指定的時間內檢測socket是否可寫,若是可寫代表connect()鏈接成功。windows

須要注意的是:linux平臺上connect()暫時不能完成返回-1,錯誤碼多是EINPROGRESS,也多是因爲被信號給中斷了,這個時候錯誤碼是:EINTR。這種狀況也要考慮到;而在windows平臺上除了用select()函數去檢測socket是否可寫,也可使用windows平臺自帶的函數WSAAsyncSelect或WSAEventSelect來檢測。數組

下面是代碼:緩存

/** *@param timeout 鏈接超時時間,單位爲秒 *@return 鏈接成功返回true,反之返回false **/
bool CSocket::Connect(int timeout)
{
    //windows將socket設置成非阻塞的方式
    unsigned long on = 1;
    if (::ioctlsocket(m_hSocket, FIONBIO, &on) < 0)
        return false;

    //linux將socket設置成非阻塞的方式
    //將新socket設置爲non-blocking
    /* int oldflag = ::fcntl(newfd, F_GETFL, 0); int newflag = oldflag | O_NONBLOCK; if (::fcntl(m_hSocket, F_SETFL, newflag) == -1) return false; */

    struct sockaddr_in addrSrv = { 0 };
    addrSrv.sin_family = AF_INET;
    addrSrv.sin_addr = htonl(addr);
    addrSrv.sin_port = htons((u_short)m_nPort);
    int ret = ::connect(m_hSocket, (struct sockaddr*)&addrSrv, sizeof(addrSrv));
    if (ret == 0)
        return true;

    //windows下檢測WSAEWOULDBLOCK
    if (ret < 0 && WSAGetLastError() != WSAEWOULDBLOCK)
        return false;


    //linux下須要檢測EINPROGRESS和EINTR
    /* if (ret < 0 && (errno != EINPROGRESS || errno != EINTR)) return false; */

    fd_set writeset;
    FD_ZERO(&writeset);
    FD_SET(m_hSocket, &writeset);
    struct timeval tv;
    tv.tv_sec = timeout;
    //能夠利用tv_usec作更小精度的超時設置
    tv.tv_usec = 0;
    if (::select(m_hSocket + 1, NULL, &writeset, NULL, &tv) != 1)
        return false;

    return true;
}

2、非阻塞socket下如何正確的收發數據 這裏不討論阻塞模式下,阻塞模式下send和recv函數若是tcp窗口過小或沒有數據的話都是阻塞在send和recv調用處的。對於收數據,通常的流程是先用select(windows和linux平臺皆可)、WSAAsyncSelect()或WSAEventSelect()(windows平臺)、poll或epoll_wait(linux平臺)檢測socket有數據可讀,而後進行收取。對於發數據,;linux平臺下epoll模型存在水平模式和邊緣模式兩種情形,若是是邊緣模式必定要一次性把socket上的數據收取乾淨才行,也就是必定要循環到recv函數出錯,錯誤碼是EWOULDBLOCK。而linux下的水平模式或者windows平臺上能夠根據業務一次性收取固定的字節數,或者收完爲止。還有個區別上文也說過,就是windows下發數據的代碼稍微有點不一樣的就是不須要檢測錯誤碼是EINTR,只須要檢測是不是WSAEWOULDBLOCK。代碼以下:服務器

用於windows或linux水平模式下收取數據,這種狀況下收取的數據能夠小於指定大小,總之一次能收到多少是多少:

bool TcpSession::Recv()
{
    //每次只收取256個字節
    char buff[256];
    //memset(buff, 0, sizeof(buff));
    int nRecv = ::recv(clientfd_, buff, 256, 0);
    if (nRecv == 0)
        return false;

    inputBuffer_.add(buff, (size_t)nRecv);

    return true;
}
若是是linux epoll邊緣模式(ET),則必定要一次性收完: 
bool TcpSession::RecvEtMode()
{
    //每次只收取256個字節
    char buff[256];
    while (true)
    {
        //memset(buff, 0, sizeof(buff));
        int nRecv = ::recv(clientfd_, buff, 256, 0);
        if (nRecv == -1)
        {
            if (errno == EWOULDBLOCK || errno == EINTR)
                return true;

            return false;
        }
        //對端關閉了socket
        else if (nRecv == 0)
            return false;

       inputBuffer_.add(buff, (size_t)nRecv);
    }

    return true;
}

用於linux平臺發送數據:

bool TcpSession::Send()
{
    while (true)
    {
        int n = ::send(clientfd_, buffer_, buffer_.length(), 0);
        if (n == -1)
        {
            //tcp窗口容量不夠, 暫且發不出去,下次再發
            if (errno == EWOULDBLOCK)
                break;
            //被信號中斷,繼續發送
            else if (errno == EINTR)
                continue;

            return false;
        }
        //對端關閉了鏈接
        else if (n == 0)
            return false;

        buffer_.erase(n);
        //所有發送完畢
        if (buffer_.length() == 0)
            break;
    }

    return true;
}

另外,收發數據還有個技巧是設置超時時間,除了用setsocketopt函數設置send和recv的超時時間之外,還能夠自定義整個收發數據過程當中的超時時間,思路是開始收數據前記錄下時間,收取完畢後記錄下時間,若是這個時間差大於超時時間,則認爲超時,代碼分別是:

long tmSend = 3*1000L;
long tmRecv = 3*1000L;
setsockopt(m_hSocket, IPPROTO_TCP, TCP_NODELAY,(LPSTR)&noDelay, sizeof(long));
setsockopt(m_hSocket, SOL_SOCKET,  SO_SNDTIMEO,(LPSTR)&tmSend, sizeof(long));
int httpclientsocket::RecvData(string& outbuf,int& pkglen)
{
    if(m_fd == -1)
        return -1;
    pkglen = 0;
    char buf[4096];
    time_t tstart = time(NULL);
    while(true)
    {
        int ret = ::recv(m_fd,buf,4096,0);
        if(ret == 0)
        {
            Close();
            return 0;//對方關閉socket了
        }
        else if(ret < 0)
        {
            if(errno == EAGAIN || errno ==EWOULDBLOCK || errno == EINTR)
            {
                if(time(NULL) - tstart > m_timeout)
                {
                    Close();
                    return 0;
                }
                else
                    continue;
            }
            else
            {
                Close();
                return ret;//接收出錯
            }
        }
        outbuf.append(buf,buf+ret);
        pkglen = GetBufLen(outbuf.data(),outbuf.length());
        if(pkglen <= 0)
        {//接收的數據有問題
            Close();       
            return pkglen;
        }
        else if(pkglen <= (int)outbuf.length())
            break;//收夠了
    }
    return pkglen;//返回該完整包的長度
}

3、如何獲取當前socket對應的接收緩衝區中有多少數據可讀

Windows上可使用ioctlsocket()這個函數,代碼以下:

ulong bytesToRecv;
if (ioctlsocket(clientsock, FIONREAD, &bytesToRecv) == 0)
{
        //在這裏,bytesToRecv的值便是當前接收緩衝區中數據字節數目
}

linux平臺我沒找到相似的方法。能夠採用我上面說的通用方法《非阻塞socket下如何正確的收發數據》來作。固然有人說能夠這麼寫(我在linux man手冊ioctl函數欄目上並無看到這個函數可使用FIONREAD這樣的標誌,不一樣機器可能也有差別,具體可不能夠得須要你根據你的linux系統去驗證):

ulong bytesToRecv;
if (ioctl(clientsock, FIONREAD, &bytesToRecv) == 0)
{
        //在這裏,bytesToRecv的值便是當前接收緩衝區中數據字節數目
}

4、上層業務如何解析和使用收到的數據包?

這個話題其實是繼上一個話題討論的。這個問題也能夠回答經常使用的面試題:如何解決數據的丟包、粘包、包不完整的問題。首先,由於tcp協議是可靠的,因此不存在丟包問題,也不存在包順序錯亂問題(udp會存在這個問題,這個時候須要本身使用序號之類的機制保證了,這裏只討論tcp)。通常的作法是先收取一個固定大小的包頭信息,接着根據包頭裏面指定的包體大小來收取包體大小(這裏「收取」既能夠從socket上收取,也能夠在已經收取的數據緩衝區裏面拿取)。舉個例子:

#pragma pack(push, 1)
struct msg
{
    int32_t  cmd;               //協議號
    int32_t  seq;               //包序列號(同一個請求包和應答包的序列號相同)
    int32_t  packagesize;       //包體大小
    int32_t  reserved1;         //保留字段,在應答包中內容保持不變
    int32_t  reserved2;         //保留字段,在應答包中內容保持不變
};

/** * 心跳包協議 **/
struct msg_heartbeat_req
{
    msg header;
};

struct msg_heartbeat_resp
{
    msg header;
};

/** * 登陸協議 **/
struct msg_login_req
{
    msg         header;
    char        user[32];
    char        password[32];
    int32_t     clienttype;     //客戶端類型
};

struct msg_login_resp
{
    msg         header;
    int32_t     status;
    char        user[32];
    int32_t     userid;
};

#pragma pack(pop)

看上面幾個協議,拿登陸請求來講,每次能夠先收取一個包頭的大小,即sizeof(msg),而後根據msg.packagesize的大小再收取包體的大小sizeof(msg_login_req) - sizeof(msg),這樣就能保證一個包完整了,若是包頭或包體大小不夠,則說明數據不完整,繼續等待更多的數據的到來。 由於tcp協議是流協議,對方發送10個字節給你,你可能先收到5個字節,再收到5個字節;或者先收到2個字節,再收到8個字節;或者先收到1個字節,再收到9個字節;或者先收到1個字節,再收到7個字節,再收到2個字節。總之,你可能以這10個字節的任意組合方式收取到。因此,通常在正式的項目中的作法是,先檢測socket上是否有數據,有的話就收一下(至於收完不收完,上文已經說了區別),收好以後,在收到的字節中先檢測夠不夠一個包頭大小,不夠下次收數據後再檢測;若是夠的話,再看看夠不夠包頭中指定的包體大小,不夠下次再處理;若是夠的話,則取出一個包的大小,解包並交給上層業務邏輯。注意,這個時候還要繼續檢測是否夠下一個包頭和包體,如此循環下去,直到不夠一個包頭或者包體大小。這種狀況很常見,尤爲對於那些對端連續發數據包的狀況下。

5、nagle算法

nagle算法的是操做系統網絡通訊層的一種發送數據包機制,若是開啓,則一次放入網卡緩衝區中的數據(利用send或write等)較小時,可能不會當即發出去,只要當屢次send或者write以後,網卡緩衝區中的數據足夠多時,纔會一次性被協議棧發送出去,操做系統利用這個算法減小網絡通訊次數,提升網絡利用率。對於實時性要求比較高的應用來講,能夠禁用nagle算法。這樣send或write的小數據包會馬上發出去。系統默認是開啓的,禁用方法以下:

long noDelay = 1;
setsockopt(m_hSocket, IPPROTO_TCP, TCP_NODELAY,(LPSTR)&noDelay, sizeof(long));

noDelay爲1禁用nagle算法,爲0啓用nagle算法。

6、select函數的第一個參數問題

select函數的原型是:

int select( _In_ int nfds, _Inout_ fd_set *readfds, _Inout_ fd_set *writefds, _Inout_ fd_set *exceptfds, _In_ const struct timeval *timeout );

使用示例:

fd_set writeset;
FD_ZERO(&writeset);
FD_SET(m_hSocket, &writeset);
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 100;
select(m_hSocket + 1, NULL, &writeset, NULL, &tv);

不管linux仍是windows,這個函數都源於Berkeley 套接字。其中readfds、writefds和exceptfds都是一個含有socket描述符句柄數組的結構體。在linux下,第一個參數必須設置成這三個參數中,全部socket描述符句柄中的最大值加1;windows雖然不使用這個參數,卻爲了保持與Berkeley 套接字兼容,保留了這個參數,因此windows平臺上這個參數能夠填寫任意值。

7、關於bind函數的綁定地址

使用bind函數時,咱們須要綁定一個地址。示例以下:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = inet_addr(ip_.c_str());
servaddr.sin_port = htons(port_);
bind(listenfd_, (sockaddr *)&servaddr, sizeof(servaddr));

這裏的ip地址,咱們通常寫0.0.0.0(即windows上的宏INADDR_ANY),或者127.0.0.1。這兩者仍是有什麼區別?若是是前者,那麼bind會綁定該機器上的任意網卡地址(特別是存在多個網卡地址的狀況下),若是是後者,只會綁定本地迴環地址127.0.0.1。這樣,使用前者綁定,可使用connect去鏈接任意一個本地的網卡地址,然後者只能鏈接127.0.0.1。舉個例子:

上文中,機器有三個網卡地址,若是使用bind到0.0.0.0上的話,則可使用192.168.27.19或 192.168.56.1或 192.168.247.1任意地址去connect,若是bind到127.0.0.1,則只能使用127.0.0.1這個地址去connect。

8、關於SO_REUSEADDR和SO_REUSEPORT

使用方法以下:

int on = 1;
setsockopt(listenfd_, SOL_SOCKET, SO_REUSEADDR, (char *)&on, sizeof(on));
setsockopt(listenfd_, SOL_SOCKET, SO_REUSEPORT, (char *)&on, sizeof(on));

這兩個socket選項,通常服務器程序用的特別多,主要是爲了解決一個socket被系統回收之後,在一個最大存活期(MSL,大約2分鐘)內,該socket綁定的地址和端口號不能被重複利用的狀況。tcp斷開鏈接時,須要進行四次揮手,爲了保證最後一步處於time_wait狀態的socket能收到ACK應答,操做系統將socket的生命週期延長至一個MSL。可是這對於服務器程序來講,尤爲是重啓的狀況下,因爲重啓以後,該地址和端口號不能馬上被使用,致使bind函數調用失敗。因此開發者要不變動地址和端口號,要不等待幾分鐘。這其中任意一個選擇都沒法承受的。因此能夠設置這個選項來避免這個問題。 可是windows上和linux上實現稍有差異,windows上是一個socket回收後,在MSL期間內,其使用的地址和端口號組合其餘進程不可使用,但本進程能夠繼續重複利用;而linux實現是全部進程在MSL期間內都不能使用,包括本進程。

9、心跳包機制

爲了維持一個tcp鏈接的正常,一般一個鏈接長時間沒有數據來往會被系統的防火牆關閉。這個時候,若是再想經過這個鏈接發送數據就會出錯,因此須要經過心跳機制來維持。雖然tcp協議棧有本身的keepalive機制,可是,咱們應該更多的經過應用層心跳包來維持鏈接存活。那麼多長時間發一次心跳包合適呢?在個人過往項目經驗中,真是衆說紛紜啊,也所以被坑了很多次。後來,我找到了一種比較科學的時間間隔: 先假設每隔30秒給對端發送一個心跳數據包,這樣須要開啓一個定時器,定時器是每過30秒發送一個心跳數據包。 除了心跳包外,與對端也會有正常的數據來往(非心跳包數據包),那麼記下這些數據的send和recv時刻。也就是說,若是最近的30秒內,發送過或者收到過非心跳包外的數據包,那麼30秒後就不要發心跳包數據。也就是說,心跳包發送必定是在兩端沒有數據來日後的30秒才須要發送。這樣不只能夠減輕服務器的壓力,同時也減小了網絡通訊流量,尤爲對於流量昂貴的移動設備。 固然,心跳包不只能夠用來維持鏈接正常,也能夠攜帶一些數據,好比按期獲得某些數據的最新值,這個時候,上面的方案可能就不太合適了,仍是須要每隔30秒發送一次。具體採起哪一種,能夠根據實際的項目需求來決定。 另外,須要補充一點的時,心跳包通常由客戶端發給服務器端,也就是說客戶端檢測本身是否保持與服務器鏈接,而不是服務器主動發給客戶端。用程序的術語來說就是調用connect函數的一方發送心跳包,調用listen的一方接收心跳包。 拓展一下,這種思路也能夠用於保持與數據庫的鏈接。好比在30秒內沒有執行數據庫操做後,按期執行一條sql,用以保持鏈接不斷開,好比一條簡單的sql:select 1 from user;

10、重連機制

在我早些年的軟件開發生涯中,我用connect函數鏈接一個對端,若是鏈接不上,那麼我會再次重試,若是仍是鏈接不上,會接着重試。如此一直反覆下去,雖然這種重連動做放在一個專門的線程裏面(對於客戶端軟件,千萬不要放在UI線程裏面,否則你的界面將會卡死)。可是若是對端始終連不上,好比由於網絡斷開。這種嘗試實際上是毫無心義的,不如不作。其實最合理的重連方式應該是結合下面的兩種方案:

  1. 若是connect鏈接不上,那麼n秒後再重試,若是仍是鏈接不上2n秒以後再重試,以此類推,4n,8n,16n......

可是上述方案,也存在問題,就是若是當重試間隔時間變的很長,網絡忽然暢通了,這個時候,須要很長時間才能鏈接服務器,這個時候,就應該採起方法2。

  1. 在網絡狀態發生變化時,嘗試重連。好比一款通信軟件,因爲網絡故障如今處於掉線狀態,忽然網絡恢復了,這個時候就應該嘗試重連。windows下檢測網絡狀態發生變化的API是IsNetworkAlive。示例代碼以下:
BOOL IUIsNetworkAlive()  
{  
    DWORD   dwFlags;        //上網方式 
    BOOL    bAlive = TRUE;        //是否在線 
    bAlive = ::IsNetworkAlive(&dwFlags);         
    return bAlive;
}

11、關於錯誤碼EINTR

這個錯誤碼是linux平臺下的。對於不少linux網絡函數,如connect、send、recv、epoll_wait等,當這些函數出錯時,必定要檢測錯誤是否是EINTR,由於若是是這種錯誤,其實只是被信號中斷了,函數調用並沒用出錯,這個時候要麼重試,如send、recv、epoll_wait,要麼利用其餘方式檢測完成狀況,如利用select檢測connect是否成功。千萬不要草草認定這些調用失敗,而作出錯誤邏輯判斷。

12、儘可能減小系統調用

對於高性能的服務器程序來講,儘可能減小系統調用也是一個值得優化的地方。每一次系統調用就意味着一次從用戶空間到內核空間的切換。例如,在libevent網絡庫,在主循環裏面,對於時間的獲取是一次獲取後就馬上緩存下來,之後若是須要這個時間,就取緩存的。可是有人說,在x86機器上gettimeofday不是系統調用,因此libevent不必這麼作。有沒有必要,咱們借鑑一下這個減小系統調用的思想而已。

十3、忽略linux信號SIGPIPE

SIGPIPE這個信號針對linux平臺的,什麼狀況下會產生這個信號呢?當一個偵聽socket被關閉之後,這個時候若是對端向本端發送數據(調用send或write)以後,再次調用send或write向本端發送數據,這個時候,本端該進程將產生SIGPIPE信號,這個信號默認處理是終止進程。可是通常程序尤爲是服務器程序確定不但願要這種默認行爲,由於不能由於客戶端給咱們亂髮數據致使咱們本身崩潰退出。因此應該忽略掉這個信號,代碼以下:

signal(SIGPIPE, SIG_IGN);

關於SIGPIPE具體狀況能夠參考這篇文章:http://blog.csdn.net/lmh12506/article/details/8457772

暫且就整理這麼多吧,歡迎交流,歡迎指出文中錯亂之處。

更新記錄:

zhangyl 於 2017.04.05 增長條款十一。

zhangyl 於 2018.02.01 增長條款三。

相關文章
相關標籤/搜索