【TCP/IP】Nagle 算法以及所谓 TCP 粘包

摘要:
约翰·纳格尔提出了一种简单有效的解决方案,现在称为纳格尔算法。看起来TCP发送的数据包被卡住了,所以出现了所谓的“卡住的数据包”问题。当应用程序层由于某种原因无法及时获取TCP数据时,它将导致多个数据段存储在TCP缓冲区中。

一、Nagle 算法

我们以 SSH 协议举例,通常在 SSH 连接中,单次击键就会引发数据流的传输。如果使用 IPv4,一次按键会生成约 88 字节大小的 TCP/IPv4 包(使用安全加密和认证):20 字节的 IP 头部,20 字节的 TCP 头部(假设没有选项),数据部分为 48 字节。这些小包(称为微型报(tinygram))会造成相当高的网络传输代价。也就是说,与包的其他部分相比,有效的应用数据所占比例甚微。

上述问题不会对局域网产生很大影响,因为大部分局域网不存在拥塞,而且这些包无需传输很远。然而对于广域网来说则会加重拥塞,严重影响网络性能。John Nagle 提出了一种简单有效的解决方法,现在称其为 Nagle 算法。下面首先介绍该算法是怎样运行的:

Nagle 算法的基本定义是任一时刻,最多只能有一个未被确认的小段。所谓“小段”,指的是长度小于 MSS 尺寸的数据块,而未被确认则是指没有收到对方的 ACK 数据包。Nagle 算法的规则(参考 tcp_output.c 文件里 tcp_nagle_check 函数注释):

  • 如果包长度达到 MSS,则允许发送;
  • 如果该数据包含有 FIN,则允许发送;
  • 设置了 TCP_NODELAY 选项,则允许发送;
  • 未设置 TCP_CORK 选项时,若所有发出去的小数据包(包长度小于 MSS)均被确认,则允许发送;
  • 上述条件都未满足,但发送了超时(一般为 200 ms),则立即发送。

该算法的精妙之处在于它实现了自时钟(self-clocking)控制:ACK 返回得快,数据传输也越快。在相对高延迟的广域网中,更需要减少微型报的数目,该算法使得单位时间内发送的报文段数据更少。也就是说,RTT 控制着发包速率。

二、TCP 粘包

1. Golang 代码演示

我们利用 Golang 先来实现一段服务端的代码,如下所示:

package main

import (
	"bufio"
	"fmt"
	"io"
	"net"
)

func main() {
	network := "tcp"
	address := "127.0.0.1:30000"
	listen, err := net.Listen(network, address)
	if err != nil {
		fmt.Printf("main | net.Listen(%s, %s) failed to execute", network, address)
		return
	}
	defer listen.Close()
	for {
		conn, err := listen.Accept()
		if err != nil {
			fmt.Println("accept failed, err:", err)
			continue
		}
		go process(conn)
	}
}

func process(conn net.Conn) {
	defer conn.Close()
	reader := bufio.NewReader(conn)
	var buf [1024]byte
	for {
		n, err := reader.Read(buf[:])
		if err == io.EOF {
			break
		}
		if err != nil {
			fmt.Println("read from client failed, err:", err)
			break
		}
		recvStr := string(buf[:n])
		fmt.Println("收到client发来的资源:", recvStr)
	}
}

紧接着来实现客户端的代码:

package main

import (
	"fmt"
	"net"
)

func main() {
	conn, err := net.Dial("tcp", "127.0.0.1:30000")
	if err != nil {
		fmt.Println("dial failed, err", err)
		return
	}
	defer conn.Close()
  // 循环发送20次 Hello World! This is a test demo.
	for i := 0; i < 20; i++ {
		msg := `Hello World! This is a test demo.`
		conn.Write([]byte(msg))
	}
}

先开启服务端代码,后允许客户端代码,输出结果如下:

收到client发来的资源: Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This isdemo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.
收到client发来的资源: Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This is a test demo.Hello World! This isdemo.

可以发现输出的结果并没有像客户端发送的次数一样,原先在客户端发送20次的代码在服务端只接收了两次。这看起来像是 TCP 发送的包被粘住了一样,故而产生了所谓“粘包”的问题。

这里为代码做下总结,“粘包”问题的缘由可能发生在发送端也可能发生在接收端:

  • 由 Nagle 算法造成的发送端的粘包:Nagle 算法是一种改善网络传输效率的算法。简单来说就是当我们提交一段数据给 TCP 发送时,TCP 并不立刻发送此段数据,而是等待一小段时间看看在等待期间是否还有要发送的数据,若有则会一次把这好几段数据发送出去。
  • 接收端接受不及时造成的接收端粘包:TCP 会把接收到的数据存在自己的缓冲区中,然后通应用层取数据。当应用层由于某些原因不能及时地把 TCP 的数据取出来,就会造成 TCP 缓冲区中存放了几段数据。

2. 粘包的本质

从上面结果我们也看到了,上层通过 TCP 传递的数据好像被胶水黏在了一起,所以有了所谓的 TCP 粘包问题。但是在这里我们需要纠正的一个点是:TCP 是流协议,根本不存在所谓的粘包一说。

send(2) Upon successful completion, the number of types which were send is returned.

Otherwise, -1 is returned and the global variable errno is set to indicate the error.

recv(2) These calls return the number of bytes received, or -1 if an error occurred.

文档中已提及:sendrecv 的返回值表示成功发送/接收端字节数。所以对于应用层来说,黏包确实是个伪命题,TCP 本来就是一个基于字节流的协议而不是消息包的协议,它只会将你的数据编程字节流发到对面去,而且保证顺序不会乱,而对于字节流的解析,就需要我们自己来搞定了。

3. 解决粘包问题

解决黏包问题的最关键一步就是确定消息边界。首先我们需要明白什么是消息,在我认为,消息就是一段有意义的信息报文,例如一次 HTTP 请求或者像我们上面代码中所要发送的 Hello World! This is a test demo.

所以我们要找到消息边界,这并不难理解,确定消息边界就是确定消息的开始或者结束。简单地说,就三个办法:

  • 定长消息:协议提前约定好包的长度为多少,每当接收端接收到固定长度的字节就确定一个包;
  • 消息分隔符:利用特殊符号标志着消息的开始或者结束,例如 HTTP 协议中的换行符;
  • 长度前缀:先发送N个字节代表包的大小(注意大端和小端问题),后续解析也按长度读取解析。

接下来我们来延时下如何利用长度前缀解决粘包问题。

我们可以自己定义一个协议,比如数据包的前4个字节为包头,里面存储的是发送的数据都长度,代码如下所示:

package proto

import (
  "bufio"
  "bytes"
  "encoding/binary"
)

// Encode 将消息message进行编码,返回byte切片
func Encode(message string) ([]byte, error) {
  // 读取消息的长度,转换成int32类型(占4个字节)
  var length = int32(len(message))
  var pkg = new(bytes.Buffer)
  // 写入消息头
  err := binary.Write(pkg, binary.LittleEndian, length)
  if err != nil {
    return nil, err
  }
  // 写入消息实体
  err = binary.Write(pkg, binary.LittleEndian, []byte(message))
  if err != nil {
    return nil, err
  }
  return pkg.Bytes(), nil
}

// Decode 将读取到二进制数据解码成字符串消息
func Decode(reader *bufio.Reader) (string, error) {
  // 读取消息的长度
  lengthByte, _ := reader.Peek(4) // 读取前4个字节的数据
  lengthBuff := bytes.NewBuffer(lengthByte)
  var length int32
  err := binary.Read(lengthBuff, binary.LittleEndian, &length)
  if err != nil {
    return "", err
  }
  // Buffered 返回缓冲中现有的可读取的字节数
  if int32(reader.Buffered()) < length+4 {
    return "", err
  }
  
  // 读取真正的数据
  pack := make([]byte, int(4+length))
  _, err = reader.Read(pack)
  if err != nil {
    return "", err
  }
  return string(pack[4:]), nil
}

接下来在服务端和客户端分别使用上面定义的 proto 包的 DecodeEncode 函数处理数据

服务端代码如下:

func main() {
  listen, err := net.Listen("tcp", "127.0.0.1:30000")
  if err != nil {
    fmt.Println("listen failed, err:", err)
    return
  }
  defer listen.Close()
  for {
    conn, err := listen.Accept()
    if err != nil {
      fmt.Println("accept failed, err:", err)
      continue
    }
    go process(conn)
  }
}

func process(conn net.Conn) {
  defer conn.Close()
  reader := bufio.NewReader(conn)
  for {
    msg, err := proto.Decode(reader)
    if err == io.EOF {
      return
    }
    if err != nil {
      fmt.Println("decode msg failed, err:", err)
      return
    }
    fmt.Println("收到client发来的数据", msg)
  }
}

客户端代码如下:

func main() {
  conn, err := net.Dial("tcp", "127.0.0.1:3000")
  if err != nil {
    fmt.Println("dial failed, err", err)
    return
  }
  defer conn.Close()
  for i := 0; i < 20; i++ {
    msg := `Hello World! This is a test demo.`
    data, err := proto.Encode(msg)
    if err != nil {
      fmt.Println("encode msg failed, err:", err)
      return
    }
    conn.Write(data)
  }
}

三、延时 ACK 与 Nagle 算法结合

延时 ACK 是指接收端不会每个包都发送一次 ACK 确认,而是当接收到一个包后延迟一段时间,以期望这段时间内仍有包被接收到,这是就可以只发送一次 ACK 确认之前收到的数据包,以减少网络带宽压力。

但若将延时 ACK 与 Nagle 算法直接结合使用,得到的效果可能不尽如人意。考虑以下情景,客户端使用延时 ACK 方法发送一个对服务器的请求,而服务端的响应数据并不适合在同一个包中传输,如下图所示:

【TCP/IP】Nagle 算法以及所谓 TCP 粘包第1张

从图中可以看到,在接收到来自服务器端端两个包以后,客户端并不立即发送 ACK,而是处于等待状态,希望有数据一同捎带发送。通常情况下,TCP 在接收到两个全长的数据包后就应返回一个 ACK,但这里并非如此。在服务器端,由于使用了 Nagle 算法,直到收到 ACK 前都不能发送新数据,因为任一时刻只允许至多一个小数据包在传。因此延时 ACK 与 Nagle 算法的结合导致了某种程度的死锁(两端互相等待对方作出行动),当然这种死锁并不是永久的,在延时 ACK 计时器或者响应端超时之后,将会得到解除。

参考资料:

【1】TCP/IP 详解 卷1:协议

免责声明:文章转载自《【TCP/IP】Nagle 算法以及所谓 TCP 粘包》仅用于学习参考。如对内容有疑问,请及时联系本站处理。

上篇css自定义滚动条样式为什么C++,中字符串不能修改下篇

宿迁高防,2C2G15M,22元/月;香港BGP,2C5G5M,25元/月 雨云优惠码:MjYwNzM=

相关文章

设计模式(2)

上一篇日志、我们应用到了设计模式三大特性的封装、今天这一片我们继续研究程序设计的另几种比较优秀的特性。 在上面的程序中这款计算器只涉及了加、减、乘、除算法、现在我们要加一个平方根运算,我们的思路会是改动我们的计算类,在switch中加一个分支就行了,但是这样的话要让加减乘除的运算都得来参与编译,如果你一不小心,把加法运算改成了减法,这岂不是大大的糟糕。这还...

【计算机网络】应用层

目录 网络应用模型 客户/服务器模型 P2P模型 域名系统(DNS) 层次域名空间 域名服务器 域名解析 文件传输协议(FTP) 工作原理 连接 电子邮件 电子邮件系统的组成结构 多用途网络邮件扩充(MIME) 简单邮件传输协议(SMTP) 邮局协议(POP3) 网际报文存取协议(IMAP) 基于万维网的电子邮件 万维网(WW...

Java八股文——网络协议

HTTP协议 一次http请求的过程 用户输入url,浏览器本地解析url,如果在host文件中存有对应ip则访问对应ip,否则将域名交给DNS服务器,DNS服务器返回对应IP地址,应用层向ip地址发送http请求,然后是传输层TCP的三次握手确认连接,第一次是客户端向服务器发送syn,第二次是服务器发送syn和ack到客户端,第三次是客户端发送s...

nginx基于TCP的反向代理

一、4层的负载均衡   Nginx Plus的商业授权版开始具有TCP负载均衡的功能。从Nginx 1.7.7版本开始加入的,现在变成了一个商业收费版本,想要试用,需要在官网申请。也就是说,Nginx除了以前常用的HTTP负载均衡外,Nginx增加基于TCP协议实现的负载均衡方法。 HTTP负载均衡,也就是我们通常所有“七层负载均衡”,工作在第七层“应用层...

用OpenCV实现Photoshop算法(三): 曲线调整

http://blog.csdn.net/c80486/article/details/52499919 系列文章: 用OpenCV实现Photoshop算法(一): 图像旋转 用OpenCV实现Photoshop算法(二): 图像剪切 用OpenCV实现Photoshop算法(三): 曲线调整 用OpenCV实现Photoshop算法(四): 色阶调整...

Linux抓包工具tcpdump详解

原文链接 tcpdump是一个用于截取网络分组,并输出分组内容的工具,简单说就是数据包抓包工具。tcpdump凭借强大的功能和灵活的截取策略,使其成为Linux系统下用于网络分析和问题排查的首选工具。 tcpdump提供了源代码,公开了接口,因此具备很强的可扩展性,对于网络维护和入侵者都是非常有用的工具。tcpdump存在于基本的Linux系统中,由于它需...