【笔记 】深入理解 TCP 协议:从原理到实战

  • 前言
  • 网络分层
  • 应用层(Application Layer)
  • 传输层(Transport Layer)
  • 网络互连层(Internet Layer)
  • 网络访问层(Network Access Layer)
  • 分层的好处是什么呢?
  • TCP概述-可靠的、面向连接的、基于字节流、全双工的协议
  • TCP 是面向连接的协议
  • 三次握手
  • TCP 协议是可靠的
  • TCP 是面向字节流的协议
  • TCP 是全双工的协议
  • 小结与思考
  • packetdrill-google协议栈测试神器-TODO
  • 详解
  • tcp基石-剖析首部字段
  • 源端口号、目标端口号
  • 序列号(Sequence number)
  • 初始序列号(Initial Sequence Number, ISN)
  • 初始序列号是如何生成的
  • 序列号回绕了怎么处理
  • 确认号
  • TCP Flags
  • 窗口大小
  • 可选项
  • 网络数据包大小-MUT与MSS
  • MUT
  • IP分段
  • MSS
  • 为什么有时候抓包看到的单个数据包大于 MTU
  • TCP 套接字选项 TCP_MAXSEG
  • 端口号
  • 详解三次握手
  • 初始序列号(Initial Sequence Number, ISN)
  • 状态变化
  • 同时打开
  • 自连接
  • 危害
  • 解决方法
  • 半连接队列、全连接队列基本概念
  • 半连接队列
  • 全连接队列
  • 详解四次挥手
  • 握手可以变为四次吗?
  • 同时关闭
  • TCP头部时间戳选项
  • 再议 TCP11 种状态
  • SO_REUSEADDR
  • SO_REUSEPORT
  • 应用
  • SO_LINGER
  • TIME_WAIT
  • RST
  • RST 包如果丢失了怎么办?
  • Broken pipe 与 Connection reset by peer
  • 快速重传机制与 SACK
  • TCP滑动窗口
  • TCP window full 与 TCP zero window
  • TCP 拥塞控制
  • nagle 算法
  • setTcpNoDelay
  • KeepAlive原理
  • 面试题



从字面上来看,很多人会认为 TCP/IP 是 TCP、IP 这两种协议,

实际上TCP/IP 协议族指的是在 IP 协议通信过程中用到的协议的统称

前言


wireshark蓝牙抓包 Sent Read By Type Request_网络协议

wireshark蓝牙抓包 Sent Read By Type Request_序列号_02

可以看到协议的分层从上往下依次是

  • Ethernet II:网络接口层以太网帧头部信息
  • Internet Protocol Version 4:互联网层 IP 包头部信息
  • Transmission Control Protocol:传输层的数据段头部信息,此处是 TCP 协议
  • Hypertext Transfer Protocol:应用层 HTTP 的信息

网络分层


wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_03

wireshark蓝牙抓包 Sent Read By Type Request_网络协议_04

应用层(Application Layer)

应用层的本质是规定了应用程序之间如何相互传递报文, 以 HTTP 协议为例,它规定了:

  • 报文的类型,是请求报文还是响应报文
  • 报文的语法,报文分为几段,各段是什么含义、用什么分隔,每个部分的每个字段什么什么含义
  • 进程应该以什么样的时序发送报文和处理响应报文

HTTP 客户端和 HTTP 服务端的首要工作就是根据 HTTP 协议的标准组装和解析 HTTP 数据包,每个 HTTP 报文格式由三部分组成:

  • 起始行(start line),起始行根据是请求报文还是响应报文分为「请求行」和「响应行」。这个例子中起始行是GET / HTTP/1.1,表示这是一个 GET 请求,请求的 URL 为/,协议版本为HTTP 1.1,起始行最后会有一个空行CRLF(\r\n)与下面的首部分隔开
  • 首部(header),首部采用形如key:value的方式,比如常见的User-AgentETagContent-Length都属于 HTTP 首部,每个首部直接也是用空行分隔
  • 可选的实体(entity),实体是 HTTP 真正要传输的内容,比如下载一个图片文件,传输的一段 HTML等

以本例的请求报文格式为例:


wireshark蓝牙抓包 Sent Read By Type Request_网络_05

除了我们熟知的 HTTP 协议,还有下面这些非常常用的应用层协议

  • 域名解析协议 DNS
  • 收发邮件 SMTP 和 POP3 协议
  • 时钟同步协议 NTP
  • 网络文件共享协议 NFS

传输层(Transport Layer)

传输层的作用是为两台主机之间的「应用进程」提供端到端的逻辑通信,相隔几千公里的两台主机的进程就好像在直接通信一样。

虽然是叫传输层,但是并不是将数据包从一台主机传送到另一台,而是对传输行为进行控制,这本小册介绍的主要内容 TCP 协议就被称为传输控制协议(Transmission Control Protocol),为下面两层协议提供数据包的重传、流量控制、拥塞控制等。


wireshark蓝牙抓包 Sent Read By Type Request_序列号_06

假设你正在电脑上用微信跟女朋友聊天,用 QQ 跟技术大佬们讨论技术细节,当电脑收到一个数据包时,它怎么知道这是一条微信的聊天内容,还是一条 QQ 的消息呢?

这就是端口号的作用。传输层用端口号来标识不同的应用程序,主机收到数据包以后根据目标端口号将数据包传递给对应的应用程序进行处理。比如这个例子中,目标端口号为 80,百度的服务器就根据这个目标端口号将请求交给监听 80 端口的应用程序(可能是 Nginx 等负载均衡器)处理。


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_07

网络互连层(Internet Layer)

网络互连层提供了主机到主机的通信,将传输层产生的的数据包封装成分组数据包发送到目标主机,并提供路由选择的能力


wireshark蓝牙抓包 Sent Read By Type Request_网络_08

IP 协议是网络层的主要协议,TCP 和 UDP 都是用 IP 协议作为网络层协议。这一层的主要作用是给包加上源地址和目标地址,将数据包传送到目标地址

IP 协议是一个无连接的协议,也不具备重发机制,这也是 TCP 协议复杂的原因之一就是基于了这样一个「不靠谱」的协议。

网络访问层(Network Access Layer)

网络访问层也有说法叫做网络接口层,以太网、Wifi、蓝牙工作在这一层,网络访问层提供了主机连接到物理网络需要的硬件和相关的协议。这一层我们不做重点讨论。

分层的好处是什么呢?

分层的本质是通过分离关注点而让复杂问题简单化,通过分层可以做到:

  • 各层独立:限制了依赖关系的范围,各层之间使用标准化的接口,各层不需要知道上下层是如何工作的,增加或者修改一个应用层协议不会影响传输层协议
  • 灵活性更好:比如路由器不需要应用层和传输层,分层以后路由器就可以只用加载更少的几个协议层
  • 易于测试和维护:提高了可测试性,可以独立的测试特定层,某一层有了更好的实现可以整体替换掉
  • 能促进标准化:每一层职责清楚,方便进行标准化

TCP概述-可靠的、面向连接的、基于字节流、全双工的协议

TCP 是面向连接的协议

  • 面向连接(connection-oriented):面向连接的协议要求正式发送数据之前需要通过「握手」建立一个逻辑连接,结束通信时也是通过有序的四次挥手来断开连接
  • 无连接(connectionless):无连接的协议则不需要

三次握手

通过三次握手协商好双方后续通信的起始序列号、窗口缩放大小等信息。


wireshark蓝牙抓包 Sent Read By Type Request_序列号_09

TCP 协议是可靠的

IP 是一种无连接、不可靠的协议:它尽最大可能将数据报从发送者传输给接收者,但并不保证包到达的顺序会与它们被传输的顺序一致,也不保证包是否重复,甚至都不保证包是否会达到接收者。不保证有序、去重、完整。

TCP 要想在 IP 基础上构建可靠的传输层协议,必须有一个复杂的机制来保障可靠性。 主要有下面几个方面:

  • 对每个包提供校验和
  • 包的序列号解决了接收数据的乱序、重复问题
  • 超时重传
  • 流量控制、拥塞控制

校验和(checksum) 每个 TCP 包首部中都有两字节用来表示校验和,防止在传输过程中有损坏。如果收到一个校验和有差错的报文,TCP 不会发送任何确认直接丢弃它,等待发送端重传。

wireshark蓝牙抓包 Sent Read By Type Request_网络协议_10

包的序列号保证了接收数据的乱序和重复问题 假设我们往 TCP 套接字里写 3000 字节的数据导致 TCP发送了 3 个数据包,每个数据包大小为 1000 字节:第一个包序列号为[1~1001),第二个包序列号为 [10012001),第三个包序号为[20013001)


wireshark蓝牙抓包 Sent Read By Type Request_序列号_11

假如因为网络的原因导致第二个、第三个包先到接收端,第一个包最后才到,接收端也不会因为他们到达的顺序不一致把包弄错,TCP 会根据他们的序号进行重新的排列然后把结果传递给上层应用程序。

如果 TCP 接收到重复的数据,可能的原因是超时重传了两次但这个包并没有丢失,接收端会收到两次同样的数据,它能够根据包序号丢弃重复的数据。

超时重传 TCP 发送数据后会启动一个定时器,等待对端确认收到这个数据包。如果在指定的时间内没有收到 ACK 确认,就会重传数据包,然后等待更长时间,如果还没有收到就再重传,在多次重传仍然失败以后,TCP 会放弃这个包。后面我们讲到超时重传模块的时候会详细介绍这部分内容。

TCP 是面向字节流的协议

TCP 是一种字节流(byte-stream)协议,流的含义是没有固定的报文边界。

假设你调用 2 次 write 函数往 socket 里依次写 500 字节、800 字节。write 函数只是把字节拷贝到内核缓冲区,最终会以多少条报文发送出去是不确定的,如下图所示


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_12

上面出现的情况取决于诸多因素:路径最大传输单元 MTU、发送窗口大小、拥塞窗口大小等。

当接收方从 TCP 套接字读数据时,它是没法得知对方每次写入的字节是多少的。接收端可能分2 次每次 650 字节读取,也有可能先分三次,一次 100 字节,一次 200 字节,一次 1000 字节进行读取。

面试官实际上是想问影响发送窗口大小的因素有哪些吗? 一次性发送的情况: kernel send buffer size < MTU && kernel send buffer size < peer kernel recv buffer size && kernel send buffer size < congestion window size 内核缓冲区中的待发送数据量 小于 MTU(以太网一般为1500) AND 内核缓冲区中的待发送数据量 小于 接收端缓冲区的大小 AND 内核缓冲区中的待发送数据量 小于 当前网络环境下拥塞控制窗口的大小。 我认为这里的“两次”其实是想表达多次的意思,在面试的环境下,有可能会这么问。 不必纠结

TCP 是全双工的协议

在 TCP 中发送端和接收端可以是客户端/服务端,也可以是服务器/客户端,通信的双方在任意时刻既可以是接收数据也可以是发送数据,每个方向的数据流都独立管理序列号、滑动窗口大小、MSS 等信息。

在 TCP 中发送端和接收端可以是客户端/服务端,也可以是服务器/客户端,通信的双方在任意时刻既可以是接收数据也可以是发送数据,每个方向的数据流都独立管理序列号、滑动窗口大小、MSS 等信息。

小结与思考

TCP 是一个可靠的(reliable)、面向连接的(connection-oriented)、基于字节流(byte-stream)、全双工(full-duplex)的协议。发送端在发送数据以后启动一个定时器,如果超时没有收到对端确认会进行重传,接收端利用序列号对收到的包进行排序、丢弃重复数据,TCP 还提供了流量控制、拥塞控制等机制保证了稳定性。

wireshark蓝牙抓包 Sent Read By Type Request_TCP_13

TCP提供了一种字节流服务,而收发双方都不保持记录的边界,应用程序应该如何提供他们自己的记录标识呢?

17.1 我们已经介绍了以下几种分组格式: I P、 I C M P、 I G M P、 U D P和T C P。每一种格式的首部中均包含一个检验和。对每种分组,说明检验和包括 I P数据报中的哪些部分,以及该检验和是强制的还是可选的?
答:除了U D P的检验和,其他都是必需的。 I P检验和只覆盖了 I P首部,而其他字段都紧接着I P首部开始。
17.2 为什么我们已经讨论的所有 I n t e r n e t协议( I P, ICMP, IGMP, UDP, TCP)收到有检验和错的分组都仅作丢弃处理?
答:源I P地址、源端口号或者协议字段可能被破坏了。
17.3 T C P提供了一种字节流服务,而收发双方都不保持记录的边界。应用程序如何提供它们
自己的记录标识?
答:很多I n t e r n e t应用使用一个回车和换行来标记每个应用记录的结束。这是 NVT ASCII采用的编码( 2 6 . 4节) 。另外一种技术是在每个记录之前加上一个记录的字节计数, D N S(习题1 4 . 4)和Sun RPC( 2 9 . 2节)采用了这种技术。
17.4 为什么在T C P首部的开始便是源和目的的端口号?
答:就像我们在6 . 5节所看到的,一个I C M P差错报文必须至少返回引起差错的 I P数据报中除了I P首部的前8 个字节。当T C P收到一个I C M P差错报文时,它需要检查两个端口号以决定差错对应于哪个连接。因此,端口号必须包含在T C P首部的前8个字节里。
17.5 为什么T C P首部有一个首部长度字段而 U D P首部(图11 - 2)中却没有?
TCP首部的最后有一些选项,但 U D P首部中没有选项。

packetdrill-google协议栈测试神器-TODO

以 centos7 为例

  1. 首先从 github 上 clone 最新的源码 github.com/google/pack…
  2. 进入源码目录cd gtests/net/packetdrill
  3. 安装 bison和 flex 库:sudo yum install -y bison flex
  4. 为避免 offload 机制对包大小的影响,修改 netdev.c 注释掉 set_device_offload_flags 函数所有内容
  5. 执行 ./configure
  6. 修改 Makefile,去掉第一行的末尾的 -static
  7. 执行 make 命令编译
  8. 确认编译无误地生成了 packetdrill 可执行文件

详解

tcp基石-剖析首部字段

这篇文章来讲讲 TCP 报文首部相关的概念,这些头部是支撑 TCP 复杂功能的基石。 完整的 TCP 头部如下图所示:

wireshark蓝牙抓包 Sent Read By Type Request_序列号_14

我们用一次访问百度网页抓包的例子来开始。


wireshark蓝牙抓包 Sent Read By Type Request_TCP_15

源端口号、目标端口号

在第一个包的详情中,首先看到的高亮部分的源端口号(Src Port)和目标端口号(Dst Port),这个例子中本地源端口号为 61024,百度目标端口号是 80。

TCP 报文头部里没有源 ip 和目标 ip 地址,只有源端口号和目标端口号

这也是初学 wireshark 抓包时很多人会有的一个疑问:过滤 ip 地址为 172.19.214.24 包的条件为什么不是 “tcp.addr == 172.19.214.24”,而是 “ip.addr == 172.19.214.24”


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_16

TCP 的报文里是没有源 ip 和目标 ip 的,因为那是 IP 层协议的事情,TCP 层只有源端口和目标端口。

源 IP、源端口、目标 IP、目标端口构成了 TCP 连接的「四元组」。一个四元组可以唯一标识一个连接。

序列号(Sequence number)

TCP 是面向字节流的协议,通过 TCP 传输的字节流的每个字节都分配了序列号,序列号(Sequence number)指的是本报文段第一个字节的序列号。

wireshark蓝牙抓包 Sent Read By Type Request_TCP_17

序列号加上报文的长度,就可以确定传输的是哪一段数据。序列号是一个 32 位的无符号整数,达到 2^32-1 后循环到 0

在 SYN 报文中,序列号用于交换彼此的初始序列号,在其它报文中,序列号用于保证包的顺序。

因为网络层(IP 层)不保证包的顺序,TCP 协议利用序列号来解决网络包乱序、重复的问题,以保证数据包以正确的顺序组装传递给上层应用

如果发送方发送的是四个报文序列号分别是1、2、3、4,但到达接收方的顺序是 2、4、3、1,接收方就可以通过序列号的大小顺序组装出原始的数据。

初始序列号(Initial Sequence Number, ISN)

在建立连接之初,通信双方都会各自选择一个序列号,称之为初始序列号。在建立连接时,通信双方通过 SYN 报文交换彼此的 ISN,如下图所示:


wireshark蓝牙抓包 Sent Read By Type Request_序列号_18

初始建立连接的过程中 SYN 报文交换过程如下图所示:


wireshark蓝牙抓包 Sent Read By Type Request_序列号_19

其中第 2 步和第 3 步可以合并一起,这就是三次握手的过程:


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_20

初始序列号是如何生成的

__u32 secure_tcp_sequence_number(__be32 saddr, __be32 daddr,
				 __be16 sport, __be16 dport)
{
	u32 hash[MD5_DIGEST_WORDS];

	net_secret_init();
	hash[0] = (__force u32)saddr;
	hash[1] = (__force u32)daddr;
	hash[2] = ((__force u16)sport << 16) + (__force u16)dport;
  //一个长度为 16 的 int 数组,只有在第一次调用 net_secret_init 的时时候会将将这个数组的值初始化为随机值。在系统重启前保持不变。
	hash[3] = net_secret[15];
	
	md5_transform(hash, net_secret);

	return seq_scale(hash[0]);
}

static u32 seq_scale(u32 seq)
{
	return seq + (ktime_to_ns(ktime_get_real()) >> 6);
}

可以看到初始序列号的计算函数 secure_tcp_sequence_number() 的逻辑是通过源地址、目标地址、源端口、目标端口和随机因子通过 MD5 进行进行计算。如果仅有这几个因子,对于四元组相同的请求,计算出的初始序列号总是相同,这必然有很大的安全风险,所以函数的最后将计算出的序列号通过 seq_scale 函数再次计算。

seq_scale 函数加入了时间因子,对于四元组相同的连接,序列号也不会重复了。

序列号回绕了怎么处理

序列号是一个 32 位的无符号整数,从前面介绍的初始序列号计算算法可以知道,ISN 并不是从 0 开始,所以同一个连接的序列号是有可能溢出回绕(sequence wraparound)的。TCP 的很多校验比如丢包、乱序判断都是通过比较包的序号来实现的,我们来看看 linux 内核是如何处理的,代码如下所示。

static inline bool before(__u32 seq1, __u32 seq2)
{
        return (__s32)(seq1-seq2) < 0;
}

其中 __u32 表示无符号的 32 位整数,__s32 表示有符号的 32 位整数。为什么 seq1 - seq2 转为有符号的 32 位整数就可以判断 seq1 和 seq2 的大小了呢?

以 seq1 为 0xFFFFFFFF、seq2 为 0x02(回绕)为例,它们相减的结果如下。

seq1 - seq2 = 0xFFFFFFFF - 0x02 = 0xFFFFFFFD

0xFFFFFFFD 最高位为 1,表示为负数,实际值为 -(0x00000002 + 1) = -3,这样即使 seq2 回绕了,也可以知道 seq1<seq2。

确认号


wireshark蓝牙抓包 Sent Read By Type Request_网络_21

TCP 使用确认号(Acknowledgment number, ACK)来告知对方下一个期望接收的序列号,小于此确认号的所有字节都已经收到。


wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_22

关于确认号有几个注意点:

  • 不是所有的包都需要确认的
  • 不是收到了数据包就立马需要确认的,可以延迟一会再确认
  • ACK 包本身不需要被确认,否则就会无穷无尽死循环了
  • 确认号永远是表示小于此确认号的字节都已经收到

TCP Flags

TCP 有很多种标记,有些用来发起连接同步初始序列号,有些用来确认数据包,还有些用来结束连接。TCP 定义了一个 8 位的字段用来表示 flags,大部分都只用到了后 6 个,如下图所示


wireshark蓝牙抓包 Sent Read By Type Request_TCP_23

下面这个是 wireshark 第一个 SYN 包的 flags 截图


wireshark蓝牙抓包 Sent Read By Type Request_TCP_24

我们通常所说的 SYN、ACK、FIN、RST 其实只是把 flags 对应的 bit 位置为 1 而已,这些标记可以组合使用,比如 SYN+ACK,FIN+ACK 等

  • SYN(Synchronize):用于发起连接数据包同步双方的初始序列号
  • ACK(Acknowledge):确认数据包
  • RST(Reset):这个标记用来强制断开连接,通常是之前建立的连接已经不在了、包不合法、或者实在无能为力处理
  • FIN(Finish):通知对方我发完了所有数据,准备断开连接,后面我不会再发数据包给你了。
  • PSH(Push):告知对方这些数据包收到以后应该马上交给上层应用,不能缓存起来

窗口大小


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_25

可以看到用于表示窗口大小的"Window Size" 只有 16 位,可能 TCP 协议设计者们认为 16 位的窗口大小已经够用了,也就是最大窗口大小是 65535 字节(64KB)。就像网传盖茨曾经说过:“640K内存对于任何人来说都足够了”一样。

自己挖的坑当然要自己填,因此TCP 协议引入了「TCP 窗口缩放」选项 作为窗口缩放的比例因子,比例因子值的范围是 0 ~ 14,其中最小值 0 表示不缩放,最大值 14。比例因子可以将窗口扩大到原来的 2 的 n 次方,比如窗口大小缩放前为 1050,缩放因子为 7,则真正的窗口大小为 1050 * 128 = 134400,如下图所示


wireshark蓝牙抓包 Sent Read By Type Request_网络_26

可选项

可选项的格式入下所示


wireshark蓝牙抓包 Sent Read By Type Request_序列号_27

以 MSS 为例,kind=2,length=4,value=1460


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_28

常用的选项有以下几个:

  • MSS:最大段大小选项,是 TCP 允许的从对方接收的最大报文段
  • SACK:选择确认选项
  • Window Scale:窗口缩放选项

网络数据包大小-MUT与MSS

前面的文章中介绍过一个应用层的数据包会经过传输层、网络层的层层包装,交给网络接口层传输。假设上层的应用调用 write 等函数往 socket 写入了 10KB 的数据,TCP 会如何处理呢?是直接加上 TCP 头直接交给网络层吗?这篇文章我们来讲讲这相关的知识

MUT

数据链路层传输的帧大小是有限制的,不能把一个太大的包直接塞给链路层,这个限制被称为「最大传输单元(Maximum Transmission Unit, MTU)」

下图是以太网的帧格式,以太网的帧最小的帧是 64 字节,除去 14 字节头部和 4 字节 CRC 字段,有效荷载最小为 46 字节。最大的帧是 1518 字节,除去 14 字节头部和 4 字节 CRC,有效荷载最大为 1500,这个值就是以太网的 MTU。因此如果传输 100KB 的数据,至少需要 (100 * 1024 / 1500) = 69 个以太网帧。


wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_29

不同的数据链路层的 MTU 是不同的。通过netstat -i 可以查看网卡的 mtu,比如在 我的 centos 机器上可以看到

IP分段

IPv4 数据报的最大大小为 65535 字节,这已经远远超过了以太网的 MTU,而且有些网络还会开启巨帧(Jumbo Frame)能达到 9000 字节。 当一个 IP 数据包大于 MTU 时,IP 会把数据报文进行切割为多个小的片段(小于 MTU),使得这些小的报文可以通过链路层进行传输。


wireshark蓝牙抓包 Sent Read By Type Request_网络_30

IP 头部中有一个表示分片偏移量的字段,用来表示该分段在原始数据报文中的位置,如下图所示


wireshark蓝牙抓包 Sent Read By Type Request_序列号_31

wireshark蓝牙抓包 Sent Read By Type Request_网络_32

前面我们提到 IP 协议不会对丢包进行重传,那么 IP 分段中有分片丢失、损坏的话,会发生什么呢? 这种情况下,目标主机将没有办法将分段的数据包重组为一个完整的数据包,依赖于传输层是否进行重传。

利用 IP 包分片的策略,有一种对应的网络攻击方式IP fragment attack,就是一直传More fragments = 1的包,导致接收方一直缓存分片,从而可能导致接收方内存耗尽。


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_33

因为有 MTU 的存在,TCP 每次发包的大小也限制了,这就是下面要介绍的 MSS。

MSS

TCP 为了避免被发送方分片,会主动把数据分割成小段再交给网络层,最大的分段大小称之为 MSS(Max Segment Size)。

MSS = MTU - IP header头大小 - TCP 头大小

这样一个 MSS 的数据恰好能装进一个 MTU 而不用分片。在以太网中 TCP 的 MSS = 1500(MTU) - 20(IP 头大小) - 20(TCP 头大小)= 1460


wireshark蓝牙抓包 Sent Read By Type Request_序列号_34

为什么有时候抓包看到的单个数据包大于 MTU

这就要说到 TSO(TCP Segment Offload)特性了,TSO 特性是指由网卡代替 CPU 实现 packet 的分段和合并,节省系统资源,因此 TCP 可以抓到超过 MTU 的包,但是不是真正传输的单个包会超过链路的 MTU

TCP 套接字选项 TCP_MAXSEG

TCP 有一个 socket 选项 TCP_MAXSEG,可以用来设置此次连接的 MSS,如果设置了这个选项,则 MSS 不能超过这个值。我们来看看实际的代码,还是以 echo server 为例,在 bind 之前调用 setsockopt 设置 socket 选项。完整的代码见:github.com/arthur-zhan…

端口号


wireshark蓝牙抓包 Sent Read By Type Request_序列号_35

详解三次握手

一次经典的三次握手的过程如下图所示:


wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_36

三次握手的最重要的是交换彼此的 ISN(初始序列号),序列号怎么计算来的可以暂时不用深究,我们需要重点掌握的是包交互过程中序列号变化的原理。

1、客户端发送的一个段是 SYN 报文,这个报文只有 SYN 标记被置位。SYN 报文不携带数据,但是它占用一个序号,下次发送数据序列号要加一。客户端会随机选择一个数字作为初始序列号(ISN)

凡是消耗序列号的 TCP 报文段,一定需要对端确认。如果这个段没有收到确认,会一直重传直到达到指定的次数为止。

2、服务端收到客户端的 SYN 段以后,将 SYN 和 ACK 标记都置位

SYN 标记的作用与步骤 1 中的一样,也是同步服务端生成的初始序列号。ACK 用来告知发送端之前发送的 SYN 段已经收到了,「确认号」字段指定了发送端下次发送段的序号,这里等于客户端 ISN 加一。 与前面类似 SYN + ACK 端虽然没有携带数据,但是因为 SYN 段需要被确认,所以它也要消耗一个序列号。

3、客户端发送三次握手最后一个 ACK 段,这个 ACK 段用来确认收到了服务端发送的 SYN 段。因为这个 ACK 段不携带任何数据,且不需要再被确认,这个 ACK 段不消耗任何序列号。

除了交换彼此的初始序列号,三次握手的另一个重要作用是交换一些辅助信息,比如最大段大小(MSS)、窗口大小(Win)、窗口缩放因子(WS)、是否支持选择确认(SACK_PERM)等,这些都会在后面的文章中重点介绍。


wireshark蓝牙抓包 Sent Read By Type Request_网络_37

初始序列号(Initial Sequence Number, ISN)

初始的序列号并非从 0 开始,通信双方各自生成,一般情况下两端生成的序列号不会相同。生成的算法是 ISN 随时间而变化,会递增的分配给后续的 TCP 连接的 ISN。

一个建议的算法是设计一个假的时钟,每 4 微妙对 ISN 加一,溢出 2^32 以后回到 0,这个算法使得猜测 ISN 变得非常困难。

ISN 能设置成一个固定值呢?

答案是不能,TCP 连接四元组(源 IP、源端口号、目标 IP、目标端口号)唯一确定,所以就算所有的连接 ISN 都是一个固定的值,连接之间也是不会互相干扰的。但是会有几个严重的问题.

1、出于安全性考虑。如果被知道了连接的ISN,很容易构造一个在对方窗口内的序列号,源 IP 和源端口号都很容易伪造,这样一来就可以伪造 RST 包,将连接强制关闭掉了。如果采用动态增长的 ISN,要想构造一个在对方窗口内的序列号难度就大很多了。

2、因为开启 SO_REUSEADDR 以后端口允许重用,收到一个包以后不知道新连接的还是旧连接的包因为网络的原因姗姗来迟,造成数据的混淆。如果采用动态增长的 ISN,那么可以保证两个连接的 ISN 不会相同,不会串包。

状态变化


wireshark蓝牙抓包 Sent Read By Type Request_序列号_38

对于客户端:

  • 初始的状态是处于 CLOSED 状态。CLOSED 并不是一个真实的状态,而是一个假想的起点和终点。
  • 客户端调用 connect 以后会发送 SYN 同步报文给服务端,然后进入 SYN-SENT 阶段,客户端将保持这个阶段直到它收到了服务端的确认包。
  • 如果在 SYN-SENT 状态收到了服务端的确认包,它将发送确认服务端 SYN 报文的 ACK 包,同时进入 ESTABLISHED 状态,表明自己已经准备好发送数据。

对于服务端:

  • 初始状态同样是 CLOSED 状态。
  • 在执行 bind、listen 调用以后进入 LISTEN状态,等待客户端连接。
  • 当收到客户端的 SYN 同步报文以后,会回复确认同时发送自己的 SYN 同步报文,这时服务端进入 SYN-RCVD 阶段等待客户端的确认。
  • 当收到客户端的确认报文以后,进入ESTABLISHED 状态。这时双方可以互相发数据了。

同时打开

TCP 支持同时打开,但是非常罕见,使用场景也比较有限,不过我们还是简单介绍一下。它们的包交互过程是怎么样的?TCP 状态变化又是怎么样的呢?


wireshark蓝牙抓包 Sent Read By Type Request_序列号_39

以其中一方为例,记为 A,另外一方记为 B

  • 最初的状态是CLOSED
  • A 发起主动打开,发送 SYN 给 B,然后进入SYN-SENT状态
  • A 还在等待 B 回复的 ACK 的过程中,收到了 B 发过来的 SYN,what are you 弄啥咧,A 没有办法,只能硬着头皮回复SYN+ACK,随后进入SYN-RCVD
  • A 依旧死等 B 的 ACK
  • 好不容易等到了 B 的 ACK,对于 A 来说连接建立成功

同时打开在通信两端时延比较大情况下比较容易模拟。

自连接

while true
do
	nc 127.0.0.1 50000
done
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name
tcp        0      0 127.0.0.1:50000         127.0.0.1:50000         ESTABLISHED 24786/telnet

可以看到源 IP、源端口是 127.0.0.1:50000,目标 ip、目标端口也是 127.0.0.1:50000,在本地我们连上了本来没有监听的端口号。

wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_40

对于自连接而言,上图中 wireshark 中的每个包的发送接收双方都是自己,所以可以理解为总共是六个包,包的交互过程如下图所示。


wireshark蓝牙抓包 Sent Read By Type Request_序列号_41

当一方主动发起连接时,操作系统会自动分配一个临时端口号给连接主动发起方。如果刚好分配的临时端口是 50000 端口,过程如下。

  • 第一个包是发送 SYN 包给 50000 端口
  • 对于发送方而已,它收到了这个 SYN 包,以为对方是想同时打开,会回复 SYN+ACK
  • 回复 SYN+ACK 以后,它自己就会收到这个 SYN+ACK,以为是对方回的,对它而已握手成功,进入 ESTABLISHED 状态
危害

设想一个如下的场景:

  • 你写的业务系统 B 会访问本机服务 A,服务 A 监听了 50000 端口
  • 业务系统 B 的代码写的稍微比较健壮,增加了对服务 A 断开重连的逻辑
  • 如果有一天服务 A 挂掉比较长时间没有启动,业务系统 B 开始不断 connect 重连
  • 系统 B 经过一段时间的重试就会出现自连接的情况
  • 这时服务 A 想启动监听 50000 端口就会出现地址被占用的异常,无法正常启动

如果出现了自连接,至少有两个显而易见的问题:

  • 自连接的进程占用了端口,导致真正需要监听端口的服务进程无法监听成功
  • 自连接的进程看起来 connect 成功,实际上服务是不正常的,无法正常进行数据通信
解决方法

自连接比较罕见,但一旦出现逻辑上就有问题了,因此要尽量避免。解决自连接有两个常见的办法。

  • 让服务监听的端口与客户端随机分配的端口不可能相同即可
  • 出现自连接的时候,主动关掉连接

对于第一种方法,客户端随机分配的范围由 /proc/sys/net/ipv4/ip_local_port_range 文件决定,在我的 Centos 8 上,这个值的范围是 32768~60999,只要服务监听的端口小于 32768 就不会出现客户端与服务端口相同的情况。这种方式比较推荐。

半连接队列、全连接队列基本概念

当服务端调用 listen 函数时,TCP 的状态被从 CLOSE 状态变为 LISTEN,于此同时内核创建了两个队列:

  • 半连接队列(Incomplete connection queue),又称 SYN 队列
  • 全连接队列(Completed connection queue),又称 Accept 队列

wireshark蓝牙抓包 Sent Read By Type Request_TCP_42

半连接队列

当客户端发起 SYN 到服务端,服务端收到以后会回 ACK 和自己的 SYN。这时服务端这边的 TCP 从 listen 状态变为 SYN_RCVD (SYN Received),此时会将这个连接信息放入「半连接队列」,半连接队列也被称为 SYN Queue,存储的是 “inbound SYN packets”

服务端回复 SYN+ACK 包以后等待客户端回复 ACK,同时开启一个定时器,如果超时还未收到 ACK 会进行 SYN+ACK 的重传,重传的次数由 tcp_synack_retries 值确定。在 CentOS 上这个值等于 5。

一旦收到客户端的 ACK,服务端就开始尝试把它加入另外一个全连接队列(Accept Queue)。

全连接队列

「全连接队列」包含了服务端所有完成了三次握手,但是还未被应用调用 accept 取走的连接队列。此时的 socket 处于 ESTABLISHED 状态。每次应用调用 accept() 函数会移除队列头的连接。如果队列为空,accept() 通常会阻塞。全连接队列也被称为 Accept 队列。

你可以把这个过程想象生产者、消费者模型。内核是一个负责三次握手的生产者,握手完的连接会放入一个队列。我们的应用程序是一个消费者,取走队列中的连接进行下一步的处理。这种生产者消费者的模式,在生产过快、消费过慢的情况下就会出现队列积压。

listen 函数的第二个参数 backlog 用来设置全连接队列大小,但不一定就会选用这一个 backlog 值,还受限于 somaxconn,等下会有更详细的内容说明全连接队列大小的计算规则。

int listen(int sockfd, int backlog)

如果全连接队列满,内核会舍弃掉 client 发过来的 ack(应用层会认为此时连接还未完全建立)

ss 命令可以查看全连接队列的大小和当前等待 accept 的连接个数,执行 ss -lnt 即可

ss -lnt | grep :9090
State      Recv-Q Send-Q Local Address:Port               Peer Address:Port
LISTEN     51     50           *:9090                     *:*

对于 LISTEN 状态的套接字,Recv-Q 表示 accept 队列排队的连接个数,Send-Q 表示全连接队列(也就是 accept 队列)的总大小。

详解四次挥手


wireshark蓝牙抓包 Sent Read By Type Request_序列号_43

1、客户端调用 close 方法,执行「主动关闭」,会发送一个 FIN 报文给服务端,从这以后客户端不能再发送数据给服务端了,客户端进入FIN-WAIT-1状态。FIN 报文其实就是将 FIN 标志位设置为 1。

FIN 段是可以携带数据的,比如客户端可以在它最后要发送的数据块可以“捎带” FIN 段。当然也可以不携带数据。不管 FIN 段是否携带数据,都需要消耗一个序列号否则,fin报文的 ack 可能会和主动发起关闭一方的数据报文的 ack 报文混淆无法区别

客户端发送 FIN 包以后不能再发送数据给服务端,但是还可以接受服务端发送的数据。这个状态就是所谓的「半关闭(half-close)」

主动发起关闭的一方称为「主动关闭方」,另外一段称为「被动关闭方」。

2、服务端收到 FIN 包以后回复确认 ACK 报文给客户端,服务端进入 CLOSE_WAIT,客户端收到 ACK 以后进入FIN-WAIT-2状态。

3、服务端也没有数据要发送了,发送 FIN 报文给客户端,然后进入LAST-ACK 状态,等待客户端的 ACK。同前面一样如果 FIN 段没有携带数据,也需要消耗一个序列号。

4、客户端收到服务端的 FIN 报文以后,回复 ACK 报文用来确认第三步里的 FIN 报文,进入TIME_WAIT状态,等待 2 个 MSL 以后进入 CLOSED状态。服务端收到 ACK 以后进入CLOSED状态。TIME_WAIT是一个很神奇的状态,后面有文章会专门介绍。

TIME_WAIT

5、因为有延迟确认的存在,把第二步的 ACK 经常会跟随第三步的 FIN 包一起捎带会对端,变成三次包交互。


wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_44

其实这个行为跟应用层有比较大的关系,因为发送 FIN 包以后,会进入半关闭(half-close)状态,表示自己不会再给对方发送数据了。因此如果服务端收到客户端发送的 FIN 包以后,只能表示客户端不会再给自己发送数据了,但是服务端这个时候是可以给客户端发送数据的。

在这种情况下,如果不及时发送 ACK 包,死等服务端这边发送数据,可能会造成客户端不必要的重发 FIN 包,如下图所示。


wireshark蓝牙抓包 Sent Read By Type Request_TCP_45

如果服务端确定没有什么数据需要发给客户端,那么当然是可以把 FIN 和 ACK 合并成一个包,四次挥手的过程就成了三次。

握手可以变为四次吗?

其实理论上完全是可以的,把三次握手的第二次的 SYN+ACK 拆成先回 ACK 包,再发 SYN 包就变成了「四次握手」

与 FIN 包不同的是,一般情况下,SYN 包都不携带数据,收到客户端的 SYN 包以后不用等待,可以立马回复 SYN+ACK,四次握手理论上可行,但是现实中我还没有见过。

同时关闭

前面介绍的都是一端收到了对端的 FIN,然后回复 ACK,随后发送自己的 FIN,等待对端的 ACK。TCP 是全双工的,当然可以两端同时发起 FIN 包。如下图所示


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_46

以客户端为例:

  • 最初客户端和服务端都处于 ESTABLISHED 状态
  • 客户端发送 FIN 包,等待对端对这个 FIN 包的 ACK,随后进入 FIN-WAIT-1 状态
  • 处于FIN-WAIT-1状态的客户端还没有等到 ACK,收到了服务端发过来的 FIN 包
  • 收到 FIN 包以后客户端会发送对这个 FIN 包的的确认 ACK 包,同时自己进入 CLOSING 状态
  • 继续等自己 FIN 包的 ACK
  • 处于 CLOSING 状态的客户端终于等到了ACK,随后进入TIME-WAIT
  • TIME-WAIT状态持续 2*MSL,进入CLOSED状态

TCP头部时间戳选项

Timestamps 选项的提出初衷是为了解决两个问题:

1、两端往返时延测量(RTTM)

2、序列号回绕(PAWS),接下来我们来进行介绍。

再议 TCP11 种状态

地址


wireshark蓝牙抓包 Sent Read By Type Request_网络_47

CLOSING状态在「同时关闭」的情况下出现。这里的同时关闭中的「同时」其实并不是时间意义上的同时,而是指的是在发送 FIN 包还未收到确认之前,收到了对端的 FIN 的情况。

SO_REUSEADDR

TCP 要求这样的四元组必须是唯一的,但大多数操作系统的实现要求更加严格,只要还有连接在使用这个本地端口,则本地端口不能被重用(bind 调用失败)

服务端主动断开连接以后,需要等 2 个 MSL 以后才最终释放这个连接,重启以后要绑定同一个端口,默认情况下,操作系统的实现都会阻止新的监听套接字绑定到这个端口上。

启用 SO_REUSEADDR 套接字选项可以解除这个限制,默认情况下这个值都为 0,表示关闭。但是为了保险起见,写 TCP、HTTP 服务一定要主动设置这个参数为 1。

SO_REUSEPORT

默认情况下,一个 IP、端口组合只能被一个套接字绑定,Linux 内核从 3.9 版本开始引入一个新的 socket 选项 SO_REUSEPORT,又称为 port sharding,允许多个套接字监听同一个IP 和端口组合。

为了充分发挥多核 CPU 的性能,多进程的处理网络请求主要有下面两种方式

  • 主进程 + 多个 worker 子进程监听相同的端口
  • 多进程 + REUSEPORT

wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_48

当一个新请求到来,内核是如何确定应该由哪个 LISTEN socket 来处理?接下来我们来看 SO_REUSEPORT 底层实现原理,

内核为处于 LISTEN 状态的 socket 分配了大小为 32 哈希桶。监听的端口号经过哈希算法运算打散到这些哈希桶中,相同哈希的端口采用拉链法解决冲突。当收到客户端的 SYN 握手报文以后,会根据目标端口号的哈希值计算出哈希冲突链表,然后遍历这条哈希链表得到最匹配的得分最高的 Socket。对于使用 SO_REUSEPORT 选项的 socket,可能会有多个 socket 得分最高,这个时候经过随机算法选择一个进行处理。


wireshark蓝牙抓包 Sent Read By Type Request_网络_49

Linux 内核在 4.5 和 4.6 版本中分别为 UDP 和 TCP 引入了 SO_REUSEPORT group 的概念,在查找匹配的 socket 时,就不用遍历整条冲突链,对于设置了 SO_REUSEPORT 选项的 socket 经过二次哈希找到对应的 SO_REUSEPORT group,从中随机选择一个进行处理。


wireshark蓝牙抓包 Sent Read By Type Request_网络_50

应用

SO_REUSEPORT 带来了两个明显的好处:

  • 实现了内核级的负载均衡
  • 支持滚动升级(Rolling updates)

内核级的负载均衡在前面的 Nginx 的例子中已经介绍过了,这里不再赘述。使用 SO_REUSEPORT 做滚动升级的过程如下图所示。


wireshark蓝牙抓包 Sent Read By Type Request_TCP_51

步骤如下所示。

  1. 新启动一个新版本 v2 ,监听同一个端口,与 v1 旧版本一起处理请求。
  2. 发送信号给 v1 版本的进程,让它不再接受新的请求
  3. 等待一段时间,等 v1 版本的用户请求都已经处理完毕时,v1 版本的进程退出,留下 v2 版本继续服务

SO_LINGER

这篇文章主要介绍了 SO_LINGER 套接字选项对关闭套接字的影响。默认行为下是调用 close 立即返回,但是如果有数据残留在套接字发送缓冲区中,系统将试着把这些数据发送给对端,SO_LINGER 可以改变这个默认设置,具体的规则见下面的思维导图。

wireshark蓝牙抓包 Sent Read By Type Request_序列号_52

TIME_WAIT

只有主动断开的那一方才会进入 TIME_WAIT 状态,且会在那个状态持续 2 个 MSL(Max Segment Lifetime)。

MSL(报文最大生存时间)是 TCP 报文在网络中的最大生存时间。这个值与 IP 报文头的 TTL 字段有密切的关系。

IP 报文头中有一个 8 位的存活时间字段(Time to live, TTL)如下图。 这个存活时间存储的不是具体的时间,而是一个 IP 报文最大可经过的路由数,每经过一个路由器,TTL 减 1,当 TTL 减到 0 时这个 IP 报文会被丢弃


wireshark蓝牙抓包 Sent Read By Type Request_tcp/ip_53

从上面可以看到 TTL 说的是「跳数」限制而不是「时间」限制,尽管如此我们依然假设最大跳数的报文在网络中存活的时间不可能超过 MSL 秒。Linux 的套接字实现假设 MSL 为 30 秒,因此在 Linux 机器上 TIME_WAIT 状态将持续 60秒。

存在的原因:

第一个原因是:数据报文可能在发送途中延迟但最终会到达,因此要等老的“迷路”的重复报文段在网络中过期失效,这样可以避免用相同源端口和目标端口创建新连接时收到旧连接姗姗来迟的数据包,造成数据错乱。

第二个原因是确保可靠实现 TCP 全双工终止连接。关闭连接的四次挥手中,最终的 ACK 由主动关闭方发出,如果这个 ACK 丢失,对端(被动关闭方)将重发 FIN,如果主动关闭方不维持 TIME_WAIT 直接进入 CLOSED 状态,则无法重传 ACK,被动关闭方因此不能及时可靠释放。

针对 TIME_WAIT 持续时间过长的问题,Linux 新增了几个相关的选项,net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_tw_recycle。下面我们来说明一下这两个参数的用意。 这两个参数都依赖于 TCP 头部的扩展选项:timestamp

RST

这篇文章我们来讲解 RST,RST 是 TCP 字发生错误时发送的一种分节,下面我们来介绍 RST 包出现常见的几种情况,方便你以后遇到 RST 包以后有一些思路。

在 TCP 协议中 RST 表示复位,用来异常的关闭连接,发送 RST 关闭连接时,不必等缓冲区的数据都发送出去,直接丢弃缓冲区中的数据,连接释放进入CLOSED状态。而接收端收到 RST 段后,也不需要发送 ACK 确认

一般常见场景:

  1. 端口未监听
  2. 一方突然断电重启,之前建立的连接信息丢失,另一方并不知道
  3. 调用 close 函数,设置了 SO_LINGER 为 true

RST 包如果丢失了怎么办?

首先需要明确 RST 是不需要确认的。 下面假定是服务端发出 RST。

RST 发送方会进入 CLOSED 状态,之后接收方因为未接收到 RST 报文可能会发送一次或者重试几次数据报文。RST 发送方收到数据报文,会直接回复RST报文。如下如所示:


wireshark蓝牙抓包 Sent Read By Type Request_网络协议_54

Broken pipe 与 Connection reset by peer

Connection reset by peer 这个错误很好理解,前面介绍了很多 RST 出现的场景。

Broken pipe出现的时机是:在一个 RST 的套接字继续写数据,就会出现Broken pipe

当一个进程向某个已收到 RST 的套接字执行写操作时,内核向该进程发送一个 SIGPIPE 信号。该信号的默认行为是终止进程,因此进程一般会捕获这个信号进行处理。不论该进程是捕获了该信号并从其信号处理函数返回,还是简单地忽略该信号,写操作都将返回 EPIPE 错误(也就Broken pipe 错误),这也是 Broken pipe 只在写操作中出现的原因。

网络协议栈被包含在内核中无法直接交换, 应用层只能通过read/write与内核交互来感知TCP的状态变化。

快速重传机制与 SACK

文章一开始我们介绍了重传的时间间隔,要等几百毫秒才会进行第一次重传。聪明的网络协议设计者们想到了一种方法:「快速重传」 快速重传的含义是:当发送端收到 3 个或以上重复 ACK,就意识到之前发的包可能丢了,于是马上进行重传,不用傻傻的等到超时再重传

这个有一个问题,发送 3、4、5 包收到的全部是 ACK=1001,快速重传解决了一个问题: 需要重传。因为除了 2 号包,3、4、5 包也有可能丢失,那到底是只重传数据包 2 还是重传 2、3、4、5 所有包呢?

聪明的网络协议设计者,想到了一个好办法

  • 收到 3 号包的时候在 ACK 包中告诉发送端:喂,小老弟,我目前收到的最大连续的包序号是 1000(ACK=1001),[1:1001]、[2001:3001] 区间的包我也收到了
  • 收到 4 号包的时候在 ACK 包中告诉发送端:喂,小老弟,我目前收到的最大连续的包序号是 1000(ACK=1001),[1:1001]、[2001:4001] 区间的包我也收到了
  • 收到 5 号包的时候在 ACK 包中告诉发送端:喂,小老弟,我目前收到的最大连续的包序号是 1000(ACK=1001),[1:1001]、[2001:5001] 区间的包我也收到了

这样发送端就清楚知道只用重传 2 号数据包就可以了,数据包 3、4、5已经确认无误被对端收到。这种方式被称为 SACK(Selective Acknowledgment)。


wireshark蓝牙抓包 Sent Read By Type Request_网络_55

TCP滑动窗口

如果从 socket 的角度来看TCP,是下面这样的


wireshark蓝牙抓包 Sent Read By Type Request_网络_56

TCP 会把要发送的数据放入发送缓冲区(Send Buffer),接收到的数据放入接收缓冲区(Receive Buffer),应用程序会不停的读取接收缓冲区的内容进行处理。流量控制做的事情就是,如果接收缓冲区已满,发送端应该停止发送数据。那发送端怎么知道接收端缓冲区是否已满呢?

为了控制发送端的速率,接收端会告知客户端自己接收窗口(rwnd),也就是接收缓冲区中空闲的部分。


wireshark蓝牙抓包 Sent Read By Type Request_TCP_57

TCP 在收到数据包回复的 ACK 包里会带上自己接收窗口的大小,接收端需要根据这个值调整自己的发送策略。

从 TCP 角度而言,数据包的状态可以分为如下图的四种:


wireshark蓝牙抓包 Sent Read By Type Request_序列号_58

发送窗口:是 TCP 滑动窗口的核心概念,它表示了在某个时刻一端能拥有的最大未确认的数据包大小(最大在途数据),发送窗口是发送端被允许发送的最大数据包大小,其大小等于上图中 #2 区域和 #3 区域加起来的总大小。

可用窗口:是发送端还能发送的最大数据包大小,它等于发送窗口的大小减去在途数据包大小,是发送端还能发送的最大数据包大小,对应于上图中的 #3 号区域。

窗口的左边界表示成功发送并已经被接收方确认的最大字节序号,窗口的右边界是发送方当前可以发送的最大字节序号,滑动窗口的大小等于右边界减去左边界。


wireshark蓝牙抓包 Sent Read By Type Request_TCP_59

当上图中的可用区域的6个字节(46~51)发送出去,可用窗口区域减小到 0,这个时候除非收到接收端的 ACK 数据,否则发送端将不能发送数据。


wireshark蓝牙抓包 Sent Read By Type Request_TCP_60

TCP window full 与 TCP zero window

这两者都是发送速率控制的手段,

  • TCP Window Full 是站在发送端角度说的,表示在途字节数等于对方接收窗口的情况,此时发送端不能再发数据给对方直到发送的数据包得到 ACK。
  • TCP zero window 是站在接收端角度来说的,是接收端接收窗口满,告知对方不能再发送数据给自己。

TCP 拥塞控制

前面的文章介绍了 TCP 利用滑动窗口来做流量控制,但流量控制这种机制确实可以防止发送端向接收端过多的发送数据,但是它只关注了发送端和接收端自身的状况,而没有考虑整个网络的通信状况。于是出现了我们今天要讲的拥塞处理。

拥塞处理主要涉及到下面这几个算法

  • 慢启动(Slow Start)
  • 拥塞避免(Congestion Avoidance)
  • 快速重传(Fast Retransmit)和快速恢复(Fast Recovery)

为了实现上面的算法,TCP 的每条连接都有两个核心状态值:

  • 拥塞窗口(Congestion Window,cwnd)
  • 慢启动阈值(Slow Start Threshold,ssthresh)

nagle 算法

简单来讲 nagle 算法讲的是减少发送端频繁的发送小包给对方。

Nagle 算法要求,当一个 TCP 连接中有在传数据(已经发出但还未确认的数据)时,小于 MSS 的报文段就不能被发送,直到所有的在传数据都收到了 ACK。同时收到 ACK 后,TCP 还不会马上就发送数据,会收集小包合并一起发送。网上有人想象的把 Nagle 算法说成是「hold 住哥」,我觉得特别形象。

setTcpNoDelay

首先必须明确两个观点:

  • 不是每个数据包都对应一个 ACK 包,因为可以合并确认。
  • 也不是接收端收到数据以后必须立刻马上回复确认包。

如果收到一个数据包以后暂时没有数据要分给对端,它可以等一段时间(Linux 上是 40ms)再确认。如果这段时间刚好有数据要传给对端,ACK 就可以随着数据一起发出去了。如果超过时间还没有数据要发送,也发送 ACK,以免对端以为丢包了。这种方式成为「延迟确认」。

这个原因跟 Nagle 算法其实一样,回复一个空的 ACK 太浪费了。

  • 如果接收端这个时候恰好有数据要回复客户端,那么 ACK 搭上顺风车一块发送。
  • 如果期间又有客户端的数据传过来,那可以把多次 ACK 合并成一个立刻发送出去
  • 如果一段时间没有顺风车,那么没办法,不能让接收端等太久,一个空包也得发。

这种机制被称为延迟确认(delayed ack)。TCP 要求 ACK 延迟的时延必须小于500ms,一般操作系统实现都不会超过200ms。

延迟确认在很多 linux 机器上是没有办法关闭的,

那么这里涉及的就是一个非常根本的问题:「收到数据包以后什么时候该回复 ACK」

可以看到需要立马回复 ACK 的场景有

  • 如果接收到了大于一个frame 的报文,且需要调整窗口大小
  • 处于 quickack 模式(tcp_in_quickack_mode)
  • 收到乱序包(We have out of order data.)

其它情况一律使用延迟确认的方式

KeepAlive原理

一个 TCP 连接上,如果通信双方都不向对方发送数据,那么 TCP 连接就不会有任何数据交换。这就是我们今天要讲的 TCP keepalive 机制的由来。

永远记住 TCP 不是轮询的协议

半打开 half open:

这一个情况就是如果在未告知另一端的情况下通信的一端关闭或终止连接,那么就认为该条TCP连接处于半打开状态。 这种情况发现在通信的一方的主机崩溃、电源断掉的情况下。 只要不尝试通过半开连接来传输数据,正常工作的一端将不会检测出另外一端已经崩溃。

面试题

  1. 收到 IP 数据包解析以后,它怎么知道这个分组应该投递到上层的哪一个协议(UDP 或 TCP)

解析: IP 头里有一个“协议”字段,指出在上层使用的协议,比如值为 6 表示数据交给 TCP、值为 17 表示数据交给 UDP

  1. TCP 提供了一种字节流服务,而收发双方都不保持记录的边界,应用程序应该如何提供他们自己的记录标识呢?

解析:应用程序使用自己约定的规则来表示消息的边界,比如有一些使用回车+换行("\r\n"),比如 Redis 的通信协议(RESP protocol)

  1. A B 两个主机之间建立了一个 TCP 连接,A 主机发给 B 主机两个 TCP 报文,大小分别是 500 和 300,第一个报文的序列号是 200,那么 B 主机接收两个报文后,返回的确认号是()
  • A、200
  • B、700
  • C、800
  • D、1000

答案:D,500+300+200

  1. 客户端的使用 ISN=2000 打开一个连接,服务器端使用 ISN=3000 打开一个连接,经过 3 次握手建立连接。连接建立起来以后,假定客户端向服务器发送一段数据 Welcome the server!(长度 20 Bytes),而服务器的回答数据 Thank you!(长度 10 Bytes ),试画出三次握手和数据传输阶段报文段序列号、确认号的情况。
  2. TCP/IP 协议中,MSS 和 MTU 分别工作在哪一层?

参考:MSS->传输层,MTU:链路层

  1. 在 MTU=1500 字节的以太网中,TCP 报文的最大载荷为多少字节?

参考:1500(MTU) - 20(IP 头大小) - 20(TCP 头大小)= 1460

  1. 小于()的 TCP/UDP 端口号已保留与现有服务一一对应,此数字以上的端口号可自由分配?
  • A、80
  • B、1024
  • C、8080
  • D、65525

参考:B,保留端口号

  1. 下列 TCP 端口号中不属于熟知端口号的是()
  • A、21
  • B、23
  • C、80
  • D、3210

参考:D,小于 1024 的端口号是熟知端口号

  1. 关于网络端口号,以下哪个说法是正确的()
  • A、通过 netstat 命令,可以查看进程监听端口的情况
  • B、https 协议默认端口号是 8081
  • C、ssh 默认端口号是 80
  • D、一般认为,0-80 之间的端口号为周知端口号(Well Known Ports)

参考:A

  1. TCP 协议三次握手建立一个连接,第二次握手的时候服务器所处的状态是()
  • A、SYN_RECV
  • B、ESTABLISHED
  • C、SYN-SENT
  • D、LAST_ACK

参考:A,收到了 SYN,发送 SYN+ACK 以后的状态,完整转换图见文章

  1. 下面关于三次握手与connect()函数的关系说法错误的是()
  • A、客户端发送 SYN 给服务器
  • B、服务器只发送 SYN 给客户端
  • C、客户端收到服务器回应后发送 ACK 给服务器
  • D、connect() 函数在三次握手的第二次返回

参考:B,服务端发送 SYN+ACK

  1. HTTP传输完成,断开进行四次挥手,第二次挥手的时候客户端所处的状态是:
  • A、CLOSE_WAIT
  • B、LAST_ACK
  • C、FIN_WAIT2
  • D、TIME_WAIT

参考:C,详细的状态切换图看文章

  1. 正常的 TCP 三次握手和四次挥手过程(客户端建连、断连)中,以下状态分别处于服务端和客户端描述正确的是
  • A、服务端:SYN-SEND,TIME-WAIT 客户端:SYN-RCVD,CLOSE-WAIT
  • B、服务端:SYN-SEND,CLOSE-WAIT 客户端:SYN-RCVD,TIME-WAIT
  • C、服务端:SYN-RCVD,CLOSE-WAIT 客户端:SYN-SEND,TIME-WAIT
  • D、服务端:SYN-RCVD,TIME-WAIT 客户端:SYN-SEND,CLOSE-WAIT

参考:C,SYN-RCVD 出现在被动打开方服务端,排除A、B,TIME-WAIT 出现在主动断开方客户端,排除 D

  1. 下列TCP连接建立过程描述正确的是:
  • A、服务端收到客户端的 SYN 包后等待 2*MSL 时间后就会进入 SYN_SENT 状态
  • B、服务端收到客户端的 ACK 包后会进入 SYN_RCVD 状态
  • C、当客户端处于 ESTABLISHED 状态时,服务端可能仍然处于 SYN_RCVD 状态
  • D、服务端未收到客户端确认包,等待 2*MSL 时间后会直接关闭连接

参考:C,建连与 2*ML 没有关系,排除 A、D,服务端在收到 SYN 包且发出去 SYN+ACK 以后 进入 SYN_RCVD 状态,排除 B。如果客户端给服务端的 ACK 丢失,客户端进入 ESTABLISHED 状态时,服务端仍然处于 SYN_RCVD 状态。

  1. TCP连接关闭,可能有经历哪几种状态:
  • A、LISTEN
  • B、TIME-WAIT
  • C、LAST-ACK
  • D、SYN-RECEIVED

参考:B、C 参考四次挥手的内容

  1. TCP 状态变迁中,存在 TIME_WAIT 状态,请问以下正确的描述是?
  • A、TIME_WAIT 状态可以帮助 TCP 的全双工连接可靠释放
  • B、TIME_WAIT 状态是 TCP 是三次握手过程中的状态
  • C、TIME_WAIT 状态是为了保证重新生成的 socket 不受之前延迟报文的影响
  • D、TIME_WAIT 状态是为了让旧数据包消失在网络中

参考:B 明显错误,TIME_WAIT 不是挥手阶段的状态。A、C、D都正确

  1. 假设 MSL 是 60s,请问系统能够初始化一个新连接然后主动关闭的最大速率是多少?(忽略1~1024区间的端口)

参考:系统可用端口号的范围:65536 - 1024 = 64512,主动关闭方会保持 TIME_WAIT 时间 2*MSL = 120s,那最大的速率是:64512 / 120 = 537.6

  1. 设 TCP 的 ssthresh (慢开始门限)的初始值为 8 (单位为报文段)。当拥塞窗口上升到 12 时网络发生了超时,TCP 使用慢开始和拥塞避免。试分别求出第 1 次到第 15 次传输的各拥塞窗口大小。

参考:过程如下表所示。

次数

拥塞窗口

描述

备注

1

1

慢开始,指数增加

2

2

慢开始,指数增加

3

4

慢开始,指数增加

4

8

慢开始,指数增加

5

9

拥塞避免,线性增加

6

10

拥塞避免,线性增加

7

11

拥塞避免,线性增加

8

12

拥塞避免,线性增加

ssthresh 减半变为 6,拥塞窗口降为 1

9

1

慢开始

10

2

慢开始

11

4

慢开始

12

6

拥塞避免,线性增加

13

7

拥塞避免,线性增加

14

8

拥塞避免,线性增加

15

9

拥塞避免,线性增加