由于網(wǎng)絡(luò)編程涉及很多細(xì)節(jié)和技巧,一直想寫篇文章來總結(jié)下這方面的心得與經(jīng)驗,希望對來者有一點幫助,那就善莫大焉了。
一、非阻塞的的connect()函數(shù)如何編寫
我們知道用connect()函數(shù)默認(rèn)是阻塞的,直到三次握手建立之后,或者實在連不上超時返回,期間程序執(zhí)行流一直阻塞在那里。那么如何利用connect()函數(shù)編寫非阻塞的連接代碼呢?
無論在windows還是linux平臺都可以采取以下思路來實現(xiàn):
- 創(chuàng)建socket時,將socket設(shè)置成非阻塞模式;
- 接著調(diào)用connect()進(jìn)行連接,如果connect()能立即連接成功,則返回0;如果此刻不能立即連接成功,則返回-1(windows上返回SOCKET_ERROR也等于-1),這個時候錯誤碼是WSAEWOULDBLOCK(windows平臺),或者是EINPROGRESS(linux平臺),表明立即暫時不能完成。
- 接著調(diào)用select()函數(shù)在指定的時間內(nèi)檢測socket是否可寫,如果可寫表明connect()連接成功。
需要注意的是:linux平臺上connect()暫時不能完成返回-1,錯誤碼可能是EINPROGRESS,也可能是由于被信號給中斷了,這個時候錯誤碼是:EINTR。這種情況也要考慮到;而在windows平臺上除了用select()函數(shù)去檢測socket是否可寫,也可以使用windows平臺自帶的函數(shù)WSAAsyncSelect或WSAEventSelect來檢測。
下面是代碼:
/**
*@param timeout 連接超時時間,單位為秒
*@return 連接成功返回true,反之返回false
**/
bool CSocket::Connect(int timeout)
{
//windows將socket設(shè)置成非阻塞的方式
unsigned long on = 1;
if (::ioctlsocket(m_hSocket, FIONBIO, &on) < 0)
return false;
//linux將socket設(shè)置成非阻塞的方式
//將新socket設(shè)置為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做更小精度的超時設(shè)置
tv.tv_usec = 0;
if (::select(m_hSocket + 1, NULL, &writeset, NULL, &tv) != 1)
return false;
return true;
}
二、非阻塞socket下如何正確的收發(fā)數(shù)據(jù)
這里不討論阻塞模式下,阻塞模式下send和recv函數(shù)如果tcp窗口太小或沒有數(shù)據(jù)的話都是阻塞在send和recv調(diào)用處的。對于收數(shù)據(jù),一般的流程是先用select(windows和linux平臺皆可)、WSAAsyncSelect()或WSAEventSelect()(windows平臺)、poll或epoll_wait(linux平臺)檢測socket有數(shù)據(jù)可讀,然后進(jìn)行收取。對于發(fā)數(shù)據(jù),;linux平臺下epoll模型存在水平模式和邊緣模式兩種情形,如果是邊緣模式一定要一次性把socket上的數(shù)據(jù)收取干凈才行,也就是一定要循環(huán)到recv函數(shù)出錯,錯誤碼是EWOULDBLOCK。而linux下的水平模式或者windows平臺上可以根據(jù)業(yè)務(wù)一次性收取固定的字節(jié)數(shù),或者收完為止。還有個區(qū)別上文也說過,就是windows下發(fā)數(shù)據(jù)的代碼稍微有點不同的就是不需要檢測錯誤碼是EINTR,只需要檢測是否是WSAEWOULDBLOCK。代碼如下:
用于windows或linux水平模式下收取數(shù)據(jù),這種情況下收取的數(shù)據(jù)可以小于指定大小,總之一次能收到多少是多少:
bool TcpSession::Recv()
{
//每次只收取256個字節(jié)
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個字節(jié)
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;
}
//對端關(guān)閉了socket
else if (nRecv == 0)
return false;
inputBuffer_.add(buff, (size_t)nRecv);
}
return true;
}
用于linux平臺發(fā)送數(shù)據(jù):
bool TcpSession::Send()
{
while (true)
{
int n = ::send(clientfd_, buffer_, buffer_.length(), 0);
if (n == -1)
{
//tcp窗口容量不夠, 暫且發(fā)不出去,下次再發(fā)
if (errno == EWOULDBLOCK)
break;
//被信號中斷,繼續(xù)發(fā)送
else if (errno == EINTR)
continue;
return false;
}
//對端關(guān)閉了連接
else if (n == 0)
return false;
buffer_.erase(n);
//全部發(fā)送完畢
if (buffer_.length() == 0)
break;
}
return true;
}
另外,收發(fā)數(shù)據(jù)還有個技巧是設(shè)置超時時間,除了用setsocketopt函數(shù)設(shè)置send和recv的超時時間以外,還可以自定義整個收發(fā)數(shù)據(jù)過程中的超時時間,思路是開始收數(shù)據(jù)前記錄下時間,收取完畢后記錄下時間,如果這個時間差大于超時時間,則認(rèn)為超時,代碼分別是:
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;//對方關(guān)閉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)
{//接收的數(shù)據(jù)有問題
Close();
return pkglen;
}
else if(pkglen <= (int)outbuf.length())
break;//收夠了
}
return pkglen;//返回該完整包的長度
}
三、如何獲取當(dāng)前socket對應(yīng)的接收緩沖區(qū)中有多少數(shù)據(jù)可讀
Windows上可以使用ioctlsocket()這個函數(shù),代碼如下:
ulong bytesToRecv;
if (ioctlsocket(clientsock, FIONREAD, &bytesToRecv) == 0)
{
//在這里,bytesToRecv的值即是當(dāng)前接收緩沖區(qū)中數(shù)據(jù)字節(jié)數(shù)目
}
linux平臺我沒找到類似的方法??梢圆捎梦疑厦嬲f的通用方法《非阻塞socket下如何正確的收發(fā)數(shù)據(jù)》來做。當(dāng)然有人說可以這么寫(我在linux man手冊ioctl函數(shù)欄目上并沒有看到這個函數(shù)可以使用FIONREAD這樣的標(biāo)志,不同機(jī)器可能也有差異,具體可不可以得需要你根據(jù)你的linux系統(tǒng)去驗證):
ulong bytesToRecv;
if (ioctl(clientsock, FIONREAD, &bytesToRecv) == 0)
{
//在這里,bytesToRecv的值即是當(dāng)前接收緩沖區(qū)中數(shù)據(jù)字節(jié)數(shù)目
}
四、上層業(yè)務(wù)如何解析和使用收到的數(shù)據(jù)包?
這個話題實際上是繼上一個話題討論的。這個問題也可以回答常用的面試題:如何解決數(shù)據(jù)的丟包、粘包、包不完整的問題。首先,因為tcp協(xié)議是可靠的,所以不存在丟包問題,也不存在包順序錯亂問題(udp會存在這個問題,這個時候需要自己使用序號之類的機(jī)制保證了,這里只討論tcp)。一般的做法是先收取一個固定大小的包頭信息,接著根據(jù)包頭里面指定的包體大小來收取包體大?。ㄟ@里“收取”既可以從socket上收取,也可以在已經(jīng)收取的數(shù)據(jù)緩沖區(qū)里面拿?。Ee個例子:
#pragma pack(push, 1)
struct msg
{
int32_t cmd; //協(xié)議號
int32_t seq; //包序列號(同一個請求包和應(yīng)答包的序列號相同)
int32_t packagesize; //包體大小
int32_t reserved1; //保留字段,在應(yīng)答包中內(nèi)容保持不變
int32_t reserved2; //保留字段,在應(yīng)答包中內(nèi)容保持不變
};
/**
* 心跳包協(xié)議
**/
struct msg_heartbeat_req
{
msg header;
};
struct msg_heartbeat_resp
{
msg header;
};
/**
* 登錄協(xié)議
**/
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)
看上面幾個協(xié)議,拿登錄請求來說,每次可以先收取一個包頭的大小,即sizeof(msg),然后根據(jù)msg.packagesize的大小再收取包體的大小sizeof(msg_login_req) - sizeof(msg),這樣就能保證一個包完整了,如果包頭或包體大小不夠,則說明數(shù)據(jù)不完整,繼續(xù)等待更多的數(shù)據(jù)的到來。
因為tcp協(xié)議是流協(xié)議,對方發(fā)送10個字節(jié)給你,你可能先收到5個字節(jié),再收到5個字節(jié);或者先收到2個字節(jié),再收到8個字節(jié);或者先收到1個字節(jié),再收到9個字節(jié);或者先收到1個字節(jié),再收到7個字節(jié),再收到2個字節(jié)??傊憧赡芤赃@10個字節(jié)的任意組合方式收取到。所以,一般在正式的項目中的做法是,先檢測socket上是否有數(shù)據(jù),有的話就收一下(至于收完不收完,上文已經(jīng)說了區(qū)別),收好之后,在收到的字節(jié)中先檢測夠不夠一個包頭大小,不夠下次收數(shù)據(jù)后再檢測;如果夠的話,再看看夠不夠包頭中指定的包體大小,不夠下次再處理;如果夠的話,則取出一個包的大小,解包并交給上層業(yè)務(wù)邏輯。注意,這個時候還要繼續(xù)檢測是否夠下一個包頭和包體,如此循環(huán)下去,直到不夠一個包頭或者包體大小。這種情況很常見,尤其對于那些對端連續(xù)發(fā)數(shù)據(jù)包的情況下。
五、nagle算法
nagle算法的是操作系統(tǒng)網(wǎng)絡(luò)通信層的一種發(fā)送數(shù)據(jù)包機(jī)制,如果開啟,則一次放入網(wǎng)卡緩沖區(qū)中的數(shù)據(jù)(利用send或write等)較小時,可能不會立即發(fā)出去,只要當(dāng)多次send或者write之后,網(wǎng)卡緩沖區(qū)中的數(shù)據(jù)足夠多時,才會一次性被協(xié)議棧發(fā)送出去,操作系統(tǒng)利用這個算法減少網(wǎng)絡(luò)通信次數(shù),提高網(wǎng)絡(luò)利用率。對于實時性要求比較高的應(yīng)用來說,可以禁用nagle算法。這樣send或write的小數(shù)據(jù)包會立刻發(fā)出去。系統(tǒng)默認(rèn)是開啟的,禁用方法如下:
long noDelay = 1;
setsockopt(m_hSocket, IPPROTO_TCP, TCP_NODELAY,(LPSTR)&noDelay, sizeof(long));
noDelay為1禁用nagle算法,為0啟用nagle算法。
六、select函數(shù)的第一個參數(shù)問題
select函數(shù)的原型是:
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,這個函數(shù)都源于Berkeley 套接字。其中readfds、writefds和exceptfds都是一個含有socket描述符句柄數(shù)組的結(jié)構(gòu)體。在linux下,第一個參數(shù)必須設(shè)置成這三個參數(shù)中,所有socket描述符句柄中的最大值加1;windows雖然不使用這個參數(shù),卻為了保持與Berkeley 套接字兼容,保留了這個參數(shù),所以windows平臺上這個參數(shù)可以填寫任意值。
七、關(guān)于bind函數(shù)的綁定地址
使用bind函數(shù)時,我們需要綁定一個地址。示例如下:
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。這二者還是有什么區(qū)別?如果是前者,那么bind會綁定該機(jī)器上的任意網(wǎng)卡地址(特別是存在多個網(wǎng)卡地址的情況下),如果是后者,只會綁定本地回環(huán)地址127.0.0.1。這樣,使用前者綁定,可以使用connect去連接任意一個本地的網(wǎng)卡地址,而后者只能連接127.0.0.1。舉個例子:
上文中,機(jī)器有三個網(wǎng)卡地址,如果使用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。
八、關(guān)于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選項,一般服務(wù)器程序用的特別多,主要是為了解決一個socket被系統(tǒng)回收以后,在一個最大存活期(MSL,大約2分鐘)內(nèi),該socket綁定的地址和端口號不能被重復(fù)利用的情況。tcp斷開連接時,需要進(jìn)行四次揮手,為了保證最后一步處于time_wait狀態(tài)的socket能收到ACK應(yīng)答,操作系統(tǒng)將socket的生命周期延長至一個MSL。但是這對于服務(wù)器程序來說,尤其是重啟的情況下,由于重啟之后,該地址和端口號不能立刻被使用,導(dǎo)致bind函數(shù)調(diào)用失敗。所以開發(fā)者要不變更地址和端口號,要不等待幾分鐘。這其中任意一個選擇都無法承受的。所以可以設(shè)置這個選項來避免這個問題。
但是windows上和linux上實現(xiàn)稍有差別,windows上是一個socket回收后,在MSL期間內(nèi),其使用的地址和端口號組合其他進(jìn)程不可以使用,但本進(jìn)程可以繼續(xù)重復(fù)利用;而linux實現(xiàn)是所有進(jìn)程在MSL期間內(nèi)都不能使用,包括本進(jìn)程。
九、心跳包機(jī)制
為了維持一個tcp連接的正常,通常一個連接長時間沒有數(shù)據(jù)來往會被系統(tǒng)的防火墻關(guān)閉。這個時候,如果再想通過這個連接發(fā)送數(shù)據(jù)就會出錯,所以需要通過心跳機(jī)制來維持。雖然tcp協(xié)議棧有自己的keepalive機(jī)制,但是,我們應(yīng)該更多的通過應(yīng)用層心跳包來維持連接存活。那么多長時間發(fā)一次心跳包合適呢?在我的過往項目經(jīng)驗中,真是眾說紛紜啊,也因此被坑了不少次。后來,我找到了一種比較科學(xué)的時間間隔:
先假設(shè)每隔30秒給對端發(fā)送一個心跳數(shù)據(jù)包,這樣需要開啟一個定時器,定時器是每過30秒發(fā)送一個心跳數(shù)據(jù)包。
除了心跳包外,與對端也會有正常的數(shù)據(jù)來往(非心跳包數(shù)據(jù)包),那么記下這些數(shù)據(jù)的send和recv時刻。也就是說,如果最近的30秒內(nèi),發(fā)送過或者收到過非心跳包外的數(shù)據(jù)包,那么30秒后就不要發(fā)心跳包數(shù)據(jù)。也就是說,心跳包發(fā)送一定是在兩端沒有數(shù)據(jù)來往后的30秒才需要發(fā)送。這樣不僅可以減輕服務(wù)器的壓力,同時也減少了網(wǎng)絡(luò)通信流量,尤其對于流量昂貴的移動設(shè)備。
當(dāng)然,心跳包不僅可以用來維持連接正常,也可以攜帶一些數(shù)據(jù),比如定期得到某些數(shù)據(jù)的最新值,這個時候,上面的方案可能就不太合適了,還是需要每隔30秒發(fā)送一次。具體采取哪種,可以根據(jù)實際的項目需求來決定。
另外,需要補(bǔ)充一點的時,心跳包一般由客戶端發(fā)給服務(wù)器端,也就是說客戶端檢測自己是否保持與服務(wù)器連接,而不是服務(wù)器主動發(fā)給客戶端。用程序的術(shù)語來講就是調(diào)用connect函數(shù)的一方發(fā)送心跳包,調(diào)用listen的一方接收心跳包。
拓展一下,這種思路也可以用于保持與數(shù)據(jù)庫的連接。比如在30秒內(nèi)沒有執(zhí)行數(shù)據(jù)庫操作后,定期執(zhí)行一條sql,用以保持連接不斷開,比如一條簡單的sql:select 1 from user;
十、重連機(jī)制
在我早些年的軟件開發(fā)生涯中,我用connect函數(shù)連接一個對端,如果連接不上,那么我會再次重試,如果還是連接不上,會接著重試。如此一直反復(fù)下去,雖然這種重連動作放在一個專門的線程里面(對于客戶端軟件,千萬不要放在UI線程里面,不然你的界面將會卡死)。但是如果對端始終連不上,比如因為網(wǎng)絡(luò)斷開。這種嘗試其實是毫無意義的,不如不做。其實最合理的重連方式應(yīng)該是結(jié)合下面的兩種方案:
- 如果connect連接不上,那么n秒后再重試,如果還是連接不上2n秒之后再重試,以此類推,4n,8n,16n......
但是上述方案,也存在問題,就是如果當(dāng)重試間隔時間變的很長,網(wǎng)絡(luò)突然暢通了,這個時候,需要很長時間才能連接服務(wù)器,這個時候,就應(yīng)該采取方法2。
- 在網(wǎng)絡(luò)狀態(tài)發(fā)生變化時,嘗試重連。比如一款通訊軟件,由于網(wǎng)絡(luò)故障現(xiàn)在處于掉線狀態(tài),突然網(wǎng)絡(luò)恢復(fù)了,這個時候就應(yīng)該嘗試重連。windows下檢測網(wǎng)絡(luò)狀態(tài)發(fā)生變化的API是IsNetworkAlive。示例代碼如下:
BOOL IUIsNetworkAlive()
{
DWORD dwFlags; //上網(wǎng)方式
BOOL bAlive = TRUE; //是否在線
bAlive = ::IsNetworkAlive(&dwFlags);
return bAlive;
}
十一、關(guān)于錯誤碼EINTR
這個錯誤碼是linux平臺下的。對于很多l(xiāng)inux網(wǎng)絡(luò)函數(shù),如connect、send、recv、epoll_wait等,當(dāng)這些函數(shù)出錯時,一定要檢測錯誤是不是EINTR,因為如果是這種錯誤,其實只是被信號中斷了,函數(shù)調(diào)用并沒用出錯,這個時候要么重試,如send、recv、epoll_wait,要么利用其他方式檢測完成情況,如利用select檢測connect是否成功。千萬不要草草認(rèn)定這些調(diào)用失敗,而做出錯誤邏輯判斷。
十二、盡量減少系統(tǒng)調(diào)用
對于高性能的服務(wù)器程序來說,盡量減少系統(tǒng)調(diào)用也是一個值得優(yōu)化的地方。每一次系統(tǒng)調(diào)用就意味著一次從用戶空間到內(nèi)核空間的切換。例如,在libevent網(wǎng)絡(luò)庫,在主循環(huán)里面,對于時間的獲取是一次獲取后就立刻緩存下來,以后如果需要這個時間,就取緩存的。但是有人說,在x86機(jī)器上gettimeofday不是系統(tǒng)調(diào)用,所以libevent沒必要這么做。有沒有必要,我們借鑒一下這個減少系統(tǒng)調(diào)用的思想而已。
十三、忽略linux信號SIGPIPE
SIGPIPE這個信號針對linux平臺的,什么情況下會產(chǎn)生這個信號呢?在 TCP 通信雙方中,為了描述方便,以下將通信雙方用 A 和 B 代替。當(dāng) A “關(guān)閉”連接時,若 B 繼續(xù)給 A 發(fā)數(shù)據(jù),根據(jù) TCP 協(xié)議的規(guī)定,B 會收到 A 的一個 RST 報文響應(yīng),如 B 繼續(xù)再往這個服務(wù)器發(fā)送數(shù)據(jù),系統(tǒng)會產(chǎn)生一個 SIGPIPE 信號給該 B 進(jìn)程,告訴該進(jìn)程這個連接已經(jīng)斷開了,不要再寫了。系統(tǒng)對 SIGPIPE 信號的默認(rèn)處理行為是讓 B 進(jìn)程退出。
所以應(yīng)該捕獲或者忽略掉這個信號,忽略該信號的代碼如下:
signal(SIGPIPE, SIG_IGN);
-
WINDOWS
+關(guān)注
關(guān)注
3文章
3521瀏覽量
88317 -
函數(shù)
+關(guān)注
關(guān)注
3文章
4277瀏覽量
62323 -
網(wǎng)絡(luò)編程
+關(guān)注
關(guān)注
0文章
68瀏覽量
10055 -
非阻塞
+關(guān)注
關(guān)注
0文章
12瀏覽量
2164
發(fā)布評論請先 登錄
相關(guān)推薦
評論