一文读懂即时通信应用中的网络心跳包机制:做用、原理、实现思路等

本文原文由做者“张小方”原创发布于“高性能服务器开发”微信公众号,原题《心跳包机制设计详解》,即时通信网收录时有改动。html

一、引言

通常来讲,没有真正动手作过网络通讯应用的开发者,很难想象即时通信应用中的心跳机制的做用。但不能否认,做为即时通信应用,心跳机制是其网络通讯技术底层中很是重要的一环,有没有心跳机制、心跳机制的算法实现好坏,都将直接影响即时通信应用在应用层的表现——好比:实时性、断网自愈能力、弱网体验等等。程序员

总之,要想真正理解即时通信应用底层的开发,心跳机制必须掌握,而这也是本文写做的目的,但愿能带给你启发。面试

须要说明的是:本文中涉及的示例代码是使用 C/C++ 语言编写,可是本文中介绍的心跳包机制设计思路和注意事项,都是是些普适性原理,一样适用于其余编程语言。虽然语言能够不一样,但逻辑不会有差异!算法

学习交流:编程

即时通信/推送技术开发交流4群:101279154[推荐]
移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》

(本文同步发布于:http://www.52im.net/thread-26...安全

二、相关文章

《为什么基于TCP协议的移动端IM仍然须要心跳保活机制?》(推荐)

《微信团队原创分享:Android版微信后台保活实战分享(网络保活篇)》(推荐)服务器

《移动端IM实践:实现Android版微信的智能心跳机制》微信

《移动端IM实践:WhatsApp、Line、微信的心跳策略分析》网络

《手把手教你用Netty实现网络通讯程序的心跳机制、断线重连机制》session

《Android端消息推送总结:实现原理、心跳保活、遇到的问题等》

三、为何须要心跳机制?

考虑如下两种典型的即时通信网络层问题情型:

1)情形一:一个客户端链接服务器之后,若是长期没有和服务器有数据来往,可能会被防火墙程序关闭链接,有时候咱们并不想要被关闭链接。例如,对于一个即时通信软件来讲,若是服务器没有消息时,咱们确实不会和服务器有任何数据交换,可是若是链接被关闭了,有新消息来时,咱们再也无法收到了,这就违背了“即时通信”的设计要求。

2)情形二:一般状况下,服务器与某个客户端通常不是位于同一个网络,其之间可能通过数个路由器和交换机,若是其中某个必经路由器或者交换器出现了故障,而且一段时间内没有恢复,致使这之间的链路再也不畅通,而此时服务器与客户端之间也没有数据进行交换,因为 TCP 链接是状态机,对于这种状况,不管是客户端或者服务器都没法感知与对方的链接是否正常,这类链接咱们通常称之为“死链”。

对于上述问题情型,即时通信应用一般的解决思路:

1)针对情形一:此应用场景要求必须保持客户端与服务器之间的链接正常,就是咱们一般所说的“保活“。如上所述,当服务器与客户端必定时间内没有有效业务数据来往时,咱们只须要给对端发送心跳包便可实现保活。

2)针对情形二:要解决死链问题,只要咱们此时任意一端给对端发送一个数据包便可检测链路是否正常,这类数据包咱们也称之为”心跳包”,这种操做咱们称之为“心跳检测”。顾名思义,若是一我的没有心跳了,可能已经死亡了;一个链接长时间没有正常数据来往,也没有心跳包来往,就能够认为这个链接已经不存在,为了节约服务器链接资源,咱们能够经过关闭 socket,回收链接资源。

总之,心跳检测机制通常有两个做用:

1)保活;

2)检测死链。

针对以上问题情型,即时通信网的另外一篇:《为什么基于TCP协议的移动端IM仍然须要心跳保活机制?》,也很是值得一读。

四、TCP的keepalive选项

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-26...

相关文章
相关标签/搜索