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

TCP粘包问题的产生

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

假设主机A send了两条消息M1和M2 各10k 给主机B,由于主机B一次提取的字节数是不确定的,接收方提取数据的情况可能是:

? 一次性提取20k 数据

? 分两次提取,第一次5k,第二次15k

? 分两次提取,第一次15k,第二次5k

? 分两次提取,第一次10k,第二次10k(仅此正确)

? 分三次提取,第一次6k,第二次8k,第三次6k

? 其他任何可能

粘包问题产生的多种原因:

1、SQ_SNDBUF 套接字本身有缓冲区大小的限制 (发送缓冲区、接受缓冲区)

2、TCP传送的端 MSS大小限制

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

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

5、文章开始提到的TCP延迟确认机制等

注: 关于MTU和MSS

MSS指的是TCP中的一个概念。MTU是一个没有固定到特定OSI层的概念,不受其他特定协议限制。也就是说第二层会有MTU,第三层会有MTU,像MPLS这样的第2.5层协议,也有自己的MTU值。并且不同层之间存在关联关系。举个例子:如果你要搬家,需要把东西打包,用车运走。这样的情况下,车的大小受路的宽度限制;箱子的大小受车限制;能够搬运的东西的大小受箱子的限制。这时可以将路的宽度理解成第二层的MTU,车的大小理解成第三层的MTU,箱子的大小理解成第四层的MTU,搬运的东西理解成MSS。

粘包问题的解决方案(本质上是要在应用层维护消息和消息之间的边界)

(1)定长包

该方式并不实用: 如果所定义的长度过长, 则会浪费网络带宽,增加网络负担;而又如果定义的长度过短, 则一条消息又会拆分成为多条, 仅在TCP的应用一层就增加了合并的开销。

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

如果消息本身含有\r\n字符,则也分不清消息的边界;

(3)报文长度+报文内容,自定义包结构

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

注:简单的使用 setsockopt 设置开启TCP_NODELAY禁用 Nagle’s Algorithm可以解决上述第5个问题(延迟确认机制)。

static void _set_tcp_nodelay(int fd) {
    int enable = 1;
    setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void*)&enable, sizeof(enable));
}

著名的Nginx服务器 默认是开启了这个选项的.....

因为TCP协议是面向流的,read和write调用的返回值往往小于参数指定的字节数。对于read调用(套接字标志为阻塞),如果接收缓冲区中有20字节,请求读100个字节,就会返回20;对于write调用,如果请求写100个字节,而发送缓冲区中只有20个字节的空闲位置,那么write会阻塞,直到把100个字节全部交给发送缓冲区才返回;还有信号中断之后需要处理为
继续读写;为避免这些情况干扰主程序的逻辑,确保读写我们所请求的字节数,我们实现了两个包装函数readn和writen,如下所示。

/**实现:
这两个函数只是按需多次调用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]; //报文的数据部分
};
//echo 回射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));
    }
...
//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;
}  

注:网络字节序和本机字节序之间是必要的转换。

按行读取(由\r\n判断)

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,这个flags能够帮助我们实现解决粘包问题的操作。

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

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.

readline实现思想:

在readline函数中,我们先用recv_peek”偷窥” 一下现在缓冲区有多少个字符并读取到pBuf,然后查看是否存在换行符‘\n‘。如果存在,则使用readn连同换行符一起读取(作用相当于清空socket缓冲区); 如果不存在,也清空一下缓冲区, 且移动pBuf的位置,回到while循环开头,再次窥看。注意,当我们调用readn读取数据时,那部分缓冲区是会被清空的,因为readn调用了read函数。还需注意一点是,如果第二次才读取到了‘\n‘,则先用returnCount保存了第一次读取的字符个数,然后返回的ret需加上原先的数据大小。

/**示例: 通过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;
}  

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));
    }
...  

server端:

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;
}  

最后附上 TLV格式及其编解码的示例   http://blog.csdn.net/chexlong/article/details/6974201

版权声明:本文为博主原创文章,未经博主允许不得转载。

时间: 2024-11-13 16:20:36

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

Linux下的socket编程实践(十) 基本UDP编程细节

在我的这两篇博客中,简单介绍并实现了基于UDP(TCP)的windows(UNIX下流程基本一致)下的服务端和客户端的程序,本文继续探讨关于UDP编程的一些细节. http://blog.csdn.net/nk_test/article/details/47733307 http://blog.csdn.net/nk_test/article/details/47756381 下图是一个简单的UDP客户/服务器模型: 我在这里也实现了一个简单的UDP回射服务器/客户端: /**实践: 实现一个基

Linux下的socket编程实践(四)TCP服务端优化和常见函数

并发下的僵尸进程处理 只有一个进程连接的时候,我们可以使用以下两种方法处理僵尸进程: 1)通过忽略SIGCHLD信号,避免僵尸进程 在server端代码中添加 signal(SIGCHLD, SIG_IGN); 2)通过wait/waitpid方法,解决僵尸进程 signal(SIGCHLD,onSignalCatch); void onSignalCatch(int signalNumber) { wait(NULL); } 那么如果是多进程状态下多个客户端同时关闭呢? 我们可以用下面的客户端

Linux下的socket编程实践(一) 网络基本知识以及 TCP/IP简述

ISO/OSI七层参考模型 1.物理层:主要定义物理设备标准,如网线的接口类型.光纤的接口类型.各种传输介质的传输速率等.它的主要作用是传输比特流(就是由1.0转化为电流强弱来进行传输,到达目的地后再转化为1.0,也就是我们常说的数模转换与模数转换).这一层的数据叫做比特.(标志:RJ-45) 2.数据链路层:定义了如何让格式化数据以进行传输,以及如何让控制对物理介质的访问.这一层通常还提供错误检测和纠正,以确保数据的可靠传输,交换机属于本层. 3.网络层:在位于不同地理位置的网络中的两个主机系

Linux下的socket编程实践(三)端口复用和 P2P多进程服务器

Socket端口复用 先说为什么要使用socket端口复用?如果你遇到过这样的问题:server程序重启之后,无法连接,需要过一段时间才能连接上? 1.一个监听(listen)server已经启动 2.当有client有连接请求的时候,server产生一个子进程去处理该client的事物. 3.server主进程终止了,但是子进程还在占用该连接处理client的事情.虽然子进程终止了,但是由于子进程没有终止,该socket的引用计数不会为0,所以该socket不会被关闭. 4.server程序重

Linux下的socket编程实践(八) Select的限制和poll(并发的初步知识)

select的限制 用select实现的并发服务器,能达到的并发数一般受两方面限制: 1)一个进程能打开的最大文件描述符限制.这可以通过调整内核参数来改变.可以通过ulimit -n(number)来调整或者使用setrlimit函数设置(需要root权限),但一个系统所能打开的最大数也是有限的,跟内存大小有关,可以通过cat /proc/sys/fs/file-max 查看. 2)select中的fd_set集合容量的限制(FD_SETSIZE,一般为1024),这需要重新编译内核才能改变.

DELPHI高性能大容量SOCKET并发(四):粘包、分包、解包

DELPHI高性能大容量SOCKET并发(四):粘包.分包.解包 粘包 使用TCP长连接就会引入粘包的问题,粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾.粘包可能由发送方造成,也可能由接收方造成.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

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

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

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 接收数据的方式不确定, 有以下