tft每日頭條

 > 科技

 > linux網絡編程入門項目

linux網絡編程入門項目

科技 更新时间:2024-05-14 09:25:38

由于網絡編程涉及很多細節和技巧,一直想寫篇文章來總結下這方面的心得與經驗,希望對來者有一點幫助,那就善莫大焉了。

一、非阻塞的的connect()函數如何編寫

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

無論在windows還是linux平台都可以采取以下思路來實現:

1. 創建socket時,将socket設置成非阻塞模式;

2. 接着調用connect()進行連接,如果connect()能立即連接成功,則返回0;如果此刻不能立即連接成功,則返回-1(windows上返回SOCKET_ERROR也等于-1),這個時候錯誤碼是WSAEWOULDBLOCK(windows平台),或者是EINPROGRESS(linux平台),表明立即暫時不能完成。

3. 接着調用select()函數在指定的時間内檢測socket是否可寫,如果可寫表明connect()連接成功。

需要注意的是: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; }

二、非阻塞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;//返回該完整包的長度 }

需要C/C Linux服務器架構師學習資料私信“資料”(資料包括C/C ,Linux,golang技術,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒體,CDN,P2P,K8S,Docker,TCP/IP,協程,DPDK,ffmpeg等),免費分享

linux網絡編程入門項目(關于linux網絡編程的一些實用技巧和細節總結)1

三、如何獲取當前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的值即是當前接收緩沖區中數據字節數目 }

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

這個話題實際上是繼上一個話題讨論的。這個問題也可以回答常用的面試題:如何解決數據的丢包、粘包、包不完整的問題。首先,因為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上是否有數據,有的話就收一下(至于收完不收完,上文已經說了區别),收好之後,在收到的字節中先檢測夠不夠一個包頭大小,不夠下次收數據後再檢測;如果夠的話,再看看夠不夠包頭中指定的包體大小,不夠下次再處理;如果夠的話,則取出一個包的大小,解包并交給上層業務邏輯。注意,這個時候還要繼續檢測是否夠下一個包頭和包體,如此循環下去,直到不夠一個包頭或者包體大小。這種情況很常見,尤其對于那些對端連續發數據包的情況下。

五、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算法。

六、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平台上這個參數可以填寫任意值。

七、關于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。舉個例子:

linux網絡編程入門項目(關于linux網絡編程的一些實用技巧和細節總結)2

上文中,機器有三個網卡地址,如果使用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。

八、關于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期間内都不能使用,包括本進程。

九、心跳包機制

為了維持一個tcp連接的正常,通常一個連接長時間沒有數據來往會被系統的防火牆關閉。這個時候,如果再想通過這個連接發送數據就會出錯,所以需要通過心跳機制來維持。雖然tcp協議棧有自己的keepalive機制,但是,我們應該更多的通過應用層心跳包來維持連接存活。那麼多長時間發一次心跳包合适呢?在我的過往項目經驗中,真是衆說紛纭啊,也因此被坑了不少次。後來,我找到了一種比較科學的時間間隔:

先假設每隔30秒給對端發送一個心跳數據包,這樣需要開啟一個定時器,定時器是每過30秒發送一個心跳數據包。

除了心跳包外,與對端也會有正常的數據來往(非心跳包數據包),那麼記下這些數據的send和recv時刻。也就是說,如果最近的30秒内,發送過或者收到過非心跳包外的數據包,那麼30秒後就不要發心跳包數據。也就是說,心跳包發送一定是在兩端沒有數據來往後的30秒才需要發送。這樣不僅可以減輕服務器的壓力,同時也減少了網絡通信流量,尤其對于流量昂貴的移動設備。

當然,心跳包不僅可以用來維持連接正常,也可以攜帶一些數據,比如定期得到某些數據的最新值,這個時候,上面的方案可能就不太合适了,還是需要每隔30秒發送一次。具體采取哪種,可以根據實際的項目需求來決定。

另外,需要補充一點的時,心跳包一般由客戶端發給服務器端,也就是說客戶端檢測自己是否保持與服務器連接,而不是服務器主動發給客戶端。用程序的術語來講就是調用connect函數的一方發送心跳包,調用listen的一方接收心跳包。

拓展一下,這種思路也可以用于保持與數據庫的連接。比如在30秒内沒有執行數據庫操作後,定期執行一條sql,用以保持連接不斷開,比如一條簡單的sql:select 1 from user;

十、重連機制

在我早些年的軟件開發生涯中,我用connect函數連接一個對端,如果連接不上,那麼我會再次重試,如果還是連接不上,會接着重試。如此一直反複下去,雖然這種重連動作放在一個專門的線程裡面(對于客戶端軟件,千萬不要放在UI線程裡面,不然你的界面将會卡死)。但是如果對端始終連不上,比如因為網絡斷開。這種嘗試其實是毫無意義的,不如不做。其實最合理的重連方式應該是結合下面的兩種方案:

1. 如果connect連接不上,那麼n秒後再重試,如果還是連接不上2n秒之後再重試,以此類推,4n,8n,16n......

但是上述方案,也存在問題,就是如果當重試間隔時間變的很長,網絡突然暢通了,這個時候,需要很長時間才能連接服務器,這個時候,就應該采取方法2。

2. 在網絡狀态發生變化時,嘗試重連。比如一款通訊軟件,由于網絡故障現在處于掉線狀态,突然網絡恢複了,這個時候就應該嘗試重連。windows下檢測網絡狀态發生變化的API是IsNetworkAlive。示例代碼如下:

BOOL IUIsNetworkAlive() { DWORD dwFlags; //上網方式 BOOL bAlive = TRUE; //是否在線 bAlive = ::IsNetworkAlive(&dwFlags); return bAlive; }

十一、關于錯誤碼EINTR

這個錯誤碼是linux平台下的。對于很多linux網絡函數,如connect、send、recv、epoll_wait等,當這些函數出錯時,一定要檢測錯誤是不是EINTR,因為如果是這種錯誤,其實隻是被信号中斷了,函數調用并沒用出錯,這個時候要麼重試,如send、recv、epoll_wait,要麼利用其他方式檢測完成情況,如利用select檢測connect是否成功。千萬不要草草認定這些調用失敗,而做出錯誤邏輯判斷。

十二、盡量減少系統調用

對于高性能的服務器程序來說,盡量減少系統調用也是一個值得優化的地方。每一次系統調用就意味着一次從用戶空間到内核空間的切換。例如,在libevent網絡庫,在主循環裡面,對于時間的獲取是一次獲取後就立刻緩存下來,以後如果需要這個時間,就取緩存的。但是有人說,在x86機器上gettimeofday不是系統調用,所以libevent沒必要這麼做。有沒有必要,我們借鑒一下這個減少系統調用的思想而已。

十三、忽略linux信号SIGPIPE

SIGPIPE這個信号針對linux平台的,什麼情況下會産生這個信号呢?在 TCP 通信雙方中,為了描述方便,以下将通信雙方用 A 和 B 代替。當 A “關閉”連接時,若 B 繼續給 A 發數據,根據 TCP 協議的規定,B 會收到 A 的一個 RST 報文響應,如 B 繼續再往這個服務器發送數據,系統會産生一個 SIGPIPE 信号給該 B 進程,告訴該進程這個連接已經斷開了,不要再寫了。系統對 SIGPIPE 信号的默認處理行為是讓 B 進程退出。

所以應該捕獲或者忽略掉這個信号,忽略該信号的代碼如下:

signal(SIGPIPE, SIG_IGN);

,

更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!

查看全部

相关科技资讯推荐

热门科技资讯推荐

网友关注

Copyright 2023-2024 - www.tftnews.com All Rights Reserved