Linux下tcp协议socket的recv函数返回时机分析(粘包)

http://www.vckbase.com/index.php/wv/10
http://blog.csdn.net/zlzlei/article/details/7689409

文章一:

当前在网络传输应用中,广泛采用的是TCP/IP通信协议及其标准的socket应用开发编程接口(API)。TCP/IP传输层有两个并列的协议:TCP和UDP。其中TCP(transport control protocol,传输控制协议)是面向连接的,提供高可靠性服务。UDP(user datagram protocol,用户数据报协议)是无连接的,提供高效率服务。在实际工程应用中,对可靠性和效率的选择取决于应用的环境和需求。一般情况下,普通数据的网络传输采用高效率的udp,重要数据的网络传输采用高可靠性的TCP。

在应用开发过程中,笔者发现基于TCP网络传输的应用程序有时会出现粘包现象(即发送方发送的若干包数据到接收方接收时粘成一包)。针对这种情况,我们进行了专题研究与实验。本文重点分析了TCP网络粘包问题,并结合实验结果提出了解决该问题的对策和方法,供有关工程技术人员参考。

一、TCP协议简介

TCP是一个面向连接的传输层协议,虽然TCP不属于iso制定的协议集,但由于其在商业界和工业界的成功应用,它已成为事实上的网络标准,广泛应用于各种网络主机间的通信。

作为一个面向连接的传输层协议,TCP的目标是为用户提供可靠的端到端连接,保证信息有序无误的传输。它除了提供基本的数据传输功能外,还为保证可靠性采用了数据编号、校验和计算、数据确认等一系列措施。它对传送的每个数据字节都进行编号,并请求接收方回传确认信息(ack)。发送方如果在规定的时间内没有收到数据确认,就重传该数据。数据编号使接收方能够处理数据的失序和重复问题。数据误码问题通过在每个传输的数据段中增加校验和予以解决,接收方在接收到数据后检查校验和,若校验和有误,则丢弃该有误码的数据段,并要求发送方重传。流量控制也是保证可靠性的一个重要措施,若无流控,可能会因接收缓冲区溢出而丢失大量数据,导致许多重传,造成网络拥塞恶性循环。TCP采用可变窗口进行流量控制,由接收方控制发送方发送的数据量。

TCP为用户提供了高可靠性的网络传输服务,但可靠性保障措施也影响了传输效率。因此,在实际工程应用中,只有关键数据的传输才采用TCP,而普通数据的传输一般采用高效率的udp。

二、粘包问题分析与对策

TCP粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。

出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。发送方引起的粘包是由TCP协议本身造成的,TCP为提高传输效率,发送方往往要收集到足够多的数据后才发送一包数据。若连续几次发送的数据都很少,通常TCP会根据优化算法把这些数据合成一包后一次发送出去,这样接收方就收到了粘包数据。接收方引起的粘包是由于接收方用户进程不及时接收数据,从而导致粘包现象。这是因为接收方先把收到的数据放在系统接收缓冲区,用户进程从该缓冲区取数据,若下一包数据到达时前一包数据尚未被用户进程取走,则下一包数据放到系统接收缓冲区时就接到前一包数据之后,而用户进程根据预先设定的缓冲区大小从系统接收缓冲区取数据,这样就一次取到了多包数据(图1所示)。

 
图1

 
图2

 
图3

粘包情况有两种,一种是粘在一起的包都是完整的数据包(图1、图2所示),另一种情况是粘在一起的包有不完整的包(图3所示),此处假设用户接收缓冲区长度为m个字节。

不是所有的粘包现象都需要处理,若传输的数据为不带结构的连续流数据(如文件传输),则不必把粘连的包分开(简称分包)。但在实际工程应用中,传输的数据一般为带结构的数据,这时就需要做分包处理。

在处理定长结构数据的粘包问题时,分包算法比较简单;在处理不定长结构数据的粘包问题时,分包算法就比较复杂。特别是如图3所示的粘包情况,由于一包数据内容被分在了两个连续的接收包中,处理起来难度较大。实际工程应用中应尽量避免出现粘包现象。

为了避免粘包现象,可采取以下几种措施。一是对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满;二是对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象;三是由接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。

以上提到的三种措施,都有其不足之处。第一种编程设置方法虽然可以避免发送方引起的粘包,但它关闭了优化算法,降低了网络发送效率,影响应用程序的性能,一般不建议使用。第二种方法只能减少出现粘包的可能性,但并不能完全避免粘包,当发送频率较高时,或由于网络突发可能使某个时间段数据包到达接收方较快,接收方还是有可能来不及接收,从而导致粘包。第三种方法虽然避免了粘包,但应用程序的效率较低,对实时应用的场合不适合。

一种比较周全的对策是:接收方创建一预处理线程,对接收到的数据包进行预处理,将粘连的包分开。对这种方法我们进行了实验,证明是高效可行的。

、编程与实现

1.实现框架

实验网络通信程序采用TCP/IP协议的socket api编程实现。socket是面向客户机/服务器模型的。TCP实现框架如图4所示。

图4

2.实验硬件环境:

服务器:pentium 350 微机

客户机:pentium 166微机

网络平台:由10兆共享式hub连接而成的局域网

3.实验软件环境:

操作系统:windows 98

编程语言:visual c++ 5.0

4.主要线程

编程采用多线程方式,服务器端共有两个线程:发送数据线程、发送统计显示线程。客户端共有三个线程:接收数据线程、接收预处理粘包线程、接收统计显示线程。其中,发送和接收线程优先级设为thread_priority_time_critical(最高优先级),预处理线程优先级为thread_priority_above_normal(高于普通优先级),显示线程优先级为thread_priority_normal(普通优先级)。

实验发送数据的数据结构如图5所示:

图5

5.分包算法

针对三种不同的粘包现象,分包算法分别采取了相应的解决办法。其基本思路是首先将待处理的接收数据流(长度设为m)强行转换成预定的结构数据形式,并从中取出结构数据长度字段,即图5中的n,而后根据n计算得到第一包数据长度。

1)若n

2)若n=m,则表明数据流内容恰好是一完整结构数据,直接将其存入临时缓冲区即可。

3)若n>m,则表明数据流内容尚不够构成一完整结构数据,需留待与下一包数据合并后再行处理。

对分包算法具体内容及软件实现有兴趣者,可与作者联系。

四、实验结果分析

实验结果如下:

1.在上述实验环境下,当发送方连续发送的若干包数据长度之和小于1500b时,常会出现粘包现象,接收方经预处理线程处理后能正确解开粘在一起的包。若程序中设置了“发送不延迟”:(setsockopt (socket_name,ipproto_tcp,tcp_nodelay,(char *) &on,sizeof on) ,其中on=1),则不存在粘包现象。

2.当发送数据为每包1kb~2kb的不定长数据时,若发送间隔时间小于10ms,偶尔会出现粘包,接收方经预处理线程处理后能正确解开粘在一起的包。

3.为测定处理粘包的时间,发送方依次循环发送长度为1.5kb、1.9kb、1.2kb、1.6kb、1.0kb数据,共计1000包。为制造粘包现象,接收线程每次接收前都等待10ms,接收缓冲区设为5000b,结果接收方收到526包数据,其中长度为5000b的有175包。经预处理线程处理可得到1000包正确数据,粘包处理总时间小于1ms。

实验结果表明,TCP粘包现象确实存在,但可通过接收方的预处理予以解决,而且处理时间非常短(实验中1000包数据总共处理时间不到1ms),几乎不影响应用程序的正常工作。

文章二:

以前老在网上找别人说recv什么时候返回,要么说的很笼统,要么完全觉得不靠谱,最近还是自己做个试验分析一下吧:

关于PUSH位的应用
        PUSH位就是用来通告接收方立即将收到的报文连同TCP接收缓存里的数据递交应用进程处理。一般会出现在发送方封装最后一个应用字段的TCP报文中,针对TCP交互式应用,则只要封装有应用字段的TCP报文,均会将PUSH位置一,当然,应用程序的开发者,可以根据需要,在某个应用功能模块或某个应用操作时,将所有封装应用字段的TCP报文PUSH位置一,以提高交互双方的处理效率,这在理论上应该也是可行的。

 

 

测试1. 
每次发送大小:1024
每次接收大小:32
结果:pack1
每send发送一个包,包中数据大小1024,带PUSH标志
每次接收满32后recv函数返回。


测试2.
每次发送大小:1024
每次接收大小:2048
结果:pack2
每send发送一个包,包中数据大小1024,带PUSH标志
每次接收满1024后recv函数返回。


测试3. 
每次发送大小:20480
每次接收大小:10240
结果:pack3
每send发送两个包,包中数据大小为16384(TCP分片大小,建立连接时商定)与4096,仅最后一个包带PUSH标志
第一次接收满10240后recv函数返回
第二次接收6144后recv函数返回(6144+10240=16384)
第三次接收4096后recv函数返回
Recv >>> [1]10240:10240
Recv >>> [2]10240:6144
Recv >>> [3]10240:4096
Recv >>> [4]10240:10240
Recv >>> [5]10240:6144
Recv >>> [6]10240:4096




测试4. 
每次发送大小:20480
每次接收大小:20480
结果:pack4
每send发送两个包,包中数据大小为16384(TCP分片大小,建立连接时商定)与4096,仅最后一个包带PUSH标志
第一次接收16384后recv函数返回
第二次接收4096后recv函数返回
Recv >>> [1]20480:16384
Recv >>> [2]20480:4096
Recv >>> [3]20480:16384
Recv >>> [4]20480:4096


测试5.
每次发送大小:400000
每次接收大小:10240
结果:pack5
发送时除最后一个包不够意外,数据都是按照16384的包大小发送,发送过程中经常性接收窗口满。
接收过程初期按照16384包大小接收,后期无规律:
Recv >>> [1]10240:10240
Recv >>> [2]10240:6144 //A//满16384
Recv >>> [3]10240:10240
Recv >>> [4]10240:6144 //B//满16384,从开始到此满32768(滑动窗口总大小)
Recv >>> [5]10240:10240
Recv >>> [6]10240:10240
Recv >>> [7]10240:10240
Recv >>> [8]10240:2048 //C//从开始到此处满65536(两个滑动窗口总大小)
Recv >>> [9]10240:10240
Recv >>> [10]10240:6144 //D//满16384
Recv >>> [11]10240:10240
Recv >>> [12]10240:6144 //E//满16384
Recv >>> [13]10240:10240 //F//从此往后均能收满整个buffer
Recv >>> [14]10240:10240
。。。。。。都是10240:10240
Recv >>> [40]10240:10240
Recv >>> [41]10240:8192 //E//
Recv >>> [42]10240:6784


测试6.
每次发送大小:400000
每次接收大小:10000
结果:pack6
发送时除最后一个包不够以外,数据都是按照16384的包大小发送,发送过程中经常性接收窗口满。
接收过程初期按照16384包大小接收,后期无规律:
Recv >>> [1]10000:10000
Recv >>> [2]10000:6384
Recv >>> [3]10000:10000
Recv >>> [4]10000:6384
Recv >>> [5]10000:10000
Recv >>> [6]10000:10000
Recv >>> [7]10000:10000
Recv >>> [8]10000:2768
Recv >>> [9]10000:10000
Recv >>> [10]10000:6384
Recv >>> [11]10000:10000
Recv >>> [12]10000:6384
Recv >>> [13]10000:10000
。。。。。。都是10000:10000
Recv >>> [41]10000:10000
Recv >>> [42]10000:4912
Recv >>> [43]10000:6784


测试7.
每次发送大小:32,发10000次
每次接收大小:102400
结果:pack7
发送过程中大部分包都以32大小发送,但也有部分包被合在了一起发送,发送的每个包都带PUSH标志
接收过程中大部分包都以32大小接收,但也有接收大于32的时候,并且与发送大于32的包并无任何关系。
Recv >>> [1]102400:32
Recv >>> [2]102400:32
Recv >>> [3]102400:32
。。。。。。都是102400:32
Recv >>> [92]102400:32
Recv >>> [93]102400:49184
Recv >>> [94]102400:32
。。。。。。都是102400:32
Recv >>> [151]102400:32
Recv >>> [152]102400:32
Recv >>> [153]102400:22720
Recv >>> [154]102400:32
。。。。。。都是102400:32
Recv >>> [268]102400:32
Recv >>> [271]102400:47840
Recv >>> [272]102400:32
。。。。。。都是102400:32
Recv >>> [397]102400:32
Recv >>> [398]102400:32
Recv >>> [399]102400:59776
Recv >>> [400]102400:32
。。。。。。都是102400:32
Recv >>> [523]102400:32
Recv >>> [526]102400:54208
Recv >>> [527]102400:32
。。。。。。都是102400:32
Recv >>> [630]102400:32
Recv >>> [631]102400:32
Recv >>> [632]102400:65568
。。。。。。都是102400:32
Recv >>> [652]102400:32
Recv >>> [653]102400:32


分析:用户从接收缓存区读取内容与kernel向接收缓存区填充内容这两个过程是互斥的,
recv只从接收缓存区中获取一次内容,并且也只关心自己获取时接收缓存区有多少内容:
a、如果当时缓存区中没有数据,则recv进入阻塞状态,等待kernel向缓存区被填入数据后重新激活。
b、如果当时缓存区中的数据比用户接收使用的buffer大,则填满buffer后recv函数返回。
c、如果当时缓存区中的数据比用户接收使用的buffer小,则取走缓存区中的所有数据后recv函数返回。
kernel向接收缓存区填充则是收到数据包后到自己的时间片并且缓存区不在被读,则将拿到的包内容全部放入缓存区中。

 

这里有个参数,决定了是否可读,当缓冲区中的数据长度大于等于SO_RCVLOWAT时,recv函数才认为皇后从去中有数据,也就是socke可读,然后recv将数据从kernel 拷贝到应用层。

 

Socket可读/写的常见情况分析:

select()返回sockfd可读:

1、Receive缓冲区的数据大于或等于low-water mark的值。low-water mark的值可通过SO_RCVLOWAT选项控制,默认是1。 (即读缓冲区中有数据)
       2、TCP连接接收到FIN,即Read half of the connections is closed。此时对sockfd的读操作将返回0,即EOF。

3、如果sockfd是一个监听套接字,则表明有新连接,可调用accept()函数建立新连接。 
       4、Socket出错,此时对sockfd的读操作将返回-1。

select()返回sockfd可写:
       1、Send缓冲区的数据大于或等于low-water mark的值。low-water mark的值可通过SO_SNDLOWAT选项控制,默认是2048。 (即缓冲区有数据)
       2、Write half of the connection is closed,对sockfd的写操作将产生SIGPIPE信号。 
       3、对非阻塞的sockfd调用connect(),connect()完成或失败。 
       4、Socket出错,此时对sockfd的写操作将返回-1。




这样就可以解释测试5中的现象了:
a、kernel第一二次都只收到了一个16384大小的包,所以接收的A处与B处都收到了整个数据包
b、慢慢的包陆陆续续的到来kernel第三次收到了两个16384大小的包,填满了整个缓存区,所以接收的C处,
在用户的时间片内用户收走了缓存区的全部数据。
c、接下来数据蜂拥而至,导致缓存区一直处于满的状态,所以后来的接收都能收满用户buffer。
d、接收的E处接收完整个缓存区,随后接收收尾的包
为了验证以上推断,进行了测试6。


测试7验证了快速发送时的发送接收情况,证明了send发送与recv接收过程完全没关系,
即使发送的包带有PUSH标记,也只能保证每一个包被单独放入接收缓存区中,无法使recv接收每收到一个包返回。
整个接收过程遵循上面的规则,何时返回,返回多少数据均要看当时缓存区内的数据多少。
像测试7中大部分包每个包都返回只是因为接收比较快导致kernel每次只来得及向接收缓存区中放一个包而已。


结论:
a、不要期待利用TCP协议一方send一次,另一方recv一次的逻辑,这种逻辑是不可靠,也没有理论依据的
b、在本机进行消息逻辑控制,或者说信令级传输,建议还是使用UDP
c、若必须要使用TCP消息传输时一定要加同步头,例如一个固定的值,用来分割数据包或者验证是否包乱序或越界

 

 

三、send行为

        默认情况下,send的功能是拷贝指定长度的数据到发送缓冲区,只有当数据被全部拷贝完成后函数才会正确返回,否则进入阻塞状态或等待超时。如果你想修改这种默认行为,将数据直接发送到目标机器,可以将发送缓冲区大小设为0(或通过TCP_NODELAY禁用Nagle算法),这样当send返回时,就表示数据已经正确的、完整的到达了目标机器。注意,这里只表示数据到达目标机器网络缓冲区,并不表示数据已经被对方应用层接收了。

        协议层在数据发送过程中,根据对方的滑动窗口,再结合MSS值共同确定TCP报文中数据段的长度,以确保对方接收缓冲区不会溢出。当本方发送缓冲区尚有数据没有发送,而对方滑动窗口已经为0时,协议层将启动探测机制,即每隔一段时间向对方发送一个字节的数据,时间间隔会从刚开始的30s调整为1分钟,最后稳定在2分钟。这个探测机制不仅可以检测到对方滑动窗口是否变化,同时也可以发现对方是否有异常退出的情况。

        push标志指示接收端应尽快将数据提交给应用层。如果send函数提交的待发送数据量较小,例如小于1460B(参照MSS值确定),那么协议层会将该报文中的TCP头部的push字段置为1;如果待发送的数据量较大,需要拆成多个数据段发送时,协议层只会将最后一个分段报文的TCP头部的push字段置1。

四、recv行为

        默认情况下,recv的功能是从接收缓冲区读取(其实就是拷贝)指定长度的数据。如果将接收缓冲区大小设为0,recv将直接从协议缓冲区(滑动窗口区)读取数据,避免了数据从协议缓冲区到接收缓冲区的拷贝。recv返回的条件有两种:

      1. recv函数传入的应用层接收缓冲区已经读满

      2. 协议层接收到push字段为1的TCP报文,此时recv返回值为实际接收的数据长度

        协议层收到TCP数据包后(保存在滑动窗口区),本方的滑动窗口合拢(窗口值减小);当协议层将数据拷贝到接收缓冲区(滑动窗口区—>接收缓冲区),或者应用层调用recv接收数据(接收缓冲区—>应用层缓冲区,滑动窗口区—>应用层缓冲区)后,本方的滑动窗口张开(窗口值增大)。收到数据更新window后,协议层向对方发送ACK确认。

        协议层的数据接收动作完全由发送动作驱动,是一个被动行为。在应用层没有任何干涉行为的情况下(比如recv操作等),协议层能够接收并保存的最大数据大小是窗口大小与接收缓冲区大小之和。Windows系统的窗口大小默认是64K,接收缓冲区默认为8K,所以默认情况下协议层最多能够被动接收并保存72K的数据。

posted @ 2015-05-29 12:39  穆穆兔兔  阅读(9154)  评论(1编辑  收藏  举报