赞
踩
可以看到协议的分层从上往下依次是
应用层的本质是规定了应用程序之间如何相互传递报文, 以 HTTP 协议为例,它规定了:
HTTP 客户端和 HTTP 服务端的首要工作就是根据 HTTP 协议的标准组装和解析 HTTP 数据包,每个 HTTP 报文格式由三部分组成:
GET / HTTP/1.1
,表示这是一个 GET
请求,请求的 URL 为/
,协议版本为HTTP 1.1
,起始行最后会有一个空行CRLF(\r\n)
与下面的首部分隔开key:value
的方式,比如常见的User-Agent
、ETag
、Content-Length
都属于 HTTP 首部,每个首部直接也是用空行分隔以本例的请求报文格式为例:
除了我们熟知的 HTTP 协议,还有下面这些非常常用的应用层协议
传输层的作用是为两台主机之间的「应用进程」提供端到端的逻辑通信,相隔几千公里的两台主机的进程就好像在直接通信一样。
虽然是叫传输层,但是并不是将数据包从一台主机传送到另一台,而是对传输行为进行控制,这本小册介绍的主要内容 TCP 协议就被称为传输控制协议(Transmission Control Protocol),为下面两层协议提供数据包的重传、流量控制、拥塞控制等。
假设你正在电脑上用微信跟女朋友聊天,用 QQ 跟技术大佬们讨论技术细节,当电脑收到一个数据包时,它怎么知道这是一条微信的聊天内容,还是一条 QQ 的消息呢?
这就是端口号的作用。传输层用端口号来标识不同的应用程序,主机收到数据包以后根据目标端口号将数据包传递给对应的应用程序进行处理。比如这个例子中,目标端口号为 80,百度的服务器就根据这个目标端口号将请求交给监听 80 端口的应用程序(可能是 Nginx 等负载均衡器)处理。
网络互连层提供了主机到主机的通信,将传输层产生的的数据包封装成分组数据包发送到目标主机,并提供路由选择的能力。
IP 协议是网络层的主要协议,TCP 和 UDP 都是用 IP 协议作为网络层协议。这一层的主要作用是给包加上源地址和目标地址,将数据包传送到目标地址。
IP 协议是一个无连接的协议,也不具备重发机制,这也是 TCP 协议复杂的原因之一就是基于了这样一个「不靠谱」的协议。
网络访问层也有说法叫做网络接口层,以太网、Wifi、蓝牙工作在这一层,网络访问层提供了主机连接到物理网络需要的硬件和相关的协议。这一层我们不做重点讨论。
分层的本质是通过分离关注点而让复杂问题简单化,通过分层可以做到:
通过三次握手协商好双方后续通信的起始序列号、窗口缩放大小等信息。
IP 是一种无连接、不可靠的协议:它尽最大可能将数据报从发送者传输给接收者,但并不保证包到达的顺序会与它们被传输的顺序一致,也不保证包是否重复,甚至都不保证包是否会达到接收者。不保证有序、去重、完整。
TCP 要想在 IP 基础上构建可靠的传输层协议,必须有一个复杂的机制来保障可靠性。 主要有下面几个方面:
校验和(checksum) 每个 TCP 包首部中都有两字节用来表示校验和,防止在传输过程中有损坏。如果收到一个校验和有差错的报文,TCP 不会发送任何确认直接丢弃它,等待发送端重传。
包的序列号保证了接收数据的乱序和重复问题 假设我们往 TCP 套接字里写 3000 字节的数据导致 TCP发送了 3 个数据包,每个数据包大小为 1000 字节:第一个包序列号为[1~1001),第二个包序列号为 [10012001),第三个包序号为[20013001)
假如因为网络的原因导致第二个、第三个包先到接收端,第一个包最后才到,接收端也不会因为他们到达的顺序不一致把包弄错,TCP 会根据他们的序号进行重新的排列然后把结果传递给上层应用程序。
如果 TCP 接收到重复的数据,可能的原因是超时重传了两次但这个包并没有丢失,接收端会收到两次同样的数据,它能够根据包序号丢弃重复的数据。
超时重传 TCP 发送数据后会启动一个定时器,等待对端确认收到这个数据包。如果在指定的时间内没有收到 ACK 确认,就会重传数据包,然后等待更长时间,如果还没有收到就再重传,在多次重传仍然失败以后,TCP 会放弃这个包。后面我们讲到超时重传模块的时候会详细介绍这部分内容。
TCP 是一种字节流(byte-stream)协议,流的含义是没有固定的报文边界。
假设你调用 2 次 write 函数往 socket 里依次写 500 字节、800 字节。write 函数只是把字节拷贝到内核缓冲区,最终会以多少条报文发送出去是不确定的,如下图所示
上面出现的情况取决于诸多因素:路径最大传输单元 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 中发送端和接收端可以是客户端/服务端,也可以是服务器/客户端,通信的双方在任意时刻既可以是接收数据也可以是发送数据,每个方向的数据流都独立管理序列号、滑动窗口大小、MSS 等信息。
在 TCP 中发送端和接收端可以是客户端/服务端,也可以是服务器/客户端,通信的双方在任意时刻既可以是接收数据也可以是发送数据,每个方向的数据流都独立管理序列号、滑动窗口大小、MSS 等信息。
TCP 是一个可靠的(reliable)、面向连接的(connection-oriented)、基于字节流(byte-stream)、全双工(full-duplex)的协议。发送端在发送数据以后启动一个定时器,如果超时没有收到对端确认会进行重传,接收端利用序列号对收到的包进行排序、丢弃重复数据,TCP 还提供了流量控制、拥塞控制等机制保证了稳定性。
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首部中没有选项。
以 centos7 为例
cd gtests/net/packetdrill
sudo yum install -y bison flex
./configure
Makefile
,去掉第一行的末尾的 -static
这篇文章来讲讲 TCP 报文首部相关的概念,这些头部是支撑 TCP 复杂功能的基石。 完整的 TCP 头部如下图所示:
我们用一次访问百度网页抓包的例子来开始。
在第一个包的详情中,首先看到的高亮部分的源端口号(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”
TCP 的报文里是没有源 ip 和目标 ip 的,因为那是 IP 层协议的事情,TCP 层只有源端口和目标端口。
源 IP、源端口、目标 IP、目标端口构成了 TCP 连接的「四元组」。一个四元组可以唯一标识一个连接。
TCP 是面向字节流的协议,通过 TCP 传输的字节流的每个字节都分配了序列号,序列号(Sequence number)指的是本报文段第一个字节的序列号。
序列号加上报文的长度,就可以确定传输的是哪一段数据。序列号是一个 32 位的无符号整数,达到 2^32-1 后循环到 0。
在 SYN 报文中,序列号用于交换彼此的初始序列号,在其它报文中,序列号用于保证包的顺序。
因为网络层(IP 层)不保证包的顺序,TCP 协议利用序列号来解决网络包乱序、重复的问题,以保证数据包以正确的顺序组装传递给上层应用。
如果发送方发送的是四个报文序列号分别是1、2、3、4,但到达接收方的顺序是 2、4、3、1,接收方就可以通过序列号的大小顺序组装出原始的数据。
在建立连接之初,通信双方都会各自选择一个序列号,称之为初始序列号。在建立连接时,通信双方通过 SYN 报文交换彼此的 ISN,如下图所示:
初始建立连接的过程中 SYN 报文交换过程如下图所示:
其中第 2 步和第 3 步可以合并一起,这就是三次握手的过程:
__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。
TCP 使用确认号(Acknowledgment number, ACK)来告知对方下一个期望接收的序列号,小于此确认号的所有字节都已经收到。
关于确认号有几个注意点:
TCP 有很多种标记,有些用来发起连接同步初始序列号,有些用来确认数据包,还有些用来结束连接。TCP 定义了一个 8 位的字段用来表示 flags,大部分都只用到了后 6 个,如下图所示
下面这个是 wireshark 第一个 SYN 包的 flags 截图
我们通常所说的 SYN、ACK、FIN、RST 其实只是把 flags 对应的 bit 位置为 1 而已,这些标记可以组合使用,比如 SYN+ACK,FIN+ACK 等
可以看到用于表示窗口大小的"Window Size" 只有 16 位,可能 TCP 协议设计者们认为 16 位的窗口大小已经够用了,也就是最大窗口大小是 65535 字节(64KB)。就像网传盖茨曾经说过:“640K内存对于任何人来说都足够了”一样。
自己挖的坑当然要自己填,因此TCP 协议引入了「TCP 窗口缩放」选项 作为窗口缩放的比例因子,比例因子值的范围是 0 ~ 14,其中最小值 0 表示不缩放,最大值 14。比例因子可以将窗口扩大到原来的 2 的 n 次方,比如窗口大小缩放前为 1050,缩放因子为 7,则真正的窗口大小为 1050 * 128 = 134400,如下图所示
可选项的格式入下所示
以 MSS 为例,kind=2,length=4,value=1460
常用的选项有以下几个:
前面的文章中介绍过一个应用层的数据包会经过传输层、网络层的层层包装,交给网络接口层传输。假设上层的应用调用 write 等函数往 socket 写入了 10KB 的数据,TCP 会如何处理呢?是直接加上 TCP 头直接交给网络层吗?这篇文章我们来讲讲这相关的知识
数据链路层传输的帧大小是有限制的,不能把一个太大的包直接塞给链路层,这个限制被称为「最大传输单元(Maximum Transmission Unit, MTU)」
下图是以太网的帧格式,以太网的帧最小的帧是 64 字节,除去 14 字节头部和 4 字节 CRC 字段,有效荷载最小为 46 字节。最大的帧是 1518 字节,除去 14 字节头部和 4 字节 CRC,有效荷载最大为 1500,这个值就是以太网的 MTU。因此如果传输 100KB 的数据,至少需要 (100 * 1024 / 1500) = 69 个以太网帧。
不同的数据链路层的 MTU 是不同的。通过netstat -i
可以查看网卡的 mtu,比如在 我的 centos 机器上可以看到
IPv4 数据报的最大大小为 65535 字节,这已经远远超过了以太网的 MTU,而且有些网络还会开启巨帧(Jumbo Frame)能达到 9000 字节。 当一个 IP 数据包大于 MTU 时,IP 会把数据报文进行切割为多个小的片段(小于 MTU),使得这些小的报文可以通过链路层进行传输。
IP 头部中有一个表示分片偏移量的字段,用来表示该分段在原始数据报文中的位置,如下图所示
前面我们提到 IP 协议不会对丢包进行重传,那么 IP 分段中有分片丢失、损坏的话,会发生什么呢? 这种情况下,目标主机将没有办法将分段的数据包重组为一个完整的数据包,依赖于传输层是否进行重传。
利用 IP 包分片的策略,有一种对应的网络攻击方式IP fragment attack
,就是一直传More fragments = 1
的包,导致接收方一直缓存分片,从而可能导致接收方内存耗尽。
因为有 MTU 的存在,TCP 每次发包的大小也限制了,这就是下面要介绍的 MSS。
TCP 为了避免被发送方分片,会主动把数据分割成小段再交给网络层,最大的分段大小称之为 MSS(Max Segment Size)。
MSS = MTU - IP header头大小 - TCP 头大小
这样一个 MSS 的数据恰好能装进一个 MTU 而不用分片。在以太网中 TCP 的 MSS = 1500(MTU) - 20(IP 头大小) - 20(TCP 头大小)= 1460
。
这就要说到 TSO(TCP Segment Offload)特性了,TSO 特性是指由网卡代替 CPU 实现 packet 的分段和合并,节省系统资源,因此 TCP 可以抓到超过 MTU 的包,但是不是真正传输的单个包会超过链路的 MTU。
TCP 有一个 socket 选项 TCP_MAXSEG,可以用来设置此次连接的 MSS,如果设置了这个选项,则 MSS 不能超过这个值。我们来看看实际的代码,还是以 echo server 为例,在 bind 之前调用 setsockopt 设置 socket 选项。完整的代码见:github.com/arthur-zhan…
一次经典的三次握手的过程如下图所示:
三次握手的最重要的是交换彼此的 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)等,这些都会在后面的文章中重点介绍。
初始的序列号并非从 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 不会相同,不会串包。
对于客户端:
CLOSED
状态。CLOSED 并不是一个真实的状态,而是一个假想的起点和终点。SYN-SENT
阶段,客户端将保持这个阶段直到它收到了服务端的确认包。SYN-SENT
状态收到了服务端的确认包,它将发送确认服务端 SYN 报文的 ACK 包,同时进入 ESTABLISHED 状态,表明自己已经准备好发送数据。对于服务端:
CLOSED
状态。LISTEN
状态,等待客户端连接。SYN-RCVD
阶段等待客户端的确认。ESTABLISHED
状态。这时双方可以互相发数据了。TCP 支持同时打开,但是非常罕见,使用场景也比较有限,不过我们还是简单介绍一下。它们的包交互过程是怎么样的?TCP 状态变化又是怎么样的呢?
以其中一方为例,记为 A,另外一方记为 B
CLOSED
SYN
给 B,然后进入SYN-SENT
状态ACK
的过程中,收到了 B 发过来的 SYN
,what are you 弄啥咧,A 没有办法,只能硬着头皮回复SYN+ACK
,随后进入SYN-RCVD
同时打开在通信两端时延比较大情况下比较容易模拟。
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 中的每个包的发送接收双方都是自己,所以可以理解为总共是六个包,包的交互过程如下图所示。
当一方主动发起连接时,操作系统会自动分配一个临时端口号给连接主动发起方。如果刚好分配的临时端口是 50000 端口,过程如下。
设想一个如下的场景:
如果出现了自连接,至少有两个显而易见的问题:
自连接比较罕见,但一旦出现逻辑上就有问题了,因此要尽量避免。解决自连接有两个常见的办法。
对于第一种方法,客户端随机分配的范围由 /proc/sys/net/ipv4/ip_local_port_range
文件决定,在我的 Centos 8 上,这个值的范围是 32768~60999,只要服务监听的端口小于 32768 就不会出现客户端与服务端口相同的情况。这种方式比较推荐。
当服务端调用 listen 函数时,TCP 的状态被从 CLOSE 状态变为 LISTEN,于此同时内核创建了两个队列:
当客户端发起 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 队列)的总大小。
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 包一起捎带会对端,变成三次包交互。
其实这个行为跟应用层有比较大的关系,因为发送 FIN 包以后,会进入半关闭(half-close)状态,表示自己不会再给对方发送数据了。因此如果服务端收到客户端发送的 FIN 包以后,只能表示客户端不会再给自己发送数据了,但是服务端这个时候是可以给客户端发送数据的。
在这种情况下,如果不及时发送 ACK 包,死等服务端这边发送数据,可能会造成客户端不必要的重发 FIN 包,如下图所示。
如果服务端确定没有什么数据需要发给客户端,那么当然是可以把 FIN 和 ACK 合并成一个包,四次挥手的过程就成了三次。
其实理论上完全是可以的,把三次握手的第二次的 SYN+ACK 拆成先回 ACK 包,再发 SYN 包就变成了「四次握手」
与 FIN 包不同的是,一般情况下,SYN 包都不携带数据,收到客户端的 SYN 包以后不用等待,可以立马回复 SYN+ACK,四次握手理论上可行,但是现实中我还没有见过。
前面介绍的都是一端收到了对端的 FIN,然后回复 ACK,随后发送自己的 FIN,等待对端的 ACK。TCP 是全双工的,当然可以两端同时发起 FIN 包。如下图所示
以客户端为例:
FIN
包,等待对端对这个 FIN 包的 ACK,随后进入 FIN-WAIT-1
状态FIN-WAIT-1
状态的客户端还没有等到 ACK,收到了服务端发过来的 FIN 包CLOSING
状态CLOSING
状态的客户端终于等到了ACK,随后进入TIME-WAIT
TIME-WAIT
状态持续 2*MSL,进入CLOSED
状态Timestamps 选项的提出初衷是为了解决两个问题:
1、两端往返时延测量(RTTM)
2、序列号回绕(PAWS),接下来我们来进行介绍。
CLOSING
状态在「同时关闭」的情况下出现。这里的同时关闭中的「同时」其实并不是时间意义上的同时,而是指的是在发送 FIN 包还未收到确认之前,收到了对端的 FIN 的情况。
TCP 要求这样的四元组必须是唯一的,但大多数操作系统的实现要求更加严格,只要还有连接在使用这个本地端口,则本地端口不能被重用(bind 调用失败)
服务端主动断开连接以后,需要等 2 个 MSL 以后才最终释放这个连接,重启以后要绑定同一个端口,默认情况下,操作系统的实现都会阻止新的监听套接字绑定到这个端口上。
启用 SO_REUSEADDR 套接字选项可以解除这个限制,默认情况下这个值都为 0,表示关闭。但是为了保险起见,写 TCP、HTTP 服务一定要主动设置这个参数为 1。
默认情况下,一个 IP、端口组合只能被一个套接字绑定,Linux 内核从 3.9 版本开始引入一个新的 socket 选项 SO_REUSEPORT,又称为 port sharding,允许多个套接字监听同一个IP 和端口组合。
为了充分发挥多核 CPU 的性能,多进程的处理网络请求主要有下面两种方式
当一个新请求到来,内核是如何确定应该由哪个 LISTEN socket 来处理?接下来我们来看 SO_REUSEPORT 底层实现原理,
内核为处于 LISTEN 状态的 socket 分配了大小为 32 哈希桶。监听的端口号经过哈希算法运算打散到这些哈希桶中,相同哈希的端口采用拉链法解决冲突。当收到客户端的 SYN 握手报文以后,会根据目标端口号的哈希值计算出哈希冲突链表,然后遍历这条哈希链表得到最匹配的得分最高的 Socket。对于使用 SO_REUSEPORT 选项的 socket,可能会有多个 socket 得分最高,这个时候经过随机算法选择一个进行处理。
Linux 内核在 4.5 和 4.6 版本中分别为 UDP 和 TCP 引入了 SO_REUSEPORT group
的概念,在查找匹配的 socket 时,就不用遍历整条冲突链,对于设置了 SO_REUSEPORT 选项的 socket 经过二次哈希找到对应的 SO_REUSEPORT group,从中随机选择一个进行处理。
SO_REUSEPORT 带来了两个明显的好处:
内核级的负载均衡在前面的 Nginx 的例子中已经介绍过了,这里不再赘述。使用 SO_REUSEPORT 做滚动升级的过程如下图所示。
步骤如下所示。
这篇文章主要介绍了 SO_LINGER 套接字选项对关闭套接字的影响。默认行为下是调用 close 立即返回,但是如果有数据残留在套接字发送缓冲区中,系统将试着把这些数据发送给对端,SO_LINGER 可以改变这个默认设置,具体的规则见下面的思维导图。
只有主动断开的那一方才会进入 TIME_WAIT 状态,且会在那个状态持续 2 个 MSL(Max Segment Lifetime)。
MSL(报文最大生存时间)是 TCP 报文在网络中的最大生存时间。这个值与 IP 报文头的 TTL 字段有密切的关系。
IP 报文头中有一个 8 位的存活时间字段(Time to live, TTL)如下图。 这个存活时间存储的不是具体的时间,而是一个 IP 报文最大可经过的路由数,每经过一个路由器,TTL 减 1,当 TTL 减到 0 时这个 IP 报文会被丢弃。
从上面可以看到 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 是 TCP 字发生错误时发送的一种分节,下面我们来介绍 RST 包出现常见的几种情况,方便你以后遇到 RST 包以后有一些思路。
在 TCP 协议中 RST 表示复位,用来异常的关闭连接,发送 RST 关闭连接时,不必等缓冲区的数据都发送出去,直接丢弃缓冲区中的数据,连接释放进入CLOSED
状态。而接收端收到 RST 段后,也不需要发送 ACK 确认。
一般常见场景:
端口未监听
一方突然断电重启,之前建立的连接信息丢失,另一方并不知道
调用 close 函数,设置了 SO_LINGER 为 true
首先需要明确 RST 是不需要确认的。 下面假定是服务端发出 RST。
RST 发送方会进入 CLOSED 状态,之后接收方因为未接收到 RST 报文可能会发送一次或者重试几次数据报文。RST 发送方收到数据报文,会直接回复RST报文。如下如所示:
Connection reset by peer 这个错误很好理解,前面介绍了很多 RST 出现的场景。
Broken pipe
出现的时机是:在一个 RST 的套接字继续写数据,就会出现Broken pipe
。
当一个进程向某个已收到 RST 的套接字执行写操作时,内核向该进程发送一个 SIGPIPE 信号。该信号的默认行为是终止进程,因此进程一般会捕获这个信号进行处理。不论该进程是捕获了该信号并从其信号处理函数返回,还是简单地忽略该信号,写操作都将返回 EPIPE 错误(也就Broken pipe 错误),这也是 Broken pipe 只在写操作中出现的原因。
网络协议栈被包含在内核中无法直接交换, 应用层只能通过read/write与内核交互来感知TCP的状态变化。
文章一开始我们介绍了重传的时间间隔,要等几百毫秒才会进行第一次重传。聪明的网络协议设计者们想到了一种方法:「快速重传」 快速重传的含义是:当发送端收到 3 个或以上重复 ACK,就意识到之前发的包可能丢了,于是马上进行重传,不用傻傻的等到超时再重传。
这个有一个问题,发送 3、4、5 包收到的全部是 ACK=1001,快速重传解决了一个问题: 需要重传。因为除了 2 号包,3、4、5 包也有可能丢失,那到底是只重传数据包 2 还是重传 2、3、4、5 所有包呢?
聪明的网络协议设计者,想到了一个好办法
这样发送端就清楚知道只用重传 2 号数据包就可以了,数据包 3、4、5已经确认无误被对端收到。这种方式被称为 SACK(Selective Acknowledgment)。
如果从 socket 的角度来看TCP,是下面这样的
TCP 会把要发送的数据放入发送缓冲区(Send Buffer),接收到的数据放入接收缓冲区(Receive Buffer),应用程序会不停的读取接收缓冲区的内容进行处理。流量控制做的事情就是,如果接收缓冲区已满,发送端应该停止发送数据。那发送端怎么知道接收端缓冲区是否已满呢?
为了控制发送端的速率,接收端会告知客户端自己接收窗口(rwnd),也就是接收缓冲区中空闲的部分。
TCP 在收到数据包回复的 ACK 包里会带上自己接收窗口的大小,接收端需要根据这个值调整自己的发送策略。
从 TCP 角度而言,数据包的状态可以分为如下图的四种:
发送窗口:是 TCP 滑动窗口的核心概念,它表示了在某个时刻一端能拥有的最大未确认的数据包大小(最大在途数据),发送窗口是发送端被允许发送的最大数据包大小,其大小等于上图中 #2 区域和 #3 区域加起来的总大小。
可用窗口:是发送端还能发送的最大数据包大小,它等于发送窗口的大小减去在途数据包大小,是发送端还能发送的最大数据包大小,对应于上图中的 #3 号区域。
窗口的左边界表示成功发送并已经被接收方确认的最大字节序号,窗口的右边界是发送方当前可以发送的最大字节序号,滑动窗口的大小等于右边界减去左边界。
当上图中的可用区域的6个字节(46~51)发送出去,可用窗口区域减小到 0,这个时候除非收到接收端的 ACK 数据,否则发送端将不能发送数据。
这两者都是发送速率控制的手段,
前面的文章介绍了 TCP 利用滑动窗口来做流量控制,但流量控制这种机制确实可以防止发送端向接收端过多的发送数据,但是它只关注了发送端和接收端自身的状况,而没有考虑整个网络的通信状况。于是出现了我们今天要讲的拥塞处理。
拥塞处理主要涉及到下面这几个算法
为了实现上面的算法,TCP 的每条连接都有两个核心状态值:
简单来讲 nagle 算法讲的是减少发送端频繁的发送小包给对方。
Nagle 算法要求,当一个 TCP 连接中有在传数据(已经发出但还未确认的数据)时,小于 MSS 的报文段就不能被发送,直到所有的在传数据都收到了 ACK。同时收到 ACK 后,TCP 还不会马上就发送数据,会收集小包合并一起发送。网上有人想象的把 Nagle 算法说成是「hold 住哥」,我觉得特别形象。
首先必须明确两个观点:
如果收到一个数据包以后暂时没有数据要分给对端,它可以等一段时间(Linux 上是 40ms)再确认。如果这段时间刚好有数据要传给对端,ACK 就可以随着数据一起发出去了。如果超过时间还没有数据要发送,也发送 ACK,以免对端以为丢包了。这种方式成为「延迟确认」。
这个原因跟 Nagle 算法其实一样,回复一个空的 ACK 太浪费了。
这种机制被称为延迟确认(delayed ack)。TCP 要求 ACK 延迟的时延必须小于500ms,一般操作系统实现都不会超过200ms。
延迟确认在很多 linux 机器上是没有办法关闭的,
那么这里涉及的就是一个非常根本的问题:「收到数据包以后什么时候该回复 ACK」
可以看到需要立马回复 ACK 的场景有:
其它情况一律使用延迟确认的方式
一个 TCP 连接上,如果通信双方都不向对方发送数据,那么 TCP 连接就不会有任何数据交换。这就是我们今天要讲的 TCP keepalive 机制的由来。
永远记住 TCP 不是轮询的协议
半打开 half open:
这一个情况就是如果在未告知另一端的情况下通信的一端关闭或终止连接,那么就认为该条TCP连接处于半打开状态。 这种情况发现在通信的一方的主机崩溃、电源断掉的情况下。 只要不尝试通过半开连接来传输数据,正常工作的一端将不会检测出另外一端已经崩溃。
解析: IP 头里有一个“协议”字段,指出在上层使用的协议,比如值为 6 表示数据交给 TCP、值为 17 表示数据交给 UDP
解析:应用程序使用自己约定的规则来表示消息的边界,比如有一些使用回车+换行("\r\n"),比如 Redis 的通信协议(RESP protocol)
答案:D,500+300+200
客户端的使用 ISN=2000 打开一个连接,服务器端使用 ISN=3000 打开一个连接,经过 3 次握手建立连接。连接建立起来以后,假定客户端向服务器发送一段数据 Welcome the server!(长度 20 Bytes),而服务器的回答数据 Thank you!(长度 10 Bytes ),试画出三次握手和数据传输阶段报文段序列号、确认号的情况。
TCP/IP 协议中,MSS 和 MTU 分别工作在哪一层?
参考:MSS->传输层,MTU:链路层
参考:1500(MTU) - 20(IP 头大小) - 20(TCP 头大小)= 1460
参考:B,保留端口号
参考:D,小于 1024 的端口号是熟知端口号
参考:A
参考:A,收到了 SYN,发送 SYN+ACK 以后的状态,完整转换图见文章
参考:B,服务端发送 SYN+ACK
参考:C,详细的状态切换图看文章
参考:C,SYN-RCVD 出现在被动打开方服务端,排除A、B,TIME-WAIT 出现在主动断开方客户端,排除 D
参考:C,建连与 2*ML 没有关系,排除 A、D,服务端在收到 SYN 包且发出去 SYN+ACK 以后 进入 SYN_RCVD 状态,排除 B。如果客户端给服务端的 ACK 丢失,客户端进入 ESTABLISHED 状态时,服务端仍然处于 SYN_RCVD 状态。
参考:B、C 参考四次挥手的内容
参考:B 明显错误,TIME_WAIT 不是挥手阶段的状态。A、C、D都正确
参考:系统可用端口号的范围:65536 - 1024 = 64512,主动关闭方会保持 TIME_WAIT 时间 2*MSL = 120s,那最大的速率是:64512 / 120 = 537.6
参考:过程如下表所示。
次数 | 拥塞窗口 | 描述 | 备注 |
---|---|---|---|
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 | 拥塞避免,线性增加 |
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。