本文原文由做者「張小方」原創發佈於「高性能服務器開發」微信公衆號,原題《心跳包機制設計詳解》,即時通信網收錄時有改動。php
通常來講,沒有真正動手作過網絡通訊應用的開發者,很難想象即時通信應用中的心跳機制的做用。但不能否認,做爲即時通信應用,心跳機制是其網絡通訊技術底層中很是重要的一環,有沒有心跳機制、心跳機制的算法實現好壞,都將直接影響即時通信應用在應用層的表現——好比:實時性、斷網自愈能力、弱網體驗等等。html
總之,要想真正理解即時通信應用底層的開發,心跳機制必須掌握,而這也是本文寫做的目的,但願能帶給你啓發。程序員
須要說明的是:本文中涉及的示例代碼是使用 C/C++ 語言編寫,可是本文中介紹的心跳包機制設計思路和注意事項,都是是些普適性原理,一樣適用於其餘編程語言。雖然語言能夠不一樣,但邏輯不會有差異!面試
學習交流:算法
- 即時通信/推送技術開發交流4羣:101279154[推薦]編程
- 移動端IM開發入門文章:《新手入門一篇就夠:從零開發移動端IM》安全
(本文同步發佈於:http://www.52im.net/thread-2697-1-1.html)服務器
考慮如下兩種典型的即時通信網絡層問題情型:微信
1)情形一:一個客戶端鏈接服務器之後,若是長期沒有和服務器有數據來往,可能會被防火牆程序關閉鏈接,有時候咱們並不想要被關閉鏈接。例如,對於一個即時通信軟件來講,若是服務器沒有消息時,咱們確實不會和服務器有任何數據交換,可是若是鏈接被關閉了,有新消息來時,咱們再也無法收到了,這就違背了「即時通信」的設計要求。網絡
2)情形二:一般狀況下,服務器與某個客戶端通常不是位於同一個網絡,其之間可能通過數個路由器和交換機,若是其中某個必經路由器或者交換器出現了故障,而且一段時間內沒有恢復,致使這之間的鏈路再也不暢通,而此時服務器與客戶端之間也沒有數據進行交換,因爲 TCP 鏈接是狀態機,對於這種狀況,不管是客戶端或者服務器都沒法感知與對方的鏈接是否正常,這類鏈接咱們通常稱之爲「死鏈」。
對於上述問題情型,即時通信應用一般的解決思路:
1)針對情形一:此應用場景要求必須保持客戶端與服務器之間的鏈接正常,就是咱們一般所說的「保活「。如上所述,當服務器與客戶端必定時間內沒有有效業務數據來往時,咱們只須要給對端發送心跳包便可實現保活。
2)針對情形二:要解決死鏈問題,只要咱們此時任意一端給對端發送一個數據包便可檢測鏈路是否正常,這類數據包咱們也稱之爲」心跳包」,這種操做咱們稱之爲「心跳檢測」。顧名思義,若是一我的沒有心跳了,可能已經死亡了;一個鏈接長時間沒有正常數據來往,也沒有心跳包來往,就能夠認爲這個鏈接已經不存在,爲了節約服務器鏈接資源,咱們能夠經過關閉 socket,回收鏈接資源。
總之,心跳檢測機制通常有兩個做用:
1)保活;
2)檢測死鏈。
針對以上問題情型,即時通信網的另外一篇:《爲什麼基於TCP協議的移動端IM仍然須要心跳保活機制?》,也很是值得一讀。
PS:如你還不瞭解tcp的keepalive是什麼,建議先閱讀:《TCP/IP詳解 - 第23章·TCP的保活定時器》
操做系統的 TCP/IP 協議棧其實提供了這個的功能,即 keepalive 選項。在 Linux 操做系統中,咱們能夠經過代碼啓用一個 socket 的心跳檢測(即每隔必定時間間隔發送一個心跳檢測包給對端)。
代碼以下:
//on 是 1 表示打開 keepalive 選項,爲 0 表示關閉,0 是默認值
inton = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on));
可是,即便開啓了這個選項,這個選項默認發送心跳檢測數據包的時間間隔是 7200 秒(2 小時),這時間間隔實在是太長了,必定也不使用。
咱們能夠經過繼續設置 keepalive 相關的三個選項來改變這個時間間隔,它們分別是 TCP_KEEPIDLE、TCP_KEEPINTVL 和 TCP_KEEPCNT。
示例代碼以下:
//發送 keepalive 報文的時間間隔
intval = 7200;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &val, sizeof(val));
//兩次重試報文的時間間隔
intinterval = 75;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &interval, sizeof(interval));
intcnt = 9;
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));
TCP_KEEPIDLE 選項設置了發送 keepalive 報文的時間間隔,發送時若是對端回覆 ACK。則本端 TCP 協議棧認爲該鏈接依然存活,繼續等 7200 秒後再發送 keepalive 報文;若是對端回覆 RESET,說明對端進程已經重啓,本端的應用程序應該關閉該鏈接。
若是對端沒有任何回覆,則本端作重試,若是重試 9 次(TCP_KEEPCNT 值)(先後重試間隔爲 75 秒(TCP_KEEPINTVL 值))仍然不可達,則嚮應用程序返回 ETIMEOUT(無任何應答)或 EHOST 錯誤信息。
咱們可使用以下命令查看 Linux 系統上的上述三個值的設置狀況:
[root@iZ238vnojlyZ ~]# sysctl -a | grep keepalive
net.ipv4.tcp_keepalive_intvl = 75
net.ipv4.tcp_keepalive_probes = 9
net.ipv4.tcp_keepalive_time = 7200
在 Windows 系統設置 keepalive 及對應選項的代碼略有不一樣:
//開啓 keepalive 選項
constcharon = 1;
setsockopt(socket, SOL_SOCKET, SO_KEEPALIVE, (char*)&on, sizeof(on);
// 設置超時詳細信息
DWORDcbBytesReturned;
tcp_keepalive klive;
// 啓用保活
klive.onoff = 1;
klive.keepalivetime = 7200;
// 重試間隔爲10秒
klive.keepaliveinterval = 1000 * 10;
WSAIoctl(socket, SIO_KEEPALIVE_VALS, &klive, sizeof(tcp_keepalive), NULL, 0, &cbBytesReturned, NULL, NULL);
因爲 keepalive 選項須要爲每一個鏈接中的 socket 開啓,這不必定是必須的,可能會產生大量無心義的帶寬浪費,且 keepalive 選項不能與應用層很好地交互,所以通常實際的服務開發中,仍是建議讀者在應用層設計本身的心跳包機制。
那麼如何設計呢?
從技術來說:心跳包其實就是一個預先規定好格式的數據包,在程序中啓動一個定時器,定時發送便可,這是最簡單的實現思路。
可是,若是通訊的兩端有頻繁的數據來往,此時到了下一個發心跳包的時間點了,此時發送一個心跳包。這實際上是一個流量的浪費,既然通訊雙方不斷有正常的業務數據包來往,這些數據包自己就能夠起到保活做用,爲何還要浪費流量去發送這些心跳包呢?
因此,對於用於保活的心跳包,咱們最佳作法是:設置一個上次包時間,每次收數據和發數據時,都更新一下這個包時間,而心跳檢測計時器每次檢測時,將這個包時間與當前系統時間作一個對比,若是時間間隔大於容許的最大時間間隔(實際開發中根據需求設置成 15 ~ 45 秒不等),則發送一次心跳包。總而言之,就是在與對端之間,沒有數據來往達到必定時間間隔時才發送一次心跳包。
發心跳包的僞碼示例:
bool CIUSocket::Send()
{
intnSentBytes = 0;
intnRet = 0;
while(true)
{
nRet = ::send(m_hSocket, m_strSendBuf.c_str(), m_strSendBuf.length(), 0);
if(nRet == SOCKET_ERROR)
{
if(::WSAGetLastError() == WSAEWOULDBLOCK)
break;
else
{
LOG_ERROR("Send data error, disconnect server:%s, port:%d.", m_strServer.c_str(), m_nPort);
Close();
returnfalse;
}
}
elseif(nRet < 1)
{
//一旦出現錯誤就馬上關閉Socket
LOG_ERROR("Send data error, disconnect server:%s, port:%d.", m_strServer.c_str(), m_nPort);
Close();
returnfalse;
}
m_strSendBuf.erase(0, nRet);
if(m_strSendBuf.empty())
break;
::Sleep(1);
}
{
//記錄一下最近一次發包時間
std::lock_guard<std::mutex> guard(m_mutexLastDataTime);
m_nLastDataTime = (long)time(NULL);
}
returntrue;
}
bool CIUSocket::Recv()
{
intnRet = 0;
charbuff[10 * 1024];
while(true)
{
nRet = ::recv(m_hSocket, buff, 10 * 1024, 0);
if(nRet == SOCKET_ERROR) //一旦出現錯誤就馬上關閉Socket
{
if(::WSAGetLastError() == WSAEWOULDBLOCK)
break;
else
{
LOG_ERROR("Recv data error, errorNO=%d.", ::WSAGetLastError());
//Close();
returnfalse;
}
}
elseif(nRet < 1)
{
LOG_ERROR("Recv data error, errorNO=%d.", ::WSAGetLastError());
//Close();
returnfalse;
}
m_strRecvBuf.append(buff, nRet);
::Sleep(1);
}
{
std::lock_guard<std::mutex> guard(m_mutexLastDataTime);
//記錄一下最近一次收包時間
m_nLastDataTime = (long)time(NULL);
}
returntrue;
}
voidCIUSocket::RecvThreadProc()
{
LOG_INFO("Recv data thread start...");
intnRet;
//上網方式
DWORDdwFlags;
BOOLbAlive;
while(!m_bStop)
{
//檢測到數據則收數據
nRet = CheckReceivedData();
//出錯
if(nRet == -1)
{
m_pRecvMsgThread->NotifyNetError();
}
//無數據
elseif(nRet == 0)
{
longnLastDataTime = 0;
{
std::lock_guard<std::mutex> guard(m_mutexLastDataTime);
nLastDataTime = m_nLastDataTime;
}
if(m_nHeartbeatInterval > 0)
{
//當前系統時間與上一次收發數據包的時間間隔超過了m_nHeartbeatInterval
//則發一次心跳包
if(time(NULL) - nLastDataTime >= m_nHeartbeatInterval)
SendHeartbeatPackage();
}
}
//有數據
elseif(nRet == 1)
{
if(!Recv())
{
m_pRecvMsgThread->NotifyNetError();
continue;
}
DecodePackages();
}// end if
}// end while-loop
LOG_INFO("Recv data thread finish...");
}
同理,檢測心跳包的一端,應該是在與對端沒有數據來往達到必定時間間隔時才作一次心跳檢測。
心跳檢測一端的僞碼示例以下:
voidBusinessSession::send(constchar* pData, intdataLength)
{
boolsent = TcpSession::send(pData, dataLength);
//發送完數據更新下發包時間
updateHeartbeatTime();
}
voidBusinessSession::handlePackge(char* pMsg, intmsgLength, bool& closeSession, std::vector<std::string>& vectorResponse)
{
//對數據合法性進行校驗
if(pMsg == NULL || pMsg[0] == 0 || msgLength <= 0 || msgLength > MAX_DATA_LENGTH)
{
//非法刺探請求,不作任何應答,直接關閉鏈接
closeSession = true;
return;
}
//更新下收包時間
updateHeartbeatTime();
//省略包處理代碼...
}
voidBusinessSession::updateHeartbeatTime()
{
std::lock_guard<std::mutex> scoped_guard(m_mutexForlastPackageTime);
m_lastPackageTime = (int64_t)time(nullptr);
}
boolBusinessSession::doHeartbeatCheck()
{
constConfig& cfg = Singleton<Config>::Instance();
int64_t now = (int64_t)time(nullptr);
std::lock_guard<std::mutex> lock_guard(m_mutexForlastPackageTime);
if(now - m_lastPackageTime >= cfg.m_nMaxClientDataInterval)
{
//心跳包檢測,超時,關閉鏈接
LOGE("heartbeat expired, close session");
shutdown();
returntrue;
}
return false;
}
void TcpServer::checkSessionHeartbeat()
{
int64_t now = (int64_t)time(nullptr);
if(now - m_nLastCheckHeartbeatTime >= m_nHeartbeatCheckInterval)
{
m_spSessionManager->checkSessionHeartbeat();
m_nLastCheckHeartbeatTime = (int64_t)time(nullptr);
}
}
voidSessionManager::checkSessionHeartbeat()
{
std::lock_guard<std::mutex> scoped_lock(m_mutexForSession);
for(constauto& iter : m_mapSessions)
{
//這裏調用 BusinessSession::doHeartbeatCheck()
iter.second->doHeartbeatCheck();
}
}
須要注意的是:通常是客戶端主動給服務器端發送心跳包,服務器端作心跳檢測決定是否斷開鏈接,而不是反過來。從客戶端的角度來講,客戶端爲了讓本身獲得服務器端的正常服務有必要主動和服務器保持鏈接狀態正常,而服務器端不會侷限於某個特定的客戶端,若是客戶端不能主動和其保持鏈接,那麼就會主動回收與該客戶端的鏈接。固然,服務器端在收到客戶端的心跳包時應該給客戶端一個心跳應答。
上面介紹的心跳包是從純技術的角度來講的,在實際應用中,有時候咱們須要定時或者不定時從服務器端更新一些數據,咱們能夠把這類數據放在心跳包中,定時或者不定時更新。
這類帶業務數據的心跳包,就再也不是純粹技術上的做用了(這裏說的技術的做用指的上文中介紹的心跳包起保活和檢測死鏈做用)。
這類心跳包實現也很容易,即在心跳包數據結構裏面加上須要的業務字段信息,而後在定時器中定時發送,客戶端發給服務器,服務器在應答心跳包中填上約定的業務數據信息便可。
一般狀況下,多數應用場景下,與服務器端保持鏈接的多個客戶端中,同一時間段活躍用戶(這裏指的是與服務器有頻繁數據來往的客戶端)通常不會太多。當鏈接數較多時,進出服務器程序的數據包一般都是心跳包(爲了保活)。因此爲了減輕網絡代碼壓力,節省流量,尤爲是針對一些 3/4 G 手機應用,咱們在設計心跳包數據格式時應該儘可能減少心跳包的數據大小。
如前文所述,對於心跳包,服務器端的邏輯通常是在必定時間間隔內沒有收到客戶端心跳包時會主動斷開鏈接。在咱們開發調試程序過程當中,咱們可能須要將程序經過斷點中斷下來,這個過程多是幾秒到幾十秒不等。等程序恢復執行時,鏈接可能由於心跳檢測邏輯已經被斷開。
調試過程當中,咱們更多的關注的是業務數據處理的邏輯是否正確,不想被一堆無心義的心跳包數據干擾實線。
鑑於以上兩點緣由,咱們通常在調試模式下關閉或者禁用心跳包檢測機制。
代碼示例大體以下:
ChatSession::ChatSession(conststd::shared_ptr<TcpConnection>& conn, intsessionid) :
TcpSession(conn),
m_id(sessionid),
m_seq(0),
m_isLogin(false)
{
m_userinfo.userid = 0;
m_lastPackageTime = time(NULL);
//這裏設置了非調試模式下才開啓心跳包檢測功能
#ifndef _DEBUG
EnableHearbeatCheck();
#endif
}
固然,你也能夠將開啓心跳檢測的開關作成配置信息放入程序配置文件中。
實際生產環境,咱們通常會將程序收到的和發出去的數據包寫入日誌中,可是無業務信息的心跳包信息是個例外,通常會刻意不寫入日誌,這是由於心跳包數據通常比較多,若是寫入日誌會致使日誌文件變得很大,且充斥大量無心義的心跳包日誌,因此通常在寫日誌時會屏蔽心跳包信息寫入。
我這裏的建議是:能夠將心跳包信息是否寫入日誌作成一個配置開關,通常處於關閉狀態,有須要時再開啓。
例如,對於一個 WebSocket 服務,ping 和 pong 是心跳包數據,下面示例代碼按需輸出心跳日誌信息:
void BusinessSession::send(std::string _view strResponse)
{
boolsuccess = WebSocketSession::send(strResponse);
if(success)
{
boolenablePingPongLog = Singleton<Config>::Instance().m_bPingPongLogEnabled;
//其餘消息正常打印,心跳消息按需打印
if(strResponse != "pong"|| enablePingPongLog)
{
LOGI("msg sent to client [%s], sessionId: %s, session: 0x%0x, clientId: %s, accountId: %s, frontId: %s, msg: %s",
getClientInfo(), m_strSessionId.c_str(), (int64_t)this, m_strClientID.c_str(), m_strAccountID.c_str(), BusinessSession::m_strFrontId.c_str(), strResponse.data());
}
}
}
[1] 有關IM/推送的心跳保活處理:
《應用保活終極總結(一):Android6.0如下的雙進程守護保活實踐》
《應用保活終極總結(二):Android6.0及以上的保活實踐(進程防殺篇)》
《應用保活終極總結(三):Android6.0及以上的保活實踐(被殺復活篇)》
《Android進程保活詳解:一篇文章解決你的全部疑問》
《Android端消息推送總結:實現原理、心跳保活、遇到的問題等》
《深刻的聊聊Android消息推送這件小事》
《爲什麼基於TCP協議的移動端IM仍然須要心跳保活機制?》
《微信團隊原創分享:Android版微信後臺保活實戰分享(進程保活篇)》
《微信團隊原創分享:Android版微信後臺保活實戰分享(網絡保活篇)》
《移動端IM實踐:實現Android版微信的智能心跳機制》
《移動端IM實踐:WhatsApp、Line、微信的心跳策略分析》
《Android P正式版即將到來:後臺應用保活、消息推送的真正噩夢》
《全面盤點當前Android後臺保活方案的真實運行效果(截止2019年前)》
《一文讀懂即時通信應用中的網絡心跳包機制:做用、原理、實現思路等》
>> 更多同類文章 ……
[2] 網絡編程基礎資料:
《TCP/IP詳解 - 第11章·UDP:用戶數據報協議》
《TCP/IP詳解 - 第17章·TCP:傳輸控制協議》
《TCP/IP詳解 - 第18章·TCP鏈接的創建與終止》
《TCP/IP詳解 - 第21章·TCP的超時與重傳》
《技術往事:改變世界的TCP/IP協議(珍貴多圖、手機慎點)》
《通俗易懂-深刻理解TCP協議(上):理論基礎》
《通俗易懂-深刻理解TCP協議(下):RTT、滑動窗口、擁塞處理》
《理論經典:TCP協議的3次握手與4次揮手過程詳解》
《理論聯繫實際:Wireshark抓包分析TCP 3次握手、4次揮手過程》
《計算機網絡通信協議關係圖(中文珍藏版)》
《UDP中一個包的大小最大能多大?》
《P2P技術詳解(一):NAT詳解——詳細原理、P2P簡介》
《P2P技術詳解(二):P2P中的NAT穿越(打洞)方案詳解》
《P2P技術詳解(三):P2P技術之STUN、TURN、ICE詳解》
《通俗易懂:快速理解P2P技術中的NAT穿透原理》
《高性能網絡編程(一):單臺服務器併發TCP鏈接數到底能夠有多少》
《高性能網絡編程(二):上一個10年,著名的C10K併發鏈接問題》
《高性能網絡編程(三):下一個10年,是時候考慮C10M併發問題了》
《高性能網絡編程(四):從C10K到C10M高性能網絡應用的理論探索》
《高性能網絡編程(五):一文讀懂高性能網絡編程中的I/O模型》
《高性能網絡編程(六):一文讀懂高性能網絡編程中的線程模型》
《鮮爲人知的網絡編程(一):淺析TCP協議中的疑難雜症(上篇)》
《鮮爲人知的網絡編程(二):淺析TCP協議中的疑難雜症(下篇)》
《鮮爲人知的網絡編程(三):關閉TCP鏈接時爲何會TIME_WAIT、CLOSE_WAIT》
《鮮爲人知的網絡編程(四):深刻研究分析TCP的異常關閉》
《鮮爲人知的網絡編程(五):UDP的鏈接性和負載均衡》
《鮮爲人知的網絡編程(六):深刻地理解UDP協議並用好它》
《鮮爲人知的網絡編程(七):如何讓不可靠的UDP變的可靠?》
《鮮爲人知的網絡編程(八):從數據傳輸層深度解密HTTP》
《網絡編程懶人入門(一):快速理解網絡通訊協議(上篇)》
《網絡編程懶人入門(二):快速理解網絡通訊協議(下篇)》
《網絡編程懶人入門(三):快速理解TCP協議一篇就夠》
《網絡編程懶人入門(四):快速理解TCP和UDP的差別》
《網絡編程懶人入門(五):快速理解爲何說UDP有時比TCP更有優點》
《網絡編程懶人入門(六):史上最通俗的集線器、交換機、路由器功能原理入門》
《網絡編程懶人入門(七):深刻淺出,全面理解HTTP協議》
《網絡編程懶人入門(八):手把手教你寫基於TCP的Socket長鏈接》
《網絡編程懶人入門(九):通俗講解,有了IP地址,爲什麼還要用MAC地址?》
《技術掃盲:新一代基於UDP的低延時網絡傳輸層協議——QUIC詳解》
《讓互聯網更快:新一代QUIC協議在騰訊的技術實踐分享》
《現代移動端網絡短鏈接的優化手段總結:請求速度、弱網適應、安全保障》
《聊聊iOS中網絡編程長鏈接的那些事》
《移動端IM開發者必讀(一):通俗易懂,理解移動網絡的「弱」和「慢」》
《移動端IM開發者必讀(二):史上最全移動弱網絡優化方法總結》
《IPv6技術詳解:基本概念、應用現狀、技術實踐(上篇)》
《IPv6技術詳解:基本概念、應用現狀、技術實踐(下篇)》
《從HTTP/0.9到HTTP/2:一文讀懂HTTP協議的歷史演變和設計思路》
《腦殘式網絡編程入門(一):跟着動畫來學TCP三次握手和四次揮手》
《腦殘式網絡編程入門(二):咱們在讀寫Socket時,究竟在讀寫什麼?》
《腦殘式網絡編程入門(三):HTTP協議必知必會的一些知識》
《腦殘式網絡編程入門(四):快速理解HTTP/2的服務器推送(Server Push)》
《腦殘式網絡編程入門(五):天天都在用的Ping命令,它究竟是什麼?》
《腦殘式網絡編程入門(六):什麼是公網IP和內網IP?NAT轉換又是什麼鬼?》
《以網遊服務端的網絡接入層設計爲例,理解實時通訊的技術挑戰》
《邁向高階:優秀Android程序員必知必會的網絡基礎》
《全面瞭解移動端DNS域名劫持等雜症:技術原理、問題根源、解決方案等》
《美圖App的移動端DNS優化實踐:HTTPS請求耗時減少近半》
《Android程序員必知必會的網絡通訊傳輸層協議——UDP和TCP》
《IM開發者的零基礎通訊技術入門(一):通訊交換技術的百年發展史(上)》
《IM開發者的零基礎通訊技術入門(二):通訊交換技術的百年發展史(下)》
《IM開發者的零基礎通訊技術入門(三):國人通訊方式的百年變遷》
《IM開發者的零基礎通訊技術入門(四):手機的演進,史上最全移動終端發展史》
《IM開發者的零基礎通訊技術入門(五):1G到5G,30年移動通訊技術演進史》
《IM開發者的零基礎通訊技術入門(六):移動終端的接頭人——「基站」技術》
《IM開發者的零基礎通訊技術入門(七):移動終端的千里馬——「電磁波」》
《IM開發者的零基礎通訊技術入門(八):零基礎,史上最強「天線」原理掃盲》
《IM開發者的零基礎通訊技術入門(九):無線通訊網絡的中樞——「核心網」》
《IM開發者的零基礎通訊技術入門(十):零基礎,史上最強5G技術掃盲》
《IM開發者的零基礎通訊技術入門(十一):爲何WiFi信號差?一文即懂!》
《IM開發者的零基礎通訊技術入門(十二):上網卡頓?網絡掉線?一文即懂!》
《IM開發者的零基礎通訊技術入門(十三):爲何手機信號差?一文即懂!》
《IM開發者的零基礎通訊技術入門(十四):高鐵上無線上網有多難?一文即懂!》
《IM開發者的零基礎通訊技術入門(十五):理解定位技術,一篇就夠》
《百度APP移動端網絡深度優化實踐分享(一):DNS優化篇》
《百度APP移動端網絡深度優化實踐分享(二):網絡鏈接優化篇》
《百度APP移動端網絡深度優化實踐分享(三):移動端弱網優化篇》
《技術大牛陳碩的分享:由淺入深,網絡編程學習經驗乾貨總結》
《可能會搞砸你的面試:你知道一個TCP鏈接上能發起多少個HTTP請求嗎?》
>> 更多同類文章 ……
(本文同步發佈於:http://www.52im.net/thread-2697-1-1.html)