oynix

于无声处听惊雷,于无色处见繁花

TCP介绍

TCP,Transmission Control Protocol,传输控制协议,是众多网络协议中较为重要的一个协议,网上多是写三次握手和四次挥手的,一搜就能看到一片又一片,多数都是CV战士的杰作,CV战士绝不认输,所以这篇文章来全面的说说TCP。

1. 引言

如果抛开复杂的协议,只看网络的话,网络传输的就是二进制数据流,也就是一串,或者一长串0011、0101的数据。网络的目的是为了通信、为了交流,这些数据流的通信方式就是,发送方和接收方约定好每个位置的0或1代表着什么,这样接收方收到数据时,就知道发送方想要表达的意思了。但是,一个发送方不可能只给一个接收方发送数据,接收方也不能只接收一个发送方,而且发送方也不总是发送方,也有可能接收数据,因此,为了方便发送和接收,不出现五花八门的约定,大家便统一了数据的格式,这就是协议(protocol)。同时,为了更好的适应不同的情形所产生的需求,各种各样的协议应用而生,数据格式只是协议中的一部分,除此之外,每种协议还规定了各自的发送、接收的特点等等诸多细节,而TCP,就是这众多协议中的一员,这是一个相当复杂的协议,作为一个可靠性连接协议,你不知道它为了能让你的数据正确且完整的到达接收端在背后付出了多大的努力。

2. 简介

数据在TCP层称为流(Stream),数据分组称为分段(Segment)。数据在IP层称为Datagram,数据分组称为分片(Fragment)。UDP中分组称为Message。

3. 运作方式

TCP协议的运行划分为三个阶段:连接创建(connection establishment)、数据传送(data transfer)和连接终止(connection termination)。操作系统将TCP连接抽象为套接字表示的本地端点(local end-point),作为编程接口给程序使用。在TCP连接的生命期内,本地端点要经历一系列的状态改变。

TCP在每一个要发送的数据包前面,都会添加一个数据头,这个数据头的长度是20个字节,长大概这个样子

一行代表32个bit,也就是4个字节。前4个字节分别存储源端口和目标端口,序列号seq和确认号ack各占4个字节,接下来的4个字节存储标识位和窗口大小,最后4个字节存储校验和和紧急指针。

3.1 创建通路

TCP用三次握手创建一个连接,通常是服务器打开一个套接字socket进行监听listen,等待被动打开,客户端连接服务器,主动打开。服务器执行listen之后,会创建两个队列:

  • SYN队列:存放完成两次握手的连接,长度由listen的参数backlog指定
  • ACCEPT队列:存放完成三次握手的连接,长度由listen的参数backlog指定

三次握手过程,下面就直接写Client和Server,不写客户端和服务端

  1. Client通过执行connect向Server发送一个SYN包,消息序列号seq为随机数A,请求主动打开
  2. Server收到一个合法的SYN包后,将该包放入SYN队列中,返回一个SYN/ACK,ACK为A+1,消息序列号seq为随机数B
  3. Client收到SYN/ACK包后,发送一个ACK为B+1的包,消息序列号seq为A+1。然后Client的connect函数返回成功。当Server收到这个ACK包后,把请求帧从SYN队列移出,放到ACCEPT中,如果accept函数处于阻塞状态,可以被唤醒来从ACCEPT中取出ACK包,重新创建一个新的用于双向通信的sockfd,并返回

三次握手的目的是为了防止已失效的连接请求报文段传送到了服务器,因而产生错误,也为了解决网络中存在延迟的重复分组。例如,Client发出的第一个SYN包没有丢失,而是在某个网络节点长时间的滞留了,以致延误到Client连接释放后的某个时间才到达Server。本来这是一个已失效的报文段,但Server收到后并不知已失效,而是当作Client的连接请求来处理,于是向Client发出确认报文段,同意创建连接。假设不采用三次握手,那么只要Server发出确认,新的连接就在Server创建了。由于Client并没有发出创建连接的请求,因此不会处理Server发过来的确认包,也就不会向Server发送数据。但是在Server端新的传输连接已经创建,并一直等待Client发来数据,这样便会一直占用Server的资源,造成浪费。采用三次握手可以防止这种情况的发生。

就像你去医院看病一样,你(Client)在大厅挂了一个号(SYN),等到医生(Server)拿到病人的号后,他需要对着等候区喊一声(ACK/SYN):253号病人在不在?你大声的回一声(ACK):在!这个时候医生才能给你看病。如果等医生喊的时候,你已经提前有事走了,那么就不用再在你身上花时间了,毕竟医生很忙。两次握手就像是医生拿到病人的号后喊了一声:253号病人该你了,就开始准备相关的资料等你来,但是你已经离开了,医生并不知情,就只能一直等,等一个等不来的人。而四次握手、五次握手,就相当于,在你回答了一声:在,之后医生又问:253号病人在不在?你说:在,医生又问:253号病人在不在?你接着说:在。可以是可以,但没必要。

插播:seq和ack

这里插播seq和ack的说明,seq以一个随机数开始,叫做ISN(之所以随机是为了克制TCP序号预测攻击),它是一个32位的无符号数。每个数据包在头信息中都会带着seq和ack,seq表示自己成功发送的数据量,而ack表示自己成功接收的有序字节的最大字节量。这里要用seq和ack减去ISN,这个时候才表示真实数据数量,这个在WireShark里叫做相对seq,relative sequence number。

这里强调有序是因为存在丢包的情况,比如发送方发了1,2,3,4,5个包,但是包3在路上丢了,所以接收方回复包4和包5的ack都是2。

seq表示自己成功发送的数据,所以每个包中都是有实际用处的。但是ack并不是在每个包中都会用到,比如建立连接时的第一个包,这个时候连接还未建立,所以谈客户端收到多少来自服务器的数据是没有意义的,因此在标识位用了一个bit来标识ack是否有效。

ack表示自己成功接收的数据量,每次收到对方的seq后,如果可以正确处理(这里要考虑异常情况,比如上面的丢包情况,这时候则不能正确处理,要等着丢失的包),则更新ack,表示已经成功接收这么多的数据,然后将ack确认回复给发送方。

seq表示自己成功发送的数据量,每次收到对方的ack后,就会更新自己的seq,表示这么多字节的数据已经成功发送。

3.2 握手异常情况

  1. 客户端第一个SYN包丢了
    这种情况下,服务器不知道客户端曾经发过包,在TCP协议中,某端的一组请求-应答中,在一定时间范围内,只要没有收到应答的ACK包,无论是请求包对方没有收到,还是对方的应答包自己没有收到,均认为是包丢了,都会触发超时重传机制。所以此时会进入重传SYN包,根据协议,会尝试三次,间隔分别为5.8s,24s,48s,共76s左右,而大多数伯克利系统建立一个连接的最长时间,限制为75s。也就是说,第一个SYN包丢了,会重传,最长重试时间是75s。

  2. 服务器收到SYN,回复的SYN/ACK包丢了
    这种情况,从客户端来看,会认为是SYN丢了没发过去,处理方式就是上面1中所说的。对于服务器而言,发送完SYN/ACK后,在超时时间内没有收到ACK包,也会触发重传,此时服务器处于SYN_RCVD状态,依次等待3s,6s,12s后,重新发送SYN/ACK包。
    这个重传次数,不同的操作系统有不同的配置,Linux中默认重试次数是5次,重试间隔从1s开始倍增,1s,2s,4s,8s,16s,共31s。第5次发送完会等待32s才认为第5次也超时了,所以,一共需要31s+32s=63s,TCP才会断开这个连接。使用TCP的三个参数来调整行为:tcp_synack_retries减少重试次数;tcp_max_syn_backlog 增加SYN连接数量;tcp_abort_on_overflow 决定超出能力时的行为。
    同时由于客户端没有收到SYN/ACK,也会重传,当客户端重传的SYN被收到后,服务器会立即重新发送SYN/ACK包。

  3. 客户端最后一次回复的ACK包丢了
    发送完ACK后,客户端进入ESTABLISHED状态,服务器因为收不到ACK会走重传机制。客户端进入ESTABLISHED状态后,则认为连接已建立,会立即发送数据,但是服务器因为没有收到最后一个ACK,依然处于SYN_RCVD状态。那么,现在的问题是,处于SYN_RCVD的服务器,收到客户端的数据包后如何处理呢?有些资料写,此时服务器会直接回复RST包,表示服务器错误(我都还没建立连接,数据咋都发过来了),并进入CLOSE状态。但是,试想一下,服务器还在通过三次握手确定对方是否真实存在,此时对方的数据已经发过来了,那肯定是存在的。
    实际情况是,处于ESTABLISHED状态下的客户端,开始发送数据时,会带上ACK,所以即便是一个单独发ACK的包丢了,服务器在收到这个最新的包时,也能通过里面的ACK,正常进入ESTABLISHED状态。

  4. 客户端故意不发最后的ACK
    上面几种异常,都是客观因素导致,比如网络环境差等,而这种情况,就是故意而为之了。服务器发送完SYN/ACK后,收不到客户端的ACK,它会认为自己发出去的包丢了,就会走异常2的逻辑,进入重传,根据上面所说我们可以知道,此时这个连接处在服务器在listen时创建的SYN队列中。如果短时间内有大量这样的请求,SYN队列就会被占满,此时再来新的SYN请求,服务器就会自动丢弃,这就是所谓的SYN FLOOD攻击。针对这种情况,现在主要有两种应对方案,
    一个是syn cookie方案,在收到SYN包后,服务器根据一定的算法,以数据包的源地址、端口号等信息作为参数计算出一个cookie值作为SYN/ACK包的序列号发给客户端,但并不立即分配资源,等到收到ACK包,重新根据数据包的源地址、端口计算该包的确认号是否正确,正确则建立连接,否则丢弃。
    另一个方案是SYN Proxy防火墙,服务器防火墙会对收到的每一个SYN包进行代理和回应,并保持连接状态,等到发送方将ACK返回后,再重新构造SYN包发送到服务器,建立真正的TCP连接。

3.3 资源使用

主机收到一个TCP包时,用两端的IP地址和端口号来标识这个TCP包属于哪个session,使用一张表来存储所有的session,表中的每条称作Transmission Control Block(TCB),tcb结构定义包括连接使用的源端口、目的端口、目的ip、序号、应答序号、对方窗口的大小、己方窗口的大小、tcp状态、tcp输入/输出队列,应用层输出队列、tcp的重传有关变量等。对于不能确认的包、接收但还没读取的数据,都会占用系统资源。

3.4 数据传输

在TCP的数据传送状态中,很多重要的机制保证了TCP的可靠性和强壮型,它们包括:使用序号,对收到的TCP报文段进行排序以及检测重复的数据,使用校验和检测报文段的错误,即无错传输,使用确认和计时器来检测和纠正丢包或延时,流控制,拥塞控制,丢包重传等。

3.5 重传机制

TCP为了实现可靠的传输,实现了重传机制,最基本的重传机制,就是超时重传,即在发送数据报文时,设定一个定时器,到达超时时间后还没有收到应答报文,就会重发该报文,那么这个时间设置多少合适呢?如果比较小,那很有可能数据没有丢失只是慢,就触发重传了,如果设置较大,那就会造成等待过长。一个数据包发出去来回的时间,即数据包的一次往返时间,叫做RTT(Round-Trip Time)。超时重传时间RTO(Retransmission Timeout),一般情况下略大于RTT,意思是在一个来回的时间内还没收到应答,发送发就重发。

但是,实际情况是,RTO就是在RTT的基础上计算出来的,毕竟由于网络不是稳定不变的,所以RTT也是一直在变的,这个公式,叫做Jacobson/Karels算法,主要利用平滑移动的思想,具体不展开了。

除了超时重传,还有快速重传机制,毕竟超时重传中每次都要等待RTO后才会触发重传,相比于此,快速重传则是基于接收端反馈的ACK来触发重传,接收方每次的应答包里ACK告知的是最大的有序报文段,当出现某个包丢失时,ACK就会卡在丢失包的前一个包,当发送方连着收到三个重复冗余的ACK后,也就是连着收到四个一样的ACK,就知道这个ACK后面的包丢失了,此时便会触发快速重传,重发丢失报文,而不用等待RTO。但这种方式会有个问题,因为发送方是连着发送多个包给接收方,发送方在收到3个冗余的ACK后,只知道接收方收到的最大有序报文是ACK,但不知道ACK之后有哪些收到了、哪些没收到,那么重传的时候,是只发送ACK后紧邻着的包,还是把ACK后面的所有的包都发一遍呢?

于是,又有了一种重传方式,带选择的快速重传SACK(Selective),它的机制就是,在快速重传的基础上,接收端返回最近收到的报文的序列号范围,这样发送方就知道接收方哪些数据没收到,很清楚就能知道需要重传哪些数据了,SACK标记是加在TCP头部的选项字段里面的。

除此之外,还有一种,D-SACK,Duplicate SACK,即重复SACK,在SACK的基础上做了一些扩展,主要用来告诉发送方,有哪些数据自己重复接受了。目的是帮助发送方判断,是否发生了包失序、ACK丢失、包重复或伪重传,让TCP可以更好的做网络控流。

3.5 滑动窗口

TCP发送一个数据,需要收到确认应答后,才会发送下一个数据,这样有个缺点,就是效率比较低。为了解决这个问题,TCP引入了窗口,它是操作系统开辟的一个缓存空间,窗口的大小表示无需等待确认应答,而可以继续发送的数据的最大值。

TCP头部有个字段叫win,16位,它告诉对方本端的TCP缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度,从而达到流量控制的目的。直白讲就是,一个包一个包发送太慢,那就多发几个,但是发的太多了,接收方可能处理不了,多出来的就被丢弃了,这样就白发了,所以,接收方在每次返回ACK的时候,顺带告诉发送方自己这边还剩下的窗口大小,这样发送方就可以根据这个窗口大小来决定发送多少数据了。

3.6 拥塞控制

拥塞控制是作用于网络的,防止过多的数据注入到网络中,避免出现网络负载过大的情况,它的目标是最大化利用网络上瓶颈链路的带宽。它和流量控制的区别是,流量控制是作用于接收方,根据接收方的实际接受能力控制发送的流量,防止数据段丢失。

发送方维护一个拥塞窗口(congestion window)的变量,用来估算在一段时间内这条链路可以承载和运输的数据的数量。它的大小代表着网络的拥塞程度,并且是动态变化的,为了达到最大的传输效率,最简单的方法就是不断增加传输的数据量,一直到出现丢包,实际上,拥塞控制主要有这几种算法:慢启动、拥塞避免、拥塞发生和快速恢复。

慢启动,在TCP建立连接之后,一开始不要发送大量的数据,而是先探测一下网络的拥塞程度,由小到大逐渐增加拥塞窗口的大小,如果没有出现丢包,每收到一个ACK,就将拥塞窗口cwnd大小加1,单位MSS(max segment size)。每轮次发送窗口增加一倍,呈指数增长,如果出现丢包,拥塞窗口就减半,进入拥塞避免阶段。

  • TCP连接完成,初始化cwnd=1,表示可以传一个MSS单位大小的数据
  • 每当收到一个ACK,cwnd就加一
  • 每当过了一个RTT,因为每个ACK都会加1,所以cwnd就会增加一倍,呈指数增长

但是,为了防止swnd增长过大引起网络阻塞,还需要设置一个慢启动阈值(slow start threshold)状态变量,当cwnd到达该阈值后,进入拥塞避免算法。一般来说,慢启动阈值是65535字节,cwnd到达阈值后

  • 每收到一个ACK,cwnd = cwnd + 1 / cwnd
  • 当每过一个RTT时,cwnd = cwnd + 1,呈线性增长

当网络拥塞发生丢包时,会出现两种情况

  • RTO超时重传
  • 快速重传
    如果是RTO超时重传,就会使用拥塞发生算法
  • 慢启动阈值 = cwnd / 2
  • cwnd = 1
  • 进入新的慢启动过程
    如果是快速重传
  • 拥塞窗口cwnd = cwnd / 2
  • 慢启动阈值= cwnd
  • 进入快速恢复算法
    快速重传和快速恢复一般同时使用,快速恢复算法认为,还有3个冗余ACK收到,说明网络也没那么糟糕,所以没有别要想RTO超时那么反应强烈
  • cwnd = 慢启动阈值 + 3
  • 重传重复的那几个ACK(即丢失的那几个数据包)
  • 如果再收到重复的ACK,那么cwnd = cwnd + 1
  • 如果收到新的ACK后,cwnd = 慢启动阈值,表明恢复过程结束,再次进入拥塞避免算法,即线性增长

3.7 Nagle算法和延迟确认

TCP/IP协议中,无论发送多少数据,总是要在数据钱带上协议头,同时,接收方收到数据,也需要发送ACK确认,为了尽可能利用网络带宽,TCP总是希望尽可能发送足够大的数据,Nagle算法便是为了发送尽可能大的数据块,避免网络中充斥着许多小数据块。基本定义是:任意时刻,最多只能有一个未被确认的小段,小段指的是小于MSS尺寸的数据块,未被确认指的是一个数据块发出去后,没有收到对方发送的ACK确认包。

延迟确认是指,在接收方收到数据包后,如果暂时没有数据要发给对端,它可以等一段时间再发送确认包(Linux默认是40ms),如果在这段时间内刚好有数据要传给对端,ACK就随着数据包一起传输,如果超时后没有数据要发送,就单独发送ACK,避免对方以为丢包。有些场景不能延迟发送确认,比如出现乱包,要及时发送ACK告知

一般二者不能同时使用,一个是延迟发送数据包,一个是延迟发送确认,二者一起会造成更大的延迟,产生性能问题。

3.8 保活定时器

根据TCPI/IP协议的描述,TCP连接建立后,如果双方都没有通信。连接可以一直保存下去,例如中间路由器崩溃或者中间的某条线路断开,只要两端的主机没有被重启,连接就一直被保持着。TCP是面向连接的,不是说两个主机之间一直存在一个连接,而是在各自的主机上面分配了一些资源,如内存,以及上面提到的session表,来存储对端的一些信息,连接断开,对于端点则是意味着清理掉这些连接信息,释放掉所占用的资源。所以,如果连接中客户端突然掉线,服务器就需要能够感知这种变化,然而,TCP规范中并未规定连接的保活功能。

尽管TCP协议中未做要求,但是在很多TCP协议的实现中,却提供了保活定时器。保活定时器一般配置的时间是2个小时,即服务器每2个小时就会想客户端发送探查消息,如果收到客户端的反馈消息,则在等2个小时再发,如果等不到反馈,则再等75s再发一次,这样连续发送10次,如果10次都没有收到反馈,就认为客户端异常断开了,此时,TCP层的程序就会向上层应用程序发送一条连接超时的反馈。

由于TCP的很多实现中,保活定时器的时间比较长(一般大于2个小时),在实际的服务器开发中,很难利用该时间来判断客户端是否断开连接,因此,服务器程序多是在上层自己提供保活功能,常见的有,心跳连接,或者ping/pong消息等。

3.9. 终结通路

连接终止使用了四次挥手过程。

  • 由客户端发起FIN请求,发出FIN后,客户端从ESTABLISHED进入FIN-WAIT-1状态,wait1,就是在等服务器的ACK
  • 服务器收到客户端的FIN后,回复ACK,并从ESTABLISHED进入CLOSED-WAIT状态,开始做一些断开连接前的准备工作。客户端收到ACK后,wait1结束,进入到FIN-WAIT-2状态,wait2是在等服务器的FIN
  • 服务器完成断开准备后,发送FIN/ACK给客户端,请求断开连接,服务器进入LAST-ACK状态,也就是等待最后一个ACK
  • 客户端收到FIN/ACK后,回复ACK,然后进入TIME_WAIT状态,等待2MSL(max segment lifetime)的等待,之后进入CLOSED状态。服务器收到ACK后进入CLOSED状态

需要四次挥手的原因在于,双方都需要发送FIN表示可以断开,同时接收对方发的ACK,来保证自己和对端状态正确。而客户端想要断开的时候,服务器不一定准备好,所以服务器回复的ACK和自己要发的FIN没法合并,要先发ACK,等到自己准备好之后再发FIN/ACK,这样一来就是四次了。而握手只需要三次是因为,服务器的ACK和SYN可以合并,这样便减少一次。

为什么服务器不需要等2MSL,而客户端需要等呢?状态同步是通信的首要基础,服务器给客户端发送了FIN,当它收到客户端发来的ACK时,对于服务器来说,他们双方都知道了服务器要关闭这件事,状态同步了,那么服务器就可以CLOSED。那么对于客户端来说呢,它在发出ACK之后,并不知道服务器是否成功接收,所以有两种可能

  • 服务器没收到自己发的ACK,那么服务器在等待超时后,会重传FIN
  • 服务器收到了自己发的ACK,那么服务器会关闭,不会再发任何消息
    不管哪种情况,客户端都要等,要取两种情况等待时间的最大值,以应对最坏的情况发生,这个最坏的情况是:
  • 去向ACK消息最大存活时间MSL + 来向FIN消息的最大存活时间MSL
    也就是客户端需要等待的2MSL。

如果不等,客户端释放的端口可能会重连刚断开的服务器端口,这样依然存活在网络里的老的TCP报文段可能与新TCP连接报文段冲突,造成数据冲突,为避免此种情况,则需要耐心等待网络中老的TCP连接的活跃报文段全部超时无效。

3.10 四次挥手异常情况

  1. 客户端的FIN包丢了
    这个和前面的SYN包丢失类似,客户端会触发超时重传。对于服务器来说,客户端发来的FIN没有收到,就没有任何感知。会在一段时间后,断开连接。

  2. 服务器第一次回复ACK包丢了
    此时客户端没有收到ACK,会触发超时重传FIN,服务器收到后,会立即再重传。而此时,服务器已经处于CLOSED-WAIT状态,开始做断开连接前的准备工作,准备好之后,会发送FIN/ACK,这个消息是带了之前ACK的响应号的。只要这个消息没丢,客户端可以凭借FIN/ACK包中的响应号,直接从FIN-WAIT-1状态进入TIME-WAIT状态,开始2MSL的等待。

  3. 服务器发的FIN/ACK丢了
    服务器会在超时后触发重传,此时客户端有两种情况,要么处于FIN-WAIT-1状态(之前的ACK也丢了,wait1是在等ACK,还没等到),要么处于FIN-WAIT-2状态(收到了ACK,在等FIN),收到服务器的重传来的FIN后,发送ACK给服务器,然后开始2MSL的等待。

  4. 客户端最后的ACK丢了
    客户端回复ACK后,会进入2MSL的TIME-WAIT等待,服务器因为没有收到ACK,会重试一段时间,直到超时服务器主动断开。在服务器重试的期间内,客户端可能释放了端口,此时如有新的客户端连接服务器,就会收到服务器发的重试消息FIN,这时客户端会回复RST,服务器收到RST后,会复位状态。

  5. 客户端收到ACK后,服务器掉线了
    客户端收到ACK后,进入了FIN-WAIT-2状态,等待FIN,如果服务器不在了,那么这个FIN将永远等不到。在TCP协议中,是没有对这个状态的处理机制的,但是操作系统会接管这个状态,例如在Linux下,可以通过tcp_fin_timeout参数,来对这个状态设定一个超时时间。需要注意的是,当超过tcp_fin_timeout的限制后,客户端的状态不是切换到TIME_WAIT,而是直接进入到CLOSED状态

  6. 客户端收到ACK后,客户端掉线
    客户端掉线后,服务器发的FIN/ACK就得不到应答,会不断的走超时重传机制,在超过一定时间后,服务器主动断开。如果在重试期间,有新的客户端接入这个连接,发送SYN给服务器,表示想要建立连接,此时这个SYN会被服务器忽略,并直接回复FIN/ACK,新客户端收到FIN/ACK后不会认的,就会给服务器发送RST,服务器收到RST,会复位状态

------------- (完) -------------
  • 本文作者: oynix
  • 本文链接: https://oynix.com/2022/04/6ebf82e50b22/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

欢迎关注我的其它发布渠道