(十一)深入浅出TCPIP之TCP粘包问题

目录

粘包和拆包问题

保护消息边界和流

粘包、拆包场景

为什么会发生TCP粘包、拆包呢?

如何处理粘包、拆包问题?

怎样封包和拆包?

其他问题

TCP为什么引入接受缓存这个数据结构?


专栏其他文章:

 

理论篇:

(一)深入浅出TCPIP之理解TCP报文格式和交互流程

  (二)深入浅出TCPIP之再识TCP,理解TCP三次握手(上)

  (三)深入浅出TCPIP之再识TCP,理解TCP四次挥手(上)

  (四)深入浅出TCPIP之TCP三次握手和四次挥手(下)的抓包分析

  (五)深入浅出TCPIP之TCP流量控制

  (六)深入浅出TCPIP之TCP拥塞控制

  (七)深入浅出TCPIP之深入浅出TCPIP之TCP重传机制

 (八)深入浅出TCPIP之TCP长连接与短连接详解

 (九)深入浅出TCPIP之网络同步异步

 (十)深入浅出TCPIP之网络阻塞和非阻塞

(十一)深入浅出TCPIP之TCP粘包问题

  (十二)深入浅出TCPIP之Nagle算法

  (十三) 深入浅出TCPIP之TCP套接字参数

  (十四)深入浅出TCPIP之初识UDP理解报文格式和交互流程

  (十五)非常全面的TCPIP面试宝典-进入大厂必备总结

 (十六)深入浅出TCPIP之Hello CDN

 ....

(二十)深入浅出TCPIP之epoll的一些思考

实践篇:

   深入浅出TCPIP之实战篇—用c++开发一个http服务器(二十一)

其他实践篇+游戏开发中的网络问题疑难杂症解读 正在完善。。。

 

粘包和拆包问题

         粘包拆包问题是处于网络比较底层的问题,在数据链路层、网络层以及传输层都有可能发生。我们日常的网络应用开发大都在传输层进行,由于UDP有消息保护边界,不会发生这个问题,因此这篇文章只讨论发生在传输层的TCP粘包拆包问题。

1)TCP为了保证可靠传输,尽量减少额外开销(每次发包都要验证),因此采用了流式传输,面向流的传输,相对于面向消息的传输,可以减少发送包的数量,从而减少了额外开销。但是,对于数据传输频繁的程序来讲,使用TCP可能会容易粘包。当然,对接收端的程序来讲,如果机器负荷很重,也会在接收缓冲里粘包。这样,就需要接收端额外拆包,增加了工作量。因此,这个特别适合的是数据要求可靠传输,但是不需要太频繁传输的场合(两次操作间隔100ms,具体是由TCP等待发送间隔决定的,取决于内核中的socket的写法)

(2)UDP,由于面向的是消息传输,它把所有接收到的消息都挂接到缓冲区的接受队列中,因此,它对于数据的提取分离就更加方便,但是,它没有粘包机制,因此,当发送数据量较小的时候,就会发生数据包有效载荷较小的情况,也会增加多次发送的系统发送开销(系统调用,写硬件等)和接收开销。因此,应该最好设置一个比较合适的数据包的包长,来进行UDP数据的发送。 

其实“粘包”和“拆包”处理起来就是一回事,如果你在用一个已有的基于TCP的应用层协议,那么你可能需要去解析它。步骤非常简单:1. 确定目前要读多少字节。2. 持续不断的读,直到刚好读满这么多字节。3. 处理。4. 回到步骤1。

说到这里我不得不提一个概念叫保护消息边界和流。

保护消息边界和流

那么什么是保护消息边界和流呢?

保护消息边界,就是指传输协议把数据当作一条独立的消息在网上传输,接收端只能接收独立的消息。也就是说存在保护消息边界,接收端一次只能接收发送端发出的一个数据包。而面向流则是指无保护消息保护边界的,如果发送端连续发送数据,接收端有可能在一次接收动作中,会接收两个或者更多的数据包。

例如,我们连续发送三个数据包,大小分别是2k,4k ,8k,这三个数据包,都已经到达了接收端的网络堆栈中,如果使用UDP协议,不管我们使用多大的接收缓冲区去接收数据,我们必须有三次接收动作,才能够把所有的数据包接收完.而使用TCP协议,我们只要把接收的缓冲区大小设置在14k以上,我们就能够一次把所有的数据包接收下来,只需要有一次接收动作。

注意:

这就是因为UDP协议的保护消息边界使得每一个消息都是独立的。而流传输却把数据当作一串数据流,他不认为数据是一个一个的消息。所以有很多人在使用tcp协议通讯的时候,并不清楚tcp是基于流的传输,当连续发送数据的时候,他们时常会认识tcp会丢包。其实不然,因为当他们使用的缓冲区足够大时,他们有可能会一次接收到两个甚至更多的数据包,而很多人往往会忽视这一点,只解析检查了第一个数据包,而已经接收的其他数据包却被忽略了。所以大家如果要作这类的网络编程的时候,必须要注意这一点

粘包、拆包场景

对于什么是粘包、拆包问题,我想先举两个简单的应用场景:

  1. 客户端和服务器建立一个连接,客户端发送一条消息,客户端关闭与服务端的连接。

  2. 客户端和服务器简历一个连接,客户端连续发送两条消息,客户端关闭与服务端的连接。

对于第一种情况,服务端的处理流程可以是这样的:当客户端与服务端的连接建立成功之后,服务端不断读取客户端发送过来的数据,当客户端与服务端连接断开之后,服务端知道已经读完了一条消息,然后进行解码和后续处理...。对于第二种情况,如果按照上面相同的处理逻辑来处理,那就有问题了,我们来看看第二种情况下客户端发送的两条消息递交到服务端有可能出现的情况:

第一种情况:

服务端一共读到两个数据包,第一个包包含客户端发出的第一条消息的完整信息,第二个包包含客户端发出的第二条消息,那这种情况比较好处理,服务器只需要简单的从网络缓冲区去读就好了,第一次读到第一条消息的完整信息,消费完再从网络缓冲区将第二条完整消息读出来消费。

(十一)深入浅出TCPIP之TCP粘包问题_第1张图片

没有发生粘包、拆包示意图

第二种情况:

服务端一共就读到一个数据包,这个数据包包含客户端发出的两条消息的完整信息,这个时候基于之前逻辑实现的服务端就蒙了,因为服务端不知道第一条消息从哪儿结束和第二条消息从哪儿开始,这种情况其实是发生了TCP粘包。

(十一)深入浅出TCPIP之TCP粘包问题_第2张图片

   TCP粘包示意图

第三种情况:

服务端一共收到了两个数据包,第一个数据包只包含了第一条消息的一部分,第一条消息的后半部分和第二条消息都在第二个数据包中,或者是第一个数据包包含了第一条消息的完整信息和第二条消息的一部分信息,第二个数据包包含了第二条消息的剩下部分,这种情况其实是发送了TCP拆,因为发生了一条消息被拆分在两个包里面发送了,同样上面的服务器逻辑对于这种情况是不好处理的。

(十一)深入浅出TCPIP之TCP粘包问题_第3张图片

TCP拆包示意图

为什么会发生TCP粘包、拆包呢?

TCP是以流的方式来处理数据,一个完整的包可能会被TCP拆分成多个包进行发送,也可能把小的封装成一个大的数据包发送。

TCP粘包/分包的原因:

应用程序写入的字节大小大于套接字发送缓冲区的大小,会发生拆包现象,

而应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包现象;

进行MSS大小的TCP分段,当TCP报文长度-TCP头部长度>MSS的时候将发生拆包

如何处理粘包、拆包问题?

知道了粘包、拆包问题及根源,那么如何处理粘包、拆包问题呢?TCP本身是面向流的,作为网络服务器,如何从这源源不断涌来的数据流中拆分出或者合并出有意义的信息呢?通常会有以下一些常用的方法:

  1. 使用带消息头的协议、消息头存储消息开始标识及消息长度信息,服务端获取消息头的时候解析出消息长度,然后向后读取该长度的内容。

  2. 设置定长消息,服务端每次读取既定长度的内容作为一条完整消息。

  3. 设置消息边界,服务端从网络流中按消息编辑分离出消息内容。

  4. ……

怎样封包和拆包?


   最初遇到"粘包"的问题时,我是通过在两次send之间调用sleep来休眠一小段时间来解决.这个解决方法的缺点是显而易见的,使传输效率大大降低,而且也并不可靠.后来就是通过应答的方式来解决,尽管在大多数时候是可行的,但是不能解决象B的那种情况,而且采用应答方式增加了通讯量,加重了网络负荷. 再后来就是对数据包进行封包和拆包的操作.
    封包:
封包就是给一段数据加上包头,这样一来数据包就分为包头和包体两部分内容了(以后讲过滤非法包时封包会加入"包尾"内容).包头其实上是个大小固定的结构体,其中有个结构体成员变量表示包体的长度,这是个很重要的变量,其他的结构体成员可根据需要自己定义.根据包头长度固定以及包头中含有包体长度的变量就能正确的拆分出一个完整的数据包.
    对于拆包目前我最常用的是以下两种方式.
   1.动态缓冲区暂存方式.

    之所以说缓冲区是动态的是因为当需要缓冲的数据长度超出缓冲区的长度时会增大缓冲区长度.
    大概过程描述如下:
    A,为每一个连接动态分配一个缓冲区,同时把此缓冲区和SOCKET关联,常用的是通过结构体关联.
    B,当接收到数据时首先把此段数据存放在缓冲区中.
    C,判断缓存区中的数据长度是否够一个包头的长度,如不够,则不进行拆包操作.
    D,根据包头数据解析出里面代表包体长度的变量.
    E,判断缓存区中除包头外的数据长度是否够一个包体的长度,如不够,则不进行拆包操作.
    F,取出整个数据包.这里的"取"的意思是不光从缓冲区中拷贝出数据包,而且要把此数据包从缓存区中删除掉.删除的办法就是把此包后面的数据移动到缓冲区的起始地址.

这种方法有两个缺点.1.为每个连接动态分配一个缓冲区增大了内存的使用.2.有三个地方需要拷贝数据,一个地方是把数据存放在缓冲区,一个地方是把完整的数据包从缓冲区取出来,一个地方是把数据包从缓冲区中删除.第二种拆包的方法会解决和完善这些缺点.

前面提到过这种方法的缺点.下面给出一个改进办法, 即采用环形缓冲.但是这种改进方法还是不能解决第一个缺点以及第一个数据拷贝,只能解决第三个地方的数据拷贝(这个地方是拷贝数据最多的地方).第2种拆包方式会解决这两个问题.
环形缓冲实现方案是定义两个指针,分别指向有效数据的头和尾.在存放数据和删除数据时只是进行头尾指针的移动.

   2.利用底层的缓冲区来进行拆包
由于TCP也维护了一个缓冲区,所以我们完全可以利用TCP的缓冲区来缓存我们的数据,这样一来就不需要为每一个连接分配一个缓冲区了.另一方面我们知道recv或者wsarecv都有一个参数,用来表示我们要接收多长长度的数据.利用这两个条件我们就可以对第一种方法进行优化.
     对于阻塞SOCKET来说,我们可以利用一个循环来接收包头长度的数据,然后解析出代表包体长度的那个变量,再用一个循环来接收包体长度的数据.

可以参考ACE的SOCK_Stream类的发送和接受处理:

int SOCK_Stream::realSend()
{
	/*
	拿到发送的数据后,如果发送缓存为空,表示已经发送完了,此时不全的数应该为空的,把这数据copy进发送缓存中,
	如果不能完全copy,则把这个packet复制给curpacket;调用发送函数进行发送,如果全部发送完,如果有curpacket,则
	把剩余的数据也copy进发送缓存,继续发送,如果把数据完成发送完毕,则发送缓存为空,curpacket也为空。
	如果发送缓存不为空,则说明进入了流量控制;此时应该放进队列,如果队列中数据量大于一半时,则进行一次发送,以避免
	在循环中没有及时发送的情况,因为网络的检测是在此循环之后的
	*/
	int rc = 0;
	while(m_sendBuffer.data_length() > 0)
	{
		rc = send(m_socket, m_sendBuffer.rd_ptr(), m_sendBuffer.data_length(), 0);
		if (rc > 0) {
			// 发送成功
			//m_current_send_length += rc;
			m_sendBuffer.rd_ptr(rc);
			if (m_sendBuffer.data_length() == 0)
			{
				m_sendBuffer.recycle();
				while(m_current_send_packet != NULL)
				{
					int nFreeSpace = m_sendBuffer.space_length();
					if (nFreeSpace >= (m_current_send_packet->getdatalen() - m_current_send_length))
					{
						m_current_send_packet->readdata(m_sendBuffer.wr_ptr(), m_current_send_packet->getdatalen() - m_current_send_length);
						m_sendBuffer.wr_ptr(m_current_send_packet->getdatalen() - m_current_send_length);
						delete m_current_send_packet;
						m_current_send_packet = NULL;
						m_current_send_length = 0;
					}
					else
					{
						m_current_send_packet->readdata(m_sendBuffer.wr_ptr(), nFreeSpace);
						m_sendBuffer.wr_ptr(nFreeSpace);
						m_current_send_length += nFreeSpace;
					}
					if (m_sendBuffer.space_length() == 0)
					{
						break;
					}
					if (m_current_send_packet == NULL)
					{
						bool brc = m_send_packet_queue.read(m_current_send_packet);
						if (false == brc) 
						{
							// 发送队列已空
							break;
						}
						m_current_send_length = 0;
					}
										
				}
			}
		}
		else {
			if (NETMANAGER_EAGAIN == error_no()) {
#ifdef WIN32
				m_net_manager->m_reactor.register_handler(this, MASK_WRITE);
#else
				m_net_manager->m_reactor.register_handler(this, MASK_READ | MASK_WRITE);
#endif
				LOG(WARN)("SOCK_Stream::handle_output error,net_id%d,peer ip:0X%08x,port:%u",m_id,m_remote_addr.get_addr(),m_remote_addr.get_port());
				return -3;	//数据未写完,需要等待后续写入
			}
			else {
				LOG(WARN)("SOCK_Stream::handle_output error, send error, errno:%d", error_no());
				return -2;
			}
		}
	}
	return 0;
}
int Net_Packet::readdata(char* pBuf, uint32_t nDataLen)
{
	m_datagood = true;
	
	//要拷贝的数据
	uint32_t ntocopysize = nDataLen;
	//当前块剩下的数据
	uint32_t nleftdatasize;
	//每次能够copy的数据
	uint32_t ncopylen = 0;
	//发生copy的数据总长度
	uint32_t nret = 0;
	while(ntocopysize > 0)
	{
		nleftdatasize = islastnode() ? m_DataLength - m_CurAbsPos : MAX_PACKET_LENGTH - m_CurPos;
		if (nleftdatasize == 0)
		{
			m_datagood = false;
			break;
		}
		ncopylen = nleftdatasize > ntocopysize ? ntocopysize : nleftdatasize;		
		memcpy(pBuf + nret, m_CurBuf->m_packet + m_CurPos, ncopylen);
		ntocopysize -= ncopylen;
		m_CurPos += ncopylen;
		m_CurAbsPos += ncopylen;
		nret += ncopylen;
		if (ntocopysize > 0 || m_CurPos == MAX_PACKET_LENGTH)
		{
			if (islastnode() && (ntocopysize > 0))
			{
				m_datagood = false;
				break;
			}
			list_head* pnext = m_CurBuf->item.next;
			m_CurBuf = list_entry(pnext, Net_Buff1, item);
			m_CurPos = 0;
		}
	}
	return nret;
}

其他问题

TCP为什么引入接受缓存这个数据结构?

如果没有接受缓存的话,或者说只有一个缓存的话,为了保证接受的数据是按顺序传输的,所以如果位于x序号之后的序号分组先到达目的主机的运输层的话必然丢弃,这样的话将在重传上花费很大的开销,所以一般如果有过大的序号达到接收端,那么会按照序号缓存起来等待之前的序号分许到达,然后一并交付到应用进程。

 

 

你可能感兴趣的:(深入浅出TCP/UDP,网络,网络协议)