TCP是一个面向连接,可靠的,基于字节流的传输层通信协议,负责建立、维护、管理端到端连接。
TCP 数据格式
- Source Port / Destination Port:源端口和目标端口,分别用16位来存储。tcp头没有ip地址,这是网络层做的事情。
- Sequence Number:序列号,占16位,用来解决网络包乱序问题。假如一个报文段的序号是500,而数据有20个字节,这就表明这个报文段的第一个字节序号是500,最后一个字节的序号是519,下一个报文段的序号要从520开始。如果溢出了就回到0。
- Acknowledgement Number:确认号,占16位,用来表示期望收到下一个报文段的第一个字节的序号,用来解决丢包的问题。假如A给B发数据,序列号是500,长度为20个字节(500 ~ 519),如果B都收到了这20个字节,就会在发给A的报文段中确认号为520,表明下一个序号应该是520
- Offset:数据偏移,占4为位,表示 TCP 报文首部信息的长度,因为 TCP 首部可能会有选项内容,所以这个长度是不确定的。看图片右边的箭头就知道了,没有选项字段的话,TCP头部长度为20字节。
- Reserved:保留字段,占6位,值通常为0
- TCP Flags: 标志位,每个标志位表示一个控制功能,用来控制 TCP 的状态机。
- URG: 紧急指针,为1时表明当前报文段有紧急数据,优先发送
- ACK:确认序号(为0表示报文中不含确认信息忽略确认号字段,为1表示确认号有效)
- PSH:PSH为1时,数据会立刻发送,不用等待其他数据进入缓冲区
- RST:重建连接,让RST为1时,表示 TCP 连接中出现了严重差错(主机崩溃或其他原因),必须释放连接然后重连。
- SYN:同步序列号,用于建立连接过程
- FIN:用于释放一个连接,当FIN为1时,表示此报文段的发送方数据已发送完毕,并要求释放连接。
- Window:窗口,占16位,就是著名的滑动窗口,窗口是指发送方的接收窗口,这个要注意。窗口值就是告诉对方,不用等待我确认应答而可以继续发送的最大值,用来解决流控问题。
- Checksum:检验和,发送端计算,接收端验证,目的是为了发现TCP首部和数据在发送端到接收端之间发生的任何改动。如果接收方检测到校验和有差错,则TCP段会被直接丢弃。计算的内容:12字节的伪首部 + 首部 + 内容,伪首部的数据是从 IP 数据报头获取的。
- Urgent Pointer:紧急指针,占16位,只有当URG标志置1时紧急指针才有效,例如 Urgent Pointer = 3,则 TCP 数据中的前4个字节为 Urgent 数据
- TCP Options:TCP 选项,最长可达40个字节。
TCP建立连接(三次握手)
A是客户端,B是服务端
一开始 A 和 B 都处于 CLOSED 状态,B 调用
listen
系统接口进行监听,B处于 LISTEN 状态。A 调用
connect
方法向B发送请求报文,其中 TCP Flags (标志位)里的 SYN = 1,ACK = 0,选择一个初始序列 seq = 0,这时候 A 处于 SYN-SENT 状态。B 收到请求报文,向 A 发送连接确认报文,SYN=1,ACK=1,确认号 ack = x + 1,同时也选择了一个初始序号 seq = y,这时候 B 处于 SYN-RCVD 状态。
A 收到 B 的连接确认报文后,还要向 B 发出确认报文,标志位 ACK=1,确认号 ack = y + 1,序列号 seq = x + 1,这时候 A 处于 ESTABLISHED 状态。`
B 收到 ACK 之后,就处于 ESTABLISHED 状态。
为什么要3次握手?
初始序列号, 序列号在 TCP 连接中有非常重要的作用(解决乱序),因为初始序列号不是0或者固定的(如果是固定的攻击者就很容易猜到后续的确认号),所以初始是从 ISN(Inital Sequence Number)开始的,这是一个通过算法计算出来的随机值,所以双方都要确认对方的初始序列号。
防止错误或者过期的连接被服务器连接,有了第三次客户端的确认报文后,服务器就能判断哪些连接是要弃掉的。
3次握手就一定可靠吗?不一定,因为你不能确认最后一个确认报文服务端收到了,有兴趣可以看下两军问题。
SYN 超时
如果在第二次握手结束后,A掉线了会怎样?这时候这个连接就处于一个中间状态,既没成功也没失败。如果B在一定时间内没收到A的确认报文会重发自己的确认报文。在Linux默认重试5次,间隔为1s,2s,4s,8s,16s,如果还没收到就会断开这个连接。
SYN Flood 攻击
SYN Flood 属于 Dos 攻击的一种。就是向某个服务器端口发送大量的SYN包,然后下线。服务器会打开大量的半开连接,分配TCB(Transmission Control Block),从而把服务器的 syn 连接队列耗尽,同时也使得正常的连接也无法被响应。
接下来说一下防攻击的方法:
最粗暴的方法就是提高 TCP 端口连接容量的同时减少半开连接资源的占用时间。也可以减少半开状态下等待ACK消息的时间或者重发 SYN-ACK 消息的次数。但是这种只是权宜之计,还是会影响部分正常的连接请求。
消耗服务器资源的主要原因是因为当SYN数据报文一到达,系统立即分配TCB,从而占用了资源,因此,可以等到正常连接建立起来后再分配TCB就可以减轻服务器资源的消耗。常见的方法就是SYN Cache 和 SYN Cookie
SYN Cache 的原理是在收到一个SYN报文后,用一个 Hash Table 保存这种半连接信息,直到收到正常的ACK报文后再分配TCB。
SYN Cookies 是通过特定的算法把这种半开连接信息编码成 Cookie
,当作 Sequence Num 发给对方,这样不用保存任何信息,如果收到了对象的ACK信息,会重新计算一遍 Cookie
,从而决定是否分配TCB资源。这种方法也有缺点:因为不会保存这种半开的连接,就会丧失了重发SYN-ACK消息的能力,会部分降低正常用户的连接成功率。
TCP 断开连接(四次挥手)
假设 A 是客户端, B 是服务端
- A 主动断开连接,发送 FIN = 1 的报文之后,进入 FIN_WAIT_1 的状态。
- B 收到 A 的 FIN 报文之后,发送确认报文,就进入 CLOSE_WAIT 状态。这时候 A 不能给 B 发消息了,但是 B 还是可以向 A 发消息。
- A 收到 B 的 确认报文之后,就进入 FIN_WAIT_2 的状态。
- 当 B 也要断开连接时,发送 FIN = 1 的报文给 A,B 从 CLOSE_WAIT 的 状态进入 LAST-ACK 状态。
- 当 A 收到 B 的 FIN 报文之后,A 会发送确认报文,然后进入 TIME_WAIT 状态。这个状态持续 2MSL 的时间,MSL 是 Maximum Segment Lifetime,报文最大生存时间,协议规定 MSL 为2分钟,Linux设置为30s
- B 收到 A 的确认报文后,进入 CLOSED 状态。
为什么要四次挥手?
因为 TCP 是全双工的,所以发送方和接收方都需要 FIN 和 ACK。
为什么会有 TIME_WAIT 状态?
最主要有两个原因:保证连接关闭,让这个连接不会跟后面的连接混在一起
保证连接关闭
TIME_WAIT 其中的一个重要作用就是确保服务端收到其发 FIN 对应的 ACK 报文,不然的话如果服务端没收到 ACK,客户端这时候重新和服务端重新建立连接就会有问题,服务端会认为之前的连接是有效的,客户端重新发送 SYN
消息请求握手时会收到服务端的 RST
消息,连接建立的过程就会被终止。
连接混合问题
要考虑一种情况就是,服务端发送的消息,由于网络延迟直到TCP连接断开了客户端都没有收到,当客户端和服务器重新连接后,这个延迟的消息才发送到客户端,然而这个过期的消息客户端会认为是合理的,正常接收的话,那问题就比较严重了。
所以 TIME_WAIT 等待的时间一定要足够长,一来一去正好等待 2MSL
TCP滑动窗口
滑动窗口可以说是TCP最重要的知识点了,TCP在早期没有窗口的时候,是使用 send-wait-send
模型。就是发送方在发送数据之后,会等待收到ACK之后再继续发送,如果超时还没收到ACK,要进行重传。这样就一个缺点就是,如果包的往返时间越长,网络的吞吐量会越差。TCP 为了解决这个问题,引入了窗口这个概念。
TCP的滑动窗口协议是传输层进行流控的一种措施,接收方通过通告发送方自己的窗口大小,从而控制发送方的发送速度,从而达到防止发送方发送速度过快而导致自己被淹没的目的。
在 TCP 头部看到有 Window 这个字段,这个窗口大小就是 接收端告诉发送端自己还有多少缓冲区无需等待确认应答可以继续发送数据的大小。
TCP的滑动窗口主要有两个作用:
- TCP的可靠性
- TCP的流控
过程:
假设 A 向 B 发送数据, 总长度500,每个报文段是100
- 连接时 B 告诉 A,我的接收窗口 rwnd = 300
- A 向 B 发送一个报文,序号为 1~100,还能发送200个字节
- A 向 B 发送一个报文,序号为 101~200,还能发送100个字节
- A 向 B 发送一个报文,序号为201~300,还能发送0个字节
- B 收到了 A 的 第1
100 和 201300的数据,中间101200的报文丢失了,此时 B 会向 A 发送一个报文 ACK=101, rwnd=200(允许 A 发送 101300序号的数据) - A 超时重传
TCP滑动窗口基本原理
TCP 发送端的 Window 分为四个部分:
Sent and Acknowledged:发送成功并且收到ACK的数据
Sent But Not Yet Acknowledged:发送但没有收到ACK的数据,认为并没有完成发送
Not Sent,Recipient Ready to Receive:还没发送的数据,但是接收端还有空间接收
Not Sent,Recipient Not Ready to Receive:还没发送的数据,接收端没有空间接收
可以看到窗口大小时20字节(这个值在三次握手的时候进行传输的),对于发送方来说,滑动窗口是黑框的部分(已发送和可用窗口),当发送方收到 ACK = 36 时的窗口滑动
如果这时发送方继续发了5个字节,那么可用窗口就耗尽了,在接收到ACK之前无法继续发送数据。
TCP 接收端的 Window 分为三个部分:
- 接收并且确认过数据
- 未收到数据但是可以接收
- 未收到数据但是不能接收
Zero Window
如果窗口大小为0了,那么发送方就不会再发数据过来,直到收到ACK之后才会根据窗口大小继续发数据,但是如果这个ACK丢包了怎么办?为了防止这个问题,TCP 使用了 Zero Window Probe技术,简称ZWP,发送端在窗口变为0后,会发送ZWP的包给接收端,让接收端ACK它的窗口大小,这个值会设置成3次,第次大约30-60秒,如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。
糊涂窗口综合症
如果接收方太忙了,来不及取走接收窗口里的数据,那么发送方的窗口就会越来越小,等到接收方只有几个字节的窗口,发送方还是义无反顾地发送这几个字节,这就是糊涂窗口综合症。
因为 TCP + IP 头就有40个字节,为了传输这几个字节,有点太浪费了。
由上可知,糊涂窗口综合症的现象可以发生在发送方和接收方:
- 接收端ACK一个小的窗口
- 发送端发送小数据
所以,只有解决上面两个问题就可以了:
- 接收端不ACK小窗口
如果收到的数据导致窗口大小小于某个值,可以直接ACK 0 给发送端,这样就可以把窗口关闭阻止发送端再发数据过来。
- 发送端避免发送小数据
使用 Nagle 算法,这个算法的思路是延时处理,它满足以下两个条件中的一条才可以发送数据:
- 等到窗口大小 >= MSS 或者 数据大小 >= MSS
- 收到ACK
MTU (Maximum Transmission Unit)最大传输单元,指的是IP数据报能经过一个物理网络的最大报文长度,一般为1500字节
MSS (Maximum Segment Size)TCP报文的最长报文长度,不包括TCP首部长度,一般来说,MSS = MTU - IP首部大小 - TCP首部大小
Nagle 算法默认是打开的,而且它并不是禁止小包发送,只是禁止了大量的小包发送。
TCP 的重传机制
在复杂的网络环境中,数据包是有可能丢的,TCP要保证所有包都可以到达,所以需要一个重传机制。
常见的重传机制:
- 超时重传
- 快速重传
- SACK
- D-SACK
超时重传
如果发送端认为发生了丢包现象(在一段时间内没有收到ACK),会重发这些数据包。有一种可能是这个数据包没有丢,只有绕了一段远路,TCP是传输层,并不知道数据在链路和物理层发生了什么。但是重发并不影响,因为接收端会自动忽略重复的包。
怎么才算超时呢?最简单的方法就是设为一个固定值,但是如果两个端距离比较远的话,可能会导致大量的数据包被重发。TCP会根据网络延迟动态调整超时时间的算法。
先来了解两个概念:
RTT(Round Trip Time): 往返时间,就是数据包从发出去到接收到ACK的时间。
RTO(Retransmission Time Out):重传超时,也就是超时时间
RTO 的值是非常重要的,如果太大会导致丢了半天才重发,效率低下。如果太小会导致还没丢就重发,浪费资源,增加网络拥塞。
在Linux中,最开始是使用比较简单的经典算法 RFC793,后来1988年提出了新的算法RFC6298。
快速重传
快速重传机制是基于接收端返回的数据来驱动,而不是时间。
例如发送端发出了 1,2,3,4,5份数据:
- 1先到了,ACK 2
- 2 可能因为某些原因没到,3 先到了,回的ACK还是2
- 后面的 4,5也到了,2还是没收,ACK2
- 发送端收到了 三个 ACK = 2 的数据,就会重传2,不需要等待计时器超时
- 接收端收到了2,因为之前的3,4,5都到了,所以 ACK 6
快速重传机制只解决了超时时间的问题,还有一个问题就是,到底该重传多少个包?在上面的例子中,应该重传2还是重传2,3,4,5呢?
SACK
SACK(Selective Acknowledgment):带选择确认的重传,这种方式是在TCP头部 options
字段加一个 SACK
的信息,就是最近收到的报文段的序列号范围,这样客户端就知道,哪些数据包已经到达服务器了。
例如:
- 发送端发送 100~199 的数据,接收端收到 ACK 200
- 发送端发送 200 ~ 299 的数据
- 发送端发送 300 ~ 399 的数据
- 这时候接收端收到了 300 ~ 399 的数据,200 ~ 299 没收到,会回 ACK 200,SACK 300-400
- 发送端发送 400 ~ 499 的数据,再发送 500 ~ 599 的数据
- 接收端 收到了 500 ~ 599 的数据,没收到 400 ~ 499 的,还有之前的 200 ~ 299 也还没收到,这时候会回 ACK 200,SACK 300-400, 500-600
这样发送端就可以根据 SACK
信息知道哪些数据到了,哪些没到。不过 SACK 有一个坑,接收方有可能把这些乱序的数据包删掉之后,再通知发送方。因为这个操作,发送端不能完全依赖SACK,还是要依赖ACK,重传计时器。
D-SACK
D-SACK(Duplicate-SACK):重复 SACK,这个机制是在 SACK 的基础上,加点额外信息,告诉发送方哪些数据自己重复接收了。
Example 1:
1 | Transmitted Received ACK Sent |
如上例所示,发送端 发送了 3000-3499 和 3500-3999 两段报文,接收端接收到了并发送ACK,但是这两个报文的ACK都丢了,发送端会以为丢数据包了,重发 3000-3499,这时候接收端发现数据重复接收,回了一个 ACK=4000,SACK = 3000-3500,因为ACK已经是4000了意味着收到了4000之前的数据,所以这个SACK就是D-SACK,告诉发送端收到了重复数据,这时候发送方就知道了,数据包没丢,丢的是ACK包。
下面的例子看文字应该都可以理解
Example 2:
1 | Transmitted Received ACK Sent |
Example 3:
1 | Transmitted Received ACK Sent |
Example 4:
1 | Transmitted Received ACK Sent |
Example 5:
1 | Transmitted Received ACK Sent |
拥塞控制
有了 TCP 的窗口控制,收发主机之间即使不再以一个数据段为单位发送确认应答,也能够连续发送大量数据包。然而,如果在通信刚开始时就发送大量数据,也可能会引发其他问题。
一般来说,计算机网络都处在一个共享的环境。因此也有可能会因为其他主机之间的通信使得网络拥堵。在网络出现拥堵时,如果突然发送一个较大量的数据,极有可能会导致整个网络的瘫痪。
拥塞控制主要是四个算法:
- 慢启动
- 拥塞避免
- 拥塞发生
- 快速恢复
慢启动(Slow Start)
慢启动为发送方的TCP增加了另一个窗口:拥塞窗口(congestion window),记为cwnd。在慢启动的时候,拥塞窗口被初始化为1个报文段(1MSS)发送数据,每收到一次ACK,拥塞窗口的值就加1。在发送数据包时,数据大小=min(rwnd, cwnd)。拥塞窗口时发送方使用的流量控制,而滑动窗口则是接收方使用的流量控制。
随着包的每次往返,拥塞窗口也会以1,2,4等指数函数的增长,当然cwnd不能一直这样无限增长下去,所以有一个慢启动阈值 ssthresh(slow start threshold),当cwnd超过该值之后,慢启动过程结束,进入拥塞避免阶段。ssthresh 的值是65536字节
拥塞避免(Congestion Avoidance)
拥塞避免的主要思想是加法增大, 也就是cwnd的值不再指数级上升,开始加法增加。当收到一个ACK时,cwnd = cwnd + 1 / cwnd,当每过一个RTT时,cwnd = cwnd + 1,这样就可以避免增长过快导致网络拥塞。
拥塞发生
上面说的两种机制都是没有检测到拥塞的情况下的行为,那么当发生了拥塞cwnd会怎么调整呢?
首先来看TCP是如何判断网络进入了拥塞状态的,TCP认为如果重传了一个报文段就是网络拥塞,之前提到过RTO超时时间,当发送一个报文超时了还没收到ACK,TCP会进行重传,这时候TCP的反应会比较强烈:
- 把 ssthresh 设置为 cwnd 值的一半
- cwnd重新设置为1
- 重新进入慢启动过程
TCP还有一种情况会进行重传,回顾一下快速重传,就是收到3个相同的ACK,这时后TCP做的事情:
- 把 ssthresh 设置为 cwnd 值的一半
- 把 cwnd 设置为 ssthresh 的值
- 进入快速恢复阶段
ps:快速恢复算法还没出来之前,第3步是重新进入拥塞避免阶段。
快速恢复
- 当收到3个重复ACK时,把 ssthresh 设置为 cwnd 的一般
- 把 cwnd 的值设置为 ssthresh + 3
- 再收到重复的ACK时,cwnd加1
- 收到新的数据包的ACK时,cwnd = ssthresh ,说明从重复ACK时的数据都已经收到,恢复过程结束, 再次进入拥塞避免状态。
“粘包”问题
有些人说是因为 Nagle
算法才会出现“粘包”问题是不太准确的,“粘包” 并不是 TCP 的问题!
因为TCP是基于字节流而不是数据包的协议,TCP只会把你的数据转成字节流发送出去,保证顺序问题问题,至于怎么才算是一个完整的数据是应用层协议设计的。
既然 TCP 协议是基于字节流的,所以要怎么拆分和组合数据其实是应用层协议要自己设计的。
最常用的两个解决方案是基于消息长度和基于终结符
基于消息长度
基于长度的话就需要在应用层的协议头表示这个消息的长度是多少,HTTP 也有用到这种方式,用 Content-Length
来表示消息长度,这样接收端就能根据接收到的字节数来重组完整的 HTTP 数据包。
基于终结符
在一个完整的消息后面加上 CRLF
或者自定义终结符,这样接收端在收到终结符的时候就知道终结符之前的数据是一个完整的数据包。
TCP 的协议先写到这里,TCP 协议是巨复杂的一个东西,这里只是冰山一角的一角,以后可能还会继续补充。