TCP“粘包” --- 字节流解析
Opened this issue · 0 comments
TCP“粘包”
世上本没有路,走的人多了就成了路 ———— 鲁迅
网络中不存在粘包,叫的人多了就有包了 ———— elvin
网络上或者是面试的时候经常会遇到问:TCP 粘包如何处理? 其实问题本身就不准确,问题的准确问题应该是:如何解析TCP字节流?更甚是如何高效的解析TCP字节流?
tcp传输是在应用层的下层,网络层的上层,将应用层的数据通过打包发送到网络层,提供应用进程间的逻辑通信,TCP的通信的真正端点并不是主机而是主机中的进程,也就是说端到端的通信是应用进程之间的通信,所以与网络层的区别是,网络层是为主机与主机之间提供逻辑通信,传输层象对上层应用层屏蔽了网络层以下的网络核心细节。每次将数据发送到网络层时都消息块的首部都包含TCP报文段,TCP报文段的首部前20个字节是固定的,包含源端口、目的端口、序号、确认号、校验和等数据,因此每个TCP报文端的最小长度是20字节。
对于应用层来说,应用层调用send
或者recv
方法时发送或者接收到一个完整地数据,此时我们会直观地表示,我们收到地是一个又一个地数据包,但是底层的实现,应用层接收到的一个数据包有可能是有多个TCP报文段合并的。
TCP 数据如何发送
tcp作为可靠的全双工传输通道有以下特点:
- 面向连接
- 一个传输通道只能端到端一对一传输
- 提供可靠交付的服务
- 全双工传输通道
- 面向字节流
以上5点,其中第5点就是“粘包”产生的主要原因
TCP中的"流"指的是流入到进程或者从进程流出的字节序列,虽然应用程序和TCP交互是一次一个数据块,但是TCP把应用程序交下来的数据仅看成是一串无结构的字节流,TCP并不知道所传输的字节流的含义,不保证接收方应用程序所接收到的数据块和发送应用程序所发出得数据块具有对应大小的关系。
有可能发送方应用程序交个TCP的是10个数据块,但是接收方的TCP可能只用了4个数据块就把收到地字节序交付上层的应用程序。
造成以上的原因都是围绕第一句话中的可靠俩个字,对于TCP的复杂程度都是基于保证可靠传输造成的,要实现消息的可靠传输就会有消息应答ack
机制,但是普通的一对一顺序应答机制会造成低效的传输,要想高效与可靠,只能出现更复杂的机制,分片或者分段传输,且到有消息应答机制,此时字节序列号
跟滑动窗口
机制出现了
序列号
实现并发分组发送即多个消息块同时发送,不需要等待前一个ack应答之后再发送下一个,解决了消息块低效的排队发送方式。但多个并发分组发送的时候,到底发多少合适?
由于TCP传输层有设置缓冲区间,应用层的消息发送时并不是直接发送,而是数据首先会复制到缓存区间,在缓冲区间的数据并不是数据包的形式存在的,而是一段无结构的字节流,TCP亦不是一次性将缓冲区间的数据直接发送,而是会对缓存区间的字节流进行分组(分成多个报文段),分组发送
,一次发送多少是根据对方给出的窗口值和当前网络拥塞的程度来决定一个报文段包含多少个字节,该值为滑动窗口大小
。
滑动窗口大小决定了一次发送多少数据,发送方顺序发送,但是接收方接收的数据是无序的,接收方接收到数据会根据消息字节序号进行重新排序,并且先到的会等待后接收到的消息,然后将数据刷新到应用层,这个时候应用层接收到的也是无序的消息,这个无序的消息其实就是无结构特征的字节流。
其中 TCP缓存位于内核空间
,发送方缓存位于用户空间
,为了减少数据在用户空间与内核空间的复制次数,在tcp缓存区会对数据进行缓冲,当足够都得时候才会将内核态数据复制到用户态空间,同样发送送的的时候,也会由于减少报文段的首部以及减少报文段的数量,会使用Nagle算法
。
Nagle 算法是一种通过减少数据包的方式提高 TCP 传输性能的算法,因为网络带宽有限,它不会将小的数据块直接发送到目的主机,而是会在本地缓冲区中等待更多待发送的数据,这种批量发送数据的策略虽然会影响实时性和网络延迟,但是能够降低网络拥堵的可能性并减少额外开销。
解析TCP数据无结构特征的字节流
既然 TCP协议是基于无结构特征的字节流,这其实就意味着应用层协议要自己划分消息的边界,在应用层协议中定义消息的边界,那么无论 TCP 协议如何对应用层协议的数据包进程拆分和重组,接收方都能根据协议的规则恢复对应的消息
消息边界
在应用层协议中,最常见的两种解决方案就是基于长度或者基于终结符
- 固定消息长度
- 标识符号
固定消息长度
所有得应用层消息都使用统一的大小,一般用于各个消息长度比较大且消息的长度不会造成大小变动很大,这样不易造成浪费,如果消息的长度变化比较大,且长度较小,这样会造成流量浪费。
标识符号
选择用标识符号可以在应用层将每个消息用特殊的符号或者是消息的长度用特定大小以及特定的编码放置于数据流的前面,在接收方遇到特殊符号或者是读取特殊的字节长度判断是否消息长度,根据消息长度读取固定的消息长度,此时一条消息完整地读取。
// 写入
func (p *PacketIo) Write(ctx context.Context, m *Message) error {
if bs, err := json.Marshal(m); err != nil {
return err
} else {
var lenNum = make([]byte, HeaderLen)
// 将消息长度写入4个字节的字节序的首部
binary.BigEndian.PutUint32(lenNum, uint32(len(bs)))
var buf = bytes.NewBuffer(lenNum)
// 在将消息内容写入
_, _ = buf.Write(bs)
if _, err := p.w.Write(buf.Bytes()); err != nil {
return err
}
return p.w.Flush()
}
}
// 读取
func (p *PacketIo) Read(ctx context.Context) (*Message, error) {
for p.scan.Scan() {
select {
case <-ctx.Done():
return nil, nil, fmt.Errorf("read closed")
default:
// golang中利用scan 扫描字节流的方式读取
err := p.scan.Err()
if err != nil && err != io.EOF {
return nil, err
}
bs := p.scan.Bytes()
var msg = &Message{}
if err := json.Unmarshal(bs, msg); err != nil {
return nil, err
}
return msg, nil
}
}
return nil, fmt.Errorf("read err")
}
func (p *PacketIo) split(data []byte, atEOF bool) (advance int, token []byte, err error) {
if len(data) < 4 {
return
}
// golang中利用scan 扫描字节流的方式读取前面4个字节的消息长度
// 读取字节流的前面4个字节,然后继续读取4个字节之后的数据,长度为前面4个字节解析出来的长度数值
length := binary.BigEndian.Uint32(data[:HeaderLen])
if !atEOF && length == uint32(len(data[HeaderLen:])) {
return len(data), data[HeaderLen:], nil
}
return
}