计算机网络笔记-4(TCP协议详解)
TCP头部
TCP头部结构如图所示
-
16位端口号, 存着源和目的端口。进行TCP通信时,客户端使用系统自动选择的临时端口号,服务器使用知名服务端口号(所有知名服务使用的端口号都定义在/etc/services中)。
-
32位序号,一个传输方向上的字节流的每个字节的编号,初始序号是由系统初始化的随机值ISN(Initial Sequence Number)。注意,一个TCP报文段包含一段数据,它的序号值是第一个字节的序号值。
-
32位确认号,如果A和B在进行TCP通信,A发送出的TCP报文段不仅携带自身序号,还包含对B送来的TCP报文段的确认号。确认号的值是收到的TCP报文段的序号值加一。
-
4位头部长度,标识着该TCP头部有多少个4字节,TCP头部最多15 * 4字节。
-
6位标志位包含如下几项:
- URG标志, 表示紧急指针是否有效。
- ACK标志,表示确认号是否有效。称携带ACK标志的TCP报文段为确认报文段。
- PSH标志,表示接收端应用程序应该立即从TCP接收缓存区读走数据,为后续数据腾出空间(如果不读走,会一直停留在TCP接收缓存区)
- RST标志,表示要求对方重新建立连接。称携带RST标志的TCP报文段为复位报文段。
- SYN标志,表示请求建立一个连接。称为同步报文段。
- FIN标志,表示通知对方本端要关闭连接了。称为结束报文段。
-
16位窗口大小,用于流量控制,这里的窗口是接收窗口(Receiver Window,RWND)。用于告诉对方,本端的接收缓存区还能容纳多少字节的数据,这样对方可以控制发送数据的速度。
-
16位校验和,发送端填充,接收端用CRC算法进行检验(不仅检验头部,还检验数据)。
-
16位紧急指针,是一个偏移量,该报文段序号值加上这个偏移量表示一个紧急数据的序号,用于发送端向接收端发送紧急数据。
以上是固定字段,占20字节,后面40字节是选项字段
TCP三次握手
TCP三次握手过程如图所示
三次握手其实就是指建立一个TCP连接时,需要客户端和服务器总共发送3个包。进行三次握手的主要作用就是为了确认双方的接收能力和发送能力是否正常、指定自己的初始化序列号为后面的可靠性传送做准备。实质上其实就是连接服务器指定端口,建立TCP连接,并同步连接双方的序列号和确认号,交换TCP窗口大小信息。
假设初始时客户端处于Closed状态,服务器处于Listen状态,三次握手详细过程如下:
-
第一次握手:客户端给服务端发一个 SYN 报文,并指明客户端的初始化序列号 ISN。此时客户端处于 SYN_SENT 状态。
首部的同步位SYN=1,初始序号seq=x,SYN=1的报文段不能携带数据,但要消耗掉一个序号。
-
第二次握手:服务器收到客户端的 SYN 报文之后,会以自己的 SYN 报文作为应答,并且也是指定了自己的初始化序列号 ISN(s)。同时会把客户端的 ISN + 1 作为ACK 的值,表示自己已经收到了客户端的 SYN,此时服务器处于 SYN_RCVD 的状态。
在确认报文段中SYN=1,ACK=1,确认号ack=x+1,初始序号seq=y。
-
第三次握手:客户端收到 SYN 报文之后,会发送一个 ACK 报文,当然,也是一样把服务器的 ISN + 1 作为 ACK 的值,表示已经收到了服务端的 SYN 报文,此时客户端处于 ESTABLISHED 状态。服务器收到 ACK 报文之后,也处于 ESTABLISHED 状态,此时,双方已建立起了连接。
确认报文段ACK=1,确认号ack=y+1,序号seq=x+1(初始为seq=x,第二个报文段所以要+1),ACK报文段可以携带数据,不携带数据则不消耗序号。
发送第一个SYN的一端将执行主动打开(active open),接收这个SYN并发回下一个SYN的另一端执行被动打开(passive open)。
在socket编程中,客户端执行connect()时,将触发三次握手。
“三次”握手的原因
每次握手的目的如下:
- 第一次握手:客户端发送网络包,服务端收到了。
这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。 - 第二次握手:服务端发包,客户端收到了。
这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。不过此时服务器并不能确认客户端的接收能力是否正常。 - 第三次握手:客户端发包,服务端收到了。
这样服务端就能得出结论:客户端的接收、发送能力正常,服务器自己的发送、接收能力也正常。
因此,需要三次握手才能确认双方的接收与发送能力是否正常。
半连接队列
服务器第一次收到客户端的 SYN 之后,就会处于 SYN_RCVD 状态,此时双方还没有完全建立其连接,服务器会把此种状态下请求连接放在一个队列里,我们把这种队列称之为半连接队列。
当然还有一个全连接队列,就是已经完成三次握手,建立起连接的就会放在全连接队列中。如果队列满了就有可能会出现丢包现象。
关于SYN-ACK 重传次数的问题:
服务器发送完SYN-ACK包,如果未收到客户确认包,服务器进行首次重传,等待一段时间仍未收到客户确认包,进行第二次重传。如果重传次数超过系统规定的最大重传次数,系统将该连接信息从半连接队列中删除。
注意,每次重传等待的时间不一定相同,一般会是指数增长,例如间隔时间为 1s,2s,4s,8s…
ISN
当一端为建立连接而发送它的SYN时,它为连接选择一个初始序号。ISN随时间而变化,因此每个连接都将具有不同的ISN。ISN可以看作是一个32比特的计数器,每4ms加1 。这样选择序号的目的在于防止在网络中被延迟的分组在以后又被传送,而导致某个连接的一方对它做错误的解释。
三次握手的其中一个重要功能是客户端和服务端交换 ISN(Initial Sequence Number),以便让对方知道接下来接收数据的时候如何按序列号组装数据。如果 ISN 是固定的,攻击者很容易猜出后续的确认号,因此 ISN 是动态生成的。
携带数据
第三次握手可以携带数据的。但是第一次、第二次握手不可以携带数据
假如第一次握手可以携带数据的话,如果有人要恶意攻击服务器,那他每次都在第一次握手中的 SYN 报文中放入大量的数据。因为攻击者根本就不理服务器的接收、发送能力是否正常,然后疯狂着重复发 SYN 报文的话,这会让服务器花费很多时间、内存空间来接收这些报文。
SYN攻击
服务器端的资源分配是在二次握手时分配的,而客户端的资源是在完成三次握手时分配的,所以服务器容易受到SYN洪泛攻击。
SYN攻击就是Client在短时间内伪造大量不存在的IP地址,并向Server不断地发送SYN包,Server则回复确认包,并等待Client确认,由于源地址不存在,因此Server需要不断重发直至超时,这些伪造的SYN包将长时间占用未连接队列,导致正常的SYN请求因为队列满而被丢弃,从而引起网络拥塞甚至系统瘫痪。SYN 攻击是一种典型的 DoS/DDoS 攻击。
检测 SYN 攻击非常的方便,当你在服务器上看到大量的半连接状态时,特别是源IP地址是随机的,基本上可以断定这是一次SYN攻击。在 Linux/Unix 上可以使用系统自带的 netstat 命令来检测 SYN 攻击。
netstat -n -p TCP | grep SYN_RECV
常见的防御 SYN 攻击的方法有如下几种:
- 缩短超时(SYN Timeout)时间
- 增加最大半连接数
- 过滤网关防护
- SYN cookies技术
TCP四次挥手
TCP四次挥手过程如图所示
建立一个连接需要三次握手,而终止一个连接要经过四次挥手。这是由TCP的半关闭(half-close)造成的。所谓的半关闭,其实就是TCP提供了连接的一端在结束它的发送后还能接收来自另一端数据的能力。
客户端或服务端均可主动发起挥手动作。刚开始双方都处于ESTABLISHED 状态,假如是客户端先发起关闭请求。四次挥手的过程如下:
-
第一次挥手:客户端发送一个 FIN 报文,报文中会指定一个序列号。此时客户端处于 FIN_WAIT1 状态。
即发出连接释放报文段(FIN=1,序号seq=u),并停止再发送数据,主动关闭TCP连接,进入FIN_WAIT1(终止等待1)状态,等待服务端的确认。
-
第二次挥手:服务端收到 FIN 之后,会发送 ACK 报文,且把客户端的序列号值 +1 作为 ACK 报文的序列号值,表明已经收到客户端的报文了,此时服务端处于 CLOSE_WAIT 状态。
即服务端收到连接释放报文段后即发出确认报文段(ACK=1,确认号ack=u+1,序号seq=v),服务端进入CLOSE_WAIT(关闭等待)状态,此时的TCP处于半关闭状态,客户端到服务端的连接释放。客户端收到服务端的确认后,进入FIN_WAIT2(终止等待2)状态,等待服务端发出的连接释放报文段。
-
第三次挥手:如果服务端也想断开连接了,和客户端的第一次挥手一样,发给 FIN 报文,且指定一个序列号。此时服务端处于 LAST_ACK 的状态。
即服务端没有要向客户端发出的数据,服务端发出连接释放报文段(FIN=1,ACK=1,序号seq=w,确认号ack=u+1),服务端进入LAST_ACK(最后确认)状态,等待客户端的确认。
-
第四次挥手:客户端收到 FIN 之后,一样发送一个 ACK 报文作为应答,且把服务端的序列号值 +1 作为自己 ACK 报文的序列号值,此时客户端处于 TIME_WAIT 状态。需要过一阵子以确保服务端收到自己的 ACK 报文之后才会进入 CLOSED 状态,服务端收到 ACK 报文之后,就处于关闭连接了,处于 CLOSED 状态。
即客户端收到服务端的连接释放报文段后,对此发出确认报文段(ACK=1,seq=u+1,ack=w+1),客户端进入TIME_WAIT(时间等待)状态。此时TCP未释放掉,需要经过时间等待计时器设置的时间2MSL后,客户端才进入CLOSED状态。
收到一个FIN只意味着在这一方向上没有数据流动。客户端执行主动关闭并进入TIME_WAIT是正常的,服务端通常执行被动关闭,不会进入TIME_WAIT状态。
在socket编程中,任何一方执行close()操作即可产生挥手操作。
“四次”挥手的原因
当服务端收到客户端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当服务端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉客户端,“你发的FIN报文我收到了”。只有等到我服务端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四次挥手。
TIME_WAIT状态
每个具体TCP实现必须选择一个报文段最大生存时间MSL(Maximum Segment Lifetime),它是任何报文段被丢弃前在网络内的最长时间。这个时间是有限的,因为TCP报文段以IP数据报在网络内传输,而IP数据报则有限制其生存时间的TTL字段。
对一个具体实现所给定的MSL值,处理的原则是:当TCP执行一个主动关闭,并发回最后一个ACK,该连接必须在TIME_WAIT状态停留的时间为2倍的MSL。这样可让TCP再次发送最后的ACK以防这个ACK丢失(另一端超时并重发最后的FIN)。
这种2MSL等待的另一个结果是这个TCP连接在2MSL等待期间,定义这个连接的插口(客户的IP地址和端口号,服务器的IP地址和端口号)不能再被使用。这个连接只能在2MSL结束后才能再被使用。
等待2MSL意义
为了保证客户端发送的最后一个ACK报文段能够到达服务器。因为这个ACK有可能丢失,从而导致处在LAST-ACK状态的服务器收不到对FIN-ACK的确认报文。服务器会超时重传这个FIN-ACK,接着客户端再重传一次确认,重新启动时间等待计时器。最后客户端和服务器都能正常的关闭。假设客户端不等待2MSL,而是在发送完ACK之后直接释放关闭,一但这个ACK丢失的话,服务器就无法正常的进入关闭连接状态。
两个理由:
-
保证客户端发送的最后一个ACK报文段能够到达服务端。
这个ACK报文段有可能丢失,使得处于LAST-ACK状态的B收不到对已发送的FIN+ACK报文段的确认,服务端超时重传FIN+ACK报文段,而客户端能在2MSL时间内收到这个重传的FIN+ACK报文段,接着客户端重传一次确认,重新启动2MSL计时器,最后客户端和服务端都进入到CLOSED状态,若客户端在TIME-WAIT状态不等待一段时间,而是发送完ACK报文段后立即释放连接,则无法收到服务端重传的FIN+ACK报文段,所以不会再发送一次确认报文段,则服务端无法正常进入到CLOSED状态。 -
防止“已失效的连接请求报文段”出现在本连接中。
客户端在发送完最后一个ACK报文段后,再经过2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。
TCP状态转移过程
TCP状态转移过程如图所示
粗虚线表示服务器端连接的状态转移,粗实线表示客户端连接的状态转移。
TCP可靠性
TCP使用序列号、确认应答、超时重传、窗口控制、拥塞控制等机制来保证可靠性。
序列号、确认应答、超时重传
数据到达接收方,接收方需要发出一个确认应答,表示已经收到该数据段,并且确认序号会说明了它下一次需要接收的数据序列号。如果发送发迟迟未收到确认应答,那么可能是发送的数据丢失,也可能是确认应答丢失,这时发送方在等待一定时间后会进行重传。
窗口控制与高速重发控制/快速重传(重复确认应答)
TCP会利用窗口控制来提高传输速度,意思是在一个窗口大小内,不用一定要等到应答才能发送下一段数据,窗口大小就是无需等待确认而可以继续发送数据的最大值。如果不使用窗口控制,每一个没收到确认应答的数据都要重发。
使用窗口控制,如果数据段1001-2000丢失,后面数据每次传输,确认应答都会不停地发送序号为1001的应答,表示我要接收1001开始的数据,发送端如果收到3次相同应答,就会立刻进行重发。
拥塞控制
如果把窗口定的很大,发送端连续发送大量的数据,可能会造成网络的拥堵甚至网络瘫痪。所以TCP在为了防止这种情况而进行了拥塞控制。
- 慢启动(慢开始):定义拥塞窗口(cwnd),一开始将该窗口大小设为1(一般设置大小为2-4个SMSS,发送者最大数据段大小),之后每次收到确认应答(经过一个rtt),将拥塞窗口大小*2。
- 拥塞避免:设置慢启动阈值(ssthresh),一般开始都设为65536(图中为16)。拥塞避免是指当拥塞窗口大小达到这个阈值,拥塞窗口的值不再指数上升,而是加法增加(每次确认应答/每个rtt,拥塞窗口大小+1),以此来避免拥塞。
将报文段的超时重传看做拥塞,则一旦发生超时重传,我们需要先将阈值设为当前窗口大小的一半,并且将窗口大小设为初值1,然后重新进入慢启动过程。 - 快速重传:在遇到3次重复确认应答(高速重发控制)时,代表收到了3个报文段,但是这之前的1个段丢失了,便对它进行立即重传。快速重传和快速恢复过程如下:
1、收到第3个重复的确认报文段时,改ssthresh,ssthresh = max(FlightSize / 2,2 * SMSS),立即重传丢失报文段,cwnd= ssthresh + 3 * SMSS
2、每次收到1个重复的确认报文段时,cwnd= cwnd+ SMSS。此时发送端可以发送新的TCP报文段(如果新的cwnd允许的话)。
3、收到新数据的确认时,设置cwnd= ssthresh(ssthresh是第一步计算得到的新的慢启动门限)。
这样可以达到:在TCP通信时,网络吞吐量呈现逐渐的上升,并且随着拥堵来降低吞吐量,再进入慢慢上升的过程,网络不会轻易的发生瘫痪。
本文提到的拥塞控制算法已经过时,现在普遍用Google的BBR算法。
BBR算法
BBR算法有两个特点:
- 反馈性差,比如Cubic搞了一个高大上的以三次方程凸凹曲线来抉择的增窗机制,但这个锯齿降的太猛,避免阻塞的策略过于保守(激烈的保守)。
- 拥塞算法被接管
在TCP拥塞控制机制发现丢包时(即RTO或者N次重复的ACK等),TCP会完全接管拥塞控制算法,自己控制拥塞窗口。然而问题是,这种所谓的丢包可能并不是真的丢包,这只是TCP认为丢包而已。
总的来讲,BBR之前的拥塞控制逻辑在执行过程中会分为两种阶段,即正常阶段和异常阶段。在正常阶段中,TCP模块化的拥塞控制算法主导窗口的调整,在异常阶段中,TCP核心的拥塞控制状态机从拥塞控制算法那里接管窗口的计算,逻辑如下:
static void tcp_cong_control(struct sock *sk, u32 ack, u32 acked_sacked, int flag)
{
if (tcp_in_cwnd_reduction(sk)) { // 异常模式
/* Reduce cwnd if state mandates */
// 在进入窗口下降逻辑之前,还需要tcp_fastretrans_alert来搜集异常信息并处理异常过程。
tcp_cwnd_reduction(sk, acked_sacked, flag);
} else if (tcp_may_raise_cwnd(sk, flag)) { // 正常模式或者安全的异常模式!
/* Advance cwnd if state allows */
tcp_cong_avoid(sk, ack, acked_sacked);
}
tcp_update_pacing_rate(sk);
}
BBR在异常模式不再让拥塞控制状态机进行接管:
static void tcp_cong_control(struct sock *sk, u32 ack, u32 acked_sacked, int flag, const struct rate_sample *rs)
{
const struct inet_connection_sock *icsk = inet_csk(sk);
// 这里是新逻辑,如果回调中宣称自己有能力解决任何拥塞问题,那么交给它
if (icsk->icsk_ca_ops->cong_control) {
icsk->icsk_ca_ops->cong_control(sk, rs);
// 直接return!TCP核心不再过问。
return;
}
// 这是老的逻辑。
if (tcp_in_cwnd_reduction(sk)) {
/* Reduce cwnd if state mandates */
// 如果不是Open状态...记住,tcp_cwnd_reduction并不受拥塞控制算法控制
tcp_cwnd_reduction(sk, acked_sacked, flag);
} else if (tcp_may_raise_cwnd(sk, flag)) {
/* Advance cwnd if state allows */
tcp_cong_avoid(sk, ack, acked_sacked);
}
tcp_update_pacing_rate(sk);
}
BBR不断采集连接内时间窗口内的最大带宽max-bw和最小RTT min-rtt,并以此计算发送速率和拥塞窗口,依据反馈的实际带宽bw和max-rtt调节增益系数。
BBR算法消除了不必要的锯齿。这种锯齿在BBR之前简直就是TCP的动力源,各种算法盲目地增窗,一旦TCP认为丢包发生(虽然可能并不是真的丢包。所以才有了各种越来越复杂的机制,比如DSACK之类的…),在留下一个ssthresh之后,所有逻辑均被接管,而这里就是锯齿的齿尖之所在。事实上,锯齿是由于TCP拥塞状态机控制逻辑和TCP拥塞控制算法之间在拥塞事件发生时“工作交接”而形成的,BBR算法中取消了这种不必要的交接,因此锯齿也自然变钝甚至磨平了。
不是Vegas,CUBIC等无法发现拥塞,是TCP并不将权力全权交给它们从而导致。这事实上可能是最初的TCP实现中的做法,比如ssthresh这个概念,事实上很多算法中并不需要。BBR没有使用ssthresh(ssthresh体现了拥塞算法与TCP拥塞状态机之间的耦合,BBR没有这种耦合,所以不需要ssthresh)。
三次握手四次挥手借鉴 猿人谷的文章