Socket编程实践(5) --TCP粘包问题与解决

TCP粘包问题

因为TCP协议是基于字节流且无边界的传输协议, 因此非常有可能产生粘包问题, 问题描写叙述例如以下

watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvempmMjgwNDQxNTg5/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" />

对于Host A 发送的M1与M2两个各10K的数据块, Host B 接收数据的方式不确定, 有以下方式接收:

先接收M1, 再接收M2(正确方式)

先接收M2, 再接收M1(错误)

一次性收到20k数据(错误)

分两次收到,第一次15k。第二次5k(错误)

分两次收到,第一次5k。第二次15k(错误)

其它不论什么可能(错误)

粘包产生的原因 

1、SQ_SNDBUF 套接字本身有缓冲区 (发送缓冲区、接受缓冲区)

2、tcp传送的端 mss限制大小

3、链路层也有MTU限制大小,假设数据包大于>MTU要在IP层进行分片。导致消息切割。

4、tcp的流量控制和拥塞控制,也可能导致粘包

5、tcp延迟发送机制等

TCP与UDP关于粘包问题的对照


TCP


UDP


字节流


数据报


无边界


有边界


对等方的一次读操作并不能保证全然把消息读完


对方接收数据包的个数是不确定的

粘包解决方式(本质上是要在应用层维护消息与消息的边界)

(1)定长包

该方式并不有用: 假设所定义的长度过长, 则会浪费网络带宽, 而又假设定义的长度过短, 则一条消息又会拆分成为多条, 仅在TCP的应用一层就添加了合并的开销, 何况在其它层(因此我在博客中并未给出定长包的演示样例, 而是将之(一个不太完好的实现)与使用自己定义报头的演示样例放到了一起, 感兴趣的读者能够下载下来查看);

(2)包尾加\r\n(FTP使用方案)

假设消息本身含有\r\n字符。则也分不清消息的边界;

(3)报文长度+报文内容

(4)更复杂的应用层协议

readn / writen实现

Socket, 管道以及某些设备(特别是终端和网络)有下列两种性质:

1)一次read操作所返回的数据可能少于所要求的数据,即使还没到达文件尾端也可能这样,但这不是一个错误,应当继续读该设备;

2)一次write操作的返回值也可能少于指定输入的字节数.这可能是因为某个因素造成的,如:内核缓冲区满...但这也不是一个错误,应当继续写余下的数据(通常,仅仅有非堵塞描写叙述符,或捕捉到一个信号时,才发生这样的write的中途返回)

在读写磁盘文件时从未见到过这样的情况,除非是文件系统用完了空间,或者接近了配额限制,不能将所要求写的数据所有写出!

通常,在读/写一个网络设备,管道或终端时,须要考虑这些特性.于是,我们就有了以下的这两个函数:readn和writen,功能各自是读/写指定的count字节数据,并处理返回值可能小于要求值的情况:

/**实现:
这两个函数仅仅是按需多次调用read和write系统调用直至读/写了count个数据
**/
/**返回值说明:
    == count: 说明正确返回, 已经真正读取了count个字节
    == -1   : 读取出错返回
    <  count: 读取到了末尾
**/
ssize_t readn(int fd, void *buf, size_t count)
{
    size_t nLeft = count;
    ssize_t nRead = 0;
    char *pBuf = (char *)buf;
    while (nLeft > 0)
    {
        if ((nRead = read(fd, pBuf, nLeft)) < 0)
        {
            //假设读取操作是被信号打断了, 则说明还能够继续读
            if (errno == EINTR)
                continue;
            //否则就是其它错误
            else
                return -1;
        }
        //读取到末尾
        else if (nRead == 0)
            return count-nLeft;

        //正常读取
        nLeft -= nRead;
        pBuf += nRead;
    }
    return count;
}
/**返回值说明:
    == count: 说明正确返回, 已经真正写入了count个字节
    == -1   : 写入出错返回
**/
ssize_t writen(int fd, const void *buf, size_t count)
{
    size_t nLeft = count;
    ssize_t nWritten = 0;
    char *pBuf = (char *)buf;
    while (nLeft > 0)
    {
        if ((nWritten = write(fd, pBuf, nLeft)) < 0)
        {
            //假设写入操作是被信号打断了, 则说明还能够继续写入
            if (errno == EINTR)
                continue;
            //否则就是其它错误
            else
                return -1;
        }
        //假设 ==0则说明是什么也没写入, 能够继续写
        else if (nWritten == 0)
            continue;

        //正常写入
        nLeft -= nWritten;
        pBuf += nWritten;
    }
    return count;
}

报文长度+报文内容实践

发报文时:前四个字节长度+报文内容一次性发送;

收报文时:先读前四个字节。求出报文内容长度;依据长度读数据。

发送结构:

struct Packet
{
    unsigned int    msgLen;     //数据部分的长度(网络字节序)
    char            text[1024]; //报文的数据部分
};
//server端echo部分的改进代码
void echo(int clientfd)
{
    struct Packet buf;
    int readBytes;
    //首先读取首部
    while ((readBytes = readn(clientfd, &buf.msgLen, sizeof(buf.msgLen))) > 0)
    {
        //网络字节序 -> 主机字节序
        int lenHost = ntohl(buf.msgLen);
        //然后读取数据部分
        readBytes = readn(clientfd, buf.text, lenHost);
        if (readBytes == -1)
            err_exit("readn socket error");
        else if (readBytes != lenHost)
        {
            cerr << "client connect closed..." << endl;
            return ;
        }
        cout << buf.text;

        //然后将其回写回socket
        if (writen(clientfd, &buf, sizeof(buf.msgLen)+lenHost) == -1)
            err_exit("write socket error");
        memset(&buf, 0, sizeof(buf));
    }
    if (readBytes == -1)
        err_exit("read socket error");
    else if (readBytes != sizeof(buf.msgLen))
        cerr << "client connect closed..." << endl;
}
//client端发送与接收代码
...
    struct Packet buf;
    memset(&buf, 0, sizeof(buf));
    while (fgets(buf.text, sizeof(buf.text), stdin) != NULL)
    {
        /**写入部分**/
        unsigned int lenHost = strlen(buf.text);
        buf.msgLen = htonl(lenHost);
        if (writen(sockfd, &buf, sizeof(buf.msgLen)+lenHost) == -1)
            err_exit("writen socket error");

        /**读取部分**/
        memset(&buf, 0, sizeof(buf));
        //首先读取首部
        ssize_t readBytes = readn(sockfd, &buf.msgLen, sizeof(buf.msgLen));
        if (readBytes == -1)
            err_exit("read socket error");
        else if (readBytes != sizeof(buf.msgLen))
        {
            cerr << "server connect closed... \nexiting..." << endl;
            break;
        }

        //然后读取数据部分
        lenHost = ntohl(buf.msgLen);
        readBytes = readn(sockfd, buf.text, lenHost);
        if (readBytes == -1)
            err_exit("read socket error");
        else if (readBytes != lenHost)
        {
            cerr << "server connect closed... \nexiting..." << endl;
            break;
        }
        //将数据部分打印输出
        cout << buf.text;
        memset(&buf, 0, sizeof(buf));
    }
...

完整实现代码:

http://download.csdn.net/detail/hanqing280441589/8460557

按行读取实践

recv/send函数

ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

与read相比,recv仅仅能用于套接字文件描写叙述符,并且多了一个flags

recv的flags參数经常使用取值:

MSG_OOB(带外数据: 通过紧急指针发送的数据[需设置TCP头部紧急指针位有效])

This flag requests receipt of out-of-band data that would not be received

in the normal data stream.  Some protocols place expedited data at the head of

the normal data queue, and  thus  this flag cannot be used with such protocols.

MSG_PEEK(能够读数据。但不从缓存区中读走[仅仅是一瞥],利用此特点能够方便的实现按行读取数据;一个一个字符的读,多次调用系统调用read方法,效率不高)

This  flag  causes the receive operation to return data from the beginning of

the receive queue without removing that  data  from the queue.  Thus, a subsequent

receive call will return the same data.

/**演示样例: 通过MSG_PEEK封装一个recv_peek函数(仅查看数据, 但不取走)**/
ssize_t recv_peek(int sockfd, void *buf, size_t len)
{
    while (true)
    {
        int ret = recv(sockfd, buf, len, MSG_PEEK);
        //假设recv是因为被信号打断, 则须要继续(continue)查看
        if (ret == -1 && errno == EINTR)
            continue;
        return ret;
    }
}

/**使用recv_peek实现按行读取readline(仅仅能用于socket)**/
/** 返回值说明:
    == 0:   对端关闭
    == -1:  读取出错
    其它:    一行的字节数(包括‘\n‘)
**/
ssize_t readline(int sockfd, void *buf, size_t maxline)
{
    int ret;
    int nRead = 0;
    int returnCount = 0;
    char *pBuf = (char *)buf;
    int nLeft = maxline;
    while (true)
    {
        ret = recv_peek(sockfd, pBuf, nLeft);
        //假设查看失败或者对端关闭, 则直接返回
        if (ret <= 0)
            return ret;
        nRead = ret;
        for (int i = 0; i < nRead; ++i)
            //在当前查看的这段缓冲区中含有‘\n‘, 则说明已经能够读取一行了
            if (pBuf[i] == ‘\n‘)
            {
                //则将缓冲区内容读出
                //注意是i+1: 将‘\n‘也读出
                ret = readn(sockfd, pBuf, i+1);
                if (ret != i+1)
                    exit(EXIT_FAILURE);
                return ret + returnCount;
            }

        // 假设在查看的这段消息中没有发现‘\n‘, 则说明还不满足一条消息,
        // 在将这段消息从缓冲中读出之后, 还须要继续查看
        ret = readn(sockfd, pBuf, nRead);;
        if (ret != nRead)
            exit(EXIT_FAILURE);
        pBuf += nRead;
        nLeft -= nRead;
        returnCount += nRead;
    }
    //假设程序能够走到这里, 则说明是出错了
    return -1;
}

readline实现思想:

在readline函数中,我们先用recv_peek”偷窥” 一下如今缓冲区有多少个字符并读取到pBuf,然后查看是否存在换行符‘\n‘。

假设存在。则使用readn连同换行符一起读取(作用相当于清空socket缓冲区); 假设不存在,也清空一下缓冲区, 且移动pBuf的位置,回到while循环开头,再次窥看。

注意,当我们调用readn读取数据时。那部分缓冲区是会被清空的。因为readn调用了read函数。还需注意一点是,假设第二次才读取到了‘\n‘。则先用returnCount保存了第一次读取的字符个数,然后返回的ret需加上原先的数据大小。

按行读取echo代码:

void echo(int clientfd)
{
    char buf[512] = {0};
    int readBytes;
    while ((readBytes = readline(clientfd, buf, sizeof(buf))) > 0)
    {
        cout << buf;
        if (writen(clientfd, buf, readBytes) == -1)
            err_exit("writen error");
        memset(buf, 0, sizeof(buf));
    }
    if (readBytes == -1)
        err_exit("readline error");
    else if (readBytes == 0)
        cerr << "client connect closed..." << endl;
}

client端读取与发送代码

...
    char buf[512] = {0};
    memset(buf, 0, sizeof(buf));
    while (fgets(buf, sizeof(buf), stdin) != NULL)
    {
        if (writen(sockfd, buf, strlen(buf)) == -1)
            err_exit("writen error");
        memset(buf, 0, sizeof(buf));
        int readBytes = readline(sockfd, buf, sizeof(buf));
        if (readBytes == -1)
            err_exit("readline error");
        else if (readBytes == 0)
        {
            cerr << "server connect closed..." << endl;
            break;
        }
        cout << buf;
        memset(buf, 0, sizeof(buf));
    }
...

完整代码实现:

http://download.csdn.net/detail/hanqing280441589/8460883

时间: 2024-10-29 19:07:07

Socket编程实践(5) --TCP粘包问题与解决的相关文章

Socket编程实践(6) --TCP粘包原因与解决

流协议与粘包 粘包的表现 Host A 发送数据给 Host B; 而Host B 接收数据的方式不确定 粘包产生的原因 说明 TCP 字节流,无边界 对等方,一次读操作,不能保证完全把消息读完 UDP 数据报,有边界 对方接受数据包的个数是不确定的 产生粘包问题的原因分析 1.SQ_SNDBUF 套接字本身有缓冲区 (发送缓冲区.接受缓冲区) 2.tcp传送的端 mss大小限制 3.链路层也有MTU大小限制,如果数据包大于>MTU要在IP层进行分片,导致消息分割. 4.tcp的流量控制和拥塞控

Socket编程实践(6) --TCP服务端注意事项

僵尸进程处理 1)通过忽略SIGCHLD信号,避免僵尸进程 在server端代码中添加 signal(SIGCHLD, SIG_IGN); 2)通过wait/waitpid方法,解决僵尸进程 signal(SIGCHLD,onSignalCatch); void onSignalCatch(int signalNumber) { wait(NULL); } 3) 如果多个客户端同时关闭, 问题描述如下面两幅图所示: /** client端实现的测试代码**/ int main() { int s

网络编程 TCP协议:三次握手,四次回收,反馈机制 socket套接字通信 粘包问题与解决方法

TCP协议:三次握手,四次挥手 TCP协议建立双向通道. 三次握手, 建连接: 1:客户端向服务端发送建立连接的请求 2:服务端返回收到请求的信息给客户端,并且发送往客户端建立连接的请求 3:客户端接收到服务端发来的请求,返回接成功给服务端,完成双向连接 第一客戶向服务端发送请求,请求建立连接 服务端同客户端的请求,并同时向客户端发送建立 连接的请求,最后客户端同意后建立 双向连接. C ----> S C <---- S - 反馈机制: 客户端往服务端发送请求,服务端必须返回响应, 告诉客户

Socket编程实践(1) --TCP/IP简述

ISO的OSI OSI(open system interconnection)开放系统互联模型是由ISO国际标准化组织定义的网络分层模型,共七层, 从下往上为: OSI七层参考模型 物理层(Physical Layer) 物理层定义了所有电子及物理设备的规范,为上层的传输提供了一个物理介质,本层中数据传输的单位为比特(bit/二进制位).属于本层定义的规范有EIA/TIA RS-232.RJ-45等,实际使用中的设备如网卡属于本层. 数据链路层(Data Link Layer) 对物理层收到的

Socket编程实践(17) --TCP/IP各层报文(2)

UDP数据报 UDP首部代码: struct udp_hdr { unsigned short src_port; unsigned short dest_port; unsigned short len; unsigned short chksum; }; TCP报文段 协议描述 源端口号和目的端口号:源和目的主机的IP地址加上端口号构成一个TCP连接 序号和确认号:序号为该TCP数据包的第一个数据字在所发送的数据流中的偏移量:确认号为希望接收的下一个数据字的序号: 首部长度,以4个字节为单位

Socket编程实践(9) --TCP服务器常见问题(4)

TCP/IP协议的11种状态 说明: 1.如下图(客户端与服务器都在本机:双方(server的子进程,与client)链接已经建立(ESTABLISHED),等待通信) 2.最先调用close的一端,后面会进入TIME_WAIT的状态(下图,server端首先关闭) 3.TIME_WAIT 时间是2MSL(报文的最长存活周期的2倍)     原因:(ACK y+1)如果发送失败可以重发. 服务器端处于closed状态,不等于客户端也处于closed状态.. 4.TCP/IP协议的第1种状态:图上

Socket编程实践(16) --TCP/IP各层报文(1)

以太网帧格式 说明1:链路层的数据包,称为以太网帧. 说明2:链路层不识别IP地址[因为IP地址是逻辑地址],链路层识别物理网卡MAC地址[硬件地址]. 说明3:需要根据IP地址找到对方的MAC地址(ARP地址解析协议)[MAC -> IP地址方向地址解析:RARP反向地址解析协议]. 说明4:应用层根据对等方的IP地址进行通讯,在数据封装过程中,链路层需要目的地址的MAC地址从何而来?需要将IP地址转换成MAC地址,也就是地址解析. 以太网首部代码: struct ethernet_hdr {

TCP粘包/拆包问题

无论是服务端还是客户端,当我们读取或者发送消息的时候,都需要考虑TCP底层的粘包/拆包机制. TCP粘包/拆包 TCP是个"流"协议,所谓流,就是没有界限的一串数据.大家可以想想河里的流水,是连成一片的,其间并没有分界线.TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的TCP粘包和拆包问题. TCP粘包/拆包问题说明 假设客户

Linux下的socket编程实践(四)TCP的粘包问题和常用解决方案

TCP粘包问题的产生 由于TCP协议是基于字节流并且无边界的传输协议, 因此很有可能产生粘包问题.此外,发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一个TCP段.若连续几次需要send的数据都很少,通常TCP会根据优化算法把这些数据合成一个TCP段后一次发送出去,但是接收方并不知道要一次接收多少字节的数据,这样接收方就收到了粘包数据.具体可以见下图: 假设主机A send了两条消息M1和M2 各10k 给主机B,由于主机B一次提取的字节数